feat: 选择连续短语,保存并加入到期复习 (#11)

- 短语与单词共用 lexgo_terms:身份键为按序规范化词形以空格连接,单词键不含空格,
  因此 kind 与词数由身份键派生,不需要新列或第二套复习逻辑
- POST /api/v1/phrases 由服务端从本人 ready 章节推导词序列与身份,切进单词的范围 400;
  章节 tokens 增加 phrases 区间,队列项增加 kind/wordCount
- 跨章节匹配按连续词形比对,重叠取最左最长;短语高亮覆盖内部单词但不修改单词数据
- 学习端新增 readerRange 纯函数层与 useTextSelection(原生拖选 + 手机手柄,不拦截
  touchmove),面板提供短语标题与按词调整端点的按钮,复习卡把整段短语挖成一个空
- Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
This commit is contained in:
ila
2026-09-14 22:21:51 +08:00
parent 1319c56cf8
commit bd77aa0a46
26 changed files with 1612 additions and 51 deletions
+25 -2
View File
@@ -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: a29e5eb9c8ff474ffdcb1278fc6b51e3b9167eb8
synchronized_at: 2026-09-14T13:38:00Z
wiki_revision: cc1522fceb8e420cee17f509101f61986b3d7ff9
synchronized_at: 2026-09-14T14:21:32Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -305,3 +305,26 @@ schema v6 新增 `lexgo_term_reviews`(每个个人词条一行排期:`due_at
`unprocessableReason` 保留一条内容一致性检查:存储的正文重新计算出的 SHA 必须等于该章节存储的 SHA,用于兜住绕过 API 的直接写入(`content_changed`),与版本门控互不重复。
学习端 `BookView.vue` 增加书名编辑对话框、章节编辑对话框(标题 + 正文,正文来自 source 接口)与两处确认弹窗(`ElMessageBox`),章节行增加「编辑」入口;`LibraryView.vue` 在书库被删后显示「书籍已删除 · 已保存的生词和短语仍保留在生词本。」;`stores/library.ts` 增加 `renameBook`、`updateChapter`、`loadChapterSource`、`deleteBook`、`deleteChapter`。正文编辑通过浏览器 textarea 输入,因此该章的行尾统一为 LF(粘贴与 TXT 导入仍保留原始 CRLF)。
## #11 短语选择、保存与复习(2026-09-11)
短语与单词共用一张表和一套复习机制:`lexgo_terms` 的 `term` 列存身份键,**单词键不含空格、短语键以空格分隔**,所以「词或短语」不需要额外列,也不需要第二套排期/队列/作答逻辑。`kind` 与词数由身份键在服务端派生(`termKind`/`termWordCount`),视图与队列项随响应返回。
`server/app/lexgo/phrase.go`:
| 部分 | 职责 |
|---|---|
| `phraseWords` | 从本人 ready 章节的 token 里取完全落在选区内的词;**切进单词的范围直接 400**,不静默丢弃;2~12 个词 |
| `phraseKey` / `phraseSource` | 身份键=按顺序的规范化词形以单个空格连接;显示片段=选区原文(内部标点与换行保留) |
| `phraseMatches` | 跨章节匹配:按首词分组后顺序比对词形,候选按 (起点, 长度降序, id) 排序并取**最左最长**的互不重叠集合 |
| `phrasesForChapter` | 按 `term LIKE '% %'` 取本人短语并匹配,供 tokens 响应使用 |
| `SavePhrase` | 由服务端推导身份后走与单词相同的 `saveTerm` upsert;`kind` 冲突返回 409 |
| 接口 | 说明 |
|---|---|
| POST /api/v1/phrases | `{chapterId,start,end,definition,examples[],status,level?}`;本人 ready 章节;服务端推导词序列与身份,不接受客户端身份;首次 201、重复 200 |
| GET /api/v1/terms/:id | 复用;响应增加 `kind` 与 `wordCount` |
| GET /api/v1/chapters/:id/tokens | 响应增加 `phrases:[{id,status,wordCount,startToken,endToken}]` |
| GET /api/v1/reviews/queue | 队列项增加 `kind` 与 `wordCount`;短语与单词同一队列、同一作答接口 |
学习端:`composables/readerRange.ts` 是纯函数层(整词对齐、内部保留、端点按词调整、命中优先级、区间换算),`composables/useTextSelection.ts` 监听 `selectionchange`(100ms 去抖)与 document 的 `pointerup` 读取浏览器原生选区并映射为 token 索引,**不拦截 touchmove、不 preventDefault**;`ReaderTokens.vue` 为每个 token 输出 `data-token-index` 与短语区间样式;`ReaderView.vue` 负责把选区变成短语、shift 点击扩展、以及面板端点调整;`LookupPanel.vue` 增加短语标题与四个端点按钮;复习卡用 `maskedPrompt` 把整段短语挖成一个空。已保存短语点击优先打开短语面板,单词数据不受影响。
+18 -2
View File
@@ -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: 96eec89f74db744d8807baa24b1d1d611ff6bf8f
synchronized_at: 2026-09-13T15:20:16Z
wiki_revision: fb3ce7506f1eb1940d7aa57226d22e4f8a270633
synchronized_at: 2026-09-14T14:21:32Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -226,3 +226,19 @@ exact优先;未命中再按WordNet异常表/词尾规则查候选,词性顺
**归属与边界**:改名、编辑、删除、读取编辑用原文都严格按会话归属;他人资源与不存在资源统一 404,未登录 401,空标题/空正文/超长文本/未知字段 400。学习端正文编辑框的行尾会统一为 LF(浏览器 textarea 行为),粘贴与 TXT 导入路径仍然保留原始 CRLF 与空白。
**范围边界**:不做封面与音频附件(#21)、不做回收站/撤销、不做批量操作、不做章节跨书移动、不做语言变更。
## #11 短语规则(2026-09-11)
**身份**:短语身份=按阅读顺序的规范化词形以单个空格连接(`a small, step` 与 `a small step` 都是 `a small step`),归属 `(owner, language)`;显示片段保存最近一次保存时的原文(内部标点、换行、多余空格原样保留,仅用于显示)。与单词共用同一张表,因此同一短语在不同章节保存只会得到一条记录,也共用同一幂等键与同一复习排期。
**范围**:两端对齐整词;首尾若落在空白或标点则跳过;**内部**标点与换行保留但不参与身份比较;不切开代理对、ZWJ 与组合字符。选区内少于 2 个词不是短语(单个词走单词面板),超过 12 个词、身份键超过 128 字符或原文片段超过 191 字符都返回 400 并给出可读提示。切进单词中间的范围被拒绝,而不是静默丢弃那个词。
**跨章节匹配**:在章节的词片段序列中找**连续词**,其规范化词形逐个相等(中间允许任意标点与空白)。因此编辑正文后:短语仍出现则继续高亮;不再出现则该章不高亮,但**词条与复习排期保留**;章节被删除同样保留。短语不存章节锚点,所以不存在悬空引用。
**重叠与点击**:同一位置多个候选按 (起点升序, 长度降序) 取互不重叠者,即「最左最长」,结果与输入顺序无关。短语高亮覆盖其内部的单词高亮,但**不修改单词数据**(状态、排期、计数都保留);点击命中规则是「在已保存短语范围内 → 打开短语面板,否则打开单词面板」。保存后的高亮立即出现,不需要重新加载分词。
**选择交互**:桌面用浏览器原生拖选(`selectionchange` 去抖 + `pointerup`),手机依赖系统选择手柄且**不拦截 touchmove**,面板提供起点/终点四个按钮按词调整,Shift 点击可把范围从一个词扩展到一个词,Escape 取消并保留阅读位置。单击(折叠选区)仍然是单词查询。真实手感属于运行验证范围,真机证据缺口保留。
**复习**:短语进入同一个到期队列与同一套间隔表;卡片正面显示短语并把**整段短语挖成一个空**(例句里没有该短语时只显示短语本身),答案面显示个人释义;答对/答错/再学与幂等、stale 规则与单词完全一致;计数归属也一致(`correct_count` 只计答对,`wrong_count` 计答错与再学)。
**范围边界**:不做短语自动合并同义形式、上下文词性消歧、短语跨书移动、批量编辑(#12)、真机手柄精细手感。短语的例句同样是手输内容,不自动关联原文句子。
+20 -2
View File
@@ -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: 251fb5de71b8ba75da3cba6eee41454d5bbd22df
synchronized_at: 2026-09-13T15:20:16Z
wiki_revision: 1643f05aa6987196d5dcc82a07e2daa07e2f2b4d
synchronized_at: 2026-09-14T14:21:32Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -416,3 +416,21 @@ node --test spikes/english/view.test.mjs
真实链路验证:真实 Go API+真实 MySQL 共 36 项检查通过(凭据只从本机安全配置读入进程,脚本可重复运行并自行清理 fixture),覆盖改名、编辑产生新版本并重新处理、重复保存幂等、删除章节重排序号、删除书籍级联、个人词条与复习队列在删除后保留、越权与非法输入拒绝;随后用临时 Playwright 用例在真实学习端完成「导入→改名→编辑正文→新版就绪→删除章节→删除书籍→书库提示」的闭环,并核对阅读器原文等于新版本。截图保存在本机 `.local/evidence/`(issue10-book-after-edit.png、issue10-chapter-deleted.png、issue10-book-deleted.png),临时用例运行后删除。
未验证:真实手机触屏详细证据与完整备份恢复演练仍属既有缺口(#14/#15);本单只用桌面浏览器检查。并发只覆盖「同一章节并发删除」与「处理中编辑」两类,没有做多用户压力测试。浏览器 textarea 会把该章的 CRLF 归一为 LF,属已知边界,已记入业务规则页。
## #11 验证与迁移(2026-09-11)
仓库根执行;Go 工具链由 `python scripts/server.py` 固定 go1.26.5。本单使用专用测试库 lexgo_test_issue9,不借用其他测试库。**本单不新增数据库列或表**,schema 保持 v6,没有迁移步骤,回退只需换回旧二进制。
| 命令 | 结果 |
|---|---|
| `go vet ./...` | 通过 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue9 python scripts/server.py test-integration` | 64 个顶层用例全部通过、0 跳过;含 #11 新增 5 个短语用例 |
| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 106 项单测、类型检查、构建、11 项 E2E 全部通过(含 #11 新增 12 单测与 4 项 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 项与严格检查通过 |
覆盖内容:身份键的标点/换行/大小写/弯撇号归一、词数上限(12 与 13)、身份键与片段长度上限、切进单词被拒绝(服务端)、单词语义不被当成短语、跨章节匹配(同一短语在不同章节写法不同仍是同一条记录,两章都高亮,第一次出现两次)、最左最长与输入顺序无关、短语覆盖内部单词而不改动单词记录、短语进同一到期队列并作答(201 应用、重复提交回放)、越权 404 与未登录 401、编辑正文后不再出现则不报错且词条与排期保留、删除章节后词条保留;前端纯函数层覆盖整词对齐、内部保留、端点按词调整不反向、命中优先级与区间换算;E2E 覆盖**真实鼠标拖选**(Chromium 真实输入)→ 短语面板 → 保存 → 两次出现同时高亮 → 端点调整 → 点击已保存短语 → 复习整段挖空。
真实链路验证:真实 Go API+真实 MySQL 共 30 项检查通过(凭据只从本机安全配置读入进程,脚本自建两章 fixture 并在结束前删除书籍)。覆盖保存与身份键、显示原文保留标点、跨章节同一条记录与两处高亮、span 两端必须是词、短语内的单词仍是独立词条、单词/切词/伪造身份/越权/未登录的拒绝、到期队列与作答(含重复提交回放)、编辑正文后不再高亮但条目与排期保留、删除章节后条目保留。随后用临时 Playwright 用例在真实学习端完成「导入 → 选择范围 → 保存短语 → 两处高亮 → 点击已保存短语 → 复习整段挖空 → 清理 fixture」闭环,并用程序化选区在同一真实页面上验证范围映射与服务端身份一致。
未验证与已知限制:真实手机手柄与滚动的手感仍是 #4 起的既有缺口,本单只用桌面浏览器检查。**Playwright 的合成鼠标拖拽在真实页面上不会扩展原生选区**(在同一浏览器里对 mock 页面是成功的,程序化选区在真实页面也能唤起面板),因此真实链路的范围构建改用真实点击 + Shift 点击,连续拖选由 mock E2E 与程序化选区覆盖;真人鼠标拖选与真机手柄仍需人工复核。短语只在同一学习者与语言内匹配,不跨账号共享。
+8 -2
View File
@@ -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: 559f97b2f00b7fc274bda52bdaad62d10f7926c8
synchronized_at: 2026-09-14T13:38:01Z
wiki_revision: 45077556545aceb54ad03ba7871e8d3614fe2e01
synchronized_at: 2026-09-14T14:21:33Z
<!-- gitea-wiki-mirror:end -->
# 产品需求总览
@@ -263,3 +263,9 @@ F03 的 TXT 文件导入已于 2026-09-13 通过用户验收:学习端导入
F01 的编辑与删除已于 2026-09-14 通过用户验收:学习端可改书名、改章节标题、编辑章节正文并按新版本重新处理,可用确认弹窗删除章节或整本书。编辑正文产生明确版本,旧处理结果被标为 `superseded` 而不覆盖新版本;删除在事务内完成并重排剩余章节序号,个人词条、复习排期与作答记录一律保留。本次没有数据库结构变化。
仍未实现并留给后续工单:短语选择与保存(#11)、词汇库搜索与编辑(#12)、阅读完成与进度(#13)、桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)。封面与音频附件(#21)、回收站/撤销、批量操作、章节跨书移动与语言变更不在本单范围。
## #11 交付范围更新(2026-09-11)
F08 的短语学习与 F10 的短语复习已实现,待用户验收:阅读器支持连续选择(桌面原生拖选、手机系统手势、面板按词调整端点),保存个人释义、例句与状态;短语与单词共用同一张表、同一到期队列与同一套幂等作答,同一短语跨章节只存一条记录并在出现处高亮;复习卡片把整段短语挖成一个空。范围规则(整词对齐、内部标点保留、最多 12 词、最左最长重叠)与失效引用回退(高亮消失、学习记录保留)已固化。本次没有数据库结构变化。
仍未实现并留给后续工单:词汇库搜索与编辑(#12,含短语编辑界面)、阅读完成与进度(#13)、桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)。短语自动合并同义形式、上下文词性消歧、短语跨书移动、批量编辑与真机手柄精细手感不在本单范围。
+4 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Home
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Home
wiki_revision: 40f720f23d608f2dd875ba0ba838297c07b7a8fa
synchronized_at: 2026-09-14T13:37:57Z
wiki_revision: 08cde00c48a957705ea5b2a9d3055ff3788e64b8
synchronized_at: 2026-09-14T14:21:32Z
<!-- gitea-wiki-mirror:end -->
# LexGo 文档入口
@@ -78,3 +78,5 @@ Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn
#9 TXT 文件导入已于 2026-09-13 通过用户验收:导入页新增「粘贴文本 / TXT 文件」来源切换,只接受 UTF-8(允许可选 BOM)且不替换损坏字符,UTF-16 与其他编码会被明确拒绝;文件只在内存中解码、不写临时文件,客户端文件名不参与任何路径也不入库;上传与粘贴共用同一分章、任务与幂等规则,重复上传同一文件只产生一章。本次没有数据库结构变化;PR #28 已 fast-forward-only 合入 main。
#10 编辑与删除书籍章节已于 2026-09-14 通过用户验收:可改书名、改章节标题、编辑章节正文并按新版本重新处理,也可用确认弹窗删除章节或整本书。编辑正文产生明确版本,旧处理结果会被标为 superseded 而不覆盖新版本;删除在事务内完成并重排剩余章节序号,已保存的个人词条、复习排期与作答记录一律保留。本次没有数据库结构变化;旧处理结果不会覆盖新版本,PR #29 已 fast-forward-only 合入 main。
#11 短语选择、保存与复习已实现,待用户验收:在正文中连续选择一个范围(桌面原生拖选、手机系统手柄、面板端点按钮与 Shift 点击调整),保存个人释义与状态;短语与单词共用同一张表、同一到期队列与同一套幂等作答,因此同一短语在不同章节只存一条记录并在出现的每处高亮,复习卡片把整段短语挖成一个空。短语与单词重叠时短语高亮覆盖、单词数据不变;编辑正文后短语不再出现时不报错,词条与复习排期保留。本次没有数据库结构变化。
+130
View File
@@ -0,0 +1,130 @@
import { expect, test, type Page } from '@playwright/test'
// Real mouse selection over the rendered chapter, against a mocked API: the interaction #4
// verified is exercised as a continuous drag, not through a preset button.
const original = 'Take a small step\nevery day.\n'
const fragments: [string, 'word' | 'space' | 'punctuation'][] = [
['Take', 'word'], [' ', 'space'], ['a', 'word'], [' ', 'space'], ['small', 'word'], [' ', 'space'],
['step', 'word'], ['\n', 'space'], ['every', 'word'], [' ', 'space'], ['day', 'word'], ['.', 'punctuation'], ['\n', 'space'],
]
let cp = 0
let utf16 = 0
const tokens = fragments.map(([text, kind]) => {
const token = { text, kind, start: cp, end: cp + [...text].length, startUtf16: utf16, endUtf16: utf16 + text.length }
cp = token.end
utf16 = token.endUtf16
return token
})
const book = { id: 1, title: 'Phrase chapter', language: 'en' }
const timestamps = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }
const chapter = { id: 55, bookId: 1, ordinal: 1, title: 'Phrase chapter', status: 'ready', charCount: [...original].length, errorReason: '', errorMessage: '', jobId: 5, contentSha256: 'fictional-sha', originalText: original, ...timestamps }
async function openChapter(page: Page, options: { phrases?: unknown[]; onPhrase?: (body: Record<string, unknown>) => void } = {}) {
const user = { id: 42, username: 'fictional-phrase', role: 'learner' }
await page.addInitScript(() => sessionStorage.setItem('lexgo-learner-token', 'fictional-session'))
await page.route('**/api/v1/**', async route => {
const path = new URL(route.request().url()).pathname
const method = route.request().method()
let data: unknown = null
let status = 200
if (path === '/api/v1/me') data = user
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
else if (path === '/api/v1/books') data = { items: [] }
else if (path === '/api/v1/chapters/55/tokens') data = { textSha256: 'fictional-sha', tokens, phrases: options.phrases ?? [] }
else if (path === '/api/v1/chapters/55') data = { book, chapter, navigation: { previousChapterId: null, nextChapterId: null } }
else if (path === '/api/v1/lookup') data = { status: 'not_found', query: 'Take', matchedForm: null, candidates: [], entries: [] }
else if (path === '/api/v1/phrases' && method === 'POST') {
options.onPhrase?.(route.request().postDataJSON() as Record<string, unknown>)
status = 201
data = {
term: { id: 9, term: 'take a small', originalForm: 'Take a small', definition: '拿一小步', examples: [], status: 'new', level: 0, kind: 'phrase', wordCount: 3 },
created: true,
}
} else if (path === '/api/v1/terms/9') data = { term: { id: 9, term: 'take a small', originalForm: 'Take a small', definition: '拿一小步', examples: ['Take a small step.'], status: 'new', level: 0, kind: 'phrase', wordCount: 3 } }
await route.fulfill({ status, json: { code: 200, data } })
})
await page.goto('/chapters/55')
await expect(page.locator('.reader-text')).toBeVisible()
}
/** Drags from the centre of one word to the centre of another, like a person selecting text. */
async function dragWords(page: Page, from: string, to: string) {
const words = page.locator('.reader-word')
const start = words.filter({ hasText: new RegExp(`^${from}$`) }).first()
const end = words.filter({ hasText: new RegExp(`^${to}$`) }).first()
const startBox = (await start.boundingBox())!
const endBox = (await end.boundingBox())!
await page.mouse.move(startBox.x + startBox.width / 2, startBox.y + startBox.height / 2)
await page.mouse.down()
await page.mouse.move(endBox.x + endBox.width / 2, endBox.y + endBox.height / 2, { steps: 8 })
await page.mouse.up()
}
test('drag a continuous phrase, save it and highlight it', async ({ page }) => {
let body: Record<string, unknown> | undefined
await openChapter(page, { onPhrase: value => { body = value } })
await dragWords(page, 'Take', 'small')
const range = page.getByTestId('phrase-range')
await expect(range).toContainText('短语 · 3 个单词')
// A phrase that is not stored yet is labelled as a new entry.
await expect(page.locator('.lookup-panel')).toContainText('新词条')
await page.getByLabel(/^我的释义/).fill('拿一小步')
await page.screenshot({ path: '../.local/evidence/issue11-phrase-selected.png' })
await page.getByTestId('term-save').click()
await expect(page.getByText('已保存 · 新词')).toBeVisible()
// The code point range covers the whole run including its interior separator.
expect(body).toMatchObject({ chapterId: 55, start: 0, end: 12, definition: '拿一小步', status: 'new' })
// The saved phrase is underlined in the text.
await expect(page.locator('.reader-word.is-phrase')).toHaveCount(3)
})
test('adjust the endpoints by whole words from the panel', async ({ page }) => {
await openChapter(page)
await dragWords(page, 'Take', 'small')
await expect(page.getByTestId('phrase-range')).toContainText('3 个单词')
await page.getByTestId('range-end-right').click()
await expect(page.getByTestId('phrase-range')).toContainText('4 个单词')
await page.getByTestId('range-start-right').click()
await expect(page.getByTestId('phrase-range')).toContainText('3 个单词')
await page.getByTestId('range-start-left').click()
await expect(page.getByTestId('phrase-range')).toContainText('4 个单词')
})
test('open a saved phrase from its highlight', async ({ page }) => {
await openChapter(page, { phrases: [{ id: 9, status: 'new', wordCount: 3, startToken: 0, endToken: 4 }] })
await expect(page.locator('.reader-word.is-phrase')).toHaveCount(3)
await page.locator('.reader-word.is-phrase').first().click()
await expect(page.getByTestId('phrase-range')).toContainText('已保存')
await expect(page.getByLabel(/^我的释义/)).toHaveValue('拿一小步')
await page.screenshot({ path: '../.local/evidence/issue11-phrase-saved.png' })
})
test('a phrase review card masks the whole run as one blank', async ({ page }) => {
const user = { id: 42, username: 'fictional-phrase', role: 'learner' }
await page.addInitScript(() => sessionStorage.setItem('lexgo-learner-token', 'fictional-session'))
await page.route('**/api/v1/**', async route => {
const path = new URL(route.request().url()).pathname
let data: unknown = null
if (path === '/api/v1/me') data = user
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
else if (path === '/api/v1/reviews/queue') {
data = {
items: [{
id: 9, term: 'a small step', originalForm: 'a small step', definition: '一小步',
examples: ['Take a small step, every day.'], status: 'new', level: 0, kind: 'phrase', wordCount: 3,
dueAt: '2026-01-01T00:00:00Z', reviewCount: 0,
}],
total: 1,
}
}
await route.fulfill({ json: { code: 200, data } })
})
await page.goto('/review')
await expect(page.getByTestId('review-position')).toContainText('到期复习 · 1 / 1')
await expect(page.getByText('Take _____, every day.')).toBeVisible()
await expect(page.getByText('短语 · 3 个单词')).toBeVisible()
await page.getByTestId('review-reveal').click()
await expect(page.getByTestId('review-definition')).toHaveText('一小步')
await page.screenshot({ path: '../.local/evidence/issue11-phrase-review.png' })
})
+2 -1
View File
@@ -21,7 +21,8 @@ const chapter = { id: 55, bookId: 1, title: '虚构章节', status: 'ready', ori
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,
id: 7, term: 'cats', originalForm: 'Cats', definition: '猫', examples: ['A fictional example.'], status: 'learning', level: 2,
kind: 'word', wordCount: 1, ...overrides,
})
let wrapper: VueWrapper | undefined
interface OpenOptions {
+171
View File
@@ -0,0 +1,171 @@
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 { createRouter, createMemoryHistory } from 'vue-router'
import ReaderView from '../views/ReaderView.vue'
import { useSessionStore } from '../stores/session'
import type { PhraseSpan, ReaderToken } from '../composables/useReaderLookup'
// The same fixture shape the phrase tests use on the server side.
const original = 'Take a small, step\nevery day.\nMira took a small step again.\n'
const fragments: [string, ReaderToken['kind']][] = [
['Take', 'word'], [' ', 'space'], ['a', 'word'], [' ', 'space'], ['small', 'word'], [',', 'punctuation'],
[' ', 'space'], ['step', 'word'], ['\n', 'space'], ['every', 'word'], [' ', 'space'], ['day', 'word'],
['.', 'punctuation'], ['\n', 'space'], ['Mira', 'word'], [' ', 'space'], ['took', 'word'], [' ', 'space'],
['a', 'word'], [' ', 'space'], ['small', 'word'], [' ', 'space'], ['step', 'word'], [' ', 'space'],
['again', 'word'], ['.', 'punctuation'], ['\n', 'space'],
]
let cp = 0
let utf16 = 0
const tokens: ReaderToken[] = fragments.map(([text, kind]) => {
const token: ReaderToken = { text, kind, start: cp, end: cp + [...text].length, startUtf16: utf16, endUtf16: utf16 + text.length }
cp = token.end
utf16 = token.endUtf16
return token
})
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 savedPhrase = {
id: 7, term: 'a small step', originalForm: 'a small, step', definition: '一小步',
examples: ['Take a small step, every day.'], status: 'new', level: 0, kind: 'phrase', wordCount: 3,
}
let wrapper: VueWrapper | undefined
interface OpenOptions {
phrases?: PhraseSpan[]
tokens?: ReaderToken[]
original?: string
save?: (body: Record<string, unknown>) => Promise<Response>
termRead?: () => Promise<Response>
}
async function open(options: OpenOptions = {}) {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input)
if (url.endsWith('/tokens')) return ok({ textSha256: 'same-sha', tokens: options.tokens ?? tokens, phrases: options.phrases ?? [] })
if (url.endsWith('/lookup')) return ok({ status: 'not_found', query: 'a', matchedForm: null, candidates: [], entries: [] })
if (url.includes('/terms/')) return options.termRead ? options.termRead() : ok({ term: savedPhrase })
if (url.endsWith('/phrases') || url.endsWith('/terms')) {
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
if (options.save) return options.save(body)
return ok({ term: savedPhrase, created: true })
}
// A test that supplies its own tokens must also supply the matching chapter text.
const text2 = options.original ?? original
return ok({ chapter: { ...chapter, originalText: text2 }, navigation: { previousChapterId: null, nextChapterId: 56 } })
})
useSessionStore().user = { id: 42, username: 'fictional', role: 'learner' }
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/chapters/:id', component: ReaderView }, { path: '/', component: { template: '<div />' } }] })
await router.push('/chapters/55')
wrapper = mount(ReaderView, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
return { fetchMock, router, view: wrapper }
}
/** Clicks one word and shift-clicks the next matching word after it to extend the range. */
async function shiftSelect(view: VueWrapper, first: string, last: string) {
const words = view.findAll('.reader-word')
const startIndex = words.findIndex(item => item.text() === first)
const endIndex = words.findIndex((item, index) => index > startIndex && item.text() === last)
await words[startIndex]!.trigger('click')
await flushPromises()
await words[endIndex]!.trigger('click', { shiftKey: true })
await flushPromises()
}
describe('phrase selection and panel', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() })
afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.restoreAllMocks() })
it('builds a phrase from a shift-click range and saves it through the phrases endpoint', async () => {
let body: Record<string, unknown> | undefined
const { view } = await open({ save: async value => { body = value; return ok({ term: savedPhrase, created: true }) } })
await shiftSelect(view, 'a', 'step')
// The panel names the phrase and shows how many words it holds.
expect(view.get('[data-testid="phrase-range"]').text()).toContain('短语 · 3 个单词')
expect(view.text()).toContain('新词条')
// A phrase has no dictionary lookup section.
expect(view.find('[data-testid="lookup-retry"]').exists()).toBe(false)
await view.get('#term-definition').setValue('一小步')
await view.get('[data-testid="term-save"]').trigger('click')
await flushPromises()
// The selection travels as code point offsets of the whole phrase, punctuation included.
expect(body).toMatchObject({ chapterId: 55, start: 5, end: 18, definition: '一小步', status: 'new' })
expect(view.text()).toContain('已保存')
// The saved phrase highlights immediately.
expect(view.findAll('.reader-word').some(item => item.classes().includes('is-phrase'))).toBe(true)
})
it('adjusts both ends by whole words from the panel', async () => {
const { view } = await open()
await shiftSelect(view, 'a', 'step')
expect(view.get('[data-testid="phrase-range"]').text()).toContain('3 个单词')
await view.get('[data-testid="range-start-right"]').trigger('click')
await flushPromises()
expect(view.get('[data-testid="phrase-range"]').text()).toContain('2 个单词')
// Moving the start back restores the three-word phrase.
await view.get('[data-testid="range-start-left"]').trigger('click')
await flushPromises()
expect(view.get('[data-testid="phrase-range"]').text()).toContain('3 个单词')
// A two-word phrase cannot shrink further, so the shrinking buttons are disabled.
await view.get('[data-testid="range-start-right"]').trigger('click')
await flushPromises()
expect(view.get('[data-testid="phrase-range"]').text()).toContain('2 个单词')
expect(view.get('[data-testid="range-start-right"]').attributes('disabled')).toBeDefined()
expect(view.get('[data-testid="range-end-left"]').attributes('disabled')).toBeDefined()
// Growing it is still possible on either side.
expect(view.get('[data-testid="range-start-left"]').attributes('disabled')).toBeUndefined()
expect(view.get('[data-testid="range-end-right"]').attributes('disabled')).toBeUndefined()
})
it('opens the saved phrase when its highlighted range is clicked', async () => {
const span: PhraseSpan = { id: 7, status: 'new', wordCount: 3, startToken: 2, endToken: 7 }
const { view, fetchMock } = await open({ phrases: [span] })
const inside = view.findAll('.reader-word').find(item => item.text() === 'small')!
expect(inside.classes()).toContain('is-phrase')
await inside.trigger('click')
await flushPromises()
// The stored entry is read through the same route a word uses, and the panel edits it.
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/terms/7'))).toBe(true)
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('一小步')
expect(view.get('[data-testid="phrase-range"]').text()).toContain('已保存')
})
it('refuses a range longer than the phrase limit and keeps the panel closed', async () => {
// One word more than a phrase may hold.
const longTokens: ReaderToken[] = []
const words: string[] = []
let cursor = 0
for (let index = 0; index < 14; index++) {
const word = `w${index}`
words.push(word)
longTokens.push({ text: word, start: cursor, end: cursor + word.length, startUtf16: cursor, endUtf16: cursor + word.length, kind: 'word' })
cursor += word.length
longTokens.push({ text: ' ', start: cursor, end: cursor + 1, startUtf16: cursor, endUtf16: cursor + 1, kind: 'space' })
cursor += 1
}
const { view } = await open({ tokens: longTokens, original: longTokens.map(token => token.text).join('') })
await shiftSelect(view, words[0]!, words[13]!)
expect(view.get('[data-testid="range-notice"]').text()).toContain('短语最多 12 个单词')
expect(view.find('[data-testid="phrase-range"]').exists()).toBe(false)
})
it('closes the phrase panel with Escape and restores the reading position', async () => {
const { view } = await open()
await shiftSelect(view, 'a', 'step')
expect(view.find('[data-testid="phrase-range"]').exists()).toBe(true)
await view.get('.lookup-panel').trigger('keydown', { key: 'Escape' })
await flushPromises()
expect(view.find('.lookup-panel').exists()).toBe(false)
})
it('keeps the typed phrase text when saving fails', async () => {
const { view } = await open({ save: async () => new Response(JSON.stringify({ code: 400, msg: '短语过长,请缩短选择范围' }), { status: 400 }) })
await shiftSelect(view, 'a', 'step')
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('虚构释义')
})
})
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest'
import { adjustTokenRange, MAX_PHRASE_WORDS, normalizeTokenRange, phrasesAt, rangeOfSpan, rangeOfWords, wordIndices } from '../composables/readerRange'
import type { ReaderToken } from '../composables/useReaderLookup'
// The same text #4 verified: interior punctuation, a line break inside the phrase, a repeated
// phrase and a combining accent.
const original = 'Take a small, step\nevery day.\nMira took a small step again.\n'
const tokens: ReaderToken[] = [
{ text: 'Take', start: 0, end: 4, startUtf16: 0, endUtf16: 4, kind: 'word' },
{ text: ' ', start: 4, end: 5, startUtf16: 4, endUtf16: 5, kind: 'space' },
{ text: 'a', start: 5, end: 6, startUtf16: 5, endUtf16: 6, kind: 'word' },
{ text: ' ', start: 6, end: 7, startUtf16: 6, endUtf16: 7, kind: 'space' },
{ text: 'small', start: 7, end: 12, startUtf16: 7, endUtf16: 12, kind: 'word' },
{ text: ',', start: 12, end: 13, startUtf16: 12, endUtf16: 13, kind: 'punctuation' },
{ text: ' ', start: 13, end: 14, startUtf16: 13, endUtf16: 14, kind: 'space' },
{ text: 'step', start: 14, end: 18, startUtf16: 14, endUtf16: 18, kind: 'word' },
{ text: '\n', start: 18, end: 19, startUtf16: 18, endUtf16: 19, kind: 'space' },
{ text: 'every', start: 19, end: 24, startUtf16: 19, endUtf16: 24, kind: 'word' },
{ text: ' ', start: 24, end: 25, startUtf16: 24, endUtf16: 25, kind: 'space' },
{ text: 'day', start: 25, end: 28, startUtf16: 25, endUtf16: 28, kind: 'word' },
{ text: '.', start: 28, end: 29, startUtf16: 28, endUtf16: 29, kind: 'punctuation' },
{ text: '\n', start: 29, end: 30, startUtf16: 29, endUtf16: 30, kind: 'space' },
{ text: 'Mira', start: 30, end: 34, startUtf16: 30, endUtf16: 34, kind: 'word' },
{ text: ' ', start: 34, end: 35, startUtf16: 34, endUtf16: 35, kind: 'space' },
{ text: 'took', start: 35, end: 39, startUtf16: 35, endUtf16: 39, kind: 'word' },
{ text: ' ', start: 39, end: 40, startUtf16: 39, endUtf16: 40, kind: 'space' },
{ text: 'a', start: 40, end: 41, startUtf16: 40, endUtf16: 41, kind: 'word' },
{ text: ' ', start: 41, end: 42, startUtf16: 41, endUtf16: 42, kind: 'space' },
{ text: 'small', start: 42, end: 47, startUtf16: 42, endUtf16: 47, kind: 'word' },
{ text: ' ', start: 47, end: 48, startUtf16: 47, endUtf16: 48, kind: 'space' },
{ text: 'step', start: 48, end: 52, startUtf16: 48, endUtf16: 52, kind: 'word' },
{ text: ' ', start: 52, end: 53, startUtf16: 52, endUtf16: 53, kind: 'space' },
{ text: 'again', start: 53, end: 58, startUtf16: 53, endUtf16: 58, kind: 'word' },
{ text: '.', start: 58, end: 59, startUtf16: 58, endUtf16: 59, kind: 'punctuation' },
{ text: '\n', start: 59, end: 60, startUtf16: 59, endUtf16: 60, kind: 'space' },
]
describe('reader phrase ranges', () => {
it('keeps interior punctuation and line breaks in the selected text', () => {
const range = normalizeTokenRange(tokens, original, 2, 7)
expect(range).not.toBeNull()
expect(range).toMatchObject({ firstWord: 2, lastWord: 7, start: 5, end: 18, wordCount: 3 })
expect(range!.text).toBe('a small, step')
// The second occurrence keeps its own spelling and offsets.
const second = normalizeTokenRange(tokens, original, 18, 22)
expect(second!.text).toBe('a small step')
expect(second!.start).toBe(40)
})
it('aligns both ends to whole words and never selects one word as a phrase', () => {
// A selection that starts on a separator begins at the next whole word, and one that ends
// on a separator ends at the previous one: words outside the selection stay untouched.
expect(normalizeTokenRange(tokens, original, 3, 7)!.firstWord).toBe(4)
expect(normalizeTokenRange(tokens, original, 2, 5)!.lastWord).toBe(4)
expect(normalizeTokenRange(tokens, original, 2, 7)!.text).toBe('a small, step')
expect(normalizeTokenRange(tokens, original, 2, 3)).toBeNull()
expect(normalizeTokenRange(tokens, original, 5, 5)).toBeNull()
expect(normalizeTokenRange(tokens, original, -1, 7)).toBeNull()
expect(normalizeTokenRange(tokens, original, 2, tokens.length + 5)).toBeNull()
})
it('reverses a backwards selection and refuses more than twelve words', () => {
const range = normalizeTokenRange(tokens, original, 7, 2)
expect(range).toMatchObject({ firstWord: 2, lastWord: 7 })
// The fixture is exactly twelve words, which is still a phrase.
expect(normalizeTokenRange(tokens, original, 0, tokens.length - 1)!.wordCount).toBe(MAX_PHRASE_WORDS)
const longer: ReaderToken[] = []
let offset = 0
for (let index = 0; index < MAX_PHRASE_WORDS + 1; index++) {
const word = `word${index}`
longer.push({ text: word, start: offset, end: offset + word.length, startUtf16: offset, endUtf16: offset + word.length, kind: 'word' })
offset += word.length
longer.push({ text: ' ', start: offset, end: offset + 1, startUtf16: offset, endUtf16: offset + 1, kind: 'space' })
offset += 1
}
expect(normalizeTokenRange(longer, longer.map(token => token.text).join(''), 0, longer.length - 1)).toBeNull()
})
it('moves one end by whole words and never inverts the range', () => {
const range = normalizeTokenRange(tokens, original, 2, 7)!
const shorterStart = adjustTokenRange(tokens, original, range, 'start', 1)
expect(shorterStart).toMatchObject({ firstWord: 4, lastWord: 7, wordCount: 2 })
expect(shorterStart!.text).toBe('small, step')
// The start can move left while a word remains before it, and not past the first word.
expect(adjustTokenRange(tokens, original, range, 'start', -1)).toMatchObject({ firstWord: 0, lastWord: 7 })
expect(adjustTokenRange(tokens, original, rangeOfWords(tokens, original, 0, 7)!, 'start', -1)).toBeNull()
const longerEnd = adjustTokenRange(tokens, original, range, 'end', 1)
expect(longerEnd).toMatchObject({ firstWord: 2, lastWord: 9, wordCount: 4 })
expect(longerEnd!.text).toBe('a small, step\nevery')
const back = adjustTokenRange(tokens, original, longerEnd!, 'end', -1)!
expect(back.text).toBe(range.text)
// Moving the end before the start is refused even when the direction is legal.
const twoWords = rangeOfWords(tokens, original, 2, 4)!
expect(adjustTokenRange(tokens, original, twoWords, 'end', -1)).toBeNull()
})
it('finds the phrase covering a word and builds its range again', () => {
const phrases = [
{ id: 1, startToken: 2, endToken: 7, status: 'new', wordCount: 3 },
{ id: 2, startToken: 4, endToken: 7, status: 'learning', wordCount: 2 },
]
// Overlapping spans: the longer phrase wins for a word both cover.
expect(phrasesAt(tokens, phrases, 4)?.id).toBe(1)
expect(phrasesAt(tokens, phrases, 7)?.id).toBe(1)
expect(phrasesAt(tokens, phrases, 0)).toBeNull()
const span = rangeOfSpan(tokens, original, { startToken: 2, endToken: 7 })!
expect(span.text).toBe('a small, step')
expect(rangeOfWords(tokens, original, 4, 7)!.text).toBe('small, step')
// A span that covers a single word is not a phrase.
expect(rangeOfSpan(tokens, original, { startToken: 2, endToken: 2 })).toBeNull()
})
})
+11 -2
View File
@@ -4,11 +4,11 @@ import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router'
import ReviewView from '../views/ReviewView.vue'
import { useSessionStore } from '../stores/session'
import { clozeSentence, useReviewStore, type ReviewItem } from '../stores/review'
import { clozeSentence, maskedPrompt, useReviewStore, type ReviewItem } from '../stores/review'
const item = (overrides: Partial<ReviewItem> = {}): ReviewItem => ({
id: 7, term: 'dogs', originalForm: 'Dogs', definition: '狗', examples: ['Dogs went home.'],
status: 'new', level: 0, dueAt: '2026-09-11T10:00:00Z', reviewCount: 0, ...overrides,
status: 'new', level: 0, kind: 'word', wordCount: 1, dueAt: '2026-09-11T10:00:00Z', reviewCount: 0, ...overrides,
})
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
const answer = (overrides: Record<string, unknown> = {}) => ({
@@ -20,6 +20,15 @@ const answerCalls = (fetchMock: MockInstance) => fetchMock.mock.calls.filter(cal
let wrapper: VueWrapper | undefined
describe('review prompt', () => {
it('masks a whole phrase as one blank and keeps the word rule for words', () => {
const phrase = item({ kind: 'phrase', wordCount: 3, term: 'a small step', originalForm: 'a small step', examples: ['Take a small step, every day.'] })
expect(maskedPrompt(phrase)).toBe('Take _____, every day.')
expect(maskedPrompt(item({ ...phrase, examples: ['Take a small,\nstep today.'] }))).toBe('Take _____ today.')
// A phrase that is not in the example is left alone, and a word still masks itself.
expect(maskedPrompt(item({ ...phrase, examples: ['Nothing to mask here.'] }))).toBe('Nothing to mask here.')
expect(maskedPrompt(item({ term: 'curiosity', originalForm: 'curiosity', examples: ['Learning begins with curiosity.'] }))).toBe('Learning begins with _____.')
})
it('masks the word in the first example and keeps other sentences usable', () => {
expect(clozeSentence(item())).toBe('_____ went home.')
expect(clozeSentence(item({ term: "isn't", originalForm: "Isn't", examples: ["It isn’t over."] }))).toBe('It _____ over.')
+14 -2
View File
@@ -5,6 +5,9 @@ import { TERM_STATUSES, type LookupResult, type TermStatus } from '../composable
defineProps<{
word: string
// Set when the selection is a phrase: the panel edits that phrase instead of a word.
phrase: { wordCount: number; stored: boolean } | null
adjust: { startLeft: boolean; startRight: boolean; endLeft: boolean; endRight: boolean } | null
result: LookupResult | null
loading: boolean
error: string
@@ -19,7 +22,7 @@ defineProps<{
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 emit = defineEmits<{ close: []; retry: []; save: []; resize: [top: number]; adjust: [edge: 'start' | 'end', direction: -1 | 1] }>()
const heading = ref<HTMLElement | null>(null)
const panel = ref<HTMLElement | null>(null)
let observer: ResizeObserver | undefined
@@ -44,7 +47,16 @@ onUnmounted(() => { observer?.disconnect(); window.removeEventListener('resize',
<h2 id="lookup-heading" ref="heading" tabindex="-1">{{ word }}</h2>
<ElButton text aria-label="关闭释义" @click="$emit('close')">关闭</ElButton>
</header>
<div class="lookup-content" aria-live="polite" :aria-busy="loading">
<div v-if="phrase" class="lookup-range" data-testid="phrase-range">
<p class="subtle">短语 · {{ phrase.wordCount }} 个单词{{ phrase.stored ? ' · 已保存' : '' }}</p>
<div class="lookup-range-actions">
<ElButton size="small" data-testid="range-start-left" :disabled="!adjust?.startLeft" @click="$emit('adjust', 'start', -1)">起点 ←</ElButton>
<ElButton size="small" data-testid="range-start-right" :disabled="!adjust?.startRight" @click="$emit('adjust', 'start', 1)">起点 →</ElButton>
<ElButton size="small" data-testid="range-end-left" :disabled="!adjust?.endLeft" @click="$emit('adjust', 'end', -1)">终点 ←</ElButton>
<ElButton size="small" data-testid="range-end-right" :disabled="!adjust?.endRight" @click="$emit('adjust', 'end', 1)">终点 →</ElButton>
</div>
</div>
<div v-if="!phrase" class="lookup-content" aria-live="polite" :aria-busy="loading">
<p v-if="loading" role="status" class="subtle">正在查询…</p>
<p v-else-if="error" role="alert" class="lookup-message">{{ error }}</p>
<template v-else-if="result">
+43 -5
View File
@@ -1,10 +1,48 @@
<script setup lang="ts">
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) }
import { termStatusOf, type PhraseSpan, type ReaderToken } from '../composables/useReaderLookup'
defineProps<{ tokens: ReaderToken[]; phrases: PhraseSpan[]; original: string; selectedStart?: number; phraseStart?: number | null }>()
const emit = defineEmits<{
select: [token: ReaderToken, element: HTMLElement, extend: boolean]
selectPhrase: [span: PhraseSpan, element: HTMLElement]
}>()
// A token belongs to the phrase that covers it; the spans come from the server match, so the
// reader never re-derives which words form a phrase.
function spanOf(phrases: PhraseSpan[], index: number): PhraseSpan | null {
let best: PhraseSpan | null = null
for (const phrase of phrases) {
if (index < phrase.startToken || index > phrase.endToken) continue
if (best === null || phrase.endToken - phrase.startToken > best.endToken - best.startToken) best = phrase
}
return best
}
function select(token: ReaderToken, event: Event, phrase: PhraseSpan | null): void {
const element = event.currentTarget as HTMLElement
if (phrase && !(event as MouseEvent).shiftKey) {
emit('selectPhrase', phrase, element)
return
}
emit('select', token, element, (event as MouseEvent).shiftKey === true)
}
</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="[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>
<article class="reader-text"><template v-if="tokens.length"><template v-for="(token, index) in tokens" :key="token.start"><span
class="reader-token"
:data-token-index="index"
:class="[
token.kind === 'word' ? 'reader-word' : 'reader-separator',
token.kind === 'word' && termStatusOf(token) ? `is-${termStatusOf(token)}` : '',
spanOf(phrases, index) ? `is-phrase is-phrase-${spanOf(phrases, index)!.status}` : '',
spanOf(phrases, index)?.startToken === index ? 'is-phrase-start' : '',
spanOf(phrases, index)?.endToken === index ? 'is-phrase-end' : '',
{ 'is-selected': token.kind === 'word' && selectedStart === token.start, 'is-phrase-selected': spanOf(phrases, index)?.id === phraseStart },
]"
v-bind="token.kind === 'word' ? { role: 'button', tabindex: '0', 'aria-pressed': selectedStart === token.start } : {}"
:aria-label="token.kind === 'word' ? (spanOf(phrases, index) ? `短语中的 ${token.text}` : termStatusOf(token) ? `查询 ${token.text},已保存` : `查询 ${token.text}`) : undefined"
@click="select(token, $event, token.kind === 'word' ? spanOf(phrases, index) : null)"
@keydown.enter.prevent="token.kind === 'word' ? select(token, $event, spanOf(phrases, index)) : undefined"
@keydown.space.prevent="token.kind === 'word' ? select(token, $event, spanOf(phrases, index)) : undefined"
>{{ token.text }}</span></template></template><template v-else>{{ original }}</template></article>
</template>
+3 -3
View File
@@ -1,12 +1,12 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { ElButton } from 'element-plus'
import { clozeSentence, type ReviewItem } from '../stores/review'
import { maskedPrompt, type ReviewItem } from '../stores/review'
const props = defineProps<{ item: ReviewItem; position: number; total: number; revealed: boolean; busy: boolean; error: string }>()
const emit = defineEmits<{ reveal: []; grade: [grade: 'correct' | 'wrong' | 'again']; end: []; retry: [] }>()
// The word and its masked example are enough to answer; the definition stays hidden.
const prompt = () => clozeSentence(props.item)
const prompt = () => maskedPrompt(props.item)
const revealButton = ref<{ $el?: HTMLElement } | null>(null)
const correctButton = ref<{ $el?: HTMLElement } | null>(null)
// Keyboard users should reach the next action without tabbing through the page again.
@@ -26,7 +26,7 @@ watch(() => props.revealed, async value => {
<p class="subtle" data-testid="review-position">到期复习 · {{ position }} / {{ total }}</p>
<h2 id="review-word" tabindex="-1" class="review-word">{{ item.originalForm }}</h2>
<p v-if="prompt()" class="review-example" lang="en">{{ prompt() }}</p>
<p class="subtle">{{ item.status === 'learning' ? `学习中 · 等级 ${item.level}` : '新词 · 尚未复习' }}</p>
<p class="subtle">{{ item.kind === 'phrase' ? `短语 · ${item.wordCount} 个单词 · ` : '' }}{{ item.status === 'learning' ? `学习中 · 等级 ${item.level}` : '新词 · 尚未复习' }}</p>
<p v-if="error" role="alert" class="notice">{{ error }}</p>
<template v-if="!revealed">
+98
View File
@@ -0,0 +1,98 @@
import type { ReaderToken } from './useReaderLookup'
/** A phrase selection over the server's own tokens, or a candidate the reader can adjust. */
export interface TokenRange {
/** Token indices of the first and last word of the phrase. */
firstWord: number
lastWord: number
/** Code point range of the whole phrase inside the original text. */
start: number
end: number
/** The original text exactly as the chapter holds it. */
text: string
wordCount: number
}
export const MAX_PHRASE_WORDS = 12
/** Indices of the word tokens, in reading order. */
export function wordIndices(tokens: ReaderToken[]): number[] {
const indices: number[] = []
for (let index = 0; index < tokens.length; index++) {
if (tokens[index]!.kind === 'word') indices.push(index)
}
return indices
}
/**
* Builds a phrase range from two token indices, aligning both ends to whole words and
* keeping interior punctuation and line breaks in the text. It mirrors the rules verified in
* #4: separators at the edges are skipped, interior ones survive, and a single word is not a
* phrase.
*/
export function normalizeTokenRange(tokens: ReaderToken[], original: string, anchor: number, focus: number): TokenRange | null {
if (!Number.isInteger(anchor) || !Number.isInteger(focus)) return null
const from = Math.min(anchor, focus)
const to = Math.max(anchor, focus)
if (from < 0 || to >= tokens.length) return null
let firstWord = -1
let lastWord = -1
for (let index = from; index <= to; index++) {
if (tokens[index]!.kind !== 'word') continue
if (firstWord < 0) firstWord = index
lastWord = index
}
if (firstWord < 0 || lastWord < 0 || firstWord === lastWord) return null
return rangeOfWords(tokens, original, firstWord, lastWord)
}
/** The phrase range for a known first and last word token. */
export function rangeOfWords(tokens: ReaderToken[], original: string, firstWord: number, lastWord: number): TokenRange | null {
const first = tokens[firstWord]
const last = tokens[lastWord]
if (!first || !last || first.kind !== 'word' || last.kind !== 'word' || lastWord < firstWord) return null
const wordCount = wordIndices(tokens.slice(firstWord, lastWord + 1)).length
if (wordCount < 2 || wordCount > MAX_PHRASE_WORDS) return null
const text = [...original].slice(first.start, last.end).join('')
if (!text) return null
return { firstWord, lastWord, start: first.start, end: last.end, text, wordCount }
}
/**
* Moves one end of a phrase by whole words, skipping separators and never inverting the range.
* Returns the adjusted range, or null when the move is impossible.
*/
export function adjustTokenRange(tokens: ReaderToken[], original: string, range: TokenRange, edge: 'start' | 'end', direction: -1 | 1): TokenRange | null {
const words = wordIndices(tokens)
const positionOf = (tokenIndex: number) => words.indexOf(tokenIndex)
const firstPosition = positionOf(range.firstWord)
const lastPosition = positionOf(range.lastWord)
if (firstPosition < 0 || lastPosition < 0) return null
if (edge === 'start') {
const next = firstPosition + direction
if (next < 0 || next > lastPosition - 1) return null
return rangeOfWords(tokens, original, words[next]!, range.lastWord)
}
const next = lastPosition + direction
if (next >= words.length || next < firstPosition + 1) return null
return rangeOfWords(tokens, original, range.firstWord, words[next]!)
}
/** The saved phrases that cover a word token, longest first, so a click prefers the phrase. */
export function phrasesAt(tokens: ReaderToken[], phrases: { id: number; startToken: number; endToken: number }[], tokenIndex: number): { id: number; startToken: number; endToken: number } | null {
let best: { id: number; startToken: number; endToken: number } | null = null
for (const phrase of phrases) {
if (tokenIndex < phrase.startToken || tokenIndex > phrase.endToken) continue
if (best === null || phrase.endToken - phrase.startToken > best.endToken - best.startToken) best = phrase
}
return best
}
/** The phrase range a saved span covers, used when a highlighted phrase is clicked. */
export function rangeOfSpan(tokens: ReaderToken[], original: string, span: { startToken: number; endToken: number }): TokenRange | null {
const words = wordIndices(tokens.slice(span.startToken, span.endToken + 1))
if (words.length < 2) return null
const firstWord = span.startToken + words[0]!
const lastWord = span.startToken + words[words.length - 1]!
return rangeOfWords(tokens, original, firstWord, lastWord)
}
+61 -8
View File
@@ -1,6 +1,7 @@
import { computed, onScopeDispose, ref, watch, type Ref } from 'vue'
import type { ChapterDetail } from '../stores/library'
import { useSessionStore } from '../stores/session'
import { adjustTokenRange, type TokenRange } from './readerRange'
export type TermStatus = 'new' | 'learning' | 'known' | 'ignored'
@@ -14,6 +15,8 @@ export interface ReaderToken {
kind: 'word' | 'space' | 'punctuation'
term?: TokenTerm | null
}
/** One saved phrase occurrence inside the chapter, in token indices. */
export interface PhraseSpan { id: number; status: TermStatus; wordCount: number; startToken: number; endToken: number }
export interface LookupResult {
status: 'exact' | 'lemma' | 'not_found' | 'resource_missing'
query: string
@@ -30,9 +33,11 @@ export interface SavedTerm {
examples: string[]
status: TermStatus
level: number
kind: 'word' | 'phrase'
wordCount: number
}
interface TermResponse { term: SavedTerm }
interface TokenResponse { textSha256: string; tokens: ReaderToken[] }
interface TokenResponse { textSha256: string; tokens: ReaderToken[]; phrases?: PhraseSpan[] }
// 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.
@@ -72,9 +77,13 @@ function matchesChapter(data: TokenResponse, chapter: ChapterDetail): boolean {
export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
const session = useSessionStore()
const tokens = ref<ReaderToken[]>([])
const phrases = ref<PhraseSpan[]>([])
const tokensError = ref('')
const tokensLoading = ref(false)
const selected = ref<ReaderToken | null>(null)
// A phrase selection: either a drag over several words or a click on a saved phrase span.
const range = ref<TokenRange | null>(null)
const rangeTermId = ref<number | null>(null)
const result = ref<LookupResult | null>(null)
const loading = ref(false)
const error = ref('')
@@ -97,7 +106,7 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | 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)
const canSave = computed(() => (selected.value !== null || range.value !== null) && !saving.value && !prefilling.value && !prefillError.value)
function exampleLines(): string[] {
return examples.value.split('\n').map(line => line.trim()).filter(Boolean)
@@ -131,6 +140,8 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
termSequence++
saveSequence++
selected.value = null
range.value = null
rangeTermId.value = null
result.value = null
loading.value = false
error.value = ''
@@ -152,6 +163,7 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
function reset() {
tokenSequence++
tokens.value = []
phrases.value = []
tokensError.value = ''
tokensLoading.value = false
close(false)
@@ -167,16 +179,18 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
if (seq !== tokenSequence) return
if (!matchesChapter(data, current)) throw new Error('分词与正文不一致,请重试。')
tokens.value = data.tokens
phrases.value = Array.isArray(data.phrases) ? data.phrases : []
} catch (reason) {
if (seq !== tokenSequence) return
tokens.value = []
phrases.value = []
tokensError.value = reason instanceof Error ? reason.message : '单词暂时无法加载。'
} finally { if (seq === tokenSequence) tokensLoading.value = false }
}
async function lookup() {
const current = chapter.value
const token = selected.value
if (!session.user || !current || !token) return
if (!session.user || !current || !token || range.value) return
const seq = ++lookupSequence
result.value = null
error.value = ''
@@ -208,15 +222,18 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
}
async function save() {
const current = chapter.value
const selection = range.value
const token = selected.value
if (!session.user || !current || !token || !canSave.value) return
if (!session.user || !current || (!selection && !token) || !canSave.value) return
const seq = ++saveSequence
saving.value = true
saveError.value = ''
saved.value = ''
const span = selection ?? token!
const path = selection ? 'phrases' : 'terms'
try {
const data = await session.request<TermResponse & { created: boolean }>('terms', 'POST', {
chapterId: current.id, start: token.start, end: token.end,
const data = await session.request<TermResponse & { created: boolean }>(path, 'POST', {
chapterId: current.id, start: span.start, end: span.end,
definition: definition.value, examples: exampleLines(), status: status.value,
})
if (seq !== saveSequence) return
@@ -224,6 +241,18 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
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 })
if (selection) {
// The saved phrase must highlight here without waiting for another token load.
rangeTermId.value = data.term.id
const savedSpan = {
id: data.term.id, status: data.term.status, wordCount: selection.wordCount,
startToken: selection.firstWord, endToken: selection.lastWord,
}
const existing = phrases.value.findIndex(item => item.id === data.term.id)
phrases.value = existing >= 0
? phrases.value.map((item, index) => (index === existing ? savedSpan : item))
: [...phrases.value, savedSpan]
}
saved.value = `已保存 · ${termStatusLabel(data.term.status)}`
} catch (reason) {
if (seq !== saveSequence) return
@@ -241,6 +270,30 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
}
void lookup()
}
/**
* Opens the phrase form for a dragged range or for a saved phrase span. A phrase is one
* entry in the same table as a word, so an existing phrase is read through the same route.
*/
function selectRange(next: TokenRange, element: HTMLElement | null, termId: number | null = null) {
close(false)
origin = element
range.value = next
rangeTermId.value = termId
if (termId !== null) void loadTerm(termId)
}
/** Moves one end of the phrase by whole words; the panel keeps the selection in sync. */
function adjustRange(edge: 'start' | 'end', direction: -1 | 1) {
const current = range.value
const currentChapter = chapter.value
if (!current || !currentChapter) return
const next = adjustTokenRange(tokens.value, currentChapter.originalText ?? '', current, edge, direction)
if (next) range.value = next
}
/** True when the panel is editing an entry that is already stored. */
const rangeStored = computed(() => rangeTermId.value !== null)
watch(() => [chapter.value?.id, chapter.value?.status, chapter.value?.contentSha256, chapter.value?.originalText], () => {
reset()
void loadTokens()
@@ -250,8 +303,8 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
watch(() => session.user, reset, { flush: 'sync' })
onScopeDispose(reset)
return {
tokens, tokensError, tokensLoading, selected, result, loading, error,
tokens, phrases, tokensError, tokensLoading, selected, range, rangeTermId, rangeStored, result, loading, error,
definition, examples, status, savedTermId, prefilling, prefillError, saving, saveError, saved, canSave,
loadTokens, lookup, loadTerm, save, select, close, reset, keepSelectionVisible,
loadTokens, lookup, loadTerm, save, select, selectRange, adjustRange, close, reset, keepSelectionVisible,
}
}
@@ -0,0 +1,80 @@
import { onScopeDispose, type Ref } from 'vue'
/**
* Maps the browser's own selection onto the server tokens. Nothing here intercepts touch or
* pointer movement: a mouse drag and the phone's system selection handles both produce a
* selectionchange, which is the interaction #4 verified.
*/
export function tokenIndexOf(node: Node | null): number {
if (!node) return -1
const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as Element)
const holder = element?.closest?.('[data-token-index]')
if (!holder) return -1
const value = Number((holder as HTMLElement).dataset.tokenIndex)
return Number.isInteger(value) ? value : -1
}
export interface TextSelectionOptions {
/** The element the selection has to start inside. */
container: Ref<HTMLElement | null>
/** Called with the first and last token index of a non-empty selection inside the reader. */
onSelect: (anchor: number, focus: number) => void
}
/**
* Watches the native selection and reports token indices. A collapsed selection (a plain
* click) reports nothing, so clicking a word keeps opening the word panel.
*/
export function useTextSelection(options: TextSelectionOptions) {
let timer: number | undefined
let disposed = false
function read(): void {
if (disposed) return
const root = options.container.value
const selection = window.getSelection?.()
if (!root || !selection || selection.rangeCount === 0 || selection.isCollapsed) return
const range = selection.getRangeAt(0)
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return
const anchor = tokenIndexOf(range.startContainer)
const focus = tokenIndexOf(range.endContainer)
if (anchor < 0 || focus < 0) return
options.onSelect(Math.min(anchor, focus), Math.max(anchor, focus))
}
// The debounce matches the verified spike: it lets the browser finish a drag before the
// range is read, and it never blocks scrolling on a touch device.
function schedule(): void {
if (timer !== undefined) window.clearTimeout(timer)
timer = window.setTimeout(() => { timer = undefined; read() }, 100)
}
function pointerUp(event: PointerEvent): void {
// Only a pointerup inside the reader can finish a selection; a click on the panel must not
// re-read a range the learner already adjusted there.
const root = options.container.value
const target = event.target as Node | null
if (!root || !target || !root.contains(target)) return
if (timer !== undefined) window.clearTimeout(timer)
// A pointerup arrives before the browser finalises the range, so the read waits a tick.
timer = window.setTimeout(() => { timer = undefined; read() }, 0)
}
// Both listeners live on the document: the reader body only exists once a chapter is ready,
// so a listener bound to the element at setup time would miss every later selection.
document.addEventListener('selectionchange', schedule)
document.addEventListener('pointerup', pointerUp)
onScopeDispose(() => {
disposed = true
if (timer !== undefined) window.clearTimeout(timer)
document.removeEventListener('selectionchange', schedule)
document.removeEventListener('pointerup', pointerUp)
})
return {
/** Drops the native highlight, e.g. when the panel closes. */
clear() { window.getSelection?.()?.removeAllRanges() },
read,
}
}
+18
View File
@@ -15,6 +15,8 @@ export interface ReviewItem {
examples: string[]
status: 'new' | 'learning'
level: number
kind: 'word' | 'phrase'
wordCount: number
dueAt: string
reviewCount: number
}
@@ -57,6 +59,22 @@ export function clozeSentence(item: ReviewItem): string | null {
return masked
}
/**
* A phrase is masked as one blank covering the whole run, which is what the accepted prototype
* shows. Interior separators of any non-letter run are allowed, matching the server's phrase
* identity rule.
*/
export function maskedPrompt(item: ReviewItem): string | null {
const line = item.examples[0]
if (!line) return null
if (item.kind !== 'phrase') return clozeSentence(item)
const forms = item.term.split(' ').filter(Boolean)
if (forms.length < 2) return clozeSentence(item)
const parts = forms.map(form => escapeRegExp(form).replace(/'/g, "['’]"))
const pattern = new RegExp(parts.join(`[\\p{L}\\p{M}]*?[^\\p{L}\\p{M}]+[\\p{L}\\p{M}]*?`), 'iu')
return line.replace(pattern, '_____')
}
export const useReviewStore = defineStore('review', () => {
const session = useSessionStore()
+6
View File
@@ -118,6 +118,12 @@ 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-range { border-bottom: 1px solid #e0e3d8; padding-bottom: 14px; }
.lookup-range p { margin: 0 0 10px; }
.lookup-range-actions { display: flex; gap: 6px; flex-wrap: wrap; }
/* A saved phrase is one unit: the run is underlined and keeps its status colour. */
.reader-word.is-phrase { text-decoration: underline; text-decoration-style: double; text-underline-offset: 2px; border-radius: 3px; }
.reader-word.is-phrase-selected { outline: 2px solid #bc803d; outline-offset: 1px; }
.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; }
+101 -5
View File
@@ -4,7 +4,9 @@ import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton } from 'element-plus'
import { canRetry, statusLabel, useLibraryStore } from '../stores/library'
import { useSessionStore } from '../stores/session'
import { useReaderLookup } from '../composables/useReaderLookup'
import { useReaderLookup, type PhraseSpan, type ReaderToken } from '../composables/useReaderLookup'
import { adjustTokenRange, MAX_PHRASE_WORDS, normalizeTokenRange, phrasesAt, rangeOfSpan, wordIndices } from '../composables/readerRange'
import { useTextSelection } from '../composables/useTextSelection'
import ReaderTokens from '../components/ReaderTokens.vue'
import LookupPanel from '../components/LookupPanel.vue'
@@ -13,6 +15,8 @@ const library = useLibraryStore()
const route = useRoute()
const router = useRouter()
const retryError = ref('')
const rangeNotice = ref('')
const readerBody = ref<HTMLElement | null>(null)
const chapterId = computed(() => Number(route.params.id))
const chapter = computed(() => library.chapter)
@@ -20,9 +24,69 @@ const lookup = useReaderLookup(chapter)
// Retry uses the job id the chapter carries, no matter where it was loaded from.
const retryable = computed(() => chapter.value !== null && canRetry(chapter.value))
/** A dragged selection becomes a phrase; a single word stays a plain word lookup. */
function pickRange(anchor: number, focus: number, element: HTMLElement | null): void {
const text = library.readerText
const count = wordIndices(lookup.tokens.value.slice(anchor, focus + 1)).length
if (count < 2) return
if (count > MAX_PHRASE_WORDS) {
rangeNotice.value = `短语最多 ${MAX_PHRASE_WORDS} 个单词,请缩短选择范围。`
return
}
const range = normalizeTokenRange(lookup.tokens.value, text, anchor, focus)
if (!range) return
rangeNotice.value = ''
lookup.selectRange(range, element)
}
const selection = useTextSelection({ container: readerBody, onSelect: (anchor, focus) => pickRange(anchor, focus, readerBody.value) })
/** Shift-clicking a word extends the phrase from the word that was picked first. */
function selectToken(token: ReaderToken, element: HTMLElement, extend: boolean): void {
rangeNotice.value = ''
const anchor = lookup.selected.value
if (extend && anchor) {
const from = Number(element.dataset.tokenIndex)
const first = lookup.tokens.value.findIndex(item => item.start === anchor.start)
if (from >= 0 && first >= 0) {
pickRange(Math.min(from, first), Math.max(from, first), element)
return
}
}
lookup.select(token, element)
}
/** Clicking inside a saved phrase edits the phrase instead of the word under the cursor. */
function selectPhrase(span: PhraseSpan, element: HTMLElement): void {
const range = rangeOfSpan(lookup.tokens.value, library.readerText, span)
if (!range) return
rangeNotice.value = ''
lookup.selectRange(range, element, span.id)
}
const adjustOptions = computed(() => {
const current = lookup.range.value
if (!current) return null
const text = library.readerText
return {
startLeft: adjustTokenRange(lookup.tokens.value, text, current, 'start', -1) !== null,
startRight: adjustTokenRange(lookup.tokens.value, text, current, 'start', 1) !== null,
endLeft: adjustTokenRange(lookup.tokens.value, text, current, 'end', -1) !== null,
endRight: adjustTokenRange(lookup.tokens.value, text, current, 'end', 1) !== null,
}
})
const panelWord = computed(() => lookup.range.value?.text ?? lookup.selected.value?.text ?? '')
function closePanel(): void {
lookup.close()
selection.clear()
}
async function load() {
lookup.reset()
retryError.value = ''
rangeNotice.value = ''
await library.loadChapter(chapterId.value)
}
@@ -61,7 +125,7 @@ onUnmounted(() => library.closeChapter())
<ElButton text @click="logout">退出登录</ElButton>
</div>
</header>
<main class="page reader-page" :class="{ 'has-lookup': lookup.selected.value }" @keydown.esc="lookup.close()">
<main class="page reader-page" :class="{ 'has-lookup': lookup.selected.value || lookup.range.value }" @keydown.esc="closePanel">
<p v-if="library.chapterLoading && !chapter" role="status" class="loading">正在加载…</p>
<div v-else-if="library.chapterError" class="notice">
<p role="alert">{{ library.chapterError }}</p>
@@ -85,15 +149,47 @@ onUnmounted(() => library.closeChapter())
</div>
<p v-else-if="chapter.status !== 'ready'" role="status" class="processing-hint">这一章还在{{ statusLabel(chapter.status) }},页面会自动刷新。</p>
<p v-if="retryError" role="alert" class="notice">{{ retryError }}</p>
<p v-if="rangeNotice" role="alert" class="notice" data-testid="range-notice">{{ rangeNotice }}</p>
<div v-if="chapter.status === 'ready'" class="reader-workspace">
<div class="reader-body">
<ReaderTokens :tokens="lookup.tokens.value" :original="library.readerText" :selected-start="lookup.selected.value?.start" @select="lookup.select" />
<div class="reader-body" ref="readerBody">
<ReaderTokens
:tokens="lookup.tokens.value"
:phrases="lookup.phrases.value"
:original="library.readerText"
:selected-start="lookup.selected.value?.start"
:phrase-start="lookup.rangeTermId.value"
@select="selectToken"
@select-phrase="selectPhrase"
/>
<div v-if="lookup.tokensError.value" class="tokens-notice">
<p role="status">{{ lookup.tokensError.value }}</p>
<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" :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" />
<LookupPanel
v-if="lookup.selected.value || lookup.range.value"
:word="panelWord"
:phrase="lookup.range.value ? { wordCount: lookup.range.value.wordCount, stored: lookup.rangeStored.value } : null"
:adjust="adjustOptions"
: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="closePanel"
@retry="lookup.lookup"
@save="lookup.save"
@adjust="(edge, direction) => { lookup.adjustRange(edge, direction); selection.clear() }"
@resize="lookup.keepSelectionVisible"
/>
</div>
<nav class="reader-nav" aria-label="章节切换">
<ElButton :disabled="library.navigation.previousChapterId === null" @click="goTo(library.navigation.previousChapterId)">上一章</ElButton>
-4
View File
@@ -98,10 +98,6 @@ func Migrate(db *gorm.DB) error {
// version the server requires before it starts.
const SchemaVersion = 6
// v6 adds review scheduling as its own table instead of altering lexgo_terms: every
// statement stays additive and therefore retry-safe after a partial migration, and a
// binary restored to v5 keeps writing personal terms unchanged. Existing saved words
// enter the queue immediately, because a saved word has never been reviewed.
var schemaV6Statements = []string{
`CREATE TABLE IF NOT EXISTS lexgo_term_reviews (
term_id BIGINT UNSIGNED PRIMARY KEY,
+10 -5
View File
@@ -58,8 +58,9 @@ type DictionaryImportResult struct {
Duplicate bool `json:"duplicate"`
}
type ChapterTokens struct {
TextSHA256 string `json:"textSha256"`
Tokens []TextToken `json:"tokens"`
TextSHA256 string `json:"textSha256"`
Tokens []TextToken `json:"tokens"`
Phrases []PhraseSpan `json:"phrases"`
}
// Each router keeps at most one immutable parsed corpus; no private chapter or
@@ -277,12 +278,16 @@ func registerDictionaryRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.C
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.
// The learner's own saved words and phrases 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
phrases, err := phrasesForChapter(tx, u.UserId, language, tokens)
if err != nil {
return nil, err
}
return ChapterTokens{chapter.ContentSHA256, tokens, phrases}, nil
}))
v.POST("/lookup", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
var input struct {
+246
View File
@@ -0,0 +1,246 @@
package lexgo
import (
"sort"
"strings"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
admin "go-admin/app/admin/models"
"gorm.io/gorm"
)
// A phrase is a run of consecutive words. Its identity is the ordered normalized word forms
// joined by single spaces, so interior punctuation, line breaks and extra whitespace never
// split one phrase into two records, and a phrase key can never collide with a word key.
const (
maxPhraseWords = 12
minPhraseWords = 2
maxPhraseKey = 128
maxPhraseSource = 191
)
// PhraseSpan is one saved phrase occurrence inside a chapter, expressed in token indices so
// the reader can style a run without re-deriving the match.
type PhraseSpan struct {
ID int64 `json:"id"`
Status string `json:"status"`
WordCount int `json:"wordCount"`
StartToken int `json:"startToken"`
EndToken int `json:"endToken"`
}
// phraseWords returns the word tokens fully inside a requested code point range. A range that
// cuts into a word is rejected instead of silently dropping it, so the client always learns
// that its selection was not a whole-word range.
func phraseWords(tokens []TextToken, start, end int) ([]TextToken, error) {
bad := failure(400, "请选择至少两个连续的完整单词")
if start < 0 || end <= start {
return nil, bad
}
words := make([]TextToken, 0, 4)
for _, token := range tokens {
if token.End <= start || token.Start >= end {
continue
}
if token.Kind != "word" {
continue
}
if token.Start < start || token.End > end {
return nil, failure(400, "请选择完整的单词,不要切在单词中间")
}
words = append(words, token)
}
if len(words) < minPhraseWords {
return nil, bad
}
if len(words) > maxPhraseWords {
return nil, failure(400, "短语最多 12 个单词")
}
return words, nil
}
// phraseKey is the identity of a phrase: the ordered normalized word forms joined by spaces.
func phraseKey(words []TextToken) string {
forms := make([]string, 0, len(words))
for _, word := range words {
forms = append(forms, normalizeWord(word.Text))
}
return strings.Join(forms, " ")
}
// phraseSource is the exact original text of the selection, punctuation and line breaks
// included, used for display only.
func phraseSource(text string, words []TextToken) string {
if len(words) == 0 {
return ""
}
runes := []rune(text)
start, end := words[0].Start, words[len(words)-1].End
if start < 0 || end > len(runes) || start >= end {
return ""
}
return string(runes[start:end])
}
// phraseMatches finds every saved phrase occurrence in a chapter. Candidates are ordered by
// their start and then longest first, and the leftmost-longest non-overlapping set is kept,
// which is the display rule #4 verified; nothing stored is modified by matching.
func phraseMatches(tokens []TextToken, phrases []Term) []PhraseSpan {
if len(phrases) == 0 {
return []PhraseSpan{}
}
// Index the phrases by their first word so a chapter only compares what can match.
words := make([]int, 0, len(tokens))
for index, token := range tokens {
if token.Kind == "word" {
words = append(words, index)
}
}
byFirst := map[string][]Term{}
for _, phrase := range phrases {
forms := strings.Split(phrase.Term, " ")
if len(forms) < minPhraseWords {
continue
}
byFirst[forms[0]] = append(byFirst[forms[0]], phrase)
}
type candidate struct {
startToken int
endToken int
phrase Term
}
found := make([]candidate, 0, 8)
for position, tokenIndex := range words {
first := normalizeWord(tokens[tokenIndex].Text)
for _, phrase := range byFirst[first] {
forms := strings.Split(phrase.Term, " ")
if position+len(forms) > len(words) {
continue
}
matched := true
for offset, form := range forms {
if normalizeWord(tokens[words[position+offset]].Text) != form {
matched = false
break
}
}
if matched {
found = append(found, candidate{words[position], words[position+len(forms)-1], phrase})
}
}
}
sort.SliceStable(found, func(i, j int) bool {
if found[i].startToken != found[j].startToken {
return found[i].startToken < found[j].startToken
}
if found[i].endToken != found[j].endToken {
return found[i].endToken > found[j].endToken
}
return found[i].phrase.ID < found[j].phrase.ID
})
spans := make([]PhraseSpan, 0, len(found))
lastEnd := -1
for _, item := range found {
if item.startToken <= lastEnd {
continue
}
spans = append(spans, PhraseSpan{
ID: item.phrase.ID, Status: item.phrase.Status, WordCount: termWordCount(item.phrase.Term),
StartToken: item.startToken, EndToken: item.endToken,
})
lastEnd = item.endToken
}
return spans
}
// phrasesForChapter loads the caller's phrases and matches them against one chapter.
func phrasesForChapter(tx *gorm.DB, owner int, language string, tokens []TextToken) ([]PhraseSpan, error) {
var phrases []Term
// A phrase key always contains a space and a word key never does, so this is the whole
// filter; it needs no column.
if err := tx.Select("id", "term", "status").
Where("owner_id = ? AND language = ? AND term LIKE ?", owner, language, "% %").
Find(&phrases).Error; err != nil {
return nil, err
}
return phraseMatches(tokens, phrases), nil
}
type PhraseInput 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"`
}
// SavePhrase derives the identity from the server's own tokens of an owned ready chapter, so a
// client can neither name a phrase it did not select nor forge an owner or language. Saving
// reuses the term table and its idempotent upsert, so one phrase stays one record with one
// review schedule.
func SavePhrase(tx *gorm.DB, owner int, language string, input PhraseInput, now time.Time) (TermSave, error) {
if input.Start == nil || input.End == nil {
return TermSave{}, failure(400, "请选择至少两个连续的完整单词")
}
chapter, err := readyOwnedChapter(tx, owner, input.ChapterID)
if err != nil {
return TermSave{}, err
}
words, err := phraseWords(Tokenize(chapter.OriginalText), *input.Start, *input.End)
if err != nil {
return TermSave{}, err
}
key := phraseKey(words)
if utf8.RuneCountInString(key) > maxPhraseKey {
return TermSave{}, failure(400, "短语过长,请缩短选择范围")
}
source := phraseSource(chapter.OriginalText, words)
if source == "" || utf8.RuneCountInString(source) > maxPhraseSource {
return TermSave{}, failure(400, "短语原文过长,请缩短选择范围")
}
level, err := termLevel(input.Status, input.Level, Term{}, false)
if err != nil {
return TermSave{}, err
}
definition, examples, err := termContent(input.Definition, input.Examples)
if err != nil {
return TermSave{}, err
}
previous, exists, err := previousTerm(tx, owner, language, key)
if err != nil {
return TermSave{}, err
}
if exists && termKind(previous.Term) != termKindPhrase {
return TermSave{}, failure(409, "该内容已被记录为单词")
}
if exists {
level, err = termLevel(input.Status, input.Level, previous, true)
if err != nil {
return TermSave{}, err
}
}
fields := TermFields{
Definition: definition, Examples: examples, Status: input.Status, Level: level,
PreviousStatus: previous.Status, PreviousLevel: previous.Level, Exists: exists,
}
// The identity is the word-form key; the original selection is only the display text.
return saveTerm(tx, owner, language, key, source, fields, now)
}
func registerPhraseRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc, now func() time.Time) {
v.POST("/phrases", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
var input PhraseInput
if err := decode(c, &input); err != nil {
return nil, err
}
language, err := languageOf(tx, u.UserId)
if err != nil {
return nil, err
}
return SavePhrase(tx, u.UserId, language, input, now())
}))
}
+386
View File
@@ -0,0 +1,386 @@
package lexgo
import (
"encoding/json"
"fmt"
"strings"
"testing"
)
// phraseFixtureText holds the cases #4 verified: interior punctuation, a line break inside a
// phrase, a repeated phrase and a combining accent.
const phraseFixtureText = "Take a small, step\nevery day.\nMira took a small step again.\n"
func phraseTokens(text string) []TextToken {
return Tokenize(text)
}
func spanOf(t *testing.T, tokens []TextToken, words ...string) (int, int) {
t.Helper()
var first, last TextToken
found := 0
for _, token := range tokens {
if token.Kind != "word" {
continue
}
if found < len(words) && normalizeWord(token.Text) == normalizeWord(words[found]) {
if found == 0 {
first = token
}
last = token
found++
}
}
if found != len(words) {
t.Fatalf("could not find %v in %q", words, text(tokens))
}
return first.Start, last.End
}
func text(tokens []TextToken) string {
var builder strings.Builder
for _, token := range tokens {
builder.WriteString(token.Text)
}
return builder.String()
}
// TestPhraseIdentityRules pins the range and identity rules of a phrase.
func TestPhraseIdentityRules(t *testing.T) {
tokens := phraseTokens(phraseFixtureText)
// Interior punctuation and a line break stay in the display text but not in the key.
start, end := spanOf(t, tokens, "a", "small", "step")
words, err := phraseWords(tokens, start, end)
if err != nil || len(words) != 3 {
t.Fatalf("phrase words: %v %v", words, err)
}
if key := phraseKey(words); key != "a small step" {
t.Fatalf("key %q, want %q", key, "a small step")
}
if source := phraseSource(phraseFixtureText, words); source != "a small, step\nevery day."[:0]+"a small, step" {
t.Fatalf("source %q", source)
}
// The same words separated by different punctuation are one identity.
secondTokens := phraseTokens("Mira took a small step again.")
secondStart, secondEnd := spanOf(t, secondTokens, "a", "small", "step")
other, err := phraseWords(secondTokens, secondStart, secondEnd)
if err != nil {
t.Fatal(err)
}
if phraseKey(other) != "a small step" {
t.Fatalf("a differently spaced phrase must share the key: %q", phraseKey(other))
}
// Case and curly apostrophes fold, exactly like word identity.
if key := phraseKey(mustWords(t, "Don’t Look Back")); key != "don't look back" {
t.Fatalf("normalized phrase key: %q", key)
}
// Endpoint alignment: a range that cuts into a word is refused instead of dropping it.
if _, err := phraseWords(tokens, start+3, end); err == nil {
t.Fatal("a range cutting into a word must be rejected")
}
if _, err := phraseWords(tokens, start, end-2); err == nil {
t.Fatal("a range cutting into the last word must be rejected")
}
// A range that starts on a separator simply begins at the next whole word.
trimmed, err := phraseWords(tokens, start+1, end)
if err != nil || len(trimmed) != 2 || normalizeWord(trimmed[0].Text) != "small" {
t.Fatalf("a separator boundary must select whole words: %v %v", trimmed, err)
}
// Surrounding whitespace and punctuation are allowed on both ends.
if _, err := phraseWords(tokens, start-1, end+1); err != nil {
t.Fatalf("surrounding separators must be accepted: %v", err)
}
// A single word, an empty range and a punctuation-only range are not phrases.
if _, err := phraseWords(tokens, words[0].Start, words[0].End); err == nil {
t.Fatal("one word is not a phrase")
}
if _, err := phraseWords(tokens, 0, 0); err == nil {
t.Fatal("an empty range is not a phrase")
}
if _, err := phraseWords(tokens, -1, 4); err == nil {
t.Fatal("a negative range is not a phrase")
}
// Limits: at most 12 words, and a key within the stored column.
long := make([]TextToken, 0, maxPhraseWords+1)
position := 0
for index := 0; index < maxPhraseWords+1; index++ {
word := "word"
long = append(long, TextToken{Text: word, Start: position, End: position + len(word), Kind: "word"})
position += len(word) + 1
}
if _, err := phraseWords(long, 0, position); err == nil {
t.Fatal("more than 12 words must be rejected")
}
if _, err := phraseWords(long[:maxPhraseWords], 0, len(long[maxPhraseWords-1].Text)+long[maxPhraseWords-1].Start); err != nil {
t.Fatalf("exactly 12 words must be accepted: %v", err)
}
}
func mustWords(t *testing.T, text string) []TextToken {
t.Helper()
words, err := phraseWords(phraseTokens(text), 0, len([]rune(text)))
if err != nil {
t.Fatal(err)
}
return words
}
// TestPhraseMatchingAndOverlap pins the cross-chapter match and the leftmost-longest display.
func TestPhraseMatchingAndOverlap(t *testing.T) {
tokens := phraseTokens(phraseFixtureText)
step := Term{ID: 1, Term: "a small step", Status: termStatusNew}
small := Term{ID: 2, Term: "small step", Status: termStatusLearning}
spans := phraseMatches(tokens, []Term{step, small})
if len(spans) != 2 {
t.Fatalf("expected one occurrence of each phrase, got %+v", spans)
}
// The longer phrase starts first, so it wins and the shorter one is dropped where they
// overlap; the later occurrence of "a small step" is a separate match.
if spans[0].ID != step.ID || spans[1].ID != step.ID {
t.Fatalf("leftmost-longest rule: %+v", spans)
}
if spans[0].StartToken >= spans[1].StartToken {
t.Fatalf("spans must be ordered: %+v", spans)
}
// A phrase that no longer occurs is simply absent, and nothing is mutated.
if len(phraseMatches(phraseTokens("Nothing to see here."), []Term{step})) != 0 {
t.Fatal("a phrase that does not occur must not match")
}
// Two different phrases that both match at the same place keep a stable choice.
spans = phraseMatches(tokens, []Term{small, step})
if len(spans) != 2 || spans[0].ID != step.ID {
t.Fatalf("order of input must not change the display: %+v", spans)
}
// A single-word entry never participates as a phrase.
if len(phraseMatches(tokens, []Term{{ID: 9, Term: "step"}})) != 0 {
t.Fatal("a single-word key is not a phrase match")
}
}
// TestMySQLPhraseSaveAndCrossChapterHighlight is the core path: saving a phrase, finding it
// again in another chapter, and keeping one record per identity.
func TestMySQLPhraseSaveAndCrossChapterHighlight(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
drainIngest(t, db)
code, first := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "phrase-fixture-0001", "title": "Phrase chapter", "text": phraseFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
// A second chapter with the same phrase in a different form.
code, second := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "phrase-fixture-0002", "title": "Another chapter", "text": "Mira took a small step again.\n", "language": "en"})
if code != 201 {
t.Fatalf("second paste %d", code)
}
drainIngest(t, db)
tokens := phraseTokens(phraseFixtureText)
start, end := spanOf(t, tokens, "a", "small", "step")
body := map[string]any{
"chapterId": first.Chapter.ID, "start": start, "end": end,
"definition": "一小步", "examples": []string{"Take a small step, every day."}, "status": termStatusNew,
}
code, msg, data := callRaw(t, r, "POST", "/api/v1/phrases", learner.Token, body)
if code != 201 {
t.Fatalf("save phrase %d (%s)", code, msg)
}
var saved struct {
Term TermView
Created bool
}
json.Unmarshal(data, &saved)
if saved.Term.Kind != "phrase" || saved.Term.WordCount != 3 || saved.Term.Term != "a small step" {
t.Fatalf("saved phrase %+v", saved.Term)
}
if saved.Term.OriginalForm != "a small, step" {
t.Fatalf("the display text keeps the original punctuation: %q", saved.Term.OriginalForm)
}
// The same phrase saved from the other chapter, written differently, stays one record.
otherTokens := phraseTokens("Mira took a small step again.\n")
otherStart, otherEnd := spanOf(t, otherTokens, "a", "small", "step")
code, msg, data = callRaw(t, r, "POST", "/api/v1/phrases", learner.Token, map[string]any{
"chapterId": second.Chapter.ID, "start": otherStart, "end": otherEnd, "definition": "一小步(更新)", "status": termStatusNew,
})
if code != 200 {
t.Fatalf("repeat phrase %d (%s)", code, msg)
}
var again struct {
Term TermView
Created bool
}
json.Unmarshal(data, &again)
if again.Created || again.Term.ID != saved.Term.ID || again.Term.OriginalForm != "a small step" {
t.Fatalf("one phrase must stay one record: %+v", again)
}
var count int64
// A phrase key always contains a space, so the key shape is the whole filter.
db.Model(&Term{}).Where("owner_id = ? AND term LIKE ?", learner.ID, "% %").Count(&count)
if count != 1 {
t.Fatalf("phrase records: %d", count)
}
// Both chapters highlight the phrase, including the one where it was not saved. The first
// chapter holds the phrase twice with different interior punctuation.
wantOccurrences := map[int64]int{first.Chapter.ID: 2, second.Chapter.ID: 1}
for chapterID, want := range wantOccurrences {
code, _, data = callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/tokens", chapterID), learner.Token, nil)
var analyzed ChapterTokens
json.Unmarshal(data, &analyzed)
if code != 200 || len(analyzed.Phrases) != want {
t.Fatalf("chapter %d phrases: %d %+v", chapterID, code, analyzed.Phrases)
}
for _, span := range analyzed.Phrases {
if span.ID != saved.Term.ID || span.WordCount != 3 || span.EndToken <= span.StartToken {
t.Fatalf("chapter %d span %+v", chapterID, span)
}
if analyzed.Tokens[span.StartToken].Kind != "word" || analyzed.Tokens[span.EndToken].Kind != "word" {
t.Fatalf("a phrase must span words: %+v", span)
}
}
}
// Another account sees no phrase at all.
code, _, data = callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/tokens", first.Chapter.ID), other.Token, nil)
if code != 404 {
t.Fatalf("another account reading this chapter: %d", code)
}
// The phrase joins the review queue with its kind, and answers like a word.
code, queue := reviewQueue(t, r, learner.Token)
if code != 200 || len(queue.Items) != 1 || queue.Items[0].Kind != "phrase" || queue.Items[0].WordCount != 3 {
t.Fatalf("phrase queue: %d %+v", code, queue.Items)
}
code, applied := answerReview(t, r, learner.Token, saved.Term.ID, answerBody("phrase-answer-0001", reviewGradeCorrect, queue.Items[0].DueAt))
if code != 201 || applied.Result != "applied" || applied.LevelAfter != 1 || applied.Item.Kind != "phrase" {
t.Fatalf("phrase answer: %d %+v", code, applied)
}
// The word-level highlight is unaffected: the same words are still ordinary words.
code, _, data = callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/tokens", first.Chapter.ID), learner.Token, nil)
var withPhrase ChapterTokens
json.Unmarshal(data, &withPhrase)
for _, index := range []int{withPhrase.Phrases[0].StartToken, withPhrase.Phrases[0].EndToken} {
if withPhrase.Tokens[index].Term != nil {
t.Fatal("a phrase must not create word-level records")
}
}
}
// TestMySQLPhraseRulesAndIsolation covers the rejected shapes and ownership.
func TestMySQLPhraseRulesAndIsolation(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "phrase-rules-0001", "title": "Phrase rules", "text": phraseFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
drainIngest(t, db)
tokens := phraseTokens(phraseFixtureText)
start, end := spanOf(t, tokens, "a", "small", "step")
singleStart, singleEnd := wordsAt(t, tokens, "a")
base := map[string]any{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": termStatusNew}
if code, _, _ := callRaw(t, r, "POST", "/api/v1/phrases", other.Token, base); code != 404 {
t.Fatal("another account must not save a phrase in this chapter")
}
if code, _, _ := callRaw(t, r, "POST", "/api/v1/phrases", "", base); code != 401 {
t.Fatal("saving a phrase needs a session")
}
invalid := []map[string]any{
{"chapterId": pasted.Chapter.ID, "start": singleStart, "end": singleEnd, "status": termStatusNew},
{"chapterId": pasted.Chapter.ID, "end": end, "status": termStatusNew},
{"chapterId": pasted.Chapter.ID, "start": start + 3, "end": end, "status": termStatusNew},
{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": "deleted"},
{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": termStatusKnown, "level": 3},
{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": termStatusNew, "definition": strings.Repeat("a", termDefinitionLimit+1)},
{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": termStatusNew, "term": "forged"},
{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": termStatusNew, "ownerId": 9},
{"chapterId": 999999, "start": start, "end": end, "status": termStatusNew},
}
for _, body := range invalid {
if code, msg, _ := callRaw(t, r, "POST", "/api/v1/phrases", learner.Token, body); code != 400 && code != 404 {
t.Fatalf("invalid phrase %v -> %d (%s)", body, code, msg)
}
}
var count int64
db.Model(&Term{}).Where("owner_id = ?", learner.ID).Count(&count)
if count != 0 {
t.Fatalf("a rejected phrase was stored: %d", count)
}
// A phrase in a chapter that is not ready is refused, exactly like a word.
if err := db.Model(&Chapter{}).Where("id = ?", pasted.Chapter.ID).Update("status", statusPending).Error; err != nil {
t.Fatal(err)
}
if code, _, _ := callRaw(t, r, "POST", "/api/v1/phrases", learner.Token, base); code != 409 {
t.Fatal("a pending chapter must refuse a phrase")
}
}
func wordsAt(t *testing.T, tokens []TextToken, word string) (int, int) {
t.Helper()
start, end := spanOf(t, tokens, word)
return start, end
}
// TestMySQLPhraseSurvivesTextEditsAndDeletion locks the fallback rule for stale references.
func TestMySQLPhraseSurvivesTextEditsAndDeletion(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "phrase-survive-0001", "title": "Phrase survival", "text": phraseFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
drainIngest(t, db)
tokens := phraseTokens(phraseFixtureText)
start, end := spanOf(t, tokens, "a", "small", "step")
code, msg, data := callRaw(t, r, "POST", "/api/v1/phrases", learner.Token, map[string]any{
"chapterId": pasted.Chapter.ID, "start": start, "end": end, "definition": "一小步", "status": termStatusNew})
if code != 201 {
t.Fatalf("save phrase %d (%s)", code, msg)
}
var saved struct {
Term TermView
}
json.Unmarshal(data, &saved)
// Editing the text so the phrase no longer occurs: the entry and its schedule stay.
if code, msg, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{"text": "Nothing left of it.\n"}); code != 200 {
t.Fatalf("edit chapter %d (%s)", code, msg)
}
drainIngest(t, db)
code, _, data = callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/tokens", pasted.Chapter.ID), learner.Token, nil)
var analyzed ChapterTokens
json.Unmarshal(data, &analyzed)
if code != 200 || len(analyzed.Phrases) != 0 {
t.Fatalf("a phrase that no longer occurs must not highlight: %d %+v", code, analyzed.Phrases)
}
if code, _, _ := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/terms/%d", saved.Term.ID), learner.Token, nil); code != 200 {
t.Fatal("the entry must survive a text edit")
}
code, queue := reviewQueue(t, r, learner.Token)
if code != 200 || len(queue.Items) != 1 {
t.Fatalf("the schedule must survive a text edit: %d %+v", code, queue.Items)
}
// Deleting the chapter keeps the entry and its schedule too.
if code, msg, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), learner.Token, nil); code != 200 {
t.Fatalf("delete chapter %d (%s)", code, msg)
}
if code, _, _ := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/terms/%d", saved.Term.ID), learner.Token, nil); code != 200 {
t.Fatal("the entry must survive a chapter deletion")
}
code, queue = reviewQueue(t, r, learner.Token)
if code != 200 || len(queue.Items) != 1 || queue.Items[0].Kind != "phrase" {
t.Fatalf("the schedule must survive a chapter deletion: %d %+v", code, queue.Items)
}
}
+4
View File
@@ -80,6 +80,8 @@ type ReviewItem struct {
Examples []string `json:"examples"`
Status string `json:"status"`
Level int `json:"level"`
Kind string `json:"kind"`
WordCount int `json:"wordCount"`
DueAt time.Time `json:"dueAt"`
ReviewCount int `json:"reviewCount"`
}
@@ -162,6 +164,7 @@ func reviewItem(term Term, review TermReview) ReviewItem {
return ReviewItem{
ID: term.ID, Term: term.Term, OriginalForm: term.OriginalForm, Definition: term.Definition,
Examples: splitExamples(term.Examples), Status: term.Status, Level: term.Level,
Kind: termKind(term.Term), WordCount: termWordCount(term.Term),
DueAt: review.DueAt, ReviewCount: review.ReviewCount,
}
}
@@ -203,6 +206,7 @@ func ReviewQueueFor(tx *gorm.DB, owner int, language string, now time.Time) (Rev
queue.Items = append(queue.Items, ReviewItem{
ID: row.ID, Term: row.Term, OriginalForm: row.OriginalForm, Definition: row.Definition,
Examples: splitExamples(row.Examples), Status: row.Status, Level: row.Level,
Kind: termKind(row.Term), WordCount: termWordCount(row.Term),
DueAt: row.DueAt, ReviewCount: row.ReviewCount,
})
}
+2 -2
View File
@@ -137,8 +137,8 @@ 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" {
// Saving the same entry again is an update of one record, not a new resource.
if c.Request.Method == "POST" && (c.FullPath() == "/api/v1/terms" || c.FullPath() == "/api/v1/phrases") {
if saved, ok := data.(TermSave); ok && saved.Created {
status = 201
}
+39 -4
View File
@@ -26,6 +26,13 @@ const (
termStatusIgnored = "ignored"
)
// One table holds both entries: a word and a phrase differ in kind and word count, and a
// phrase key always contains a space, so the identity keys cannot collide.
const (
termKindWord = "word"
termKindPhrase = "phrase"
)
const (
termFormLimit = 128
termDefinitionLimit = 2000
@@ -60,6 +67,23 @@ type Term struct {
UpdatedAt time.Time
}
// Kind and word count need no columns: a phrase key is the ordered normalized word forms
// joined by single spaces, and a word form can never contain a space, so the identity key
// itself carries both facts. One helper keeps that invariant in a single place.
func termKind(key string) string {
if strings.Contains(key, " ") {
return termKindPhrase
}
return termKindWord
}
func termWordCount(key string) int {
if !strings.Contains(key, " ") {
return 1
}
return len(strings.Split(key, " "))
}
func (Term) TableName() string { return "lexgo_terms" }
type TermView struct {
@@ -71,6 +95,8 @@ type TermView struct {
Examples []string `json:"examples"`
Status string `json:"status"`
Level int `json:"level"`
Kind string `json:"kind"`
WordCount int `json:"wordCount"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
@@ -97,7 +123,12 @@ func splitExamples(text string) []string {
}
func termView(t Term) TermView {
return TermView{t.ID, t.Language, t.Term, t.OriginalForm, t.Definition, splitExamples(t.Examples), t.Status, t.Level, t.CreatedAt, t.UpdatedAt}
return TermView{
ID: t.ID, Language: t.Language, Term: t.Term, OriginalForm: t.OriginalForm, Definition: t.Definition,
Examples: splitExamples(t.Examples), Status: t.Status, Level: t.Level,
Kind: termKind(t.Term), WordCount: termWordCount(t.Term),
CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt,
}
}
// termLevel enforces the documented status/level boundary: only a learning entry carries a
@@ -215,10 +246,13 @@ func previousTerm(tx *gorm.DB, owner int, language, term string) (Term, bool, er
// 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) {
// saveTerm stores one identity: key is the canonical form used for lookup, display is what the
// learner sees. For a word both are the word form; for a phrase the key joins normalized word
// forms and the display keeps the original punctuation.
func saveTerm(tx *gorm.DB, owner int, language, key, display string, fields TermFields, now time.Time) (TermSave, error) {
when := stamp(now)
row := Term{
OwnerID: owner, Language: language, Term: normalizeWord(word), OriginalForm: word,
OwnerID: owner, Language: language, Term: key, OriginalForm: display,
Definition: fields.Definition, Examples: fields.Examples, Status: fields.Status, Level: fields.Level,
CreatedAt: when, UpdatedAt: when,
}
@@ -353,7 +387,7 @@ func registerTermRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context
Definition: definition, Examples: examples, Status: input.Status, Level: level,
PreviousStatus: previous.Status, PreviousLevel: previous.Level, Exists: exists,
}
return saveTerm(tx, u.UserId, language, word, fields, now())
return saveTerm(tx, u.UserId, language, normalizeWord(word), 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)
@@ -370,4 +404,5 @@ func registerTermRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context
}
return gin.H{"term": termView(term)}, nil
}))
registerPhraseRoutes(v, protect, now)
}