dsh-plugin-lookatstudy 0.22.2 → 0.24.0

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.
package/lib/index.mjs CHANGED
@@ -228,6 +228,13 @@ const ZH = {
228
228
  "review.banner": "正在复习:{lesson}",
229
229
  "review.threadTitle": "复习:{lesson}",
230
230
  "threads.label": "课时线程",
231
+ "threads.scope": "跨课时连续对话",
232
+ "threads.scope.on": "已开启",
233
+ "threads.scope.off": "未开启",
234
+ "threads.coverage": "覆盖",
235
+ "ask.title": "导师想先确认一下",
236
+ "ask.free": "在下方输入框直接回复即可",
237
+ "ask.answered": "已回答",
231
238
  "meter.label": "上下文用量",
232
239
  "meter.aria": "上下文已用 {pct}%",
233
240
  "meter.brk.system": "系统提示",
@@ -256,6 +263,10 @@ const ZH = {
256
263
  "rail.due.start": "开始复习",
257
264
  "rail.due.none": "暂时没有到期的复习,保持节奏!",
258
265
  "chat.reasoning": "思考过程({n} 字)",
266
+ "chat.think": "思考",
267
+ "langview.original": "原文",
268
+ "langview.bilingual": "对照",
269
+ "langview.translation": "译文",
259
270
  "msg.speak": "朗读",
260
271
  "note.pin": "置顶笔记",
261
272
  "note.unpin": "取消置顶",
@@ -576,6 +587,13 @@ const EN = {
576
587
  "palette.kind.action": "Action",
577
588
  "palette.empty": "No matches",
578
589
  "threads.label": "Lesson threads",
590
+ "threads.scope": "Continuous course thread",
591
+ "threads.scope.on": "on",
592
+ "threads.scope.off": "off",
593
+ "threads.coverage": "covers",
594
+ "ask.title": "The tutor wants a quick confirm",
595
+ "ask.free": "Answer in the composer below",
596
+ "ask.answered": "answered",
579
597
  "threads.open": "{n} threads",
580
598
  "threads.now": "current",
581
599
  "threads.new": "New thread (your next message starts it)",
@@ -616,6 +634,10 @@ const EN = {
616
634
  "rail.due.start": "Start reviewing",
617
635
  "rail.due.none": "Nothing due right now — keep the streak!",
618
636
  "chat.reasoning": "Thinking ({n} chars)",
637
+ "chat.think": "Think",
638
+ "langview.original": "Original",
639
+ "langview.bilingual": "Both",
640
+ "langview.translation": "Translation",
619
641
  "msg.speak": "Speak",
620
642
  "note.pin": "Pin note",
621
643
  "note.unpin": "Unpin",
@@ -1482,6 +1504,23 @@ function computeStreakTransition(state, now = /* @__PURE__ */ new Date()) {
1482
1504
  };
1483
1505
  }
1484
1506
  //#endregion
1507
+ //#region src/thread-key.ts
1508
+ /** Resolve the thread-group map key for a send from `lessonId`. Absent scope
1509
+ * is legacy lesson behavior — old files never move. */
1510
+ function threadOwnerKey(scope, courseId, lessonId) {
1511
+ return scope === "course" ? `course:${courseId}` : lessonId;
1512
+ }
1513
+ /** True when a thread-group key names a course-scoped group. Disambiguated
1514
+ * by COLON COUNT, not by prefix alone: a course titled "Course" slugs to
1515
+ * `course`, and its lesson keys (`course:0:0`) live inside the naive
1516
+ * `course:` prefix space (caught live by the B7 trash test). Lesson ids are
1517
+ * always `<slug>:<si>:<li>` — three segments, since slugs strip the colon —
1518
+ * while a course key is exactly `course:<slug>`, two segments. */
1519
+ function isCourseOwnerKey(key) {
1520
+ if (!key.startsWith("course:")) return false;
1521
+ return !key.slice(7).includes(":");
1522
+ }
1523
+ //#endregion
1485
1524
  //#region src/state.ts
1486
1525
  /**
1487
1526
  * Learning state: courses → sections → lessons with mastery-driven gating,
@@ -1814,7 +1853,7 @@ function deleteCourse(state, courseId) {
1814
1853
  const [course] = state.courses.splice(i, 1);
1815
1854
  const prefix = `${courseId}:`;
1816
1855
  const lessonThreads = [];
1817
- for (const key of Object.keys(state.lessonThreads)) if (key.startsWith(prefix)) {
1856
+ for (const key of Object.keys(state.lessonThreads)) if (key.startsWith(prefix) || key === `course:${courseId}`) {
1818
1857
  lessonThreads.push([key, state.lessonThreads[key]]);
1819
1858
  delete state.lessonThreads[key];
1820
1859
  }
@@ -1871,7 +1910,11 @@ function restoreCourse(state, courseId) {
1871
1910
  }));
1872
1911
  for (const p of entry.proposals) p.lessonId = remap(p.lessonId);
1873
1912
  entry.artifacts = entry.artifacts.map(([k, v]) => [remap(k), v]);
1874
- entry.lessonThreads = entry.lessonThreads.map(([k, v]) => [remap(k), v]);
1913
+ entry.lessonThreads = entry.lessonThreads.map(([k, v]) => {
1914
+ if (!isCourseOwnerKey(k)) return [remap(k), v];
1915
+ for (const t of v.threads) if (t.touchedLessons !== void 0) t.touchedLessons = t.touchedLessons.map(remap);
1916
+ return [`course:${id}`, v];
1917
+ });
1875
1918
  entry.lessonSessions = entry.lessonSessions.map(([k, v]) => [remap(k), v]);
1876
1919
  if (entry.focusLessonId !== null) entry.focusLessonId = remap(entry.focusLessonId);
1877
1920
  }
@@ -2303,25 +2346,67 @@ function recordAnswer(state, lessonId, correct, concept, now) {
2303
2346
  * @returns completion result including the unlocked lessons.
2304
2347
  */
2305
2348
  /**
2349
+ * Set a course's thread granularity (0.23.0). Switching never touches the
2350
+ * sediment: both sides' groups stay put and are resumed when the scope flips
2351
+ * back. Unknown ids fail loud.
2352
+ */
2353
+ function setCourseThreadScope(state, courseId, scope) {
2354
+ const course = state.courses.find((c) => c.id === courseId);
2355
+ if (course === void 0) throw new Error(`lookatstudy-plugin: unknown course id ${JSON.stringify(courseId)}`);
2356
+ course.threadScope = scope;
2357
+ }
2358
+ /**
2359
+ * Resolve the thread-group map key a send from `lessonId` belongs to — the
2360
+ * 0.23.0 granularity funnel: lesson scope keeps legacy lessonId keys, course
2361
+ * scope namespaces the course. Every thread-group access in this module goes
2362
+ * through here (or through an ownerKey already derived from it).
2363
+ */
2364
+ function ownerKeyFor(state, lessonId) {
2365
+ const ref = findLesson(state, lessonId);
2366
+ const scope = ref.course.threadScope === "course" ? "course" : "lesson";
2367
+ return {
2368
+ key: threadOwnerKey(scope, ref.course.id, lessonId),
2369
+ scope
2370
+ };
2371
+ }
2372
+ /**
2373
+ * Under course scope, mirrors drift per lesson (each remembers the last
2374
+ * thread IT used) — drift onto a LIVE thread is fine. The moment a thread
2375
+ * leaves the live set (archived, deleted, or rolled off the active pointer),
2376
+ * every mirror still naming it is a dead reference — re-point them at the
2377
+ * group's current active thread (or drop them when none is left). Lesson
2378
+ * scope keeps the legacy exact-mirror behavior at its own key.
2379
+ */
2380
+ function repointMirrorsOff(state, key, threadId, group) {
2381
+ if (threadId === null || !isCourseOwnerKey(key)) return;
2382
+ for (const lessonId of Object.keys(state.lessonSessions)) if (state.lessonSessions[lessonId] === threadId) {
2383
+ if (group.active === null) delete state.lessonSessions[lessonId];
2384
+ else state.lessonSessions[lessonId] = group.active;
2385
+ }
2386
+ }
2387
+ /**
2306
2388
  * Bind a dsh session into a lesson's thread group (the issue-#11 write, the
2307
2389
  * upstream sendMessage/ensureThreadForSend translation): an UNKNOWN session id
2308
2390
  * appends a new thread (titled by the caller — the first message's text or a
2309
2391
  * short action label) and becomes active; a KNOWN id re-activates it (bumping
2310
2392
  * lastAt); `null` clears the active pointer (the +新建 affordance — the next
2311
2393
  * send mints a fresh thread). The legacy lessonSessions map stays in lockstep
2312
- * with the active pointer so pre-0.22 readers never disagree.
2394
+ * with the active pointer so pre-0.22 readers never disagree. Under course
2395
+ * scope (0.23.0) the group is the course's, the send-from lesson joins the
2396
+ * thread's touchedLessons coverage, and mirrors sync per
2397
+ * {@link repointRolledMirrors}.
2313
2398
  */
2314
2399
  function bindLessonThread(state, lessonId, sessionId, title) {
2315
- findLesson(state, lessonId);
2400
+ const { key, scope } = ownerKeyFor(state, lessonId);
2316
2401
  state.lessonThreads ??= {};
2317
- const group = state.lessonThreads[lessonId] ?? {
2402
+ const group = state.lessonThreads[key] ?? {
2318
2403
  active: null,
2319
2404
  threads: []
2320
2405
  };
2321
2406
  if (sessionId === null) {
2322
2407
  group.active = null;
2323
- state.lessonThreads[lessonId] = group;
2324
- delete state.lessonSessions[lessonId];
2408
+ state.lessonThreads[key] = group;
2409
+ if (scope === "lesson") delete state.lessonSessions[lessonId];
2325
2410
  return group;
2326
2411
  }
2327
2412
  const existing = group.threads.find((t) => t.id === sessionId);
@@ -2331,31 +2416,37 @@ function bindLessonThread(state, lessonId, sessionId, title) {
2331
2416
  id: sessionId,
2332
2417
  title: (title ?? "").trim() !== "" ? title.trim() : lessonId,
2333
2418
  createdAt: now,
2334
- lastAt: now
2419
+ lastAt: now,
2420
+ touchedLessons: [lessonId]
2335
2421
  });
2336
2422
  } else {
2337
2423
  existing.lastAt = (/* @__PURE__ */ new Date()).toISOString();
2338
2424
  if (existing.status === "archived") existing.status = "active";
2425
+ const touched = existing.touchedLessons ??= [];
2426
+ if (!touched.includes(lessonId)) touched.push(lessonId);
2339
2427
  }
2340
2428
  group.active = sessionId;
2341
- state.lessonThreads[lessonId] = group;
2429
+ state.lessonThreads[key] = group;
2342
2430
  state.lessonSessions[lessonId] = sessionId;
2343
2431
  return group;
2344
2432
  }
2345
2433
  /** Roll the group's active pointer to the freshest ACTIVE thread (or null)
2346
2434
  * and keep the legacy lessonSessions mirror in lockstep. Shared by the
2347
2435
  * archive/delete operations when they take out the current thread. */
2348
- function rollActiveToFresh(state, lessonId, group) {
2436
+ function rollActiveToFresh(state, key, group) {
2437
+ const prev = group.active;
2349
2438
  const live = group.threads.filter((t) => t.status !== "archived");
2350
2439
  group.active = live.length > 0 ? live.reduce((a, b) => a.lastAt > b.lastAt ? a : b).id : null;
2351
- if (group.active !== null) state.lessonSessions[lessonId] = group.active;
2352
- else delete state.lessonSessions[lessonId];
2440
+ if (isCourseOwnerKey(key)) repointMirrorsOff(state, key, prev, group);
2441
+ else if (group.active !== null) state.lessonSessions[key] = group.active;
2442
+ else delete state.lessonSessions[key];
2353
2443
  }
2354
2444
  /** Rename a thread's stored title (issue #11 management: upstream gear menu).
2355
2445
  * The dsh session's own title is renamed host-side by the client (best
2356
2446
  * effort) — this is the durable plugin-side half. Throws on unknown ids. */
2357
2447
  function renameLessonThread(state, lessonId, sessionId, title) {
2358
- const group = state.lessonThreads?.[lessonId];
2448
+ const key = ownerKeyFor(state, lessonId).key;
2449
+ const group = state.lessonThreads?.[key];
2359
2450
  if (group === void 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread group`);
2360
2451
  const thread = group.threads.find((t) => t.id === sessionId);
2361
2452
  if (thread === void 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread ${JSON.stringify(sessionId)}`);
@@ -2368,12 +2459,14 @@ function renameLessonThread(state, lessonId, sessionId, title) {
2368
2459
  * threads leave the switcher list entirely (no unarchive entry there; the
2369
2460
  * state API keeps `archived: false` for symmetry/future UI). */
2370
2461
  function archiveLessonThread(state, lessonId, sessionId, archived) {
2371
- const group = state.lessonThreads?.[lessonId];
2462
+ const key = ownerKeyFor(state, lessonId).key;
2463
+ const group = state.lessonThreads?.[key];
2372
2464
  if (group === void 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread group`);
2373
2465
  const thread = group.threads.find((t) => t.id === sessionId);
2374
2466
  if (thread === void 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread ${JSON.stringify(sessionId)}`);
2375
2467
  thread.status = archived ? "archived" : "active";
2376
- if (archived && group.active === sessionId) rollActiveToFresh(state, lessonId, group);
2468
+ if (archived && group.active === sessionId) rollActiveToFresh(state, key, group);
2469
+ if (archived) repointMirrorsOff(state, key, sessionId, group);
2377
2470
  if (!archived && group.active === null) {
2378
2471
  group.active = sessionId;
2379
2472
  state.lessonSessions[lessonId] = sessionId;
@@ -2385,12 +2478,14 @@ function archiveLessonThread(state, lessonId, sessionId, archived) {
2385
2478
  * the host session face (no delete primitive) — it stays in the host's session
2386
2479
  * list; the UI discloses this. Throws on unknown ids. */
2387
2480
  function deleteLessonThread(state, lessonId, sessionId) {
2388
- const group = state.lessonThreads?.[lessonId];
2481
+ const key = ownerKeyFor(state, lessonId).key;
2482
+ const group = state.lessonThreads?.[key];
2389
2483
  if (group === void 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread group`);
2390
2484
  const idx = group.threads.findIndex((t) => t.id === sessionId);
2391
2485
  if (idx < 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread ${JSON.stringify(sessionId)}`);
2392
2486
  group.threads.splice(idx, 1);
2393
- if (group.active === sessionId) rollActiveToFresh(state, lessonId, group);
2487
+ if (group.active === sessionId) rollActiveToFresh(state, key, group);
2488
+ repointMirrorsOff(state, key, sessionId, group);
2394
2489
  return group;
2395
2490
  }
2396
2491
  function completeLesson(state, lessonId, now) {
@@ -2965,6 +3060,7 @@ function workbenchState(state, now) {
2965
3060
  return {
2966
3061
  courseId: course.id,
2967
3062
  title: course.title,
3063
+ threadScope: course.threadScope === "course" ? "course" : "lesson",
2968
3064
  mastered: lessons.filter((l) => l.status === "mastered").length,
2969
3065
  total: lessons.length,
2970
3066
  avgMasteryPct: answered.length === 0 ? null : Math.round(answered.reduce((sum, l) => sum + (l.mastery ?? 0), 0) / answered.length * 100),
@@ -3025,9 +3121,14 @@ function workbenchState(state, now) {
3025
3121
  quote: n.quote,
3026
3122
  pinned: n.pinned === true
3027
3123
  })),
3028
- html: renderMarkdown(normalizeMathNotation(ref.lesson.translation === void 0 ? ref.lesson.body : renderBilingual(ref.lesson.body, ref.lesson.translation))),
3124
+ html: renderMarkdown(normalizeMathNotation(ref.lesson.body)),
3029
3125
  markdown: ref.lesson.body,
3030
- speechText: normalizeSpeechText(normalizeMathNotation(ref.lesson.body))
3126
+ speechText: normalizeSpeechText(normalizeMathNotation(ref.lesson.body)),
3127
+ ...ref.lesson.translation !== void 0 ? {
3128
+ bilingualHtml: renderMarkdown(normalizeMathNotation(renderBilingual(ref.lesson.body, ref.lesson.translation))),
3129
+ translationHtml: renderMarkdown(normalizeMathNotation(ref.lesson.translation)),
3130
+ translationLang: ref.lesson.translationLang ?? ""
3131
+ } : {}
3031
3132
  };
3032
3133
  } catch {
3033
3134
  lesson = null;
@@ -3272,6 +3373,7 @@ function registerDashboard(webServer, deps) {
3272
3373
  }
3273
3374
  try {
3274
3375
  const ref = findLesson(deps.store.get(), body.lessonId);
3376
+ if (ref.lesson.kind !== "exam" && ref.lesson.status === "available") attemptLesson(deps.store.get(), ref.lesson.id, /* @__PURE__ */ new Date());
3275
3377
  deps.store.get().focus = { lessonId: ref.lesson.id };
3276
3378
  deps.store.save();
3277
3379
  sendJson(res, 200, { ok: true });
@@ -3662,6 +3764,7 @@ function registerDashboard(webServer, deps) {
3662
3764
  return;
3663
3765
  }
3664
3766
  const state = deps.store.get();
3767
+ let group = null;
3665
3768
  try {
3666
3769
  if (body.op === "rename") {
3667
3770
  if (typeof body.title !== "string" || body.title.trim() === "") {
@@ -3671,7 +3774,7 @@ function registerDashboard(webServer, deps) {
3671
3774
  });
3672
3775
  return;
3673
3776
  }
3674
- renameLessonThread(state, body.lessonId, body.sessionId, body.title);
3777
+ group = renameLessonThread(state, body.lessonId, body.sessionId, body.title);
3675
3778
  } else if (body.op === "archive") {
3676
3779
  if (typeof body.archived !== "boolean") {
3677
3780
  sendJson(res, 400, {
@@ -3680,8 +3783,8 @@ function registerDashboard(webServer, deps) {
3680
3783
  });
3681
3784
  return;
3682
3785
  }
3683
- archiveLessonThread(state, body.lessonId, body.sessionId, body.archived);
3684
- } else deleteLessonThread(state, body.lessonId, body.sessionId);
3786
+ group = archiveLessonThread(state, body.lessonId, body.sessionId, body.archived);
3787
+ } else group = deleteLessonThread(state, body.lessonId, body.sessionId);
3685
3788
  } catch (error) {
3686
3789
  sendJson(res, 404, {
3687
3790
  ok: false,
@@ -3690,7 +3793,6 @@ function registerDashboard(webServer, deps) {
3690
3793
  return;
3691
3794
  }
3692
3795
  deps.store.save();
3693
- const group = state.lessonThreads[body.lessonId];
3694
3796
  sendJson(res, 200, {
3695
3797
  ok: true,
3696
3798
  active: group?.active ?? null,
@@ -3698,6 +3800,32 @@ function registerDashboard(webServer, deps) {
3698
3800
  });
3699
3801
  return;
3700
3802
  }
3803
+ if (req.method === "POST" && pathname === "/lookatstudy/api/course-scope") {
3804
+ const body = await readJsonBodySafe(req, res);
3805
+ if (body === void 0) return;
3806
+ if (typeof body.courseId !== "string" || body.scope !== "lesson" && body.scope !== "course") {
3807
+ sendJson(res, 400, {
3808
+ ok: false,
3809
+ error: "courseId and scope (lesson|course) required"
3810
+ });
3811
+ return;
3812
+ }
3813
+ try {
3814
+ setCourseThreadScope(deps.store.get(), body.courseId, body.scope);
3815
+ } catch (error) {
3816
+ sendJson(res, 404, {
3817
+ ok: false,
3818
+ error: error instanceof Error ? error.message : String(error)
3819
+ });
3820
+ return;
3821
+ }
3822
+ deps.store.save();
3823
+ sendJson(res, 200, {
3824
+ ok: true,
3825
+ scope: body.scope
3826
+ });
3827
+ return;
3828
+ }
3701
3829
  if (req.method === "GET" && pathname === "/lookatstudy/api/course-pack") {
3702
3830
  const courseId = new URL(req.url ?? "/", "http://x").searchParams.get("courseId") ?? "";
3703
3831
  const course = findCourseForPack(deps.store.get(), courseId);
@@ -3779,6 +3907,26 @@ function registerDashboard(webServer, deps) {
3779
3907
  });
3780
3908
  return;
3781
3909
  }
3910
+ if (req.method === "POST" && pathname === "/lookatstudy/api/prefs") {
3911
+ const body = await readJsonBodySafe(req, res);
3912
+ if (body === void 0) return;
3913
+ if (typeof body.interfaceLang !== "string" || body.interfaceLang === "" || body.interfaceLang.length > 35) {
3914
+ sendJson(res, 400, {
3915
+ ok: false,
3916
+ error: "interfaceLang (non-empty string, max 35 chars) required"
3917
+ });
3918
+ return;
3919
+ }
3920
+ if (deps.store.get().interfaceLang !== body.interfaceLang) {
3921
+ deps.store.get().interfaceLang = body.interfaceLang;
3922
+ deps.store.save();
3923
+ }
3924
+ sendJson(res, 200, {
3925
+ ok: true,
3926
+ interfaceLang: body.interfaceLang
3927
+ });
3928
+ return;
3929
+ }
3782
3930
  if (req.method === "POST" && pathname === "/lookatstudy/api/attachment") {
3783
3931
  const body = await readJsonBodySafe(req, res, 29360128);
3784
3932
  if (body === void 0) return;
@@ -4863,6 +5011,104 @@ async function fetchRepoFileTree(owner, repo, branch, _fetchFn, signal) {
4863
5011
  }
4864
5012
  /** 文件数上限(防爆,和 IPC handler 一致) */
4865
5013
  const MAX_FILES = 500;
5014
+ /** 从 markdown 链接中提取翻译语言列表 */
5015
+ function extractLanguagesFromReadme(readmeMd) {
5016
+ const pattern = /\[([^\]]+)\]\(\.?\/?translations\/([^/)]+)\/README\.md\)/g;
5017
+ const langs = [];
5018
+ const seen = /* @__PURE__ */ new Set();
5019
+ let m;
5020
+ while ((m = pattern.exec(readmeMd)) !== null) {
5021
+ const name = m[1].trim();
5022
+ const code = m[2].trim();
5023
+ if (!seen.has(code)) {
5024
+ seen.add(code);
5025
+ langs.push({
5026
+ code,
5027
+ name
5028
+ });
5029
+ }
5030
+ }
5031
+ return langs;
5032
+ }
5033
+ /**
5034
+ * 净化翻译版 markdown —— 翻译内容是 CDN 原样拉取的,未经原文管道的
5035
+ * code-fence-aware parser 处理,可能含畸形结构导致 react-markdown 崩溃。
5036
+ *
5037
+ * 处理:
5038
+ * 1. 未闭合代码围栏:统计 ``` 和 ~~~ 数量,奇数则补一个闭合围栏
5039
+ * 2. 去除 <script>/<style>/<iframe> 等危险 HTML(防 XSS + 防渲染崩溃)
5040
+ * 3. 去 BOM、统一换行
5041
+ *
5042
+ * 这是确定性规则处理(高置信度),不是 LLM 判断 —— 格式修复是规则擅长的。
5043
+ */
5044
+ function sanitizeTranslatedMarkdown(md) {
5045
+ let s = md.replace(/^\uFEFF/, "");
5046
+ s = s.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
5047
+ const lines = s.split("\n");
5048
+ let fence = null;
5049
+ for (const line of lines) {
5050
+ const m = line.match(/^\s*(```|~~~)/);
5051
+ if (m) fence = fence ? null : m[1];
5052
+ }
5053
+ if (fence) s = s + "\n" + fence + "\n";
5054
+ s = s.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "");
5055
+ s = s.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, "");
5056
+ s = s.replace(/<\/?(iframe|object|embed)\b[^>]*>/gi, "");
5057
+ return s.trim();
5058
+ }
5059
+ /**
5060
+ * 拉取翻译版课程内容。
5061
+ *
5062
+ * 策略:不依赖翻译版 README 的链接(很多翻译只翻译了大纲,README 里没有 lesson 链接,
5063
+ * 或链接指向其他语言翻译)。直接用原文课程的文件路径,在前面加 translations/<code>/
5064
+ * 前缀去探测翻译版是否存在。5 并发拉取,404 跳过(该课无翻译)。
5065
+ *
5066
+ * @returns Map<originalPath, { title, content }> — key 是原文路径
5067
+ */
5068
+ async function fetchTranslatedContent(owner, repo, branch, langCode, originalFiles, fetchFn, onProgress) {
5069
+ const send = (msg) => onProgress?.(msg);
5070
+ const result = /* @__PURE__ */ new Map();
5071
+ const transReadmeUrl = cdnUrl(owner, repo, branch, `translations/${langCode}/README.md`);
5072
+ send(`检查翻译版 README (${langCode})…`);
5073
+ try {
5074
+ const r = await fetchFn(transReadmeUrl);
5075
+ if (!r.ok) {
5076
+ send(`翻译版不存在 (${r.status}),跳过`);
5077
+ return result;
5078
+ }
5079
+ } catch {
5080
+ send("翻译版检查失败,跳过");
5081
+ return result;
5082
+ }
5083
+ const mdFiles = originalFiles.filter((f) => !f.path.toLowerCase().endsWith(".ipynb"));
5084
+ send(`探测 ${mdFiles.length} 个文件的翻译版(${langCode})…`);
5085
+ const transPrefix = `translations/${langCode}/`;
5086
+ const CONCURRENCY = 5;
5087
+ let done = 0;
5088
+ for (let i = 0; i < mdFiles.length; i += CONCURRENCY) {
5089
+ const batch = mdFiles.slice(i, i + CONCURRENCY);
5090
+ const results = await Promise.allSettled(batch.map(async (file) => {
5091
+ const r = await fetchFn(cdnUrl(owner, repo, branch, transPrefix + file.path));
5092
+ if (!r.ok) return null;
5093
+ const md = await r.text();
5094
+ return {
5095
+ originalPath: file.path,
5096
+ title: file.title,
5097
+ content: sanitizeTranslatedMarkdown(md)
5098
+ };
5099
+ }));
5100
+ for (const res of results) {
5101
+ done++;
5102
+ if (res.status === "fulfilled" && res.value) result.set(res.value.originalPath, {
5103
+ title: res.value.title,
5104
+ content: res.value.content
5105
+ });
5106
+ }
5107
+ if (done % 10 === 0 || done === mdFiles.length) send(`翻译探测 ${done}/${mdFiles.length}(命中 ${result.size})`);
5108
+ }
5109
+ send(`翻译版拉取完成: ${result.size}/${mdFiles.length} 文件有翻译`);
5110
+ return result;
5111
+ }
4866
5112
  /**
4867
5113
  * Step 1: 拉取仓库清单 —— README 全文 + 完整目录树 + 课程文件列表。
4868
5114
  *
@@ -5958,6 +6204,42 @@ function estimateTokens(text) {
5958
6204
  return Math.ceil(cjk * 11 / 10 + other / 4);
5959
6205
  }
5960
6206
  /**
6207
+ * Match the learner's interface language against the repo's declared
6208
+ * translation languages (0.24.0): exact code first (case-insensitive), then
6209
+ * the base subtag family — a zh-CN host matches a zh-cn mirror, pt-PT still
6210
+ * finds pt-br. No family match (or nothing recorded) returns null and the
6211
+ * import stays original-language. Pure.
6212
+ */
6213
+ function matchTranslationLang(want, available) {
6214
+ if (want === void 0 || want.trim() === "" || available.length === 0) return null;
6215
+ const norm = (code) => code.trim().toLowerCase();
6216
+ const exact = available.find((a) => norm(a.code) === norm(want));
6217
+ if (exact !== void 0) return exact;
6218
+ const base = (code) => norm(code).split("-")[0];
6219
+ const wantBase = base(want);
6220
+ return available.find((a) => base(a.code) === wantBase) ?? null;
6221
+ }
6222
+ /**
6223
+ * 0.23.1 issue 5: the pacing gate's hard cap (the brief's 3000-8000 target's
6224
+ * upper edge). A sliced lesson over this WITH splittable in-span headings is
6225
+ * rejected — the brief has always carried the rule, but nothing enforced it,
6226
+ * so a flash-tier tutor's 1:1 file→lesson design shipped 45k-char lessons.
6227
+ */
6228
+ const LESSON_MAX_CHARS = 8e3;
6229
+ /**
6230
+ * Count the in-span headings a lesson could be split by: direct children for
6231
+ * an anchored lesson (an H2 anchor's span ends at the next ≤-level heading,
6232
+ * so its H3s are the split unit; an H3 anchor is the terminal unit — nothing
6233
+ * deeper exists to split by), any H2/H3 for a whole-file lesson.
6234
+ */
6235
+ function splittableHeadingCount(headings, titleIndex) {
6236
+ if (titleIndex < 0) return headings.filter((h) => h.level >= 2).length;
6237
+ const anchorLevel = headings[titleIndex].level;
6238
+ let count = 0;
6239
+ for (let i = titleIndex + 1; i < headings.length && headings[i].level > anchorLevel; i++) if (headings[i].level === anchorLevel + 1) count++;
6240
+ return count;
6241
+ }
6242
+ /**
5961
6243
  * Build the pending design from Step 1 (inventory) + Step 3 (outlines).
5962
6244
  * @param url - the GitHub URL the learner asked to import.
5963
6245
  * @param owner - repo owner.
@@ -6130,6 +6412,7 @@ function renderDesignBrief(pending, part = 1) {
6130
6412
  }
6131
6413
  lines.push("");
6132
6414
  lines.push("### Design rules (from the LookatStudy import pipeline)");
6415
+ if (pending.availableLangs !== void 0 && pending.availableLangs.length > 0) lines.push(`Translations available in this repo: ${pending.availableLangs.map((l) => `${l.name} (${l.code})`).join(", ")}. study_apply_design automatically pairs the learner's interface-language mirror (translations/<code>/ file copies) with each lesson — no action needed from you; mention the availability to the learner.`);
6133
6416
  lines.push("Classify every lesson as exactly one of:");
6134
6417
  lines.push("- **study**: explanation/theory/tutorial content — the learning-world spine, its own lesson.");
6135
6418
  lines.push("- **practice**: Exercise / Lab / notebook — its own lesson.");
@@ -6143,7 +6426,7 @@ function renderDesignBrief(pending, part = 1) {
6143
6426
  lines.push("Other rules:");
6144
6427
  lines.push("- role hints are references, not verdicts — README tables usually mark real roles (Lesson link = study, Notebook/Lab = practice).");
6145
6428
  lines.push("- if the directory layout is already clear (e.g. lessons/N-Topic/), keep its sections; do not over-reorganize.");
6146
- lines.push(`- anchor is the full heading text used to slice the body; omit it for whole-file lessons.${shown.length > 80 ? " This part is large: design at file granularity (omit anchors, one lesson per file) to keep the JSON manageable." : ""}`);
6429
+ lines.push(`- anchor is the full heading text used to slice the body; omit it for whole-file lessons.${shown.length > 80 ? " This part is large: design at file granularity (omit anchors, one lesson per file) to keep the JSON manageable — EXCEPT files over 8000 chars: those still need anchor splitting (anchor = an H2/H3 heading) into 3000-8000-char lessons; study_apply_design rejects over-long lessons whose file has splittable headings." : ""}`);
6147
6430
  lines.push("");
6148
6431
  lines.push("### Language-course check (decide while designing)");
6149
6432
  lines.push("Is this course teaching a language ITSELF (vocabulary, grammar, reading/writing/listening of English/Japanese/Korean/Chinese/…), rather than a knowledge/tech course that merely happens to be written in some language? Typical tells: vocab lists, grammar-point explanations, dialogues/readings, sentence-translation pairs, level markers (N5/N1, TOEFL/IELTS, HSK, JLPT), pronunciation content.");
@@ -6330,42 +6613,56 @@ function buildCourseFromDesign(courseTitle, validated, contents, translations) {
6330
6613
  return cached;
6331
6614
  };
6332
6615
  const firstLessonSeen = /* @__PURE__ */ new Set();
6333
- return {
6334
- title: courseTitle,
6335
- sections: validated.sections.map((section) => {
6336
- const lessons = section.lessons.map((lesson) => {
6337
- const content = contents.get(lesson.file);
6338
- if (content === void 0) throw new Error(`lookatstudy-plugin: no fetched content for designed file ${JSON.stringify(lesson.file)} — fetch failed earlier; retry study_apply_design`);
6339
- const headings = headingsOf(lesson.file);
6340
- const titleIndex = lesson.anchor === null ? -1 : findTitleIndex(headings, lesson.anchor);
6341
- const isFirst = !firstLessonSeen.has(lesson.file);
6342
- firstLessonSeen.add(lesson.file);
6343
- const pair = translations?.get(lesson.file);
6344
- let translation;
6345
- if (pair !== void 0) {
6346
- const tHeadings = extractHeadings(pair.content);
6347
- const tIndex = lesson.anchor === null ? -1 : findTitleIndex(tHeadings, lesson.anchor);
6348
- translation = sliceLessonBody(pair.content, tHeadings, tIndex, isFirst);
6349
- }
6350
- return {
6351
- title: lesson.title,
6352
- anchor: slugAnchor(lesson.title),
6353
- body: sliceLessonBody(content, headings, titleIndex, isFirst),
6354
- sourceFilePath: lesson.file,
6355
- world: lesson.world,
6356
- ...translation !== void 0 ? {
6357
- translation,
6358
- translationLang: pair.lang
6359
- } : {}
6360
- };
6616
+ const pacingViolations = [];
6617
+ const sections = validated.sections.map((section) => {
6618
+ const lessons = section.lessons.map((lesson) => {
6619
+ const content = contents.get(lesson.file);
6620
+ if (content === void 0) throw new Error(`lookatstudy-plugin: no fetched content for designed file ${JSON.stringify(lesson.file)} — fetch failed earlier; retry study_apply_design`);
6621
+ const headings = headingsOf(lesson.file);
6622
+ const titleIndex = lesson.anchor === null ? -1 : findTitleIndex(headings, lesson.anchor);
6623
+ const isFirst = !firstLessonSeen.has(lesson.file);
6624
+ firstLessonSeen.add(lesson.file);
6625
+ const pair = translations?.get(lesson.file);
6626
+ let translation;
6627
+ if (pair !== void 0) {
6628
+ const tHeadings = extractHeadings(pair.content);
6629
+ const tIndex = lesson.anchor === null ? -1 : findTitleIndex(tHeadings, lesson.anchor);
6630
+ translation = sliceLessonBody(pair.content, tHeadings, tIndex, isFirst);
6631
+ }
6632
+ const body = sliceLessonBody(content, headings, titleIndex, isFirst);
6633
+ if (body.length > 8e3 && splittableHeadingCount(headings, titleIndex) > 0) pacingViolations.push({
6634
+ title: lesson.title,
6635
+ file: lesson.file,
6636
+ chars: body.length,
6637
+ splittable: splittableHeadingCount(headings, titleIndex)
6361
6638
  });
6362
6639
  return {
6363
- title: section.title,
6364
- anchor: slugAnchor(section.title),
6365
- world: lessons.every((l) => l.world === "practice") ? "practice" : "study",
6366
- lessons
6640
+ title: lesson.title,
6641
+ anchor: slugAnchor(lesson.title),
6642
+ body,
6643
+ sourceFilePath: lesson.file,
6644
+ world: lesson.world,
6645
+ ...translation !== void 0 ? {
6646
+ translation,
6647
+ translationLang: pair.lang
6648
+ } : {}
6367
6649
  };
6368
- })
6650
+ });
6651
+ return {
6652
+ title: section.title,
6653
+ anchor: slugAnchor(section.title),
6654
+ world: lessons.every((l) => l.world === "practice") ? "practice" : "study",
6655
+ lessons
6656
+ };
6657
+ });
6658
+ if (pacingViolations.length > 0) {
6659
+ const listed = pacingViolations.slice(0, 5).map((v) => `「${v.title}」 ${v.file} (${v.chars} chars, ${v.splittable} splittable heading${v.splittable === 1 ? "" : "s"})`).join("; ");
6660
+ const more = pacingViolations.length > 5 ? ` (+${pacingViolations.length - 5} more)` : "";
6661
+ throw new Error(`lookatstudy-plugin: pacing gate — ${pacingViolations.length} lesson${pacingViolations.length === 1 ? "" : "s"} over ${LESSON_MAX_CHARS} chars while the file has splittable H2/H3 sections (design rule: 3000-8000 chars per lesson): ${listed}${more}. Split each into one lesson per H2/H3 (anchor = the heading's full title, merge fragments under 1000 chars), then re-submit study_apply_design`);
6662
+ }
6663
+ return {
6664
+ title: courseTitle,
6665
+ sections
6369
6666
  };
6370
6667
  }
6371
6668
  //#endregion
@@ -7028,6 +7325,26 @@ function studyTools(store, deps = {}) {
7028
7325
  type: "integer",
7029
7326
  description: "Pending-design sequence (audit C29): renders detect stale briefs."
7030
7327
  },
7328
+ availableLangs: {
7329
+ type: "array",
7330
+ description: "Translation languages the repo README declares (translations/<code>/ mirrors) — apply pairs the learner's interface language automatically.",
7331
+ items: {
7332
+ type: "object",
7333
+ additionalProperties: false,
7334
+ properties: {
7335
+ code: {
7336
+ type: "string",
7337
+ required: true,
7338
+ description: "BCP-47-ish code as it appears in the translations/ path."
7339
+ },
7340
+ name: {
7341
+ type: "string",
7342
+ required: true,
7343
+ description: "Display name from the README link text."
7344
+ }
7345
+ }
7346
+ }
7347
+ },
7031
7348
  part: {
7032
7349
  type: "integer",
7033
7350
  description: "Brief part this call rendered (context-budget batching)."
@@ -7131,6 +7448,10 @@ function studyTools(store, deps = {}) {
7131
7448
  courseTitle: partCount > 1 ? `${pd.courseTitle} (part ${part})` : pd.courseTitle,
7132
7449
  fileCount: planBriefParts(pd.files)[Math.min(part, partCount) - 1].length,
7133
7450
  fullTreeCount: pd.fullTreeCount,
7451
+ ...pd.availableLangs !== void 0 && pd.availableLangs.length > 0 ? { availableLangs: pd.availableLangs.map((l) => ({
7452
+ code: l.code,
7453
+ name: l.name
7454
+ })) } : {},
7134
7455
  ...partCount > 1 ? {
7135
7456
  part,
7136
7457
  partCount
@@ -7151,6 +7472,8 @@ function studyTools(store, deps = {}) {
7151
7472
  };
7152
7473
  const inventory = await fetchRepoInventory(owner, repo, resolvedBranch, fetchFn, void 0, exec.signal);
7153
7474
  const pd = buildPendingDesign(canonicalRef, owner, repo, inventory, await fetchFileOutlines(inventory.fileList.map((f) => f.path), owner, repo, inventory.branch, fetchFn, void 0, exec.signal));
7475
+ const availableLangs = extractLanguagesFromReadme(inventory.readmeMd);
7476
+ if (availableLangs.length > 0) pd.availableLangs = availableLangs;
7154
7477
  setPendingDesign(pd);
7155
7478
  if (pd.files.length === 0) throw new Error("lookatstudy-plugin: course files were discovered but no outlines could be fetched (CDN unreachable?)");
7156
7479
  pendingPart = part;
@@ -7287,7 +7610,11 @@ ${p.text}`));
7287
7610
  parameters: {
7288
7611
  languageTarget: {
7289
7612
  type: "string",
7290
- description: "Set ONLY when this course teaches a language ITSELF (vocabulary, grammar, reading/writing of English/Japanese/Chinese/…) rather than being a knowledge/tech course that merely happens to be written in some language: the taught language as a BCP-47 tag (\"en\", \"ja\", \"zh-CN\", …). The writing language is NOT the taught language — a Chinese-written English-textbook repo has languageTarget \"en\". Typical tells: vocab lists, grammar points, dialogues/readings, sentence-translation pairs, level markers (N5/N1, TOEFL, HSK, JLPT), pronunciation content. When unsure, omit it — a normal course misflagged as a language course is worse than the reverse."
7613
+ description: "Set ONLY when this course teaches a language ITSELF (vocabulary, grammar, reading/writing of English/Japanese/Chinese/…) rather than being a knowledge/tech course that merely happens to be written in some language: the taught language as a BCP-47 tag (\"en\", \"ja\", \"zh-CN\", …). The writing language is NOT the taught language — a Chinese-written English-textbook repo has languageTarget \"en\". Typical tells: vocab lists, grammar points, dialogues/readings, sentence-translation pairs, level markers (N5/N1, TOEFL/IELTS, HSK, JLPT), pronunciation content. When unsure, omit it — a normal course misflagged as a language course is worse than the reverse."
7614
+ },
7615
+ translationLang: {
7616
+ type: "string",
7617
+ description: "Override the translation mirror language (0.24.0). Default: the learner's interface language, matched against the repo's declared translations (zh-CN matches zh-cn, en-US falls back to en). Set this only when the LEARNER explicitly asks for a different language than their interface."
7291
7618
  },
7292
7619
  sections: {
7293
7620
  type: "array",
@@ -7367,6 +7694,11 @@ ${p.text}`));
7367
7694
  type: "integer",
7368
7695
  required: true
7369
7696
  },
7697
+ translations: {
7698
+ type: "integer",
7699
+ required: true,
7700
+ description: "Lessons that landed with a paired translation (the interface-language mirror existed and its anchor matched) — their teach tab offers 原文/对照/译文."
7701
+ },
7370
7702
  created: {
7371
7703
  type: "boolean",
7372
7704
  required: true,
@@ -7380,7 +7712,7 @@ ${p.text}`));
7380
7712
  },
7381
7713
  render: (_args, value) => [{
7382
7714
  type: "text",
7383
- text: value.created === false ? `“${value.title}” (id ${value.courseId}) was ALREADY imported — the design did not create a new course. Continue with the existing course, or delete it first (study_delete_course) to rebuild from this design.` : `Imported designed course “${value.title}” (${value.sections} sections, ${value.lessons} lessons${value.droppedLessons > 0 ? `, ${value.droppedLessons} hallucinated lesson(s) dropped` : ""})${typeof value.languageTarget === "string" ? ` — language course (${value.languageTarget}): keep reading passages, example sentences, and quiz language material in the target language` : ""}. First lesson: “${value.firstLessonTitle}” (id ${value.firstLessonId}). Present the course map to the learner.`
7715
+ text: value.created === false ? `“${value.title}” (id ${value.courseId}) was ALREADY imported — the design did not create a new course. Continue with the existing course, or delete it first (study_delete_course) to rebuild from this design.` : `Imported designed course “${value.title}” (${value.sections} sections, ${value.lessons} lessons${value.droppedLessons > 0 ? `, ${value.droppedLessons} hallucinated lesson(s) dropped` : ""})${typeof value.translations === "number" && value.translations > 0 ? ` — ${value.translations} lesson(s) carry the learner's-language translation (原文/对照/译文 in the teach tab)` : ""}${typeof value.languageTarget === "string" ? ` — language course (${value.languageTarget}): keep reading passages, example sentences, and quiz language material in the target language` : ""}. First lesson: “${value.firstLessonTitle}” (id ${value.firstLessonId}). Present the course map to the learner.`
7384
7716
  }]
7385
7717
  },
7386
7718
  async execute(args, exec) {
@@ -7407,7 +7739,22 @@ ${p.text}`));
7407
7739
  if (contents.size === 0) throw new Error(`lookatstudy-plugin: every designed file failed to fetch (${failed.length}) — the CDN path is unreachable; retry or re-import`);
7408
7740
  if (failed.length > 0) throw new Error(`lookatstudy-plugin: ${failed.length} designed file(s) failed to fetch: ${failed.join(", ")} — drop or fix them and call study_apply_design again`);
7409
7741
  }
7410
- const parsed = buildCourseFromDesign(pd.part !== void 0 && pd.partCount !== void 0 && pd.partCount > 1 ? `${pd.courseTitle} (part ${pd.part})` : pd.courseTitle, validated, contents, pd.translations);
7742
+ let translations = pd.translations;
7743
+ if (pd.localContents === void 0 && pd.availableLangs !== void 0 && pd.availableLangs.length > 0) {
7744
+ const match = matchTranslationLang(typeof args.translationLang === "string" && args.translationLang.trim() !== "" ? args.translationLang : store.get().interfaceLang, pd.availableLangs);
7745
+ if (match !== null && !exec.signal.aborted) {
7746
+ const fetched = await fetchTranslatedContent(pd.owner, pd.repo, pd.branch, match.code, uniqueFiles.map((f) => ({
7747
+ path: f,
7748
+ title: f,
7749
+ md: ""
7750
+ })), signalFetch(exec.signal, baseFetch));
7751
+ if (fetched.size > 0) translations = new Map([...fetched].map(([path, v]) => [path, {
7752
+ lang: match.code,
7753
+ content: v.content
7754
+ }]));
7755
+ }
7756
+ }
7757
+ const parsed = buildCourseFromDesign(pd.part !== void 0 && pd.partCount !== void 0 && pd.partCount > 1 ? `${pd.courseTitle} (part ${pd.part})` : pd.courseTitle, validated, contents, translations);
7411
7758
  requireParsedLessons(parsed);
7412
7759
  const languageTarget = typeof args.languageTarget === "string" && args.languageTarget.trim() !== "" ? args.languageTarget.trim().slice(0, 35) : null;
7413
7760
  const value = mutate((state) => {
@@ -7419,9 +7766,11 @@ ${p.text}`));
7419
7766
  });
7420
7767
  setPendingDesign(null);
7421
7768
  pendingPart = 1;
7769
+ const translatedLessons = parsed.sections.reduce((n, s) => n + s.lessons.filter((l) => l.translation !== void 0).length, 0);
7422
7770
  return {
7423
7771
  ...value,
7424
7772
  droppedLessons: validated.droppedLessons,
7773
+ translations: translatedLessons,
7425
7774
  ...languageTarget !== null ? { languageTarget } : {}
7426
7775
  };
7427
7776
  },
@@ -10144,6 +10493,26 @@ function snapshotSectionText(state) {
10144
10493
  if (snap.dueCount > 0) lines.push(`今日待复习: ${snap.dueCount} 项`);
10145
10494
  if (snap.pendingProposal !== null) lines.push(`待决提案 ${snap.pendingProposal.id}: ${sanitizePromptText(snap.pendingProposal.rationale, 160)}(等学习者表态)`);
10146
10495
  const focusCourse = state.courses.find((c) => c.id === snap.focus?.courseId);
10496
+ if (focusCourse?.threadScope === "course" && snap.focus !== null) {
10497
+ const group = state.lessonThreads[threadOwnerKey("course", focusCourse.id, snap.focus.lessonId)];
10498
+ const touched = group?.threads.find((t) => t.id === group.active)?.touchedLessons;
10499
+ const titleOf = /* @__PURE__ */ new Map();
10500
+ for (const sec of focusCourse.sections) for (const l of sec.lessons) titleOf.set(l.id, l.title);
10501
+ const digest = focusCourse.sections.flatMap((sec) => sec.lessons.filter((l) => l.kind !== "exam")).slice(0, 60).map((l) => {
10502
+ const pct = l.mastery === null ? "" : ` ${String(Math.round(l.mastery * 100))}%`;
10503
+ const weak = (conceptViews(l) ?? []).some((c) => c.weak) ? " ⚡薄弱" : "";
10504
+ const fric = l.friction.length > 0 ? ` ⚑卡点${String(l.friction.length)}` : "";
10505
+ return `- ${sanitizePromptText(l.title)}[${String(l.status)}${pct}]${weak}${fric}`;
10506
+ });
10507
+ lines.push("");
10508
+ lines.push("【本课程进度摘要】(跨课时连续对话:本线跨课时延续,换课时不改线)");
10509
+ lines.push(...digest);
10510
+ if (touched !== void 0 && touched.length > 0) {
10511
+ const names = touched.map((id) => sanitizePromptText(titleOf.get(id) ?? id)).join(" · ");
10512
+ lines.push(`本对话线已覆盖课时: ${names}; 当前焦点: ${sanitizePromptText(snap.focus.lessonTitle)}`);
10513
+ }
10514
+ lines.push("出跨课时综合题前,先调用 study_lesson 调出相关课时正文再命题——组合题优先利用本线已有的跨课时现场。");
10515
+ }
10147
10516
  if (focusCourse?.languageTarget != null && focusCourse.languageTarget !== "") {
10148
10517
  const t = localeToLanguageName(focusCourse.languageTarget);
10149
10518
  lines.push("");