feat: 保存个人词义与状态,并在其他章节同步显示 (#7)
- schema v5 新增 lexgo_terms:身份为学习者+语言+规范化词形,唯一键保证
重复保存只更新同一条记录,不产生冲突副本
- POST /api/v1/terms 幂等保存并返回 created,GET /api/v1/terms/:id 仅本人可读,
章节 tokens 为 word 片段附带 term:{id,status,level}
- 状态与等级边界:新词/学习中/已知/忽略,只有学习中带 1~7 级,其余必须为 0,
并由数据库检查约束守住
- 学习端面板可编辑释义、例句与学习状态,正文按状态高亮;打开已保存词先读取原内容,
读取失败时禁用保存,切换账号或退出后清理表单、状态与高亮
- 同步 Architecture-and-Code-Map、Business-Rules-and-Glossary、
Local-Development-and-Verification、Product-Requirements-Overview 与 Home
This commit is contained in:
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: 0cd603320b62774c0bee5670c58c92880df50f0f
|
||||
synchronized_at: 2026-09-11T06:46:49Z
|
||||
wiki_revision: cb6d420632527a436f8b9a7a6a18413a0773f93d
|
||||
synchronized_at: 2026-09-11T08:31:30Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -243,3 +243,17 @@ lexgo_books(owner_id, title, language)、lexgo_chapters(book_id, owner_id, ordin
|
||||
管理端 `Dictionaries.vue` + `dictionaries.mjs` + session multipart 方法,复用 go-admin 导航/表单与身份失效保护。学习端 `useReaderLookup.ts` 校验原文片段/SHA/所有位置,`ReaderTokens.vue` 渲染可聚焦单词,`LookupPanel.vue` 展示释义及临时个人草稿。桌面侧栏,手机固定底部45dvh面板;关闭恢复焦点,仅无后续手动滚动时恢复自动调整前位置。旧响应在换词/换章/退出/离页后失效。
|
||||
|
||||
参考:[WordNet 数据格式](https://wordnet.princeton.edu/documentation/wndb5wn)、[词形规则](https://wordnet.princeton.edu/documentation/morphy7wn)。#3 仅历史实验,#6 不调用其实验服务。
|
||||
|
||||
## #7 个人词条与阅读器状态(2026-09-11)
|
||||
|
||||
schema v5 新增 lexgo_terms:一个学习者对一个词形一条记录。身份键为 `(owner_id, language, term)`,`term` 是 Go 侧 `normalizeWord` 的结果(NFC、小写、弯撇号转直撇号),列使用 `utf8mb4_bin`,避免折叠 `resume`/`résumé`;`original_form` 保存最近一次保存的原词形供显示。`definition`/`examples` 是学习者自己的文本,例句按行存储;共享词典仍只在 `lexgo_dictionaries`,两者不混存。`status` 与 `level` 由数据库检查约束守住:只有 `learning` 允许 1~7,其他状态必须为 0。
|
||||
|
||||
| 接口 | 权限与输入/输出 |
|
||||
|---|---|
|
||||
| POST /api/v1/terms | 本人;`{chapterId,start,end,definition,examples[],status,level?}`;服务端按 #6 同一套 token 范围反推词形,`owner`/`language`/`term` 一律不接受客户端输入;唯一键 upsert,重复保存更新同一行;首次 201、更新 200,返回 `{term,created}` |
|
||||
| GET /api/v1/terms/:id | 本人;他人编号与不存在编号统一 404,不泄露存在性 |
|
||||
| GET /api/v1/chapters/:id/tokens | 在原响应上为 word 片段增加可选 `term:{id,status,level}`;服务端按本章词形分批(每批 500)查本人词条,其他章节保存的同形词同样命中 |
|
||||
|
||||
`server/app/lexgo/terms.go` 负责身份、状态/等级边界、文本上限、upsert 与章节点词状态;`database.go` 提供 v5;`dictionary.go` 的 tokens 与 lookup 共用 `wordAtRange`,保存与查询必须落在同一个完整单词范围上,所以客户端无法命名自己没有读到的词。个人词条不写审计日志。
|
||||
|
||||
学习端 `useReaderLookup.ts` 在原有查询状态上增加个人释义、例句、状态、已保存编号、预填与保存;打开已保存词先读 `GET /terms/:id`,读取失败时禁用保存,避免用空表单覆盖原内容。`LookupPanel.vue` 提供状态单选、释义与例句输入、保存与简短反馈;`ReaderTokens.vue` 按状态高亮 `is-new`/`is-learning`/`is-known`/`is-ignored`。换词、换章、离页、退出或切换账号都会清空表单、状态与高亮。管理端无改动。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: 935630005c9b1d7103550cf09eb10edfdbf8f3af
|
||||
synchronized_at: 2026-09-11T03:59:01Z
|
||||
wiki_revision: 71389f97df86e17699f4af930022251fa354631b
|
||||
synchronized_at: 2026-09-11T08:31:32Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -153,3 +153,22 @@ POST /lookup 接收 {surface,lemma?}。查词键单独 casefold/NFC/弯撇号转
|
||||
exact优先;未命中再按WordNet异常表/词尾规则查候选,词性顺序n/v/a/r、最多12条释义,保留原数据s词性。返回lemma表示规则候选,不承诺上下文消歧;歧义不会合并个人学习状态。not_found与resource_missing区分,网络错误可重试,不阻断阅读。#3使用spaCy上下文lemma的实验路径由全Go规则候选替代。
|
||||
|
||||
个人释义目前仅当前选择的临时草稿,界面标记未保存;换词、关闭、换章、离页和身份变化清理。持久化与个人词汇状态由#7实现,不把临时输入宣传为保存成功。
|
||||
|
||||
## #7 个人词条规则(2026-09-11)
|
||||
|
||||
个人词条身份是「学习者+语言+规范化词形」:`normalizeWord` 做 NFC、小写与弯撇号转换,而原文、章节文本与显示用的原词形都不归一化。同一词形不同大小写是同一条记录;不同词形(`dog` 与 `dogs`)是不同记录,不按 WordNet 候选或 lemma 合并,与 #6「不按候选合并个人学习状态」一致。语言取自已登录学习者的英语空间,不从请求读取。
|
||||
|
||||
| 业务状态 | level | 含义 | 进入到期复习 | 原版 stage |
|
||||
|---|---|---|---|---|
|
||||
| new 新词 | 0 | 已保存、尚未开始学习;首次保存默认 | 由 #8 决定 | 2 |
|
||||
| learning 学习中 | 1~7 | 正在复习,越接近 7 越熟 | 是 | -1~-7 |
|
||||
| known 已知 | 0 | 已掌握,不再进入到期复习 | 否 | 0 |
|
||||
| ignored 忽略 | 0 | 明确忽略,不计入已知 | 否 | 1 |
|
||||
|
||||
上表是原版合并编码(状态与等级在同一字段)的显式替代,供 CSV 导出与旧数据迁移映射;#7 只保存与返回等级,#8 负责复习推进与到期时间。非 `learning` 状态携带非 0 等级、`learning` 等级超出 1~7、以及未知状态一律拒绝,`learning` 缺省等级为 1。
|
||||
|
||||
个人释义可为空(允许只记录状态),最长 2000 字符,可含换行与制表符;例句最多 5 条、每条最长 500 字符,不能为空行或含换行。保存失败保留学习者已输入的内容,成功后显示「已保存 · 状态」并立即更新正文高亮。
|
||||
|
||||
保存幂等由唯一键承担:重复提交同一词形只更新同一行,不产生第二条冲突记录;同一账号多端同时编辑为最后写入生效,本版不引入版本冲突拒绝。所有读写都属于会话本人:章节必须本人且已就绪,篡改 id、owner、language、term 或携带未知字段返回 400,他人编号与本人不可见编号统一 404。跨账号不共享任何数据与前端缓存,退出或切换账号后不保留前一账号的词条与高亮。
|
||||
|
||||
例句只保存学习者手输内容,不自动关联原文句子:当前分词只有词/空白/标点边界,没有句子切分规则。原型 v1 中「原文例句已关联到词条」是演示文案,不作为契约。等级选择器属于 #8/#12 的编辑界面,阅读器面板只提供四个状态。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Local-Development-and-Verification
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Local-Development-and-Verification.-
|
||||
wiki_revision: bbd6e618f4339aa45d81bba0492045b60581a735
|
||||
synchronized_at: 2026-09-11T03:59:02Z
|
||||
wiki_revision: 919545b0d716180a49ac7dd2fe96bbeccdba0880
|
||||
synchronized_at: 2026-09-11T08:31:32Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -320,3 +320,23 @@ node --test spikes/english/view.test.mjs
|
||||
主审真实API联调使用真实管理端session模块经5174代理上传;经5173代理两测试账号分别创建虚构章节并验证精确/不规则词形、Unicode原文片段、越权404/普通用户管理403、停用/重复导入启用、错误ZIP保留资源。新增测试书籍id3/4、章节id8/9归issue5_a/issue5_b,没有修改其他账号的书籍。
|
||||
|
||||
常驻5173一度返回空白页:Vue模块转换500、代理缺失;同代码隔离服务正常,只重启lexgo-learner加载配置后恢复,默认E2E通过,未改启动配置。桌面交互浏览器工具因旧会话失效未完成手工真实UI联调;已有项目Playwright使用模拟API,真实API验证另列。手机仅窄屏自动测试,真机证据仍未补齐。
|
||||
|
||||
## #7 验证与迁移(2026-09-11)
|
||||
|
||||
仓库根执行;Go 工具链由 `python scripts/server.py` 固定 go1.26.5。本单使用专用测试库 lexgo_test_issue7,不借用其他测试库。
|
||||
|
||||
| 命令 | 结果 |
|
||||
|---|---|
|
||||
| `go vet ./...` | 通过 |
|
||||
| `LEXGO_TEST_DB_NAME=lexgo_test_issue7 python scripts/server.py test-integration` | 34 个顶层用例全部通过、0 跳过;含 #7 新增 3 个 MySQL 用例、4 个单元用例和 1 个 v4→v5 迁移用例 |
|
||||
| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 58 项单测、类型检查、构建、3 项默认 E2E 全部通过 |
|
||||
| `cd admin`:`npx pnpm test` / `npx pnpm lint` | 31 项与 lint 通过;管理端本单无代码改动 |
|
||||
| `python -m unittest discover -s tests` / `python dev_scripts/harness.py check --strict` | 56 项与严格检查通过 |
|
||||
|
||||
覆盖内容:非法状态与等级边界、文本上限、词形身份与大小写合并、词形不按 lemma 合并、重复与并发保存只留一条记录、跨章节同形词状态一致、两账号互不影响、篡改 owner/language/term 被拒绝、非本人章节 404、未登录 401、未就绪章节 409、v4→v5 迁移保留既有数据与检查约束。
|
||||
|
||||
本机开发库 lexgo_dev 已显式从 v4 升级到 v5:升级前后 sys_user 6、lexgo_spaces 6、lexgo_sessions 8、lexgo_books 4、lexgo_chapters 9、lexgo_ingest_jobs 9、lexgo_dictionaries 1 全部不变,新增空的 lexgo_terms。旧二进制备份在忽略的 `.local/lexgo-pre-issue7.exe`。回退:停止 lexgo-api,恢复旧二进制,把 `lexgo_schema` 标记改回 4 后启动;保留 lexgo_terms 与全部既有数据,不删除数据、不重新 bootstrap。
|
||||
|
||||
真实链路验证:真实 Go API+真实 MySQL 共 30 项检查通过(凭据只从本机安全配置读入进程),覆盖两个虚构测试账号 issue5_a/issue5_b 的登录、保存、幂等、状态边界、跨章节一致、跨账号隔离与越权拒绝;随后用临时 Playwright 用例在真实学习端+真实 API 上以两个账号复核保存、重新加载后的高亮与预填,以及 390×844 窄屏底部面板。截图保存在本机 `.local/evidence/`(issue7-reader-desktop.png、issue7-reader-mobile-390.png、issue7-account-b.png),临时用例运行后删除。
|
||||
|
||||
未验证:真实手机触屏详细证据与完整备份恢复演练仍属既有缺口(#14/#15);本单只用桌面浏览器窄屏检查,不当作真机结果。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Product-Requirements-Overview
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Product-Requirements-Overview.-
|
||||
wiki_revision: 099f1c803ffb69edc3fe07582d22dcf9a57647a1
|
||||
synchronized_at: 2026-09-11T03:59:10Z
|
||||
wiki_revision: b5eaaf1d0664e1e3c564079c857ccb5042540022
|
||||
synchronized_at: 2026-09-11T08:31:32Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 产品需求总览
|
||||
@@ -239,3 +239,9 @@ Anki(U20)、YouTube/Jellyfin 远程字幕(U07/A08)、全量旧实例迁
|
||||
## #6 交付范围更新(2026-09-11)
|
||||
|
||||
用户确认全Go后,英语词典配置与阅读器点词查义已实现待验收:共享WordNet3.0英语释义、管理员导入/启停、本人章节点击/键盘查词、加载/无结果/资源不可用/网络失败/关闭状态。词形结果是规则候选,不提供上下文词性消歧。个人释义输入是未保存临时草稿,#7才持久化。沿用已验收v1;手机底部45dvh面板自动测试通过,真机缺口保留。#5已关闭并合入main,#6尚不关闭。
|
||||
|
||||
## #7 交付范围更新(2026-09-11)
|
||||
|
||||
F07 的个人词语记录已实现,待用户验收:阅读器可以保存与修改个人释义、例句和状态(新词/学习中/已知/忽略),同一词形在本人其他章节显示相同状态与高亮,两个账号的数据互不影响。个人释义与共享 WordNet 词典分开存储,个人释义不进入审计日志。schema 升级为 v5,新增 lexgo_terms。
|
||||
|
||||
仍未实现并留给后续工单:词汇库搜索与编辑(#12)、短语(#11)、到期复习与等级推进(#8)、阅读完成与进度(#13)、TXT 导入(#9)、书籍章节编辑删除(#10)。词形候选仍不提供上下文消歧,个人学习状态不按候选合并;例句不自动关联原文句子。
|
||||
|
||||
+5
-3
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Home
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Home
|
||||
wiki_revision: c41ce5131153be66a9b5bd762bb73b52d76dfa38
|
||||
synchronized_at: 2026-09-11T06:46:45Z
|
||||
wiki_revision: 6fd4c754e3bb6ab215edb6077974fbd401f24103
|
||||
synchronized_at: 2026-09-11T08:31:29Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# LexGo 文档入口
|
||||
@@ -69,4 +69,6 @@ Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn
|
||||
|
||||
## 当前进度(2026-09-11)
|
||||
|
||||
#5已验收,#17/#19/#20/#22/#23按依赖顺序合入main。#6已按用户确认的全Go方向实现英语词典与阅读点词,2026-09-11通过用户验收,PR #25已合入main;原文/账户隔离保留。正式NLP不使用Python服务。管理端“英语词典”导入指定WordNet3.0,学习端打开本人章节即可查词。个人释义当前不保存,后续#7实现。详见#6工单、架构和本地开发页面。
|
||||
#5已验收,#17/#19/#20/#22/#23按依赖顺序合入main。#6已按用户确认的全Go方向实现英语词典与阅读点词,2026-09-11通过用户验收,PR #25已合入main;原文/账户隔离保留。正式NLP不使用Python服务。管理端“英语词典”导入指定WordNet3.0,学习端打开本人章节即可查词。个人释义与学习状态已实现持久化(#7,待用户验收)。详见#6工单、架构和本地开发页面。
|
||||
|
||||
#7 已实现个人词条(schema v5 新增 lexgo_terms):阅读器可保存释义、例句与状态,同一词形在本人其他章节显示一致高亮,两个账号数据独立;保存幂等,跨账号与篡改身份均被拒绝。本单待用户验收。
|
||||
|
||||
@@ -9,6 +9,8 @@ test('paste English text, watch a chapter finish processing, then read it verbat
|
||||
const timestamps = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }
|
||||
// The worker reports the fresh chapter as processing until the worker settles it.
|
||||
let status: 'processing' | 'ready' = 'processing'
|
||||
// The personal record the learner saves during this run, served back on reload.
|
||||
let savedTerm: { id: number; term: string; originalForm: string; definition: string; examples: string[]; status: string; level: number } | null = null
|
||||
const chapterPayload = () => ({
|
||||
id: 55,
|
||||
bookId: book.id,
|
||||
@@ -59,8 +61,20 @@ test('paste English text, watch a chapter finish processing, then read it verbat
|
||||
offset += text.length
|
||||
return { text, start, end: offset, startUtf16: start, endUtf16: offset, kind: /^[A-Za-z]+$/.test(text) ? 'word' : /^\s+$/.test(text) ? 'space' : 'punctuation' }
|
||||
})
|
||||
data = { textSha256: 'fictional-sha256', tokens }
|
||||
} else if (path === '/api/v1/lookup') {
|
||||
data = {
|
||||
textSha256: 'fictional-sha256',
|
||||
tokens: tokens.map(token => savedTerm && token.text === 'First'
|
||||
? { ...token, term: { id: savedTerm.id, status: savedTerm.status, level: savedTerm.level } }
|
||||
: token),
|
||||
}
|
||||
} else if (path === '/api/v1/terms' && method === 'POST') {
|
||||
const body = route.request().postDataJSON() as { chapterId: number; start: number; end: number; definition: string; examples: string[]; status: string }
|
||||
expect(body).toEqual({ chapterId: 55, start: 0, end: 5, definition: '虚构的个人释义', examples: ['A fictional example.'], status: 'new' })
|
||||
savedTerm = { id: 9, term: 'first', originalForm: 'First', definition: body.definition, examples: body.examples, status: body.status, level: 0 }
|
||||
statusCode = 201
|
||||
data = { term: savedTerm, created: true }
|
||||
} else if (path === '/api/v1/terms/9') data = { term: savedTerm }
|
||||
else if (path === '/api/v1/lookup') {
|
||||
expect(route.request().postDataJSON()).toEqual({ chapterId: 55, start: 0, end: 5 })
|
||||
data = { status: 'exact', query: 'first', matchedForm: 'first', candidates: [], entries: [{ lemma: 'first', pos: 'adjective', definition: 'Coming before all others.', examples: ['The first fictional chapter.'] }] }
|
||||
}
|
||||
@@ -100,15 +114,12 @@ test('paste English text, watch a chapter finish processing, then read it verbat
|
||||
expect(await readerText.evaluate(element => getComputedStyle(element).whiteSpace)).toBe('pre-wrap')
|
||||
await expect(page.getByRole('button', { name: '上一章' })).toBeDisabled()
|
||||
await expect(page.getByRole('button', { name: '下一章' })).toBeDisabled()
|
||||
const word = page.getByRole('button', { name: '查询 First', exact: true })
|
||||
const word = page.locator('.reader-word').first()
|
||||
await expect(word).toHaveAttribute('aria-label', '查询 First')
|
||||
await word.focus()
|
||||
await word.press('Enter')
|
||||
await expect(page.getByText('Coming before all others.')).toBeVisible()
|
||||
expect(await readerText.evaluate(element => element.textContent)).toBe(pasted)
|
||||
await page.getByLabel('个人释义 未保存').fill('虚构的本地草稿')
|
||||
await page.getByRole('button', { name: '关闭释义' }).press('Escape')
|
||||
await expect(page.locator('.lookup-panel')).toHaveCount(0)
|
||||
await expect(word).toBeFocused()
|
||||
|
||||
// Browser narrow viewport check only; this is not real-device acceptance.
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
@@ -120,5 +131,27 @@ test('paste English text, watch a chapter finish processing, then read it verbat
|
||||
expect(await page.locator('.lookup-panel').evaluate(element => getComputedStyle(element).position)).toBe('fixed')
|
||||
const wordBounds = await word.boundingBox()
|
||||
expect(wordBounds!.y + wordBounds!.height).toBeLessThanOrEqual(panelBounds!.y)
|
||||
expect(await page.getByLabel('个人释义 未保存').inputValue()).toBe('')
|
||||
expect(await page.getByLabel('我的释义 新词条').inputValue()).toBe('')
|
||||
|
||||
// Save a personal record: definition, example and the default status.
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await page.getByLabel('我的释义 新词条').fill('虚构的个人释义')
|
||||
await page.getByLabel('例句 每行一条,最多 5 条').fill('A fictional example.')
|
||||
await page.getByRole('button', { name: '保存到生词本' }).click()
|
||||
await expect(page.getByText('已保存 · 新词')).toBeVisible()
|
||||
await expect(word).toHaveClass(/is-new/)
|
||||
expect(await readerText.evaluate(element => element.textContent)).toBe(pasted)
|
||||
await page.getByRole('button', { name: '关闭释义' }).press('Escape')
|
||||
await expect(page.locator('.lookup-panel')).toHaveCount(0)
|
||||
await expect(word).toBeFocused()
|
||||
|
||||
// Returning to the chapter shows the same state and the stored text.
|
||||
await page.reload()
|
||||
const reloadedWord = page.locator('.reader-word').first()
|
||||
await expect(reloadedWord).toHaveAttribute('aria-label', '查询 First,已保存')
|
||||
await expect(reloadedWord).toHaveClass(/is-new/)
|
||||
await reloadedWord.click()
|
||||
await expect(page.getByLabel('我的释义 已保存')).toHaveValue('虚构的个人释义')
|
||||
await expect(page.getByLabel('例句 每行一条,最多 5 条')).toHaveValue('A fictional example.')
|
||||
await expect(page.getByRole('radio', { name: '新词' })).toBeChecked()
|
||||
})
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import ReaderView from '../views/ReaderView.vue'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
import { useReaderLookup } from '../composables/useReaderLookup'
|
||||
import { useReaderLookup, type SavedTerm } from '../composables/useReaderLookup'
|
||||
import { effectScope, ref } from 'vue'
|
||||
import type { ChapterDetail } from '../stores/library'
|
||||
|
||||
// Fictitious text includes astral, combining, CRLF and repeated whitespace.
|
||||
const original = '😀 Cats\r\n café!'
|
||||
const fragments = [['😀', 'punctuation'], [' ', 'space'], ['Cats', 'word'], ['\r\n ', 'space'], ['café', 'word'], ['!', 'punctuation']]
|
||||
const original = '😀 Cats\r\n café!'
|
||||
const fragments = [['😀', 'punctuation'], [' ', 'space'], ['Cats', 'word'], ['\r\n ', 'space'], ['café', 'word'], ['!', 'punctuation']]
|
||||
let cp = 0, utf16 = 0
|
||||
const tokens = fragments.map(([text = '', kind]) => {
|
||||
const token = { text, kind, start: cp, end: cp + [...text].length, startUtf16: utf16, endUtf16: utf16 + text.length }
|
||||
@@ -20,11 +20,24 @@ const tokens = fragments.map(([text = '', kind]) => {
|
||||
const chapter = { id: 55, bookId: 1, title: '虚构章节', status: 'ready', originalText: original, contentSha256: 'same-sha' }
|
||||
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
|
||||
const result = (query: string, status = 'exact') => ({ status, query, matchedForm: query, candidates: [], entries: status === 'exact' ? [{ lemma: query, pos: 'noun', definition: `Definition of ${query}`, examples: ['A fictional example.'] }] : [] })
|
||||
const savedTerm = (overrides: Partial<SavedTerm> = {}): SavedTerm => ({
|
||||
id: 7, term: 'cats', originalForm: 'Cats', definition: '猫', examples: ['A fictional example.'], status: 'learning', level: 2, ...overrides,
|
||||
})
|
||||
let wrapper: VueWrapper | undefined
|
||||
async function open(mockLookup: (body: Record<string, number>) => Promise<Response> = async () => ok(result('cat')), tokenData: unknown = { textSha256: 'same-sha', tokens }) {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, options) => {
|
||||
if (String(input).endsWith('/tokens')) return ok(tokenData)
|
||||
if (String(input).endsWith('/lookup')) return mockLookup(JSON.parse(String(options?.body)))
|
||||
interface OpenOptions {
|
||||
lookup?: (body: Record<string, number>) => Promise<Response>
|
||||
tokenData?: unknown
|
||||
termRead?: () => Promise<Response>
|
||||
termWrite?: (body: Record<string, unknown>) => Promise<Response>
|
||||
}
|
||||
async function open(options: OpenOptions = {}) {
|
||||
const { lookup = async () => ok(result('cat')), tokenData = { textSha256: 'same-sha', tokens } } = options
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith('/tokens')) return ok(tokenData)
|
||||
if (url.endsWith('/lookup')) return lookup(JSON.parse(String(init?.body)))
|
||||
if (url.includes('/terms/')) return options.termRead ? options.termRead() : ok({ term: savedTerm() })
|
||||
if (url.endsWith('/terms')) return options.termWrite ? options.termWrite(JSON.parse(String(init?.body))) : ok({ term: savedTerm(), created: true })
|
||||
return ok({ chapter, navigation: { previousChapterId: null, nextChapterId: 56 } })
|
||||
})
|
||||
useSessionStore().user = { id: 42, username: 'fictional', role: 'learner' }
|
||||
@@ -34,6 +47,12 @@ async function open(mockLookup: (body: Record<string, number>) => Promise<Respon
|
||||
await flushPromises()
|
||||
return { fetchMock, router, view: wrapper }
|
||||
}
|
||||
const callsTo = (fetchMock: MockInstance, suffix: string) => fetchMock.mock.calls.filter(call => String(call[0]).endsWith(suffix))
|
||||
// Personal state is attached to the word itself, never to a space or punctuation token.
|
||||
const withTerm = (text: string, term: { id: number; status: string; level: number }) => ({
|
||||
textSha256: 'same-sha',
|
||||
tokens: tokens.map(token => token.text === text ? { ...token, term } : token),
|
||||
})
|
||||
describe('reader word lookup', () => {
|
||||
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() })
|
||||
afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.restoreAllMocks() })
|
||||
@@ -43,38 +62,38 @@ describe('reader word lookup', () => {
|
||||
const words = view.findAll('.reader-word')
|
||||
expect(words).toHaveLength(2)
|
||||
await words[0]!.trigger('click'); await flushPromises()
|
||||
const call = fetchMock.mock.calls.find(([url]) => String(url).endsWith('/lookup'))!
|
||||
const call = callsTo(fetchMock, '/lookup')[0]!
|
||||
expect(call[1]?.method).toBe('POST')
|
||||
expect(JSON.parse(String(call[1]?.body))).toEqual({ chapterId: 55, start: 2, end: 6 })
|
||||
expect(view.text()).toContain('Definition of cat')
|
||||
expect(view.text()).toContain('A fictional example.')
|
||||
})
|
||||
it('discards older word responses and clears the selection draft', async () => {
|
||||
it('discards older word responses and clears the selection form', async () => {
|
||||
let finish!: (response: Response) => void
|
||||
const { view } = await open(body => body.start === 2 ? new Promise(resolve => { finish = resolve }) : Promise.resolve(ok(result('cafe', 'not_found'))))
|
||||
const { view } = await open({ lookup: body => body.start === 2 ? new Promise(resolve => { finish = resolve }) : Promise.resolve(ok(result('cafe', 'not_found'))) })
|
||||
await view.findAll('.reader-word')[0]!.trigger('click')
|
||||
expect(view.text()).toContain('正在查询')
|
||||
await view.findAll('.reader-word')[1]!.trigger('click'); await flushPromises()
|
||||
await view.get('textarea').setValue('虚构私人草稿')
|
||||
await view.get('#term-definition').setValue('虚构私人草稿')
|
||||
finish(ok(result('OLD'))); await flushPromises()
|
||||
expect(view.text()).not.toContain('Definition of OLD')
|
||||
expect(view.text()).toContain('未找到释义')
|
||||
expect(view.text()).toContain('未保存')
|
||||
expect(view.text()).toContain('新词条')
|
||||
await view.findAll('.reader-word')[0]!.trigger('click')
|
||||
expect((view.get('textarea').element as HTMLTextAreaElement).value).toBe('')
|
||||
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('')
|
||||
})
|
||||
it.each(['resource_missing', 'not_found', 'error'])('keeps reading and permits a local draft and retry for %s', async status => {
|
||||
const { view, fetchMock } = await open(async () => { if (status === 'error') throw new Error('暂时无法查询'); return ok(result('cat', status)) })
|
||||
it.each(['resource_missing', 'not_found', 'error'])('keeps reading and permits a saved word and retry for %s', async status => {
|
||||
const { view, fetchMock } = await open({ lookup: async () => { if (status === 'error') throw new Error('暂时无法查询'); return ok(result('cat', status)) } })
|
||||
await view.get('.reader-word').trigger('click'); await flushPromises()
|
||||
await view.get('textarea').setValue('仅当前词的虚构草稿')
|
||||
await view.get('#term-definition').setValue('仅当前词的虚构释义')
|
||||
expect(view.get('.reader-text').element.textContent).toBe(original)
|
||||
expect(view.text()).toContain('未保存')
|
||||
expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/lookup'))).toHaveLength(1)
|
||||
expect(view.get('[data-testid="term-save"]').attributes('disabled')).toBeUndefined()
|
||||
expect(callsTo(fetchMock, '/lookup')).toHaveLength(1)
|
||||
await view.get('[data-testid="lookup-retry"]').trigger('click'); await flushPromises()
|
||||
expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/lookup'))).toHaveLength(2)
|
||||
expect(callsTo(fetchMock, '/lookup')).toHaveLength(2)
|
||||
})
|
||||
it.each([{ textSha256: 'wrong', tokens }, { textSha256: 'same-sha', tokens: tokens.slice(1) }])('falls back to original when tokens do not match', async data => {
|
||||
const { view } = await open(undefined, data)
|
||||
const { view } = await open({ tokenData: data })
|
||||
expect(view.get('.reader-text').element.textContent).toBe(original)
|
||||
expect(view.find('.reader-word').exists()).toBe(false)
|
||||
expect(view.find('[data-testid="tokens-retry"]').exists()).toBe(true)
|
||||
@@ -105,7 +124,7 @@ describe('reader word lookup', () => {
|
||||
expect(view.findAll('.reader-word')).toHaveLength(2)
|
||||
})
|
||||
it('does not accept a late token response after identity changes', async () => {
|
||||
const { view, fetchMock } = await open(undefined, { textSha256: 'wrong', tokens })
|
||||
const { view, fetchMock } = await open({ tokenData: { textSha256: 'wrong', tokens } })
|
||||
let finish!: (response: Response) => void
|
||||
fetchMock.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
|
||||
await view.get('[data-testid="tokens-retry"]').trigger('click')
|
||||
@@ -130,7 +149,7 @@ describe('reader word lookup', () => {
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: 'instant' })
|
||||
scope.stop()
|
||||
})
|
||||
it('preserves a later manual reading scroll when closing the sheet', () => {
|
||||
it('preserves a later manual reading scroll when closing the sheet', async () => {
|
||||
useSessionStore().user = { id: 42, username: 'fictional', role: 'learner' }
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok(result('cat')))
|
||||
let scrollY = 100
|
||||
@@ -154,7 +173,7 @@ describe('reader word lookup', () => {
|
||||
})
|
||||
it.each(['logout', 'chapter', 'unmount'])('invalidates pending lookup on %s', async action => {
|
||||
let finish!: (response: Response) => void
|
||||
const { view, router } = await open(() => new Promise(resolve => { finish = resolve }))
|
||||
const { view, router } = await open({ lookup: () => new Promise(resolve => { finish = resolve }) })
|
||||
await view.get('.reader-word').trigger('click')
|
||||
if (action === 'logout') await useSessionStore().logout()
|
||||
else if (action === 'chapter') await router.push('/chapters/56')
|
||||
@@ -165,3 +184,69 @@ describe('reader word lookup', () => {
|
||||
expect(view.text()).not.toContain('Definition of OLD')
|
||||
})
|
||||
})
|
||||
describe('personal word records', () => {
|
||||
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() })
|
||||
afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.restoreAllMocks() })
|
||||
it('saves the definition, examples and status, then marks the word in the text', async () => {
|
||||
let body: Record<string, unknown> | undefined
|
||||
const { view, fetchMock } = await open({ termWrite: async value => { body = value; return ok({ term: savedTerm({ status: 'learning', level: 2 }), created: true }) } })
|
||||
await view.findAll('.reader-word')[0]!.trigger('click'); await flushPromises()
|
||||
await view.get('#term-definition').setValue(' 猫 ')
|
||||
await view.get('#term-examples').setValue('A fictional example.\n\n Second line ')
|
||||
await view.findAll('input[type="radio"]')[1]!.setValue()
|
||||
await view.get('[data-testid="term-save"]').trigger('click'); await flushPromises()
|
||||
expect(body).toEqual({ chapterId: 55, start: 2, end: 6, definition: ' 猫 ', examples: ['A fictional example.', 'Second line'], status: 'learning' })
|
||||
expect(view.text()).toContain('已保存 · 学习中')
|
||||
expect(view.findAll('.reader-word')[0]!.classes()).toContain('is-learning')
|
||||
expect(callsTo(fetchMock, '/terms')).toHaveLength(1)
|
||||
})
|
||||
it('keeps the typed input and reports the failure when saving fails', async () => {
|
||||
const { view } = await open({ termWrite: async () => { throw new Error('保存暂时不可用') } })
|
||||
await view.findAll('.reader-word')[0]!.trigger('click'); await flushPromises()
|
||||
await view.get('#term-definition').setValue('虚构释义')
|
||||
await view.get('[data-testid="term-save"]').trigger('click'); await flushPromises()
|
||||
expect(view.text()).toContain('保存暂时不可用')
|
||||
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('虚构释义')
|
||||
expect(view.find('.lookup-panel').exists()).toBe(true)
|
||||
expect(view.findAll('.reader-word')[0]!.classes()).not.toContain('is-new')
|
||||
})
|
||||
it('loads the stored record when a saved word is opened and marks it in the text', async () => {
|
||||
const tokenData = withTerm('Cats', { id: 7, status: 'known', level: 0 })
|
||||
const { view, fetchMock } = await open({ tokenData, termRead: async () => ok({ term: savedTerm({ status: 'known', level: 0 }) }) })
|
||||
expect(view.findAll('.reader-word')[0]!.classes()).toContain('is-known')
|
||||
expect(view.findAll('.reader-word')[0]!.attributes('aria-label')).toContain('已保存')
|
||||
await view.findAll('.reader-word')[0]!.trigger('click'); await flushPromises()
|
||||
expect(callsTo(fetchMock, '/terms/7')).toHaveLength(1)
|
||||
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('猫')
|
||||
expect((view.get('#term-examples').element as HTMLTextAreaElement).value).toBe('A fictional example.')
|
||||
expect((view.findAll('input[type="radio"]')[2]!.element as HTMLInputElement).checked).toBe(true)
|
||||
expect(view.text()).toContain('已保存')
|
||||
})
|
||||
it('blocks saving until a failed read of the stored record is retried', async () => {
|
||||
const tokenData = withTerm('Cats', { id: 7, status: 'learning', level: 1 })
|
||||
let attempts = 0
|
||||
const { view, fetchMock } = await open({ tokenData, termRead: async () => { attempts++; if (attempts === 1) throw new Error('已保存的内容暂时无法读取'); return ok({ term: savedTerm() }) } })
|
||||
await view.findAll('.reader-word')[0]!.trigger('click'); await flushPromises()
|
||||
expect(view.text()).toContain('已保存的内容暂时无法读取')
|
||||
expect(view.get('[data-testid="term-save"]').attributes('disabled')).toBeDefined()
|
||||
await view.get('.reader-word').trigger('click'); await flushPromises()
|
||||
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('猫')
|
||||
expect(view.get('[data-testid="term-save"]').attributes('disabled')).toBeUndefined()
|
||||
expect(callsTo(fetchMock, '/terms')).toHaveLength(0)
|
||||
})
|
||||
it('drops the previous account words and highlights after an identity change', async () => {
|
||||
const { view, fetchMock } = await open({ termWrite: async () => ok({ term: savedTerm({ status: 'new', level: 0 }), created: true }) })
|
||||
await view.findAll('.reader-word')[0]!.trigger('click'); await flushPromises()
|
||||
await view.get('[data-testid="term-save"]').trigger('click'); await flushPromises()
|
||||
expect(view.findAll('.reader-word')[0]!.classes()).toContain('is-new')
|
||||
fetchMock.mockResolvedValueOnce(ok({ textSha256: 'same-sha', tokens }))
|
||||
useSessionStore().user = { id: 43, username: 'other-fictional', role: 'learner' }
|
||||
await flushPromises()
|
||||
expect(view.find('.lookup-panel').exists()).toBe(false)
|
||||
// The previous account's words and chapter are gone: the reader falls back to
|
||||
// nothing until the new identity loads its own chapter.
|
||||
expect(view.find('.reader-word').exists()).toBe(false)
|
||||
expect(view.find('.reader-text').exists()).toBe(false)
|
||||
expect(view.text()).not.toContain('已保存')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ElButton } from 'element-plus'
|
||||
import type { LookupResult } from '../composables/useReaderLookup'
|
||||
defineProps<{ word: string; result: LookupResult | null; loading: boolean; error: string }>()
|
||||
const draft = defineModel<string>('draft', { required: true })
|
||||
const emit = defineEmits<{ close: []; retry: []; resize: [top: number] }>()
|
||||
import { ElButton, ElInput, ElOption, ElRadio, ElRadioGroup, ElSelect } from 'element-plus'
|
||||
import { TERM_STATUSES, type LookupResult, type TermStatus } from '../composables/useReaderLookup'
|
||||
|
||||
defineProps<{
|
||||
word: string
|
||||
result: LookupResult | null
|
||||
loading: boolean
|
||||
error: string
|
||||
saving: boolean
|
||||
saveError: string
|
||||
saved: string
|
||||
savedTermId: number | null
|
||||
prefilling: boolean
|
||||
prefillError: string
|
||||
canSave: boolean
|
||||
}>()
|
||||
const definition = defineModel<string>('definition', { required: true })
|
||||
const examples = defineModel<string>('examples', { required: true })
|
||||
const status = defineModel<TermStatus>('status', { required: true })
|
||||
const emit = defineEmits<{ close: []; retry: []; save: []; resize: [top: number] }>()
|
||||
const heading = ref<HTMLElement | null>(null)
|
||||
const panel = ref<HTMLElement | null>(null)
|
||||
let observer: ResizeObserver | undefined
|
||||
@@ -47,9 +62,24 @@ onUnmounted(() => { observer?.disconnect(); window.removeEventListener('resize',
|
||||
</template>
|
||||
<ElButton v-if="!loading && (error || result?.status === 'resource_missing' || result?.status === 'not_found')" data-testid="lookup-retry" @click="$emit('retry')">重试查询</ElButton>
|
||||
</div>
|
||||
<div class="lookup-draft">
|
||||
<label for="definition-draft">个人释义 <span>未保存</span></label>
|
||||
<textarea id="definition-draft" v-model="draft" rows="3" placeholder="记下当前词的释义" />
|
||||
<div class="lookup-term">
|
||||
<p class="lookup-term-title">学习状态</p>
|
||||
<ElRadioGroup v-model="status" :disabled="saving || prefilling || !!prefillError" aria-label="学习状态">
|
||||
<ElRadio v-for="item in TERM_STATUSES" :key="item.value" :value="item.value">{{ item.label }}</ElRadio>
|
||||
</ElRadioGroup>
|
||||
<p v-if="status === 'learning'" class="subtle">学习中按 1~7 级记录,等级由复习推进。</p>
|
||||
<label for="term-definition">我的释义 <span>{{ savedTermId ? '已保存' : '新词条' }}</span></label>
|
||||
<ElInput id="term-definition" v-model="definition" type="textarea" :rows="3" :disabled="saving || prefilling || !!prefillError" placeholder="记下当前词的释义" />
|
||||
<label for="term-examples">例句 <span>每行一条,最多 5 条</span></label>
|
||||
<ElInput id="term-examples" v-model="examples" type="textarea" :rows="2" :disabled="saving || prefilling || !!prefillError" placeholder="可选,每行一条" />
|
||||
<p v-if="prefilling" role="status" class="subtle">正在读取已保存的内容…</p>
|
||||
<p v-else-if="prefillError" role="alert" class="lookup-message">{{ prefillError }}</p>
|
||||
<p v-if="saved" role="status" class="lookup-saved">✓ {{ saved }}</p>
|
||||
<p v-if="saveError" role="alert" class="lookup-message">{{ saveError }}</p>
|
||||
<div class="lookup-actions">
|
||||
<ElButton type="primary" data-testid="term-save" :loading="saving" :disabled="!canSave" @click="$emit('save')">保存到生词本</ElButton>
|
||||
<ElButton text @click="$emit('close')">关闭,继续阅读</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { ReaderToken } from '../composables/useReaderLookup'
|
||||
import { termStatusOf, type ReaderToken } from '../composables/useReaderLookup'
|
||||
defineProps<{ tokens: ReaderToken[]; original: string; selectedStart?: number }>()
|
||||
const emit = defineEmits<{ select: [token: ReaderToken, element: HTMLElement] }>()
|
||||
function select(token: ReaderToken, event: Event) { emit('select', token, event.currentTarget as HTMLElement) }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="reader-text"><template v-if="tokens.length"><template v-for="token in tokens" :key="token.start"><span v-if="token.kind === 'word'" role="button" tabindex="0" class="reader-word" :class="{ 'is-selected': selectedStart === token.start }" :aria-label="`查询 ${token.text}`" :aria-pressed="selectedStart === token.start" @click="select(token, $event)" @keydown.enter.prevent="select(token, $event)" @keydown.space.prevent="select(token, $event)">{{ token.text }}</span><template v-else>{{ token.text }}</template></template></template><template v-else>{{ original }}</template></article>
|
||||
<article class="reader-text"><template v-if="tokens.length"><template v-for="token in tokens" :key="token.start"><span v-if="token.kind === 'word'" role="button" tabindex="0" class="reader-word" :class="[termStatusOf(token) ? `is-${termStatusOf(token)}` : '', { 'is-selected': selectedStart === token.start }]" :aria-label="termStatusOf(token) ? `查询 ${token.text},已保存` : `查询 ${token.text}`" :aria-pressed="selectedStart === token.start" @click="select(token, $event)" @keydown.enter.prevent="select(token, $event)" @keydown.space.prevent="select(token, $event)">{{ token.text }}</span><template v-else>{{ token.text }}</template></template></template><template v-else>{{ original }}</template></article>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { onScopeDispose, ref, watch, type Ref } from 'vue'
|
||||
import { computed, onScopeDispose, ref, watch, type Ref } from 'vue'
|
||||
import type { ChapterDetail } from '../stores/library'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
|
||||
export interface ReaderToken { text: string; start: number; end: number; startUtf16: number; endUtf16: number; kind: 'word' | 'space' | 'punctuation' }
|
||||
export type TermStatus = 'new' | 'learning' | 'known' | 'ignored'
|
||||
|
||||
export interface TokenTerm { id: number; status: TermStatus; level: number }
|
||||
export interface ReaderToken {
|
||||
text: string
|
||||
start: number
|
||||
end: number
|
||||
startUtf16: number
|
||||
endUtf16: number
|
||||
kind: 'word' | 'space' | 'punctuation'
|
||||
term?: TokenTerm | null
|
||||
}
|
||||
export interface LookupResult {
|
||||
status: 'exact' | 'lemma' | 'not_found' | 'resource_missing'
|
||||
query: string
|
||||
@@ -11,8 +22,38 @@ export interface LookupResult {
|
||||
entries: { lemma: string; pos: string; definition: string; examples: string[] }[]
|
||||
resource?: { name: string; version: string }
|
||||
}
|
||||
export interface SavedTerm {
|
||||
id: number
|
||||
term: string
|
||||
originalForm: string
|
||||
definition: string
|
||||
examples: string[]
|
||||
status: TermStatus
|
||||
level: number
|
||||
}
|
||||
interface TermResponse { term: SavedTerm }
|
||||
interface TokenResponse { textSha256: string; tokens: ReaderToken[] }
|
||||
|
||||
// The four learner-visible states and the level rule behind them. Only a learning
|
||||
// entry carries a level, so every other status reports level 0.
|
||||
export const TERM_STATUSES: { value: TermStatus; label: string }[] = [
|
||||
{ value: 'new', label: '新词' },
|
||||
{ value: 'learning', label: '学习中' },
|
||||
{ value: 'known', label: '已知' },
|
||||
{ value: 'ignored', label: '忽略' },
|
||||
]
|
||||
|
||||
export function termStatusLabel(status: TermStatus): string {
|
||||
return TERM_STATUSES.find(item => item.value === status)?.label ?? status
|
||||
}
|
||||
|
||||
/** Unknown or malformed personal state never becomes a highlight class. */
|
||||
export function termStatusOf(token: ReaderToken): TermStatus | null {
|
||||
const term = token.term
|
||||
if (!term || typeof term.id !== 'number' || term.id <= 0) return null
|
||||
return TERM_STATUSES.some(item => item.value === term.status) ? term.status : null
|
||||
}
|
||||
|
||||
// Validate every coordinate before enabling selection; original text is always the fallback.
|
||||
function matchesChapter(data: TokenResponse, chapter: ChapterDetail): boolean {
|
||||
if (!data || data.textSha256 !== chapter.contentSha256 || !Array.isArray(data.tokens)) return false
|
||||
@@ -37,12 +78,38 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
|
||||
const result = ref<LookupResult | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const draft = ref('')
|
||||
let tokenSequence = 0, lookupSequence = 0
|
||||
|
||||
// The learner's own record for the selected word.
|
||||
const definition = ref('')
|
||||
const examples = ref('')
|
||||
const status = ref<TermStatus>('new')
|
||||
const savedTermId = ref<number | null>(null)
|
||||
const prefilling = ref(false)
|
||||
const prefillError = ref('')
|
||||
const saving = ref(false)
|
||||
const saveError = ref('')
|
||||
const saved = ref('')
|
||||
|
||||
let tokenSequence = 0, lookupSequence = 0, termSequence = 0, saveSequence = 0
|
||||
let origin: HTMLElement | null = null
|
||||
let scrollBeforeAdjustment: { top: number; left: number } | null = null
|
||||
let lastAdjustment: { top: number; left: number } | null = null
|
||||
|
||||
// Sending is blocked until the stored text is on screen, so a slow or failed read
|
||||
// can never let an empty form overwrite what the learner already wrote.
|
||||
const canSave = computed(() => selected.value !== null && !saving.value && !prefilling.value && !prefillError.value)
|
||||
|
||||
function exampleLines(): string[] {
|
||||
return examples.value.split('\n').map(line => line.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
function applyTerm(next: TokenTerm) {
|
||||
savedTermId.value = next.id
|
||||
const index = tokens.value.findIndex(token => token.start === selected.value?.start)
|
||||
const target = index >= 0 ? tokens.value[index] : undefined
|
||||
if (target) tokens.value[index] = { ...target, term: next }
|
||||
}
|
||||
|
||||
function stillAtAdjustment() {
|
||||
return lastAdjustment !== null && Math.abs(window.scrollY - lastAdjustment.top) <= 2
|
||||
&& Math.abs(window.scrollX - lastAdjustment.left) <= 2
|
||||
@@ -61,11 +128,21 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
|
||||
|
||||
function close(restoreFocus = true) {
|
||||
lookupSequence++
|
||||
termSequence++
|
||||
saveSequence++
|
||||
selected.value = null
|
||||
result.value = null
|
||||
loading.value = false
|
||||
error.value = ''
|
||||
draft.value = ''
|
||||
definition.value = ''
|
||||
examples.value = ''
|
||||
status.value = 'new'
|
||||
savedTermId.value = null
|
||||
prefilling.value = false
|
||||
prefillError.value = ''
|
||||
saving.value = false
|
||||
saveError.value = ''
|
||||
saved.value = ''
|
||||
if (restoreFocus && origin?.isConnected) origin.focus({ preventScroll: true })
|
||||
if (restoreFocus && scrollBeforeAdjustment && stillAtAdjustment()) window.scrollTo({ ...scrollBeforeAdjustment, behavior: 'instant' })
|
||||
scrollBeforeAdjustment = null
|
||||
@@ -111,18 +188,70 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
|
||||
if (seq === lookupSequence) error.value = reason instanceof Error ? reason.message : '暂时无法查询,请重试。'
|
||||
} finally { if (seq === lookupSequence) loading.value = false }
|
||||
}
|
||||
// Opening a word the learner already saved loads that record, so the panel shows
|
||||
// the stored text and the same id other chapters show.
|
||||
async function loadTerm(id: number) {
|
||||
const seq = ++termSequence
|
||||
prefilling.value = true
|
||||
prefillError.value = ''
|
||||
try {
|
||||
const data = await session.request<TermResponse>(`terms/${id}`)
|
||||
if (seq !== termSequence) return
|
||||
definition.value = data.term.definition
|
||||
examples.value = data.term.examples.join('\n')
|
||||
status.value = data.term.status
|
||||
applyTerm({ id: data.term.id, status: data.term.status, level: data.term.level })
|
||||
} catch (reason) {
|
||||
if (seq !== termSequence) return
|
||||
prefillError.value = reason instanceof Error ? reason.message : '已保存的内容暂时无法读取。'
|
||||
} finally { if (seq === termSequence) prefilling.value = false }
|
||||
}
|
||||
async function save() {
|
||||
const current = chapter.value
|
||||
const token = selected.value
|
||||
if (!session.user || !current || !token || !canSave.value) return
|
||||
const seq = ++saveSequence
|
||||
saving.value = true
|
||||
saveError.value = ''
|
||||
saved.value = ''
|
||||
try {
|
||||
const data = await session.request<TermResponse & { created: boolean }>('terms', 'POST', {
|
||||
chapterId: current.id, start: token.start, end: token.end,
|
||||
definition: definition.value, examples: exampleLines(), status: status.value,
|
||||
})
|
||||
if (seq !== saveSequence) return
|
||||
definition.value = data.term.definition
|
||||
examples.value = data.term.examples.join('\n')
|
||||
status.value = data.term.status
|
||||
applyTerm({ id: data.term.id, status: data.term.status, level: data.term.level })
|
||||
saved.value = `已保存 · ${termStatusLabel(data.term.status)}`
|
||||
} catch (reason) {
|
||||
if (seq !== saveSequence) return
|
||||
saveError.value = reason instanceof Error ? reason.message : '保存失败,请稍后重试。'
|
||||
} finally { if (seq === saveSequence) saving.value = false }
|
||||
}
|
||||
function select(token: ReaderToken, element: HTMLElement) {
|
||||
close(false)
|
||||
origin = element
|
||||
selected.value = token
|
||||
const existing = termStatusOf(token)
|
||||
if (existing !== null && token.term) {
|
||||
status.value = existing
|
||||
void loadTerm(token.term.id)
|
||||
}
|
||||
void lookup()
|
||||
}
|
||||
watch(() => [chapter.value?.id, chapter.value?.status, chapter.value?.contentSha256, chapter.value?.originalText], () => {
|
||||
reset()
|
||||
void loadTokens()
|
||||
}, { immediate: true, flush: 'sync' })
|
||||
// Watch the identity object, including clear → login for the same account.
|
||||
// Watch the identity object, including clear → login for the same account: leaving
|
||||
// or switching an account drops every word saved by the previous one.
|
||||
watch(() => session.user, reset, { flush: 'sync' })
|
||||
onScopeDispose(reset)
|
||||
return { tokens, tokensError, tokensLoading, selected, result, loading, error, draft, loadTokens, lookup, select, close, reset, keepSelectionVisible }
|
||||
return {
|
||||
tokens, tokensError, tokensLoading, selected, result, loading, error,
|
||||
definition, examples, status, savedTermId, prefilling, prefillError, saving, saveError, saved, canSave,
|
||||
loadTokens, lookup, loadTerm, save, select, close, reset, keepSelectionVisible,
|
||||
}
|
||||
}
|
||||
|
||||
+14
-5
@@ -100,6 +100,11 @@ a.chapter-name:hover { color: #315c43; text-decoration: underline; }
|
||||
.has-lookup .reader-workspace { grid-template-columns: minmax(0, 1fr) 320px; }
|
||||
.reader-body { min-width: 0; }
|
||||
.reader-word { cursor: pointer; border-radius: 3px; }
|
||||
/* Saved words keep one style per status everywhere they appear. */
|
||||
.reader-word.is-new { border-bottom: 2px dotted #bc803d; }
|
||||
.reader-word.is-learning { background: #f6e7c9; }
|
||||
.reader-word.is-known { background: #dee9d6; }
|
||||
.reader-word.is-ignored { color: #949c94; }
|
||||
.reader-word:hover, .reader-word.is-selected { background: #e3e9d9; color: #264a35; }
|
||||
.reader-word:focus-visible { outline: 2px solid #bc803d; outline-offset: 2px; background: #eef2eb; }
|
||||
.tokens-notice { color: #748073; font-size: 14px; margin-top: 24px; }
|
||||
@@ -113,11 +118,15 @@ a.chapter-name:hover { color: #315c43; text-decoration: underline; }
|
||||
.lookup-senses p { margin: 8px 0; }
|
||||
.sense-heading span { color: #748073; font-size: 13px; }
|
||||
.lookup-senses blockquote { border-left: 2px solid #cbd9c9; margin: 10px 0; padding-left: 12px; color: #687568; font-style: italic; }
|
||||
.lookup-draft { border-top: 1px solid #e0e3d8; padding-top: 18px; margin-top: 20px; }
|
||||
.lookup-draft label { display: flex; justify-content: space-between; gap: 10px; font-size: 14px; }
|
||||
.lookup-draft label span { color: #8b794e; font-size: 12px; }
|
||||
.lookup-draft textarea { width: 100%; resize: vertical; padding: 12px; margin-top: 10px; font: inherit; line-height: 1.6; color: #233d31; border: 1px solid #d6dccf; border-radius: 8px; background: #fffefa; }
|
||||
.lookup-draft textarea:focus-visible { outline: 2px solid #bc803d; outline-offset: 2px; }
|
||||
.lookup-term { border-top: 1px solid #e0e3d8; padding-top: 18px; margin-top: 20px; }
|
||||
.lookup-term label { display: flex; justify-content: space-between; gap: 10px; font-size: 14px; margin-top: 14px; }
|
||||
.lookup-term-title { display: flex; font-size: 14px; margin: 0; }
|
||||
.lookup-term label span { color: #8b794e; font-size: 12px; }
|
||||
.lookup-term .el-radio-group { margin-top: 8px; flex-wrap: wrap; gap: 4px 12px; }
|
||||
.lookup-term .el-textarea { margin-top: 8px; }
|
||||
.lookup-term .el-textarea textarea { font: inherit; line-height: 1.6; }
|
||||
.lookup-saved { color: #2f6b45; font-size: 14px; margin: 12px 0 0; }
|
||||
.lookup-actions { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
|
||||
@media (max-width: 760px) {
|
||||
.login-page { grid-template-columns: 1fr; }
|
||||
.welcome { padding: 28px; }
|
||||
|
||||
@@ -93,7 +93,7 @@ onUnmounted(() => library.closeChapter())
|
||||
<ElButton data-testid="tokens-retry" :loading="lookup.tokensLoading.value" @click="lookup.loadTokens">重试加载单词</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<LookupPanel v-if="lookup.selected.value" :word="lookup.selected.value.text" :result="lookup.result.value" :loading="lookup.loading.value" :error="lookup.error.value" v-model:draft="lookup.draft.value" @close="lookup.close()" @retry="lookup.lookup" @resize="lookup.keepSelectionVisible" />
|
||||
<LookupPanel v-if="lookup.selected.value" :word="lookup.selected.value.text" :result="lookup.result.value" :loading="lookup.loading.value" :error="lookup.error.value" :saving="lookup.saving.value" :save-error="lookup.saveError.value" :saved="lookup.saved.value" :saved-term-id="lookup.savedTermId.value" :prefilling="lookup.prefilling.value" :prefill-error="lookup.prefillError.value" :can-save="lookup.canSave.value" v-model:definition="lookup.definition.value" v-model:examples="lookup.examples.value" v-model:status="lookup.status.value" @close="lookup.close()" @retry="lookup.lookup" @save="lookup.save" @resize="lookup.keepSelectionVisible" />
|
||||
</div>
|
||||
<nav class="reader-nav" aria-label="章节切换">
|
||||
<ElButton :disabled="library.navigation.previousChapterId === null" @click="goTo(library.navigation.previousChapterId)">上一章</ElButton>
|
||||
|
||||
@@ -60,7 +60,7 @@ func Migrate(db *gorm.DB) error {
|
||||
if err = conn.QueryRowContext(ctx, "SELECT version,product FROM lexgo_schema WHERE id=1").Scan(¤t, &product); err != nil {
|
||||
return err
|
||||
}
|
||||
if product != "lexgo" || current < 0 || current > schemaVersion {
|
||||
if product != "lexgo" || current < 0 || current > SchemaVersion {
|
||||
return errors.New("unknown schema version")
|
||||
}
|
||||
// Each known version contributes its own statements; the version row advances only
|
||||
@@ -75,6 +75,9 @@ func Migrate(db *gorm.DB) error {
|
||||
if current < 4 {
|
||||
statements = append(statements, schemaV4Statements...)
|
||||
}
|
||||
if current < 5 {
|
||||
statements = append(statements, schemaV5Statements...)
|
||||
}
|
||||
for i, s := range statements {
|
||||
if _, err = conn.ExecContext(ctx, s); err != nil {
|
||||
var sqlErr *driver.MySQLError
|
||||
@@ -84,11 +87,34 @@ func Migrate(db *gorm.DB) error {
|
||||
return fmt.Errorf("migration statement %d failed", i+1)
|
||||
}
|
||||
}
|
||||
_, err = conn.ExecContext(ctx, fmt.Sprintf("UPDATE lexgo_schema SET version=%d WHERE id=1", schemaVersion))
|
||||
_, err = conn.ExecContext(ctx, fmt.Sprintf("UPDATE lexgo_schema SET version=%d WHERE id=1", SchemaVersion))
|
||||
return err
|
||||
}
|
||||
|
||||
const schemaVersion = 4
|
||||
// SchemaVersion is the version an explicit migration leaves behind, and the
|
||||
// version the server requires before it starts.
|
||||
const SchemaVersion = 5
|
||||
|
||||
// v5 adds one learner's own records for a word form. Identity is the normalized
|
||||
// form under the owner's language, so re-saving the same word updates one row
|
||||
// instead of creating a second, conflicting record. utf8mb4_bin keeps the key
|
||||
// byte-exact: the Go side normalizes, and an accent-insensitive collation must
|
||||
// not fold "resume" and "résumé" into the same entry.
|
||||
var schemaV5Statements = []string{
|
||||
`CREATE TABLE IF NOT EXISTS lexgo_terms (
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, owner_id BIGINT NOT NULL,
|
||||
language VARCHAR(16) NOT NULL DEFAULT 'en',
|
||||
term VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||
original_form VARCHAR(191) NOT NULL,
|
||||
definition TEXT NOT NULL, examples TEXT NOT NULL,
|
||||
status VARCHAR(16) NOT NULL, level TINYINT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL,
|
||||
UNIQUE KEY uq_term_identity (owner_id, language, term),
|
||||
CONSTRAINT ck_term_status CHECK (status IN ('new','learning','known','ignored')),
|
||||
CONSTRAINT ck_term_level CHECK ((status = 'learning' AND level BETWEEN 1 AND 7) OR (status <> 'learning' AND level = 0)),
|
||||
FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
}
|
||||
|
||||
var schemaV4Statements = []string{
|
||||
`CREATE TABLE IF NOT EXISTS lexgo_dictionaries (
|
||||
@@ -177,7 +203,7 @@ func CheckSchema(db *gorm.DB) error {
|
||||
Version int
|
||||
Product string
|
||||
}
|
||||
if err := db.Table("lexgo_schema").Where("id=1").First(&r).Error; err != nil || r.Version != schemaVersion || r.Product != "lexgo" {
|
||||
if err := db.Table("lexgo_schema").Where("id=1").First(&r).Error; err != nil || r.Version != SchemaVersion || r.Product != "lexgo" {
|
||||
return errors.New("run the explicit migration before starting")
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -272,7 +272,17 @@ func registerDictionaryRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.C
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ChapterTokens{chapter.ContentSHA256, Tokenize(chapter.OriginalText)}, nil
|
||||
tokens := Tokenize(chapter.OriginalText)
|
||||
language, err := languageOf(tx, u.UserId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The learner's own saved words are part of the chapter view, so the reader
|
||||
// shows the same status here as everywhere else.
|
||||
if err = attachTerms(tx, u.UserId, language, tokens); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ChapterTokens{chapter.ContentSHA256, tokens}, nil
|
||||
}))
|
||||
v.POST("/lookup", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
|
||||
var input struct {
|
||||
@@ -290,15 +300,9 @@ func registerDictionaryRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.C
|
||||
if input.Start == nil || input.End == nil {
|
||||
return nil, failure(400, "请选择完整单词")
|
||||
}
|
||||
query := ""
|
||||
for _, token := range Tokenize(chapter.OriginalText) {
|
||||
if token.Kind == "word" && token.Start == *input.Start && token.End == *input.End {
|
||||
query = token.Text
|
||||
break
|
||||
}
|
||||
}
|
||||
if query == "" || utf8.RuneCountInString(query) > 128 {
|
||||
return nil, failure(400, "请选择不超过 128 个字符的完整单词")
|
||||
query, err := wordAtRange(chapter, *input.Start, *input.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
missing := LookupResult{Status: "resource_missing", Query: query, Candidates: []string{}, Entries: []DictionaryEntry{}}
|
||||
var resource DictionaryResource
|
||||
|
||||
@@ -136,8 +136,8 @@ func TestMigrationFromV2PreservesExistingData(t *testing.T) {
|
||||
}
|
||||
var version int
|
||||
db.Raw("SELECT version FROM lexgo_schema WHERE id=1").Scan(&version)
|
||||
if version != schemaVersion {
|
||||
t.Fatalf("schema version %d after re-upgrade, want %d", version, schemaVersion)
|
||||
if version != SchemaVersion {
|
||||
t.Fatalf("schema version %d after re-upgrade, want %d", version, SchemaVersion)
|
||||
}
|
||||
db.Table("sys_user").Where("user_id = 7").Count(&users)
|
||||
if users != 1 {
|
||||
@@ -145,6 +145,78 @@ func TestMigrationFromV2PreservesExistingData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationFromV4KeepsLibraryAndAddsPersonalTerms(t *testing.T) {
|
||||
db := emptyMigrationDB(t)
|
||||
statements := []string{"CREATE TABLE lexgo_schema (id INT PRIMARY KEY,version INT,product VARCHAR(32))", "INSERT INTO lexgo_schema VALUES (1,4,'lexgo')"}
|
||||
statements = append(statements, schemaV2Statements...)
|
||||
statements = append(statements, schemaV3Statements...)
|
||||
statements = append(statements, schemaV4Statements...)
|
||||
for _, statement := range statements {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := db.Exec("INSERT INTO sys_user (user_id,username,password,role_id) VALUES (9,'fixture_v4','fictional-not-a-real-hash',2)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := stamp(time.Now())
|
||||
book := Book{OwnerID: 9, Title: "Fictional migration", Language: "en", CreatedAt: now, UpdatedAt: now}
|
||||
if err := db.Create(&book).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := "😀 Original e\u0301\r\n"
|
||||
chapter := Chapter{BookID: book.ID, OwnerID: 9, Ordinal: 1, Title: "Fixture", OriginalText: original, ContentSHA256: contentSHA(original), Status: statusReady, CharCount: len([]rune(original)), CreatedAt: now, UpdatedAt: now}
|
||||
if err := db.Create(&chapter).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resource := DictionaryResource{ID: 1, Name: "Fixture WordNet", Language: "en", Version: "3.0", Source: WordNetSource, Format: "wordnet-3.0-zip", SHA256: strings.Repeat("a", 64), EntryCount: 5, Enabled: true, Archive: []byte("fixture archive"), UpdatedAt: now}
|
||||
if err := db.Create(&resource).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CheckSchema(db); err == nil {
|
||||
t.Fatal("old schema accepted before explicit migration")
|
||||
}
|
||||
if err := Migrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CheckSchema(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var restored Chapter
|
||||
if err := db.First(&restored, chapter.ID).Error; err != nil || restored.OriginalText != original || restored.ContentSHA256 != chapter.ContentSHA256 {
|
||||
t.Fatal("migration changed original chapter", err)
|
||||
}
|
||||
var restoredResource DictionaryResource
|
||||
if err := db.Omit("archive").First(&restoredResource, 1).Error; err != nil || restoredResource.SHA256 != resource.SHA256 || restoredResource.EntryCount != 5 {
|
||||
t.Fatal("migration changed the shared dictionary resource", err)
|
||||
}
|
||||
var terms int64
|
||||
if err := db.Model(&Term{}).Count(&terms).Error; err != nil || terms != 0 {
|
||||
t.Fatal("migration must create an empty personal term table", terms, err)
|
||||
}
|
||||
// The new table is usable and enforces the documented status boundary.
|
||||
valid := Term{OwnerID: 9, Language: "en", Term: "curiosity", OriginalForm: "Curiosity", Status: termStatusNew, CreatedAt: now, UpdatedAt: now}
|
||||
if err := db.Create(&valid).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&Term{OwnerID: 9, Language: "en", Term: "forged", OriginalForm: "forged", Status: "deleted", CreatedAt: now, UpdatedAt: now}).Error; err == nil {
|
||||
t.Fatal("an unknown status must be rejected by the schema check")
|
||||
}
|
||||
if err := db.Create(&Term{OwnerID: 9, Language: "en", Term: "leveled", OriginalForm: "leveled", Status: termStatusKnown, Level: 5, CreatedAt: now, UpdatedAt: now}).Error; err == nil {
|
||||
t.Fatal("a level outside 0~7 must be rejected by the schema check")
|
||||
}
|
||||
// Rolling the marker back for a binary rollback and upgrading again keeps rows.
|
||||
if err := db.Exec("UPDATE lexgo_schema SET version=4 WHERE id=1").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Migrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&Term{}).Count(&terms).Error; err != nil || terms != 1 {
|
||||
t.Fatal("re-upgrade changed existing personal terms", terms, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationRefusesUnownedOrUnsupportedSchema(t *testing.T) {
|
||||
for _, tc := range []struct{ name, marker string }{
|
||||
{"empty_marker", ""},
|
||||
|
||||
@@ -135,6 +135,12 @@ func Router(db *gorm.DB, now func() time.Time) *gin.Engine {
|
||||
status = 201
|
||||
}
|
||||
}
|
||||
// Saving the same word again is an update of one record, not a new resource.
|
||||
if c.Request.Method == "POST" && c.FullPath() == "/api/v1/terms" {
|
||||
if saved, ok := data.(TermSave); ok && saved.Created {
|
||||
status = 201
|
||||
}
|
||||
}
|
||||
respond(c, status, data, err)
|
||||
}
|
||||
}
|
||||
@@ -295,6 +301,7 @@ func Router(db *gorm.DB, now func() time.Time) *gin.Engine {
|
||||
return gin.H{"job": job, "chapter": chapter}, nil
|
||||
}))
|
||||
registerDictionaryRoutes(v, protect, now)
|
||||
registerTermRoutes(v, protect, now)
|
||||
r.NoRoute(func(c *gin.Context) { respond(c, 404, nil, failure(404, "页面或接口不存在")) })
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
package lexgo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
admin "go-admin/app/admin/models"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// The four learner-visible statuses are stored explicitly instead of the upstream
|
||||
// merged status/level code. The mapping stays available for CSV export and for
|
||||
// migrating an existing LinguaCafe instance: new=2, ignored=1, known=0, and
|
||||
// learning level N=-N. A level is only meaningful while learning, so every other
|
||||
// status keeps level 0 and the database check constraints repeat that rule.
|
||||
const (
|
||||
termStatusNew = "new"
|
||||
termStatusLearning = "learning"
|
||||
termStatusKnown = "known"
|
||||
termStatusIgnored = "ignored"
|
||||
)
|
||||
|
||||
const (
|
||||
termFormLimit = 128
|
||||
termDefinitionLimit = 2000
|
||||
termExampleLimit = 500
|
||||
termExamplesLimit = 5
|
||||
termLevelMax = 7
|
||||
// One chapter may contain many distinct words, so identity lookups are batched
|
||||
// instead of sending an unbounded IN list.
|
||||
termLookupBatch = 500
|
||||
)
|
||||
|
||||
var termStatuses = map[string]bool{
|
||||
termStatusNew: true,
|
||||
termStatusLearning: true,
|
||||
termStatusKnown: true,
|
||||
termStatusIgnored: true,
|
||||
}
|
||||
|
||||
// Term is one learner's own record for a word form. Identity is the normalized
|
||||
// form: the original spelling is kept for display only.
|
||||
type Term struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
OwnerID int
|
||||
Language string
|
||||
Term string
|
||||
OriginalForm string `gorm:"column:original_form"`
|
||||
Definition string
|
||||
Examples string
|
||||
Status string
|
||||
Level int
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (Term) TableName() string { return "lexgo_terms" }
|
||||
|
||||
type TermView struct {
|
||||
ID int64 `json:"id"`
|
||||
Language string `json:"language"`
|
||||
Term string `json:"term"`
|
||||
OriginalForm string `json:"originalForm"`
|
||||
Definition string `json:"definition"`
|
||||
Examples []string `json:"examples"`
|
||||
Status string `json:"status"`
|
||||
Level int `json:"level"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// TokenTerm is what a reader token needs to render its own highlight: the entry
|
||||
// id for the follow-up read, and enough state to style the word.
|
||||
type TokenTerm struct {
|
||||
ID int64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Level int `json:"level"`
|
||||
}
|
||||
|
||||
type TermSave struct {
|
||||
Term TermView `json:"term"`
|
||||
Created bool `json:"created"`
|
||||
}
|
||||
|
||||
func termView(t Term) TermView {
|
||||
examples := []string{}
|
||||
if t.Examples != "" {
|
||||
examples = strings.Split(t.Examples, "\n")
|
||||
}
|
||||
return TermView{t.ID, t.Language, t.Term, t.OriginalForm, t.Definition, examples, t.Status, t.Level, t.CreatedAt, t.UpdatedAt}
|
||||
}
|
||||
|
||||
// termLevel enforces the documented status/level boundary: only a learning entry
|
||||
// carries a level, entering learning defaults to 1, and every other status must
|
||||
// leave the level at 0.
|
||||
func termLevel(status string, level *int) (int, error) {
|
||||
if !termStatuses[status] {
|
||||
return 0, failure(400, "词语状态无效")
|
||||
}
|
||||
if status == termStatusLearning {
|
||||
if level == nil || *level == 0 {
|
||||
return 1, nil
|
||||
}
|
||||
if *level < 1 || *level > termLevelMax {
|
||||
return 0, failure(400, "学习等级须为 1~7")
|
||||
}
|
||||
return *level, nil
|
||||
}
|
||||
if level != nil && *level != 0 {
|
||||
return 0, failure(400, "只有学习中的词语可以设置等级")
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func hasControlRune(text string, allowNewline bool) bool {
|
||||
for _, r := range text {
|
||||
if allowNewline && (r == '\n' || r == '\t') {
|
||||
continue
|
||||
}
|
||||
if unicode.IsControl(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// termContent validates the learner's own text and returns it in storage form:
|
||||
// the definition as typed (trimmed) and examples joined by newline.
|
||||
func termContent(definition string, examples []string) (string, string, error) {
|
||||
definition = strings.TrimSpace(definition)
|
||||
if utf8.RuneCountInString(definition) > termDefinitionLimit {
|
||||
return "", "", failure(400, "个人释义不能超过 2000 个字符")
|
||||
}
|
||||
if hasControlRune(definition, true) {
|
||||
return "", "", failure(400, "个人释义包含不支持的字符")
|
||||
}
|
||||
if len(examples) > termExamplesLimit {
|
||||
return "", "", failure(400, "例句不能超过 5 条")
|
||||
}
|
||||
cleaned := make([]string, 0, len(examples))
|
||||
for _, example := range examples {
|
||||
example = strings.TrimSpace(example)
|
||||
if example == "" {
|
||||
return "", "", failure(400, "例句不能为空行")
|
||||
}
|
||||
if utf8.RuneCountInString(example) > termExampleLimit {
|
||||
return "", "", failure(400, "每条例句不能超过 500 个字符")
|
||||
}
|
||||
if hasControlRune(example, false) {
|
||||
return "", "", failure(400, "例句包含不支持的字符")
|
||||
}
|
||||
cleaned = append(cleaned, example)
|
||||
}
|
||||
return definition, strings.Join(cleaned, "\n"), nil
|
||||
}
|
||||
|
||||
// wordAtRange returns the word the learner actually selected. Identity is always
|
||||
// derived from the server's own tokens, so a client cannot name a word, user or
|
||||
// language that it did not read from this chapter.
|
||||
func wordAtRange(chapter Chapter, start, end int) (string, error) {
|
||||
for _, token := range Tokenize(chapter.OriginalText) {
|
||||
if token.Kind == "word" && token.Start == start && token.End == end {
|
||||
if utf8.RuneCountInString(token.Text) <= termFormLimit {
|
||||
return token.Text, nil
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return "", failure(400, "请选择不超过 128 个字符的完整单词")
|
||||
}
|
||||
|
||||
// languageOf reads the owner's study language. A missing space row falls back to
|
||||
// the English default that account creation and login already establish.
|
||||
func languageOf(tx *gorm.DB, owner int) (string, error) {
|
||||
var space Space
|
||||
err := tx.Where("owner_id = ?", owner).First(&space).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "en", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return space.Language, nil
|
||||
}
|
||||
|
||||
// saveTerm writes one identity with INSERT ... ON DUPLICATE KEY UPDATE: a repeated
|
||||
// save updates the same row instead of adding a second, conflicting record, and
|
||||
// two concurrent saves of the same word still leave exactly one.
|
||||
func saveTerm(tx *gorm.DB, owner int, language, word string, fields TermFields, now time.Time) (TermSave, error) {
|
||||
when := stamp(now)
|
||||
row := Term{
|
||||
OwnerID: owner, Language: language, Term: normalizeWord(word), OriginalForm: word,
|
||||
Definition: fields.Definition, Examples: fields.Examples, Status: fields.Status, Level: fields.Level,
|
||||
CreatedAt: when, UpdatedAt: when,
|
||||
}
|
||||
// MySQL reports affected rows 1 for an insert and 0 or 2 for an update, so the
|
||||
// counter distinguishes "created" from "saved again" without a second read.
|
||||
insert := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "owner_id"}, {Name: "language"}, {Name: "term"}},
|
||||
DoUpdates: clause.Assignments(map[string]any{
|
||||
"original_form": row.OriginalForm, "definition": row.Definition, "examples": row.Examples,
|
||||
"status": row.Status, "level": row.Level, "updated_at": row.UpdatedAt,
|
||||
}),
|
||||
}).Create(&row)
|
||||
if insert.Error != nil {
|
||||
return TermSave{}, insert.Error
|
||||
}
|
||||
var stored Term
|
||||
if err := tx.Where("owner_id = ? AND language = ? AND term = ?", owner, language, row.Term).First(&stored).Error; err != nil {
|
||||
return TermSave{}, err
|
||||
}
|
||||
return TermSave{termView(stored), insert.RowsAffected == 1}, nil
|
||||
}
|
||||
|
||||
// attachTerms marks the tokens this learner already saved. Matching is by
|
||||
// normalized form across the whole vocabulary, so a word saved in another chapter
|
||||
// is highlighted here with the same status.
|
||||
func attachTerms(tx *gorm.DB, owner int, language string, tokens []TextToken) error {
|
||||
keys := make([]string, 0, 16)
|
||||
seen := map[string]bool{}
|
||||
for _, token := range tokens {
|
||||
if token.Kind != "word" || utf8.RuneCountInString(token.Text) > termFormLimit {
|
||||
continue
|
||||
}
|
||||
key := normalizeWord(token.Text)
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
byKey := map[string]TokenTerm{}
|
||||
for start := 0; start < len(keys); start += termLookupBatch {
|
||||
end := min(start+termLookupBatch, len(keys))
|
||||
var rows []Term
|
||||
if err := tx.Select("id", "term", "status", "level").
|
||||
Where("owner_id = ? AND language = ? AND term IN ?", owner, language, keys[start:end]).
|
||||
Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range rows {
|
||||
byKey[row.Term] = TokenTerm{ID: row.ID, Status: row.Status, Level: row.Level}
|
||||
}
|
||||
}
|
||||
for i := range tokens {
|
||||
if tokens[i].Kind != "word" {
|
||||
continue
|
||||
}
|
||||
if term, ok := byKey[normalizeWord(tokens[i].Text)]; ok {
|
||||
value := term
|
||||
tokens[i].Term = &value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TermFields carries the validated learner text and state into storage.
|
||||
type TermFields struct {
|
||||
Definition string
|
||||
Examples string
|
||||
Status string
|
||||
Level int
|
||||
}
|
||||
|
||||
type TermInput struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
Start *int `json:"start"`
|
||||
End *int `json:"end"`
|
||||
Definition string `json:"definition"`
|
||||
Examples []string `json:"examples"`
|
||||
Status string `json:"status"`
|
||||
Level *int `json:"level"`
|
||||
}
|
||||
|
||||
// registerTermRoutes exposes the learner's own word records. Nothing here is
|
||||
// written to the audit log: personal learning content stays out of it, and the
|
||||
// account-level audit already covers administrative changes.
|
||||
func registerTermRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc, now func() time.Time) {
|
||||
v.POST("/terms", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
|
||||
var input TermInput
|
||||
if err := decode(c, &input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.Start == nil || input.End == nil {
|
||||
return nil, failure(400, "请选择完整单词")
|
||||
}
|
||||
chapter, err := readyOwnedChapter(tx, u.UserId, input.ChapterID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
word, err := wordAtRange(chapter, *input.Start, *input.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
level, err := termLevel(input.Status, input.Level)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
definition, examples, err := termContent(input.Definition, input.Examples)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
language, err := languageOf(tx, u.UserId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fields := TermFields{Definition: definition, Examples: examples, Status: input.Status, Level: level}
|
||||
return saveTerm(tx, u.UserId, language, word, fields, now())
|
||||
}))
|
||||
v.GET("/terms/:id", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return nil, failure(404, "词条不存在")
|
||||
}
|
||||
// Another account's id and a missing id answer identically, so the response
|
||||
// never confirms that someone else's entry exists.
|
||||
var term Term
|
||||
if err := tx.Where("id = ? AND owner_id = ?", id, u.UserId).First(&term).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, failure(404, "词条不存在")
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gin.H{"term": termView(term)}, nil
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
package lexgo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
admin "go-admin/app/admin/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestTermLevelBoundary(t *testing.T) {
|
||||
level := func(v int) *int { return &v }
|
||||
cases := []struct {
|
||||
status string
|
||||
level *int
|
||||
want int
|
||||
ok bool
|
||||
}{
|
||||
{termStatusNew, nil, 0, true},
|
||||
{termStatusNew, level(0), 0, true},
|
||||
{termStatusNew, level(1), 0, false},
|
||||
{termStatusKnown, level(0), 0, true},
|
||||
{termStatusKnown, level(3), 0, false},
|
||||
{termStatusIgnored, level(0), 0, true},
|
||||
{termStatusIgnored, level(-1), 0, false},
|
||||
{termStatusLearning, nil, 1, true},
|
||||
{termStatusLearning, level(0), 1, true},
|
||||
{termStatusLearning, level(1), 1, true},
|
||||
{termStatusLearning, level(7), 7, true},
|
||||
{termStatusLearning, level(8), 0, false},
|
||||
{termStatusLearning, level(-1), 0, false},
|
||||
{"", nil, 0, false},
|
||||
{"Learning", nil, 0, false},
|
||||
{"deleted", nil, 0, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := termLevel(tc.status, tc.level)
|
||||
if tc.ok && (err != nil || got != tc.want) {
|
||||
t.Fatalf("%s/%v: got %d, %v; want %d", tc.status, tc.level, got, err, tc.want)
|
||||
}
|
||||
if !tc.ok && err == nil {
|
||||
t.Fatalf("%s/%v: invalid state accepted as %d", tc.status, tc.level, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTermContentRules(t *testing.T) {
|
||||
definition, examples, err := termContent(" 好奇\t心\n求知 ", []string{" A fictional line. ", "Second line"})
|
||||
if err != nil || definition != "好奇\t心\n求知" || examples != "A fictional line.\nSecond line" {
|
||||
t.Fatalf("trim and join: %q %q %v", definition, examples, err)
|
||||
}
|
||||
if definition, examples, err = termContent(" ", nil); err != nil || definition != "" || examples != "" {
|
||||
t.Fatalf("an empty definition is allowed: %q %q %v", definition, examples, err)
|
||||
}
|
||||
rejected := []struct {
|
||||
name string
|
||||
def string
|
||||
examples []string
|
||||
}{
|
||||
{"definition too long", strings.Repeat("字", termDefinitionLimit+1), nil},
|
||||
{"definition control character", "a\u0007b", nil},
|
||||
{"empty example", "ok", []string{"fine", " "}},
|
||||
{"example with newline", "ok", []string{"two\nlines"}},
|
||||
{"example too long", "ok", []string{strings.Repeat("a", termExampleLimit+1)}},
|
||||
{"too many examples", "ok", []string{"1", "2", "3", "4", "5", "6"}},
|
||||
}
|
||||
for _, tc := range rejected {
|
||||
if _, _, err := termContent(tc.def, tc.examples); err == nil {
|
||||
t.Fatalf("%s was accepted", tc.name)
|
||||
}
|
||||
}
|
||||
if _, _, err := termContent(strings.Repeat("字", termDefinitionLimit), []string{strings.Repeat("a", termExampleLimit)}); err != nil {
|
||||
t.Fatalf("boundary values must be accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWordAtRangeUsesServerTokens(t *testing.T) {
|
||||
chapter := Chapter{OriginalText: "Cats went. Dogs"}
|
||||
for _, tc := range []struct {
|
||||
start, end int
|
||||
want string
|
||||
}{
|
||||
{0, 4, "Cats"},
|
||||
{5, 9, "went"},
|
||||
{11, 15, "Dogs"},
|
||||
} {
|
||||
got, err := wordAtRange(chapter, tc.start, tc.end)
|
||||
if err != nil || got != tc.want {
|
||||
t.Fatalf("%d-%d: got %q %v, want %q", tc.start, tc.end, got, err, tc.want)
|
||||
}
|
||||
}
|
||||
for _, span := range [][2]int{{0, 3}, {0, 5}, {4, 5}, {9, 10}, {10, 11}, {0, 15}, {-1, 3}, {12, 15}, {0, 0}} {
|
||||
if _, err := wordAtRange(chapter, span[0], span[1]); err == nil {
|
||||
t.Fatalf("range %v accepted as a word", span)
|
||||
}
|
||||
}
|
||||
long := "a" + strings.Repeat("b", termFormLimit)
|
||||
if _, err := wordAtRange(Chapter{OriginalText: long}, 0, len([]rune(long))); err == nil {
|
||||
t.Fatal("over-long word accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTermViewSplitsExamples(t *testing.T) {
|
||||
base := Term{ID: 3, Language: "en", Term: "curiosity", OriginalForm: "Curiosity", Status: termStatusNew}
|
||||
if got := termView(base); len(got.Examples) != 0 || got.Examples == nil {
|
||||
t.Fatalf("empty examples must serialize as []: %#v", got.Examples)
|
||||
}
|
||||
base.Examples = "One.\nTwo."
|
||||
if got := termView(base); len(got.Examples) != 2 || got.Examples[1] != "Two." {
|
||||
t.Fatalf("examples: %#v", got.Examples)
|
||||
}
|
||||
}
|
||||
|
||||
// termFixture creates one learner with a private book holding one ready chapter per
|
||||
// text. Fictional content only; nothing here touches another account's data.
|
||||
func termFixture(t *testing.T, db *gorm.DB, r *gin.Engine, texts ...string) (admin.SysUser, string, []Chapter) {
|
||||
t.Helper()
|
||||
u := admin.SysUser{Username: randomName("term"), Password: fixturePassword, RoleId: 2, Status: "2"}
|
||||
if err := db.Create(&u).Error; err != nil {
|
||||
t.Fatal("fixture learner creation failed")
|
||||
}
|
||||
now := stamp(time.Now())
|
||||
book := Book{OwnerID: u.UserId, Title: "Fictional terms", Language: "en", CreatedAt: now, UpdatedAt: now}
|
||||
if err := db.Create(&book).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var chapters []Chapter
|
||||
for index, text := range texts {
|
||||
chapter := Chapter{BookID: book.ID, OwnerID: u.UserId, Ordinal: index + 1, Title: fmt.Sprintf("Fictional %d", index+1),
|
||||
OriginalText: text, ContentSHA256: contentSHA(text), CharCount: len([]rune(text)), Status: statusReady, CreatedAt: now, UpdatedAt: now}
|
||||
if err := db.Create(&chapter).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chapters = append(chapters, chapter)
|
||||
}
|
||||
return u, loginToken(t, r, u.Username, fixturePassword), chapters
|
||||
}
|
||||
|
||||
func saveTermAPI(t *testing.T, r *gin.Engine, token string, body map[string]any) (int, TermSave) {
|
||||
t.Helper()
|
||||
code, data := callAPI(t, r, "POST", "/api/v1/terms", token, body)
|
||||
var saved TermSave
|
||||
if data != nil {
|
||||
json.Unmarshal(data, &saved)
|
||||
}
|
||||
return code, saved
|
||||
}
|
||||
|
||||
func chapterTokens(t *testing.T, r *gin.Engine, token string, chapterID int64) (int, ChapterTokens) {
|
||||
t.Helper()
|
||||
code, data := callAPI(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/tokens", chapterID), token, nil)
|
||||
var analyzed ChapterTokens
|
||||
if data != nil {
|
||||
json.Unmarshal(data, &analyzed)
|
||||
}
|
||||
return code, analyzed
|
||||
}
|
||||
|
||||
// wordTokens returns only the selectable words, so a test can talk about the third
|
||||
// word instead of the sixth token.
|
||||
func wordTokens(tokens []TextToken) []TextToken {
|
||||
words := make([]TextToken, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
if token.Kind == "word" {
|
||||
words = append(words, token)
|
||||
}
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
||||
// TestMySQLTermIdentityAndCrossChapterConsistency covers the acceptance items that
|
||||
// one saved word stays one record, that case is not a second word, that another
|
||||
// inflection is its own entry, and that another chapter shows the same state.
|
||||
func TestMySQLTermIdentityAndCrossChapterConsistency(t *testing.T) {
|
||||
db := testDB(t)
|
||||
r := Router(db, time.Now)
|
||||
learner, token, chapters := termFixture(t, db, r, "Dogs dogs dog.", "Dogs elsewhere")
|
||||
chapter, elsewhere := chapters[0], chapters[1]
|
||||
|
||||
code, saved := saveTermAPI(t, r, token, map[string]any{
|
||||
"chapterId": chapter.ID, "start": 0, "end": 4, "definition": "狗", "examples": []string{"Fictional dogs."}, "status": termStatusNew,
|
||||
})
|
||||
if code != 201 || !saved.Created || saved.Term.ID == 0 {
|
||||
t.Fatalf("first save: %d %#v", code, saved)
|
||||
}
|
||||
if saved.Term.Term != "dogs" || saved.Term.OriginalForm != "Dogs" || saved.Term.Language != "en" || saved.Term.Level != 0 {
|
||||
t.Fatalf("stored identity: %#v", saved.Term)
|
||||
}
|
||||
first := saved.Term.ID
|
||||
|
||||
// The exact same save is idempotent: same record, same status code, no duplicate.
|
||||
code, repeated := saveTermAPI(t, r, token, map[string]any{
|
||||
"chapterId": chapter.ID, "start": 0, "end": 4, "definition": "狗", "examples": []string{"Fictional dogs."}, "status": termStatusNew,
|
||||
})
|
||||
if code != 200 || repeated.Created || repeated.Term.ID != first {
|
||||
t.Fatalf("identical repeat must update one record: %d %#v", code, repeated)
|
||||
}
|
||||
|
||||
// Repeating the save updates the same record instead of adding a second one.
|
||||
code, again := saveTermAPI(t, r, token, map[string]any{
|
||||
"chapterId": chapter.ID, "start": 0, "end": 4, "definition": "狗;犬", "examples": []string{}, "status": termStatusLearning, "level": 3,
|
||||
})
|
||||
if code != 200 || again.Created || again.Term.ID != first || again.Term.Definition != "狗;犬" || again.Term.Status != termStatusLearning || again.Term.Level != 3 {
|
||||
t.Fatalf("repeated save: %d %#v", code, again)
|
||||
}
|
||||
// The same word in another case is the same identity.
|
||||
code, lower := saveTermAPI(t, r, token, map[string]any{
|
||||
"chapterId": chapter.ID, "start": 5, "end": 9, "definition": "狗", "status": termStatusKnown,
|
||||
})
|
||||
if code != 200 || lower.Created || lower.Term.ID != first || lower.Term.OriginalForm != "dogs" || lower.Term.Level != 0 {
|
||||
t.Fatalf("case folded identity: %d %#v", code, lower)
|
||||
}
|
||||
// A different inflection stays its own entry instead of merging by lemma.
|
||||
code, inflection := saveTermAPI(t, r, token, map[string]any{
|
||||
"chapterId": chapter.ID, "start": 10, "end": 13, "definition": "狗(单数)", "status": termStatusNew,
|
||||
})
|
||||
if code != 201 || inflection.Term.ID == first {
|
||||
t.Fatalf("inflection must be a separate entry: %d %#v", code, inflection)
|
||||
}
|
||||
var owned int64
|
||||
if err := db.Model(&Term{}).Where("owner_id = ?", learner.UserId).Count(&owned).Error; err != nil || owned != 2 {
|
||||
t.Fatalf("one word must stay one record: count=%d err=%v", owned, err)
|
||||
}
|
||||
|
||||
// The status saved here is the status the reader shows for this chapter.
|
||||
code, tokens := chapterTokens(t, r, token, chapter.ID)
|
||||
words := wordTokens(tokens.Tokens)
|
||||
if code != 200 || len(words) != 3 {
|
||||
t.Fatalf("tokens: %d %#v", code, tokens.Tokens)
|
||||
}
|
||||
if words[0].Term == nil || words[0].Term.ID != first || words[0].Term.Status != termStatusKnown {
|
||||
t.Fatalf("first token term: %#v", words[0])
|
||||
}
|
||||
if words[2].Term == nil || words[2].Term.ID != inflection.Term.ID {
|
||||
t.Fatalf("second entry token term: %#v", words[2])
|
||||
}
|
||||
for _, token := range tokens.Tokens {
|
||||
if token.Kind != "word" && token.Term != nil {
|
||||
t.Fatalf("spaces and punctuation must not carry a term: %#v", token)
|
||||
}
|
||||
}
|
||||
// Another chapter of the same learner shows the same entry without saving again.
|
||||
code, crossTokens := chapterTokens(t, r, token, elsewhere.ID)
|
||||
crossWords := wordTokens(crossTokens.Tokens)
|
||||
if code != 200 || len(crossWords) != 2 || crossWords[0].Term == nil || crossWords[0].Term.ID != first || crossWords[0].Term.Status != termStatusKnown {
|
||||
t.Fatalf("cross-chapter highlight: %d %#v", code, crossTokens.Tokens)
|
||||
}
|
||||
// Another account sees the same text with no personal state at all.
|
||||
_, otherToken, otherChapters := termFixture(t, db, r, "Dogs elsewhere")
|
||||
code, otherTokens := chapterTokens(t, r, otherToken, otherChapters[0].ID)
|
||||
otherWords := wordTokens(otherTokens.Tokens)
|
||||
if code != 200 || len(otherWords) != 2 || otherWords[0].Term != nil {
|
||||
t.Fatalf("another account must not see a term: %d %#v", code, otherTokens.Tokens)
|
||||
}
|
||||
if code, _ := chapterTokens(t, r, otherToken, chapter.ID); code != 404 {
|
||||
t.Fatalf("foreign chapter must be 404: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMySQLTermIsolationAndInputRules covers ownership, tampered input and the
|
||||
// status/level boundary through the real HTTP surface.
|
||||
func TestMySQLTermIsolationAndInputRules(t *testing.T) {
|
||||
db := testDB(t)
|
||||
r := Router(db, time.Now)
|
||||
_, ownerToken, chapters := termFixture(t, db, r, "Cats went.")
|
||||
chapter := chapters[0]
|
||||
otherUser, otherToken, _ := termFixture(t, db, r, "Cats went.")
|
||||
|
||||
code, saved := saveTermAPI(t, r, ownerToken, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "definition": "猫", "status": termStatusNew})
|
||||
if code != 201 {
|
||||
t.Fatalf("owner save: %d", code)
|
||||
}
|
||||
if code, _ := callAPI(t, r, "GET", fmt.Sprintf("/api/v1/terms/%d", saved.Term.ID), ownerToken, nil); code != 200 {
|
||||
t.Fatalf("owner read: %d", code)
|
||||
}
|
||||
if code, _ := callAPI(t, r, "GET", fmt.Sprintf("/api/v1/terms/%d", saved.Term.ID), otherToken, nil); code != 404 {
|
||||
t.Fatalf("foreign read must be 404: %d", code)
|
||||
}
|
||||
for _, id := range []string{"0", "-1", "abc", "99999999999999999999"} {
|
||||
if code, _ := callAPI(t, r, "GET", "/api/v1/terms/"+id, ownerToken, nil); code != 404 {
|
||||
t.Fatalf("invalid id %s: %d", id, code)
|
||||
}
|
||||
}
|
||||
if code, _ := callAPI(t, r, "POST", "/api/v1/terms", "", map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusNew}); code != 401 {
|
||||
t.Fatal("anonymous save must be 401")
|
||||
}
|
||||
if code, _ := saveTermAPI(t, r, otherToken, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusNew}); code != 404 {
|
||||
t.Fatal("saving into a foreign chapter must be 404")
|
||||
}
|
||||
// A client cannot name the owner, the language, the word or the record.
|
||||
for _, body := range []map[string]any{
|
||||
{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusNew, "ownerId": otherUser.UserId},
|
||||
{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusNew, "language": "fr"},
|
||||
{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusNew, "term": "forged"},
|
||||
{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusNew, "id": 1},
|
||||
} {
|
||||
if code, _ := saveTermAPI(t, r, ownerToken, body); code != 400 {
|
||||
t.Fatalf("tampered input %v must be 400: %d", body, code)
|
||||
}
|
||||
}
|
||||
// Invalid or partial ranges, an unknown status and a forbidden level.
|
||||
for _, body := range []map[string]any{
|
||||
{"chapterId": chapter.ID, "start": 0, "status": termStatusNew},
|
||||
{"chapterId": chapter.ID, "start": 0, "end": 3, "status": termStatusNew},
|
||||
{"chapterId": chapter.ID, "start": 1, "end": 4, "status": termStatusNew},
|
||||
{"chapterId": chapter.ID, "start": 4, "end": 5, "status": termStatusNew},
|
||||
{"chapterId": chapter.ID, "start": 0, "end": 4, "status": "deleted"},
|
||||
{"chapterId": chapter.ID, "start": 0, "end": 4},
|
||||
{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusKnown, "level": 4},
|
||||
{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusLearning, "level": 8},
|
||||
{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusNew, "definition": strings.Repeat("a", termDefinitionLimit+1)},
|
||||
{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusNew, "examples": []string{"1", "2", "3", "4", "5", "6"}},
|
||||
} {
|
||||
if code, _ := saveTermAPI(t, r, ownerToken, body); code != 400 {
|
||||
t.Fatalf("invalid input %v must be 400: %d", body, code)
|
||||
}
|
||||
}
|
||||
// Entering learning without a level starts at 1 and still updates the same entry.
|
||||
code, learning := saveTermAPI(t, r, ownerToken, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusLearning})
|
||||
if code != 200 || learning.Term.ID != saved.Term.ID || learning.Term.Level != 1 {
|
||||
t.Fatalf("learning default level: %d %#v", code, learning)
|
||||
}
|
||||
// A chapter that is not ready refuses the save instead of storing a word.
|
||||
if err := db.Model(&Chapter{}).Where("id = ?", chapter.ID).Update("status", statusPending).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code, _ := saveTermAPI(t, r, ownerToken, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusNew}); code != 409 {
|
||||
t.Fatal("pending chapter must be 409")
|
||||
}
|
||||
// An unknown chapter id never confirms whether it belongs to someone else.
|
||||
if code, _ := saveTermAPI(t, r, ownerToken, map[string]any{"chapterId": 999999, "start": 0, "end": 4, "status": termStatusNew}); code != 404 {
|
||||
t.Fatal("unknown chapter must be 404")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMySQLTermTextRoundTrip checks the learner's own text, including characters
|
||||
// that are never normalized away.
|
||||
func TestMySQLTermTextRoundTrip(t *testing.T) {
|
||||
db := testDB(t)
|
||||
r := Router(db, time.Now)
|
||||
_, token, chapters := termFixture(t, db, r, "Café Dogs")
|
||||
code, saved := saveTermAPI(t, r, token, map[string]any{
|
||||
"chapterId": chapters[0].ID, "start": 0, "end": 4,
|
||||
"definition": "咖啡\n附带说明", "examples": []string{"A fictional example.", "😀 second"},
|
||||
"status": termStatusLearning, "level": 7,
|
||||
})
|
||||
if code != 201 {
|
||||
t.Fatalf("save: %d", code)
|
||||
}
|
||||
code, data := callAPI(t, r, "GET", fmt.Sprintf("/api/v1/terms/%d", saved.Term.ID), token, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("read: %d", code)
|
||||
}
|
||||
var response struct {
|
||||
Term TermView `json:"term"`
|
||||
}
|
||||
json.Unmarshal(data, &response)
|
||||
if response.Term.Definition != "咖啡\n附带说明" || len(response.Term.Examples) != 2 || response.Term.Examples[1] != "😀 second" || response.Term.Level != 7 {
|
||||
t.Fatalf("round trip: %#v", response.Term)
|
||||
}
|
||||
}
|
||||
@@ -20,12 +20,13 @@ const maxDictionaryZip = 32 << 20
|
||||
const maxDictionaryInflated = 128 << 20
|
||||
|
||||
type TextToken struct {
|
||||
Text string `json:"text"`
|
||||
Start int `json:"start"`
|
||||
End int `json:"end"`
|
||||
StartUtf16 int `json:"startUtf16"`
|
||||
EndUtf16 int `json:"endUtf16"`
|
||||
Kind string `json:"kind"`
|
||||
Text string `json:"text"`
|
||||
Start int `json:"start"`
|
||||
End int `json:"end"`
|
||||
StartUtf16 int `json:"startUtf16"`
|
||||
EndUtf16 int `json:"endUtf16"`
|
||||
Kind string `json:"kind"`
|
||||
Term *TokenTerm `json:"term,omitempty"`
|
||||
}
|
||||
|
||||
// Tokenize never normalizes text. Letters begin words; marks continue them and
|
||||
@@ -61,7 +62,7 @@ func Tokenize(text string) []TextToken {
|
||||
utf16Offset++
|
||||
}
|
||||
}
|
||||
result = append(result, TextToken{string(runes[start:i]), start, i, start16, utf16Offset, kind})
|
||||
result = append(result, TextToken{Text: string(runes[start:i]), Start: start, End: i, StartUtf16: start16, EndUtf16: utf16Offset, Kind: kind})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ func run() error {
|
||||
if err = lexgo.Migrate(db); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("LexGo schema version 4 ready")
|
||||
fmt.Println("LexGo schema version", lexgo.SchemaVersion, "ready")
|
||||
return nil
|
||||
}
|
||||
if err = lexgo.CheckSchema(db); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user