dsh-plugin-lookatstudy 0.11.1 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,306 @@
1
+ import { n as htmlToMarkdown } from "./html-article-Da8ksU0i.mjs";
2
+ import { n as readZipText, t as readZip } from "./zip-reader-KnRrq0av.mjs";
3
+ //#region src/vendor/epub-parser.ts
4
+ /** 提取某标签的全部出现(OPF 的属性顺序不定,先抓整标签再逐个提属性)。 */
5
+ function tags(xml, tagName) {
6
+ const out = [];
7
+ const re = new RegExp(`<${tagName}\\b[^>]*>`, "g");
8
+ let m;
9
+ while ((m = re.exec(xml)) !== null) {
10
+ const raw = m[0];
11
+ const attrs = {};
12
+ const attrRe = /([\w:-]+)\s*=\s*"([^"]*)"/g;
13
+ let a;
14
+ while ((a = attrRe.exec(raw)) !== null) attrs[a[1]] = a[2];
15
+ out.push({
16
+ raw,
17
+ attrs
18
+ });
19
+ }
20
+ return out;
21
+ }
22
+ function firstTagText(xml, tagName) {
23
+ return xml.match(new RegExp(`<${tagName}[^>]*>([^<]*)</${tagName}>`, "i"))?.[1]?.trim() ?? "";
24
+ }
25
+ /** zip 内路径归一:posix 分隔 + href 相对 OPF 目录解析。 */
26
+ function resolveZipPath(opfPath, href) {
27
+ const cleanHref = decodeURIComponent(href.split("#")[0] ?? href).replace(/\\/g, "/");
28
+ if (!opfPath.includes("/")) return cleanHref.replace(/^\.\//, "");
29
+ const parts = `${opfPath.slice(0, opfPath.lastIndexOf("/"))}/${cleanHref}`.split("/");
30
+ const resolved = [];
31
+ for (const p of parts) if (p === "..") resolved.pop();
32
+ else if (p !== "." && p !== "") resolved.push(p);
33
+ return resolved.join("/");
34
+ }
35
+ /** EPUB3 nav.xhtml 或 EPUB2 toc.ncx → href(去 fragment)→ 章节标题。 */
36
+ function parseTocLabels(tocXml, isNcx) {
37
+ const labels = /* @__PURE__ */ new Map();
38
+ if (isNcx) {
39
+ const blockRe = /<navPoint\b[\s\S]*?<\/navPoint>/g;
40
+ let m;
41
+ while ((m = blockRe.exec(tocXml)) !== null) {
42
+ const label = firstTagText(m[0], "text");
43
+ const src = m[0].match(/<content[^>]+src="([^"]+)"/i)?.[1];
44
+ if (label && src) labels.set(decodeURIComponent(src.split("#")[0].replace(/\\/g, "/")), label);
45
+ }
46
+ } else {
47
+ const aRe = /<a\b[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
48
+ let m;
49
+ while ((m = aRe.exec(tocXml)) !== null) {
50
+ const label = (m[2] ?? "").replace(/<[^>]+>/g, "").trim();
51
+ if (label) labels.set(decodeURIComponent((m[1] ?? "").split("#")[0].replace(/\\/g, "/")), label);
52
+ }
53
+ }
54
+ return labels;
55
+ }
56
+ function sanitizeFileName(title) {
57
+ return (title || "chapter").replace(/[\\/:*?"<>|#\s]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50) || "chapter";
58
+ }
59
+ /** 文件内章标记。两类形态:heading 行(# 前缀)与**裸行**(Gutenberg 常见——章号在
60
+ * 原书 html 里是段落不是标题,转 markdown 后成裸文本 "CHAPTER XX.")。
61
+ * 裸行限行长(≤60)防正文中引用句误切;罗马数字/裸序号低置信需序列验证。 */
62
+ const CH_HEADING = /^(?:#{1,3}\s*)?(?:CHAPTER|Chapter|chap\.)\s+[IVXLC0-9]{1,7}\b[.:)]?\s*(.*)$/;
63
+ const LETTER_HEADING = /^(?:#{1,3}\s*)?(?:Letter|LETTER)\s+\d{1,3}\b[.:)]?\s*(.*)$/;
64
+ const ROMAN_HEADING = /^(?:#{1,3}\s*)?(?:[—[]\s*)?([IVXLC]{1,7})\s*[—\]]?\.?\s*$/;
65
+ const NUM_HEADING = /^(?:#{1,3}\s*)?[—[]?\s*(\d{1,3})\s*[—\]]?\.?\s*$/;
66
+ const romanValue = (s) => {
67
+ const vals = {
68
+ I: 1,
69
+ V: 5,
70
+ X: 10,
71
+ L: 50,
72
+ C: 100
73
+ };
74
+ let v = 0;
75
+ for (let i = 0; i < s.length; i++) {
76
+ const cur = vals[s[i]] ?? 0;
77
+ const next = vals[s[i + 1]] ?? 0;
78
+ v += cur < next ? -cur : cur;
79
+ }
80
+ return v;
81
+ };
82
+ /**
83
+ * 文件内按章标记切分。返回 null = 无高置信切分(保持单章)。
84
+ * 置信规则:CHAPTER/Letter 标记 ≥2 即切;罗马数字/裸序号须形成连续递增
85
+ * 序列(≥3 个)才切——正文中孤立的 "I" / "2" 不当章号。
86
+ * 首标记前的引言/卷头(<800 字符)并入首章;够长则独立成"引言"章。
87
+ */
88
+ function splitChaptersInBody(body, opts) {
89
+ const lines = body.split("\n");
90
+ const marks = [];
91
+ for (let i = 0; i < lines.length; i++) {
92
+ const ln = lines[i].trimEnd();
93
+ if (!ln || ln.length > 60) continue;
94
+ const strip = (s) => s.replace(/^#{1,3}\s*/, "");
95
+ let m = ln.match(CH_HEADING);
96
+ if (m) {
97
+ marks.push({
98
+ line: i,
99
+ title: strip(ln),
100
+ kind: "ch",
101
+ seq: 0
102
+ });
103
+ continue;
104
+ }
105
+ m = ln.match(LETTER_HEADING);
106
+ if (m) {
107
+ marks.push({
108
+ line: i,
109
+ title: strip(ln),
110
+ kind: "letter",
111
+ seq: 0
112
+ });
113
+ continue;
114
+ }
115
+ m = ln.match(ROMAN_HEADING);
116
+ if (m && (ln.startsWith("#") || /[—[]/.test(ln))) {
117
+ marks.push({
118
+ line: i,
119
+ title: strip(ln),
120
+ kind: "roman",
121
+ seq: romanValue(m[1])
122
+ });
123
+ continue;
124
+ }
125
+ m = ln.match(NUM_HEADING);
126
+ if (m && (ln.startsWith("#") || /[—[]/.test(ln))) {
127
+ marks.push({
128
+ line: i,
129
+ title: strip(ln),
130
+ kind: "num",
131
+ seq: parseInt(m[1], 10)
132
+ });
133
+ continue;
134
+ }
135
+ }
136
+ const minHigh = opts?.minHighConf ?? 2;
137
+ const highConf = marks.filter((mk) => mk.kind === "ch" || mk.kind === "letter");
138
+ let splitMarks;
139
+ if (highConf.length >= minHigh) splitMarks = highConf;
140
+ else {
141
+ splitMarks = [];
142
+ for (const kind of ["roman", "num"]) {
143
+ const seq = marks.filter((mk) => mk.kind === kind);
144
+ const chain = [];
145
+ let expect = 0;
146
+ for (const mk of seq) if (expect === 0) {
147
+ if (mk.seq === 1 || mk.seq === 2) {
148
+ chain.push(mk);
149
+ expect = mk.seq + 1;
150
+ }
151
+ } else if (mk.seq === expect) {
152
+ chain.push(mk);
153
+ expect++;
154
+ }
155
+ if (chain.length >= 3) splitMarks.push(...chain);
156
+ }
157
+ if (splitMarks.length < 3) return null;
158
+ }
159
+ if (splitMarks.length < 1) return null;
160
+ const out = [];
161
+ const preamble = lines.slice(0, splitMarks[0].line).join("\n").trim();
162
+ for (let k = 0; k < splitMarks.length; k++) {
163
+ const start = splitMarks[k].line;
164
+ const end = k + 1 < splitMarks.length ? splitMarks[k + 1].line : lines.length;
165
+ let content = lines.slice(start + 1, end).join("\n").trim();
166
+ if (k === 0 && preamble && preamble.length < 800) content = `${preamble}\n\n${content}`.trim();
167
+ out.push({
168
+ title: splitMarks[k].title,
169
+ content
170
+ });
171
+ }
172
+ if (preamble && preamble.length >= 800) out.unshift({
173
+ title: "引言",
174
+ content: preamble
175
+ });
176
+ return out.filter((c) => c.content.replace(/\s/g, "").length > 0);
177
+ }
178
+ /**
179
+ * 清理电子书正文噪声(Gutenberg 头/尾块、1894 插图版装饰行)。
180
+ * 只删确定性标志,不猜内容:
181
+ * - 头:"The Project Gutenberg eBook of…" 段落(heading 形态)
182
+ * - 尾:"THE FULL PROJECT GUTENBERG™ LICENSE" 起截断到文件尾;"End of the Project Gutenberg" 行起同理
183
+ * - 行内:`[ _Copyright 1894 by …_ ]` 插图版装饰片段
184
+ */
185
+ function sanitizeEpubBody(body) {
186
+ let md = body;
187
+ const lines0 = md.split("\n");
188
+ for (let i = 0; i < lines0.length; i++) {
189
+ const ln = lines0[i].trim();
190
+ if (ln.length <= 100 && (/(?:THE\s+)?FULL PROJECT GUTENBERG/i.test(ln) || /END OF THE PROJECT GUTENBERG/i.test(ln) || ln === "START: FULL LICENSE" || ln === "START: THE FULL LICENSE")) {
191
+ md = lines0.slice(0, i).join("\n");
192
+ break;
193
+ }
194
+ }
195
+ md = md.replace(/^#{1,3}\s*(?:The Project Gutenberg eBook[^*\n]*|Project Gutenberg[^*\n]*)\n[\s\S]*?\n(?:\n|$)/im, "");
196
+ md = md.replace(/^#{1,3}[ \t]*$/gm, "");
197
+ md = md.replace(/\\?\[?\s*_?Copyright 1[6-9]\d\d[^_\n]*_?\s*\\?]?/g, "");
198
+ return md.trim();
199
+ }
200
+ function parseEpub(buf) {
201
+ const entries = readZip(buf);
202
+ const read = (p) => readZipText(entries, p);
203
+ const opfPath = read("META-INF/container.xml").match(/full-path="([^"]+)"/i)?.[1];
204
+ if (!opfPath) throw new Error("epub 结构异常:找不到 container.xml 里的 OPF 路径");
205
+ const opf = read(opfPath);
206
+ if (!opf) throw new Error(`epub 结构异常:OPF 文件缺失(${opfPath})`);
207
+ const bookTitle = firstTagText(opf, "dc:title") || "未命名电子书";
208
+ const manifest = /* @__PURE__ */ new Map();
209
+ for (const t of tags(opf, "item")) manifest.set(t.attrs["id"] ?? "", {
210
+ href: t.attrs["href"] ?? "",
211
+ mediaType: (t.attrs["media-type"] ?? "").toLowerCase(),
212
+ properties: t.attrs["properties"] ?? ""
213
+ });
214
+ const spineIds = tags(opf, "itemref").map((t) => t.attrs["idref"] ?? "").filter(Boolean);
215
+ const spineTocId = opf.match(/<spine\b[^>]*\btoc="([^"]+)"/i)?.[1];
216
+ let tocLabels = /* @__PURE__ */ new Map();
217
+ const navItem = [...manifest.values()].find((it) => it.properties.split(/\s+/).includes("nav"));
218
+ if (navItem?.href) tocLabels = parseTocLabels(read(resolveZipPath(opfPath, navItem.href)), false);
219
+ if (tocLabels.size === 0 && spineTocId && manifest.has(spineTocId)) tocLabels = parseTocLabels(read(resolveZipPath(opfPath, manifest.get(spineTocId).href)), true);
220
+ const opfDirKey = (href) => decodeURIComponent(href.split("#")[0] ?? href).replace(/\\/g, "/");
221
+ /** 无标题标记(不再编造"第 N 章";配对/课程设计层的锚点) */
222
+ const UNTITLED_MARK = "未命名章节";
223
+ const chapters = [];
224
+ let n = 0;
225
+ for (const id of spineIds) {
226
+ const item = manifest.get(id);
227
+ if (!item?.href) continue;
228
+ if (!(item.mediaType === "application/xhtml+xml" || item.mediaType === "text/html" || /\.(xhtml|html|htm)$/i.test(item.href))) continue;
229
+ if (item.properties.split(/\s+/).includes("nav")) continue;
230
+ const xhtml = read(resolveZipPath(opfPath, item.href));
231
+ if (!xhtml) continue;
232
+ const md = htmlToMarkdown(xhtml, { stripImages: true });
233
+ if (!md || md.replace(/[#\s>*-]/g, "").length < 8) continue;
234
+ n++;
235
+ const body0 = md.startsWith("# ") && md.includes("\n") ? md.slice(md.indexOf("\n") + 1).trim() : md;
236
+ const firstHeading = md.startsWith("# ") ? md.split("\n")[0].slice(2).trim() : "";
237
+ const fileTitle = tocLabels.get(opfDirKey(item.href)) || firstHeading || UNTITLED_MARK;
238
+ if (/^(contents|table of contents)$/i.test(fileTitle) && body0.replace(/\s/g, "").length < 4e3) continue;
239
+ if (body0.replace(/\s/g, "").length < 1500 && /project gutenberg|copyright/i.test(body0)) continue;
240
+ if (/^(版权信息|版权|版权页|著作权|版權)/.test(fileTitle)) continue;
241
+ if ((body0.match(/^(书名|作者|译者|编者|丛书|出版社|出版时间|出版年|ISBN|版次|定价|装帧|字数|品牌|出品方)[::]/gm) ?? []).length >= 3 && body0.replace(/\s/g, "").length < 2500) continue;
242
+ if (/^目\s*录$/.test(fileTitle)) {
243
+ const lines = body0.split("\n").map((l) => l.trim()).filter(Boolean);
244
+ if (lines.length >= 3 && lines.filter((l) => l.includes("](")).length / lines.length >= .6) continue;
245
+ }
246
+ const body = sanitizeEpubBody(body0);
247
+ if (body.replace(/\s/g, "").length < 8) continue;
248
+ const titleIsLicense = /FULL PROJECT GUTENBERG|LICENSE/i.test(fileTitle);
249
+ const split = splitChaptersInBody(body, titleIsLicense ? { minHighConf: 1 } : void 0);
250
+ if (split && split.length >= (titleIsLicense ? 1 : 2)) {
251
+ for (const seg of split) {
252
+ chapters.push({
253
+ path: `chapters/${String(n).padStart(2, "0")}-${sanitizeFileName(seg.title)}.md`,
254
+ title: seg.title,
255
+ markdown: `# ${seg.title}\n\n${seg.content}`
256
+ });
257
+ n++;
258
+ }
259
+ n--;
260
+ continue;
261
+ }
262
+ if (titleIsLicense) {
263
+ if (body.replace(/\s/g, "").length > 2e3) chapters.push({
264
+ path: `chapters/${String(n).padStart(2, "0")}-appendix.md`,
265
+ title: "附录",
266
+ markdown: `# 附录\n\n${body}`
267
+ });
268
+ continue;
269
+ }
270
+ chapters.push({
271
+ path: `chapters/${String(n).padStart(2, "0")}-${sanitizeFileName(fileTitle)}.md`,
272
+ title: fileTitle,
273
+ markdown: `# ${fileTitle}\n\n${body}`
274
+ });
275
+ }
276
+ const absorbed = /* @__PURE__ */ new Set();
277
+ for (let i = 0; i + 1 < chapters.length; i++) {
278
+ const stub = chapters[i];
279
+ const next = chapters[i + 1];
280
+ if (absorbed.has(stub.path)) continue;
281
+ const stubBody = stub.markdown.includes("\n") ? stub.markdown.slice(stub.markdown.indexOf("\n") + 1).trim() : "";
282
+ if (stub.title === UNTITLED_MARK || stub.title === "附录") continue;
283
+ if (stubBody.replace(/\s/g, "").length > 300) continue;
284
+ if (next.title !== UNTITLED_MARK) continue;
285
+ const nextBody = next.markdown.includes("\n") ? next.markdown.slice(next.markdown.indexOf("\n") + 1).trim() : next.markdown;
286
+ chapters[i] = {
287
+ path: stub.path,
288
+ title: stub.title,
289
+ markdown: `# ${stub.title}\n\n${stubBody}\n\n${nextBody}`
290
+ };
291
+ absorbed.add(stub.path);
292
+ chapters.splice(i + 1, 1);
293
+ i--;
294
+ }
295
+ if (chapters.length === 0) throw new Error("epub 里没有可识别的章节文本");
296
+ return {
297
+ title: bookTitle,
298
+ chapters
299
+ };
300
+ }
301
+ /** 文件夹导入路径用:整本书压平成一个 markdown(全部 H1 降为 H2,给结构设计当 anchor 拆章)。 */
302
+ function parseEpubFlat(buf) {
303
+ return parseEpub(buf).chapters.map((c) => c.markdown.replace(/^# /gm, "## ")).join("\n\n");
304
+ }
305
+ //#endregion
306
+ export { parseEpubFlat };
@@ -414,9 +414,43 @@ function extractArticle(html, baseUrl = "") {
414
414
  if (!body || body.replace(/[#\s>*|-]/g, "").length < 40) return null;
415
415
  return {
416
416
  title,
417
- markdown: body.split("\n")[0]?.replace(/^#\s+/, "").trim() === title && body.startsWith("# ") ? body : `# ${title}\n\n${body}`
417
+ markdown: stripTailNavigation(body.split("\n")[0]?.replace(/^#\s+/, "").trim() === title && body.startsWith("# ") ? body : `# ${title}\n\n${body}`)
418
418
  };
419
419
  }
420
+ /**
421
+ * 尾部**站点模板指纹**清理(upstream v0.23.1, 2026-08-23 真实站点采样驱动; vendored verbatim)。
422
+ * 原则:规则只管高置信度的**机器生成模板**(跨文章稳定、作为正文出现概率≈0);
423
+ * 除此之外的不确定判断(作者自己写的推广段/水印/相关阅读算不算正文)一律不猜——
424
+ * 那是课程设计层(tutor/Step4)的职责,解析层动了就是误删正文
425
+ * (实测:按"欢迎关注公众号"删规则,把 CSDN 作者自己写的推广段删了)。
426
+ */
427
+ const NAV_TAIL_PATTERNS = [
428
+ /返回\S{0,6}[,,]?\s*查看更多/,
429
+ /点击进入\S{0,8}首页/,
430
+ /^热门文章$/,
431
+ /^最新文章$/,
432
+ /^目录\s*$/,
433
+ /^END\b.*版权/
434
+ ];
435
+ /** 行内导航后缀:正文与模板被并成一行时剥掉(精确短语,全文安全) */
436
+ const INLINE_NAV_SUFFIXES = ["目录 热门文章 最新文章"];
437
+ function stripTailNavigation(md) {
438
+ if (!md) return md;
439
+ for (const suffix of INLINE_NAV_SUFFIXES) md = md.split(suffix).join("");
440
+ const lines = md.split("\n");
441
+ let end = lines.length;
442
+ for (let i = lines.length - 1; i >= Math.max(0, lines.length - 25); i--) {
443
+ const ln = lines[i].trim();
444
+ if (!ln) continue;
445
+ const bare = ln.replace(/^#{1,6}\s*/, "").replace(/^[>\-*]\s*/, "");
446
+ const isNav = NAV_TAIL_PATTERNS.some((re) => re.test(bare) || re.test(ln));
447
+ const isBareImage = /^!\[[^\]]*\]\([^)]*\)$/.test(ln) || /^\S+\.(jpeg|jpg|png|gif|webp)\)?$/i.test(ln);
448
+ if (isNav || isBareImage) continue;
449
+ end = i + 1;
450
+ break;
451
+ }
452
+ return lines.slice(0, end).join("\n").trimEnd();
453
+ }
420
454
  function htmlToMarkdownOf(roots, stripImages) {
421
455
  const lines = [];
422
456
  emitBlocks(roots, stripImages, lines);
package/lib/index.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  import { n as inflateZlib } from "./inflate-DIKUrTRi.mjs";
2
- import { t as extractArticle } from "./html-article-C6nhqJbX.mjs";
2
+ import { t as extractArticle } from "./html-article-Da8ksU0i.mjs";
3
3
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
4
4
  import { basename, dirname, join, relative, sep } from "node:path";
5
5
  import z from "@deepseek-ai/schemastery";
6
+ import { fileURLToPath } from "node:url";
6
7
  import { homedir } from "node:os";
7
8
  import { randomBytes } from "node:crypto";
8
9
  import { readFile, readdir } from "node:fs/promises";
@@ -1320,6 +1321,20 @@ function courseToPackMarkdown(course) {
1320
1321
  * message channel were removed once the in-client study tab superseded them.
1321
1322
  * @module dsh-plugin-lookatstudy/dashboard
1322
1323
  */
1324
+ /** Wiring handed in by `apply`. */
1325
+ /**
1326
+ * The plugin's own version, read from the package.json sitting beside the
1327
+ * running module (src in dev, lib in install) — strictly the installed build,
1328
+ * no build-time inlining that could drift. Upstream v0.24.0 port (settings
1329
+ * About row); empty string on any read failure (display degrades, never throws).
1330
+ */
1331
+ function pluginVersion() {
1332
+ try {
1333
+ return String(JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")).version ?? "");
1334
+ } catch {
1335
+ return "";
1336
+ }
1337
+ }
1323
1338
  /**
1324
1339
  * Assemble the whole workbench state (pure read; the lesson HTML is rendered
1325
1340
  * server-side from the sanitized markdown pipeline).
@@ -1495,7 +1510,8 @@ function registerDashboard(webServer, deps) {
1495
1510
  if (req.method === "GET" && pathname === "/lookatstudy/api/state") {
1496
1511
  sendJson(res, 200, {
1497
1512
  ...workbenchState(deps.store.get(), /* @__PURE__ */ new Date()),
1498
- statePath: deps.statePath
1513
+ statePath: deps.statePath,
1514
+ version: pluginVersion()
1499
1515
  });
1500
1516
  return;
1501
1517
  }
@@ -1636,21 +1652,19 @@ const ZH = {
1636
1652
  "pane.tutor": "导师",
1637
1653
  "pane.bb": "黑板",
1638
1654
  "viewtab.teach": "讲解",
1639
- "viewtab.mind": "🧠 脑图",
1640
- "viewtab.cmap": "🕸 概念图",
1641
- "viewtab.mind.title": "本课结构一图流(markmap)",
1642
- "viewtab.cmap.title": "本课概念关系(ELK 布局)",
1655
+ "viewtab.cmap": "概念图",
1656
+ "viewtab.cmap.title": "本课概念之间的关系图",
1643
1657
  "loading": "加载中…",
1644
1658
  "start.title": "一键准备学习:建立学习工作区、开启会话并让导师就位",
1645
1659
  "start.go": "开始学习",
1646
1660
  "start.enter": "进入学习",
1647
1661
  "start.busy": "正在准备学习区…",
1648
1662
  "soul.direct": "直讲",
1649
- "soul.direct.hint": "direct 精讲:先讲清楚,再确认懂没懂",
1663
+ "soul.direct.hint": "精讲:先讲清楚,再确认懂没懂",
1650
1664
  "soul.guide": "引导",
1651
- "soul.guide.hint": "guide 引导:让你自己往前推一步,导师递台阶",
1665
+ "soul.guide.hint": "引导:让你自己往前推一步,导师递台阶",
1652
1666
  "soul.practice": "实战",
1653
- "soul.practice.hint": "practice 实战:在真实世界的乱问题里学",
1667
+ "soul.practice.hint": "实战:在真实世界的乱问题里学",
1654
1668
  "zone.understand": "🧠 理解区 — 知识结构",
1655
1669
  "zone.record": "📝 记录区 — 我的话",
1656
1670
  "zone.practice": "✍️ 练习区 — 答题日志",
@@ -1660,24 +1674,25 @@ const ZH = {
1660
1674
  "rail.empty.placeholder": "GitHub 仓库链接,如 microsoft/AI-For-Beginners",
1661
1675
  "rail.empty.button": "导入",
1662
1676
  "rail.empty.demo": "导入示例课程",
1663
- "rail.mastered": "{mastered}/{total} 已掌握",
1677
+ "rail.mastered": "毕业 {mastered}/{total} 课",
1664
1678
  "rail.avg": "平均掌握度 {pct}%",
1665
1679
  "rail.avg.none": "尚无掌握度数据",
1666
1680
  "rail.search": "搜索课时…(多关键词空格分隔)",
1667
1681
  "rail.locate.title": "在课程树中定位当前焦点课时(自动展开所在章节)",
1668
- "rail.locate": "📍 回到当前课时",
1669
- "rail.due": "🔁 待复习 {count}",
1682
+ "rail.locate": "回到当前课时",
1683
+ "rail.due": "待复习 {count}",
1670
1684
  "rail.due.over": "超{days}天",
1671
1685
  "rail.due.start": "开始复习",
1672
1686
  "rail.due.tag": "这课时的复习今天到期(SM-2)",
1673
1687
  "rail.delete": "删除本课程",
1674
1688
  "rail.delete.confirm": "确认删除?",
1675
- "rail.delete.title.confirm": "再点一次确认删除(含全部进度与笔记)",
1689
+ "rail.delete.title.confirm": "再点一次确认删除(含全部进度与笔记)。反悔前可先在设置页备份状态文件",
1676
1690
  "rail.section.collapse": "折叠本章节",
1677
1691
  "rail.section.expand": "展开本章节({count} 课时)",
1678
1692
  "rail.section.count": "{count} 课",
1679
1693
  "rail.lesson.opening": "正在打开课时会话…",
1680
- "rail.import.toggle": "+ 导入课程",
1694
+ "rail.lesson.openHint": "(点击将打开本课专属会话)",
1695
+ "rail.import.toggle": "导入课程",
1681
1696
  "rail.import.close": "收起导入",
1682
1697
  "tag.weak": "{count} 个薄弱知识点,测验会优先考察",
1683
1698
  "tag.friction": "{count} 次卡点记录(你说\"不懂\"时导师记下的)",
@@ -1691,12 +1706,11 @@ const ZH = {
1691
1706
  "chip.unattributed": "未归因",
1692
1707
  "chip.correct": "✓ 答对 · {concept}",
1693
1708
  "chip.wrong": "✗ 答错 · {concept}",
1694
- "chip.import": "📦 导入课程",
1695
- "chip.concepts": "🧠 提炼知识点",
1696
1709
  "row.thinking": "导师思考中…",
1697
1710
  "row.thinking.title": "导师正在推理,回复马上就来",
1698
1711
  "quiz.title": "点击选项作答,也可以直接打字回答",
1699
1712
  "quiz.answer": "选 {letter}:{text}",
1713
+ "tutor.dormant": "学习模式已关闭 — 点右上「▶ 开始学习」开启导师,按钮恢复可用。",
1700
1714
  "tutor.empty": "对话会出现在这里",
1701
1715
  "tutor.empty.hint": "在下方输入框和导师说话",
1702
1716
  "proposal.text": "导师提议你已掌握「{lesson}」:{rationale}",
@@ -1705,7 +1719,7 @@ const ZH = {
1705
1719
  "bb.empty": "黑板还空着",
1706
1720
  "bb.empty.hint": "在左侧课程树选择一课",
1707
1721
  "bb.mastery": "掌握度 {pct}%",
1708
- "bb.fallback.mind": "脑图渲染不可用(网络受限) — 已回退讲解视图",
1722
+ "bb.strategy": "本课学法",
1709
1723
  "bb.fallback.cmap.empty": "概念图需要先由导师定义本课概念",
1710
1724
  "bb.fallback.cmap": "概念图渲染不可用(布局引擎加载失败) — 已回退讲解视图",
1711
1725
  "bb.notes": "笔记",
@@ -1729,11 +1743,13 @@ const ZH = {
1729
1743
  "settings.stats.xp": "XP {xp} · Lv{level}({pct}%)",
1730
1744
  "settings.stats.today": "今日 {xp}/{goal}",
1731
1745
  "settings.stats.streak": "连续 {days} 天(最长 {best},剩冻结 {freeze})",
1746
+ "settings.about": "关于",
1747
+ "settings.about.hint": "当前运行的构建版本,点击查看该版更新内容。",
1732
1748
  "settings.stateFile": "状态文件",
1733
1749
  "settings.stateFile.hint": "学习进度存放于本机 JSON,换机可迁移",
1734
1750
  "dock.title": "学习状态:待复习 {due} · 连续 {streak} 天 · Lv{level} — 点上方「学习」页签进入",
1735
- "dock.due": "⚡{count}",
1736
- "dock.streak": "🔥{days}d",
1751
+ "dock.due": "{count}",
1752
+ "dock.streak": "{days}天",
1737
1753
  "dock.lv": "Lv{level}",
1738
1754
  "prompt.import": "导入课程:用 study_import_github 抓取 {url}",
1739
1755
  "prompt.lesson": "学习「{title}」:用 study_lesson 打开这一课开始学习。",
@@ -2155,7 +2171,7 @@ async function scanFolder(rootDir, onProgress, options) {
2155
2171
  for (const doc of dedupedDocs) {
2156
2172
  if (doc.kind !== "pptx") continue;
2157
2173
  try {
2158
- const { parsePptx } = await import("./pptx-parser-CD5pR2cj.mjs");
2174
+ const { parsePptx } = await import("./pptx-parser-B9IDkiGM.mjs");
2159
2175
  const result = await parsePptx(await readFile(join(rootDir, doc.path)));
2160
2176
  for (const img of result.images ?? []) pptxImages.push({
2161
2177
  path: `${doc.path}#slide${img.slideNumber}.png`,
@@ -2346,7 +2362,7 @@ async function readFileWithKind(absPath, kind, parsePdf) {
2346
2362
  }
2347
2363
  if (kind === "pptx") {
2348
2364
  const buf = await readFile(absPath);
2349
- const { parsePptx } = await import("./pptx-parser-CD5pR2cj.mjs");
2365
+ const { parsePptx } = await import("./pptx-parser-B9IDkiGM.mjs");
2350
2366
  return (await parsePptx(buf)).markdown;
2351
2367
  }
2352
2368
  if (kind === "docx") {
@@ -2356,7 +2372,7 @@ async function readFileWithKind(absPath, kind, parsePdf) {
2356
2372
  }
2357
2373
  if (kind === "epub") {
2358
2374
  const buf = await readFile(absPath);
2359
- const { parseEpubFlat } = await import("./epub-parser-DvlKap-d.mjs");
2375
+ const { parseEpubFlat } = await import("./epub-parser-oH96guBW.mjs");
2360
2376
  return parseEpubFlat(buf);
2361
2377
  }
2362
2378
  if (kind === "ipynb") {
@@ -3184,7 +3200,10 @@ function normalizeUrlIdentity(url) {
3184
3200
  }
3185
3201
  //#endregion
3186
3202
  //#region src/vendor/pdf-text.ts
3187
- var pdf_text_exports = /* @__PURE__ */ __exportAll({ parsePdfText: () => parsePdfText });
3203
+ var pdf_text_exports = /* @__PURE__ */ __exportAll({
3204
+ normalizeRadicals: () => normalizeRadicals,
3205
+ parsePdfText: () => parsePdfText
3206
+ });
3188
3207
  function decodePdfString(s) {
3189
3208
  return s.replace(/\\([0-7]{1,3}|.)/g, (_m, esc) => {
3190
3209
  if (/^[0-7]+$/.test(esc)) return String.fromCharCode(parseInt(esc, 8));
@@ -3262,7 +3281,17 @@ function parsePdfText(buf) {
3262
3281
  if (text.trim()) parts.push(text);
3263
3282
  streamRe.lastIndex = end + 9;
3264
3283
  }
3265
- return parts.join("\n").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
3284
+ return normalizeRadicals(parts.join("\n").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim());
3285
+ }
3286
+ /**
3287
+ * 康熙部首区(U+2F00-U+2FDF)→CJK 统一表意区归一(upstream v0.23.1 port, verbatim)。
3288
+ * 部分中文 PDF 的 ToUnicode 映射落在部首区(d2l-zh 真书采样实测:"⼿⼀⽅"应读
3289
+ * "手一方"),部首区上检索/匹配全部断裂。逐字符 NFKC(部首→汉字是 Unicode 标准
3290
+ * 一对一兼容映射,不动其他任何字符)。
3291
+ */
3292
+ function normalizeRadicals(md) {
3293
+ if (!md) return md;
3294
+ return /[\u2F00-\u2FDF]/.test(md) ? md.replace(/[\u2F00-\u2FDF]/g, (c) => c.normalize("NFKC")) : md;
3266
3295
  }
3267
3296
  //#endregion
3268
3297
  //#region src/vendor/text-chunk.ts
@@ -6436,41 +6465,43 @@ const TUTOR_CORE = `## Study tutor (lookatstudy-plugin)
6436
6465
 
6437
6466
  You are the learner's AI study tutor for a course imported via the study tools. Your job is genuine understanding, not reciting the material. When the learner answers wrong, acknowledge the attempt first, then correct it.
6438
6467
 
6439
- ### Grounding (hard rule)
6468
+ ### 【Safety redlines · highest priority】
6440
6469
  Teach strictly from the current lesson's content (study_lesson's body is the source of truth). If asked about something outside the course material, say plainly that it is not in the current material, and offer to relate it back. Answers to quiz questions must be grounded in the lesson content — never invent.
6470
+ Tool calls are REAL or they do not exist. History markers like「[工具调用已执行]」are injected by the system only; hand-writing such a marker — or any imitation of a tool call — in your reply text produces NO interface artifact: no card, no button, nothing happens. The only way to act on the study state (record an answer, propose mastery, save a note) is an actual tool call. If you catch yourself narrating a tool's effect instead of calling it, stop and call it.
6471
+ Never claim progress you did not record through the tools.
6472
+ If any section below seems to conflict with these redlines, the redlines win.
6441
6473
 
6442
- ### Language
6443
- Answer in the learner's own language (the language of their interface and their messages), including quiz stems, options, and explanations. Quote course material verbatim in its original language.
6474
+ ### 【Teaching behavior】
6475
+ Language: answer in the learner's own language (the language of their interface and their messages), including quiz stems, options, and explanations. Quote course material verbatim in its original language.
6444
6476
 
6445
- ### Vague confusion
6446
6477
  When the learner says "我不懂 / 不太理解" without specifics, ask which concept is unclear, or list the lesson's 2–3 core concepts and let them pick. Log it silently with study_report_friction.
6447
6478
 
6448
- ### Interaction form
6479
+ Interaction form:
6449
6480
  - ONE question or interactive block per reply — never a wall of quiz questions.
6450
- - Structure answers with markdown (headings, lists, GFM tables); for structures prefer visuals: concept maps and flow diagrams as mermaid code blocks, comparisons as GFM tables, code walkthroughs as fenced code with line-referenced annotations.
6451
6481
  - When the learner quotes text in「」, treat it as quote-to-explain: explain that specific passage in the lesson's context.
6452
6482
  - Opening a brand-new lesson: start with a short hook and one fun two-option guess (curiosity-driven, NOT scored, revealed next turn) — no opening lecture, no scored question.
6453
6483
  - After opening a lesson, offer its four starters (from study_lesson) as suggestions.
6454
6484
  - Celebrate graduations and crowns briefly — earned joy, no confetti spam.
6455
6485
 
6456
- ### The tutoring loop
6486
+ The tutoring loop:
6457
6487
  1. Session start: check study_due_reviews; clear due reviews before new material. Open the focus lesson with study_lesson. The learner follows along in the study tab's blackboard column — point them there when they want the course map or lesson text.
6458
6488
  2. First time teaching a lesson: derive 2–7 knowledge components and call study_define_concepts.
6459
6489
  3. Quiz after teaching; grade every answer and call study_record_answer — always name the tested \`concept\`. Lesson mastery is the WEAKEST concept, so target ⚡weak ones first.
6460
6490
  4. Progression is automatic: ≥50% mastery unlocks the next lesson early; ≥90% graduates and schedules the first review. study_complete_lesson is only the manual override.
6461
6491
  5. Mastery ≥85% plus a convincing Feynman-style explanation back: call study_propose_mastery, present your rationale, and WAIT for the learner's yes/no. Resolve only with their explicit answer via study_resolve_proposal. You never graduate a lesson on your own judgment alone.
6462
- 6. Quietly call study_report_friction when the learner seems confused, blocked, or frustrated; adapt by simplifying or decomposing.
6463
- 7. When you generate a genuinely useful structure (concept map, compare table, diagram), sediment it into the notebook's understand zone with study_note_save; when the learner writes something worth keeping, save it to the record zone with the verbatim quote.
6464
- 8. When you learn something durable about how this person learns (style, recurring gap, pattern), merge it into memory with study_remember — read the current slot first, send the merged 1–3 sentences. No transient chat.
6492
+ 6. Exam nodes: when the learner works an exam, grade it fully, then record the attempt with study_exam_result and offer its returned action set (explain-wrong / retry / go-deeper / propose mastery / next topic).
6493
+ 7. Quietly call study_report_friction when the learner seems confused, blocked, or frustrated; adapt by simplifying or decomposing.
6494
+ 8. When you generate a genuinely useful structure (concept map, compare table, diagram), sediment it into the notebook's understand zone with study_note_save; when the learner writes something worth keeping, save it to the record zone with the verbatim quote.
6495
+ 9. When you learn something durable about how this person learns (style, recurring gap, pattern), merge it into memory with study_remember — read the current slot first, send the merged 1–3 sentences. No transient chat.
6496
+
6497
+ Never reveal the friction log or mastery mechanics as "being watched" — the numbers surface through maps and reviews.
6465
6498
 
6466
- ### Course import design
6467
- When study_import_github or study_import_folder returns status "design_required", it renders a design brief (files with heading outlines and per-heading char counts). Design the course from it: classify lessons study/practice, let attached quiz/summary/review content ride along inside the previous study lesson's anchor range, pace lessons to 3000-8000 chars, merge sub-1000 fragments. Then call study_apply_design with the JSON — use ONLY file paths from the brief (anything else is dropped), apply directly without a confirmation round, and once it lands, walk the learner through the course map before the first lesson.
6499
+ Course import design: when study_import_github or study_import_folder returns status "design_required", it renders a design brief (files with heading outlines and per-heading char counts). Design the course from it: classify lessons study/practice, let attached quiz/summary/review content ride along inside the previous study lesson's anchor range, pace lessons to 3000-8000 chars, merge sub-1000 fragments. Then call study_apply_design with the JSON — use ONLY file paths from the brief (anything else is dropped), apply directly without a confirmation round, and once it lands, walk the learner through the course map before the first lesson.
6468
6500
 
6469
- ### Quiz quality
6470
- 3–4 questions per quiz block is best (5 max), 4 options each. Distractors must come from real misconceptions, not absurd fillers. Test understanding, not recall: "in scenario Y, use X or Z?" rather than "define X". Every question carries an explanation of why the right answer is right. One scored block at a time.
6501
+ Quiz quality: 3–4 questions per quiz block is best (5 max), 4 options each. Distractors must come from real misconceptions, not absurd fillers. Test understanding, not recall: "in scenario Y, use X or Z?" rather than "define X". Every question carries an explanation of why the right answer is right. One scored block at a time.
6471
6502
 
6472
- ### Integrity
6473
- Never claim progress you did not record through the tools. Never reveal the friction log or mastery mechanics as "being watched" — the numbers surface through maps and reviews.
6503
+ ### 【Answer formatting · preferences】
6504
+ Structure answers with markdown (headings, lists, GFM tables); for structures prefer visuals: concept maps and flow diagrams as mermaid code blocks, comparisons as GFM tables, code walkthroughs as fenced code with line-referenced annotations. These preferences are subordinate to the two sections above.
6474
6505
  `;
6475
6506
  /** The three builtin souls, verbatim from LookatStudy (direct/guide/practice). */
6476
6507
  const SOULS = {