dsh-plugin-lookatstudy 0.23.0 → 0.25.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
@@ -163,6 +163,7 @@ const ZH = {
163
163
  "toast.close": "关闭",
164
164
  "composer.busy": "导师正在回复…",
165
165
  "rail.empty.demo": "导入示例课程",
166
+ "import.session.title": "课程导入",
166
167
  "import.cta": "导入新课程",
167
168
  "import.tab.url": "URL",
168
169
  "import.tab.md": "MD",
@@ -263,6 +264,10 @@ const ZH = {
263
264
  "rail.due.start": "开始复习",
264
265
  "rail.due.none": "暂时没有到期的复习,保持节奏!",
265
266
  "chat.reasoning": "思考过程({n} 字)",
267
+ "chat.think": "思考",
268
+ "langview.original": "原文",
269
+ "langview.bilingual": "对照",
270
+ "langview.translation": "译文",
266
271
  "msg.speak": "朗读",
267
272
  "note.pin": "置顶笔记",
268
273
  "note.unpin": "取消置顶",
@@ -530,6 +535,7 @@ const EN = {
530
535
  "toast.close": "Close",
531
536
  "composer.busy": "The tutor is replying…",
532
537
  "rail.empty.demo": "Import the demo course",
538
+ "import.session.title": "Course import",
533
539
  "import.cta": "Import a new course",
534
540
  "import.tab.url": "URL",
535
541
  "import.tab.md": "MD",
@@ -630,6 +636,10 @@ const EN = {
630
636
  "rail.due.start": "Start reviewing",
631
637
  "rail.due.none": "Nothing due right now — keep the streak!",
632
638
  "chat.reasoning": "Thinking ({n} chars)",
639
+ "chat.think": "Think",
640
+ "langview.original": "Original",
641
+ "langview.bilingual": "Both",
642
+ "langview.translation": "Translation",
633
643
  "msg.speak": "Speak",
634
644
  "note.pin": "Pin note",
635
645
  "note.unpin": "Unpin",
@@ -2345,7 +2355,18 @@ function recordAnswer(state, lessonId, correct, concept, now) {
2345
2355
  function setCourseThreadScope(state, courseId, scope) {
2346
2356
  const course = state.courses.find((c) => c.id === courseId);
2347
2357
  if (course === void 0) throw new Error(`lookatstudy-plugin: unknown course id ${JSON.stringify(courseId)}`);
2358
+ if ((course.threadScope === "course" ? "course" : "lesson") === scope) return;
2348
2359
  course.threadScope = scope;
2360
+ const courseKey = `course:${courseId}`;
2361
+ const focusLessonId = state.focus?.lessonId ?? null;
2362
+ if (focusLessonId === null || !course.sections.some((s) => s.lessons.some((l) => l.id === focusLessonId))) return;
2363
+ const lessonKey = focusLessonId;
2364
+ const src = scope === "course" ? state.lessonThreads[lessonKey] : state.lessonThreads[courseKey];
2365
+ const dst = scope === "course" ? state.lessonThreads[courseKey] : state.lessonThreads[lessonKey];
2366
+ if (src === void 0 || src.threads.length === 0) return;
2367
+ if (dst !== void 0 && (dst.active !== null || dst.threads.length > 0)) return;
2368
+ state.lessonThreads[scope === "course" ? courseKey : lessonKey] = src;
2369
+ delete state.lessonThreads[scope === "course" ? lessonKey : courseKey];
2349
2370
  }
2350
2371
  /**
2351
2372
  * Resolve the thread-group map key a send from `lessonId` belongs to — the
@@ -3113,9 +3134,14 @@ function workbenchState(state, now) {
3113
3134
  quote: n.quote,
3114
3135
  pinned: n.pinned === true
3115
3136
  })),
3116
- html: renderMarkdown(normalizeMathNotation(ref.lesson.translation === void 0 ? ref.lesson.body : renderBilingual(ref.lesson.body, ref.lesson.translation))),
3137
+ html: renderMarkdown(normalizeMathNotation(ref.lesson.body)),
3117
3138
  markdown: ref.lesson.body,
3118
- speechText: normalizeSpeechText(normalizeMathNotation(ref.lesson.body))
3139
+ speechText: normalizeSpeechText(normalizeMathNotation(ref.lesson.body)),
3140
+ ...ref.lesson.translation !== void 0 ? {
3141
+ bilingualHtml: renderMarkdown(normalizeMathNotation(renderBilingual(ref.lesson.body, ref.lesson.translation))),
3142
+ translationHtml: renderMarkdown(normalizeMathNotation(ref.lesson.translation)),
3143
+ translationLang: ref.lesson.translationLang ?? ""
3144
+ } : {}
3119
3145
  };
3120
3146
  } catch {
3121
3147
  lesson = null;
@@ -3360,6 +3386,7 @@ function registerDashboard(webServer, deps) {
3360
3386
  }
3361
3387
  try {
3362
3388
  const ref = findLesson(deps.store.get(), body.lessonId);
3389
+ if (ref.lesson.kind !== "exam" && ref.lesson.status === "available") attemptLesson(deps.store.get(), ref.lesson.id, /* @__PURE__ */ new Date());
3363
3390
  deps.store.get().focus = { lessonId: ref.lesson.id };
3364
3391
  deps.store.save();
3365
3392
  sendJson(res, 200, { ok: true });
@@ -3893,6 +3920,26 @@ function registerDashboard(webServer, deps) {
3893
3920
  });
3894
3921
  return;
3895
3922
  }
3923
+ if (req.method === "POST" && pathname === "/lookatstudy/api/prefs") {
3924
+ const body = await readJsonBodySafe(req, res);
3925
+ if (body === void 0) return;
3926
+ if (typeof body.interfaceLang !== "string" || body.interfaceLang === "" || body.interfaceLang.length > 35) {
3927
+ sendJson(res, 400, {
3928
+ ok: false,
3929
+ error: "interfaceLang (non-empty string, max 35 chars) required"
3930
+ });
3931
+ return;
3932
+ }
3933
+ if (deps.store.get().interfaceLang !== body.interfaceLang) {
3934
+ deps.store.get().interfaceLang = body.interfaceLang;
3935
+ deps.store.save();
3936
+ }
3937
+ sendJson(res, 200, {
3938
+ ok: true,
3939
+ interfaceLang: body.interfaceLang
3940
+ });
3941
+ return;
3942
+ }
3896
3943
  if (req.method === "POST" && pathname === "/lookatstudy/api/attachment") {
3897
3944
  const body = await readJsonBodySafe(req, res, 29360128);
3898
3945
  if (body === void 0) return;
@@ -4977,6 +5024,104 @@ async function fetchRepoFileTree(owner, repo, branch, _fetchFn, signal) {
4977
5024
  }
4978
5025
  /** 文件数上限(防爆,和 IPC handler 一致) */
4979
5026
  const MAX_FILES = 500;
5027
+ /** 从 markdown 链接中提取翻译语言列表 */
5028
+ function extractLanguagesFromReadme(readmeMd) {
5029
+ const pattern = /\[([^\]]+)\]\(\.?\/?translations\/([^/)]+)\/README\.md\)/g;
5030
+ const langs = [];
5031
+ const seen = /* @__PURE__ */ new Set();
5032
+ let m;
5033
+ while ((m = pattern.exec(readmeMd)) !== null) {
5034
+ const name = m[1].trim();
5035
+ const code = m[2].trim();
5036
+ if (!seen.has(code)) {
5037
+ seen.add(code);
5038
+ langs.push({
5039
+ code,
5040
+ name
5041
+ });
5042
+ }
5043
+ }
5044
+ return langs;
5045
+ }
5046
+ /**
5047
+ * 净化翻译版 markdown —— 翻译内容是 CDN 原样拉取的,未经原文管道的
5048
+ * code-fence-aware parser 处理,可能含畸形结构导致 react-markdown 崩溃。
5049
+ *
5050
+ * 处理:
5051
+ * 1. 未闭合代码围栏:统计 ``` 和 ~~~ 数量,奇数则补一个闭合围栏
5052
+ * 2. 去除 <script>/<style>/<iframe> 等危险 HTML(防 XSS + 防渲染崩溃)
5053
+ * 3. 去 BOM、统一换行
5054
+ *
5055
+ * 这是确定性规则处理(高置信度),不是 LLM 判断 —— 格式修复是规则擅长的。
5056
+ */
5057
+ function sanitizeTranslatedMarkdown(md) {
5058
+ let s = md.replace(/^\uFEFF/, "");
5059
+ s = s.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
5060
+ const lines = s.split("\n");
5061
+ let fence = null;
5062
+ for (const line of lines) {
5063
+ const m = line.match(/^\s*(```|~~~)/);
5064
+ if (m) fence = fence ? null : m[1];
5065
+ }
5066
+ if (fence) s = s + "\n" + fence + "\n";
5067
+ s = s.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "");
5068
+ s = s.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, "");
5069
+ s = s.replace(/<\/?(iframe|object|embed)\b[^>]*>/gi, "");
5070
+ return s.trim();
5071
+ }
5072
+ /**
5073
+ * 拉取翻译版课程内容。
5074
+ *
5075
+ * 策略:不依赖翻译版 README 的链接(很多翻译只翻译了大纲,README 里没有 lesson 链接,
5076
+ * 或链接指向其他语言翻译)。直接用原文课程的文件路径,在前面加 translations/<code>/
5077
+ * 前缀去探测翻译版是否存在。5 并发拉取,404 跳过(该课无翻译)。
5078
+ *
5079
+ * @returns Map<originalPath, { title, content }> — key 是原文路径
5080
+ */
5081
+ async function fetchTranslatedContent(owner, repo, branch, langCode, originalFiles, fetchFn, onProgress) {
5082
+ const send = (msg) => onProgress?.(msg);
5083
+ const result = /* @__PURE__ */ new Map();
5084
+ const transReadmeUrl = cdnUrl(owner, repo, branch, `translations/${langCode}/README.md`);
5085
+ send(`检查翻译版 README (${langCode})…`);
5086
+ try {
5087
+ const r = await fetchFn(transReadmeUrl);
5088
+ if (!r.ok) {
5089
+ send(`翻译版不存在 (${r.status}),跳过`);
5090
+ return result;
5091
+ }
5092
+ } catch {
5093
+ send("翻译版检查失败,跳过");
5094
+ return result;
5095
+ }
5096
+ const mdFiles = originalFiles.filter((f) => !f.path.toLowerCase().endsWith(".ipynb"));
5097
+ send(`探测 ${mdFiles.length} 个文件的翻译版(${langCode})…`);
5098
+ const transPrefix = `translations/${langCode}/`;
5099
+ const CONCURRENCY = 5;
5100
+ let done = 0;
5101
+ for (let i = 0; i < mdFiles.length; i += CONCURRENCY) {
5102
+ const batch = mdFiles.slice(i, i + CONCURRENCY);
5103
+ const results = await Promise.allSettled(batch.map(async (file) => {
5104
+ const r = await fetchFn(cdnUrl(owner, repo, branch, transPrefix + file.path));
5105
+ if (!r.ok) return null;
5106
+ const md = await r.text();
5107
+ return {
5108
+ originalPath: file.path,
5109
+ title: file.title,
5110
+ content: sanitizeTranslatedMarkdown(md)
5111
+ };
5112
+ }));
5113
+ for (const res of results) {
5114
+ done++;
5115
+ if (res.status === "fulfilled" && res.value) result.set(res.value.originalPath, {
5116
+ title: res.value.title,
5117
+ content: res.value.content
5118
+ });
5119
+ }
5120
+ if (done % 10 === 0 || done === mdFiles.length) send(`翻译探测 ${done}/${mdFiles.length}(命中 ${result.size})`);
5121
+ }
5122
+ send(`翻译版拉取完成: ${result.size}/${mdFiles.length} 文件有翻译`);
5123
+ return result;
5124
+ }
4980
5125
  /**
4981
5126
  * Step 1: 拉取仓库清单 —— README 全文 + 完整目录树 + 课程文件列表。
4982
5127
  *
@@ -6072,6 +6217,42 @@ function estimateTokens(text) {
6072
6217
  return Math.ceil(cjk * 11 / 10 + other / 4);
6073
6218
  }
6074
6219
  /**
6220
+ * Match the learner's interface language against the repo's declared
6221
+ * translation languages (0.24.0): exact code first (case-insensitive), then
6222
+ * the base subtag family — a zh-CN host matches a zh-cn mirror, pt-PT still
6223
+ * finds pt-br. No family match (or nothing recorded) returns null and the
6224
+ * import stays original-language. Pure.
6225
+ */
6226
+ function matchTranslationLang(want, available) {
6227
+ if (want === void 0 || want.trim() === "" || available.length === 0) return null;
6228
+ const norm = (code) => code.trim().toLowerCase();
6229
+ const exact = available.find((a) => norm(a.code) === norm(want));
6230
+ if (exact !== void 0) return exact;
6231
+ const base = (code) => norm(code).split("-")[0];
6232
+ const wantBase = base(want);
6233
+ return available.find((a) => base(a.code) === wantBase) ?? null;
6234
+ }
6235
+ /**
6236
+ * 0.23.1 issue 5: the pacing gate's hard cap (the brief's 3000-8000 target's
6237
+ * upper edge). A sliced lesson over this WITH splittable in-span headings is
6238
+ * rejected — the brief has always carried the rule, but nothing enforced it,
6239
+ * so a flash-tier tutor's 1:1 file→lesson design shipped 45k-char lessons.
6240
+ */
6241
+ const LESSON_MAX_CHARS = 8e3;
6242
+ /**
6243
+ * Count the in-span headings a lesson could be split by: direct children for
6244
+ * an anchored lesson (an H2 anchor's span ends at the next ≤-level heading,
6245
+ * so its H3s are the split unit; an H3 anchor is the terminal unit — nothing
6246
+ * deeper exists to split by), any H2/H3 for a whole-file lesson.
6247
+ */
6248
+ function splittableHeadingCount(headings, titleIndex) {
6249
+ if (titleIndex < 0) return headings.filter((h) => h.level >= 2).length;
6250
+ const anchorLevel = headings[titleIndex].level;
6251
+ let count = 0;
6252
+ for (let i = titleIndex + 1; i < headings.length && headings[i].level > anchorLevel; i++) if (headings[i].level === anchorLevel + 1) count++;
6253
+ return count;
6254
+ }
6255
+ /**
6075
6256
  * Build the pending design from Step 1 (inventory) + Step 3 (outlines).
6076
6257
  * @param url - the GitHub URL the learner asked to import.
6077
6258
  * @param owner - repo owner.
@@ -6244,6 +6425,7 @@ function renderDesignBrief(pending, part = 1) {
6244
6425
  }
6245
6426
  lines.push("");
6246
6427
  lines.push("### Design rules (from the LookatStudy import pipeline)");
6428
+ 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.`);
6247
6429
  lines.push("Classify every lesson as exactly one of:");
6248
6430
  lines.push("- **study**: explanation/theory/tutorial content — the learning-world spine, its own lesson.");
6249
6431
  lines.push("- **practice**: Exercise / Lab / notebook — its own lesson.");
@@ -6257,7 +6439,7 @@ function renderDesignBrief(pending, part = 1) {
6257
6439
  lines.push("Other rules:");
6258
6440
  lines.push("- role hints are references, not verdicts — README tables usually mark real roles (Lesson link = study, Notebook/Lab = practice).");
6259
6441
  lines.push("- if the directory layout is already clear (e.g. lessons/N-Topic/), keep its sections; do not over-reorganize.");
6260
- 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." : ""}`);
6442
+ 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." : ""}`);
6261
6443
  lines.push("");
6262
6444
  lines.push("### Language-course check (decide while designing)");
6263
6445
  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.");
@@ -6444,42 +6626,56 @@ function buildCourseFromDesign(courseTitle, validated, contents, translations) {
6444
6626
  return cached;
6445
6627
  };
6446
6628
  const firstLessonSeen = /* @__PURE__ */ new Set();
6447
- return {
6448
- title: courseTitle,
6449
- sections: validated.sections.map((section) => {
6450
- const lessons = section.lessons.map((lesson) => {
6451
- const content = contents.get(lesson.file);
6452
- 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`);
6453
- const headings = headingsOf(lesson.file);
6454
- const titleIndex = lesson.anchor === null ? -1 : findTitleIndex(headings, lesson.anchor);
6455
- const isFirst = !firstLessonSeen.has(lesson.file);
6456
- firstLessonSeen.add(lesson.file);
6457
- const pair = translations?.get(lesson.file);
6458
- let translation;
6459
- if (pair !== void 0) {
6460
- const tHeadings = extractHeadings(pair.content);
6461
- const tIndex = lesson.anchor === null ? -1 : findTitleIndex(tHeadings, lesson.anchor);
6462
- translation = sliceLessonBody(pair.content, tHeadings, tIndex, isFirst);
6463
- }
6464
- return {
6465
- title: lesson.title,
6466
- anchor: slugAnchor(lesson.title),
6467
- body: sliceLessonBody(content, headings, titleIndex, isFirst),
6468
- sourceFilePath: lesson.file,
6469
- world: lesson.world,
6470
- ...translation !== void 0 ? {
6471
- translation,
6472
- translationLang: pair.lang
6473
- } : {}
6474
- };
6629
+ const pacingViolations = [];
6630
+ const sections = validated.sections.map((section) => {
6631
+ const lessons = section.lessons.map((lesson) => {
6632
+ const content = contents.get(lesson.file);
6633
+ 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`);
6634
+ const headings = headingsOf(lesson.file);
6635
+ const titleIndex = lesson.anchor === null ? -1 : findTitleIndex(headings, lesson.anchor);
6636
+ const isFirst = !firstLessonSeen.has(lesson.file);
6637
+ firstLessonSeen.add(lesson.file);
6638
+ const pair = translations?.get(lesson.file);
6639
+ let translation;
6640
+ if (pair !== void 0) {
6641
+ const tHeadings = extractHeadings(pair.content);
6642
+ const tIndex = lesson.anchor === null ? -1 : findTitleIndex(tHeadings, lesson.anchor);
6643
+ translation = sliceLessonBody(pair.content, tHeadings, tIndex, isFirst);
6644
+ }
6645
+ const body = sliceLessonBody(content, headings, titleIndex, isFirst);
6646
+ if (body.length > 8e3 && splittableHeadingCount(headings, titleIndex) > 0) pacingViolations.push({
6647
+ title: lesson.title,
6648
+ file: lesson.file,
6649
+ chars: body.length,
6650
+ splittable: splittableHeadingCount(headings, titleIndex)
6475
6651
  });
6476
6652
  return {
6477
- title: section.title,
6478
- anchor: slugAnchor(section.title),
6479
- world: lessons.every((l) => l.world === "practice") ? "practice" : "study",
6480
- lessons
6653
+ title: lesson.title,
6654
+ anchor: slugAnchor(lesson.title),
6655
+ body,
6656
+ sourceFilePath: lesson.file,
6657
+ world: lesson.world,
6658
+ ...translation !== void 0 ? {
6659
+ translation,
6660
+ translationLang: pair.lang
6661
+ } : {}
6481
6662
  };
6482
- })
6663
+ });
6664
+ return {
6665
+ title: section.title,
6666
+ anchor: slugAnchor(section.title),
6667
+ world: lessons.every((l) => l.world === "practice") ? "practice" : "study",
6668
+ lessons
6669
+ };
6670
+ });
6671
+ if (pacingViolations.length > 0) {
6672
+ const listed = pacingViolations.slice(0, 5).map((v) => `「${v.title}」 ${v.file} (${v.chars} chars, ${v.splittable} splittable heading${v.splittable === 1 ? "" : "s"})`).join("; ");
6673
+ const more = pacingViolations.length > 5 ? ` (+${pacingViolations.length - 5} more)` : "";
6674
+ 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`);
6675
+ }
6676
+ return {
6677
+ title: courseTitle,
6678
+ sections
6483
6679
  };
6484
6680
  }
6485
6681
  //#endregion
@@ -7142,6 +7338,26 @@ function studyTools(store, deps = {}) {
7142
7338
  type: "integer",
7143
7339
  description: "Pending-design sequence (audit C29): renders detect stale briefs."
7144
7340
  },
7341
+ availableLangs: {
7342
+ type: "array",
7343
+ description: "Translation languages the repo README declares (translations/<code>/ mirrors) — apply pairs the learner's interface language automatically.",
7344
+ items: {
7345
+ type: "object",
7346
+ additionalProperties: false,
7347
+ properties: {
7348
+ code: {
7349
+ type: "string",
7350
+ required: true,
7351
+ description: "BCP-47-ish code as it appears in the translations/ path."
7352
+ },
7353
+ name: {
7354
+ type: "string",
7355
+ required: true,
7356
+ description: "Display name from the README link text."
7357
+ }
7358
+ }
7359
+ }
7360
+ },
7145
7361
  part: {
7146
7362
  type: "integer",
7147
7363
  description: "Brief part this call rendered (context-budget batching)."
@@ -7163,7 +7379,7 @@ function studyTools(store, deps = {}) {
7163
7379
  };
7164
7380
  const designOrImportedRender = (_args, value) => [{
7165
7381
  type: "text",
7166
- text: value.status === "design_required" ? pendingDesign === null ? "Course design required — the brief is no longer pending; call the import tool again to re-fetch it." : typeof value.designSeq === "number" && value.designSeq !== designSeq ? "This design brief is STALE — a newer import replaced the pending design. Call the import tool again for the fresh brief; do not design against this one." : renderDesignBrief(pendingDesign, pendingPart) : `Imported course “${value.title}” (${value.sections} sections, ${value.lessons} lessons). First lesson: “${value.firstLessonTitle}” (id ${value.firstLessonId}).`
7382
+ text: value.status === "design_required" ? pendingDesign === null ? "Course design required — the brief is no longer pending; call the import tool again to re-fetch it." : typeof value.designSeq === "number" && value.designSeq !== designSeq ? "This design brief is STALE — a newer import replaced the pending design. Call the import tool again for the fresh brief; do not design against this one." : renderDesignBrief(pendingDesign, pendingPart) + (store.get().interfaceLang === void 0 && (pendingDesign.availableLangs?.length ?? 0) > 0 ? "\nNote: the learner's interface language is not recorded — when applying, pass translationLang explicitly as the language the learner writes to you in (e.g. \"zh-CN\" for Chinese), so the translation mirrors pair." : "") : `Imported course “${value.title}” (${value.sections} sections, ${value.lessons} lessons). First lesson: “${value.firstLessonTitle}” (id ${value.firstLessonId}).`
7167
7383
  }];
7168
7384
  const designOrImportedPresentationMeta = (_args, value) => value.status === "design_required" ? designBriefLines(value) : importLines(value);
7169
7385
  const designOrImportedPresent = { presentResult: (_args, result) => ({
@@ -7245,6 +7461,10 @@ function studyTools(store, deps = {}) {
7245
7461
  courseTitle: partCount > 1 ? `${pd.courseTitle} (part ${part})` : pd.courseTitle,
7246
7462
  fileCount: planBriefParts(pd.files)[Math.min(part, partCount) - 1].length,
7247
7463
  fullTreeCount: pd.fullTreeCount,
7464
+ ...pd.availableLangs !== void 0 && pd.availableLangs.length > 0 ? { availableLangs: pd.availableLangs.map((l) => ({
7465
+ code: l.code,
7466
+ name: l.name
7467
+ })) } : {},
7248
7468
  ...partCount > 1 ? {
7249
7469
  part,
7250
7470
  partCount
@@ -7265,6 +7485,8 @@ function studyTools(store, deps = {}) {
7265
7485
  };
7266
7486
  const inventory = await fetchRepoInventory(owner, repo, resolvedBranch, fetchFn, void 0, exec.signal);
7267
7487
  const pd = buildPendingDesign(canonicalRef, owner, repo, inventory, await fetchFileOutlines(inventory.fileList.map((f) => f.path), owner, repo, inventory.branch, fetchFn, void 0, exec.signal));
7488
+ const availableLangs = extractLanguagesFromReadme(inventory.readmeMd);
7489
+ if (availableLangs.length > 0) pd.availableLangs = availableLangs;
7268
7490
  setPendingDesign(pd);
7269
7491
  if (pd.files.length === 0) throw new Error("lookatstudy-plugin: course files were discovered but no outlines could be fetched (CDN unreachable?)");
7270
7492
  pendingPart = part;
@@ -7401,7 +7623,11 @@ ${p.text}`));
7401
7623
  parameters: {
7402
7624
  languageTarget: {
7403
7625
  type: "string",
7404
- 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."
7626
+ 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."
7627
+ },
7628
+ translationLang: {
7629
+ type: "string",
7630
+ 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."
7405
7631
  },
7406
7632
  sections: {
7407
7633
  type: "array",
@@ -7481,6 +7707,11 @@ ${p.text}`));
7481
7707
  type: "integer",
7482
7708
  required: true
7483
7709
  },
7710
+ translations: {
7711
+ type: "integer",
7712
+ required: true,
7713
+ description: "Lessons that landed with a paired translation (the interface-language mirror existed and its anchor matched) — their teach tab offers 原文/对照/译文."
7714
+ },
7484
7715
  created: {
7485
7716
  type: "boolean",
7486
7717
  required: true,
@@ -7494,7 +7725,7 @@ ${p.text}`));
7494
7725
  },
7495
7726
  render: (_args, value) => [{
7496
7727
  type: "text",
7497
- 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.`
7728
+ 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.`
7498
7729
  }]
7499
7730
  },
7500
7731
  async execute(args, exec) {
@@ -7521,7 +7752,22 @@ ${p.text}`));
7521
7752
  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`);
7522
7753
  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`);
7523
7754
  }
7524
- 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);
7755
+ let translations = pd.translations;
7756
+ if (pd.localContents === void 0 && pd.availableLangs !== void 0 && pd.availableLangs.length > 0) {
7757
+ const match = matchTranslationLang(typeof args.translationLang === "string" && args.translationLang.trim() !== "" ? args.translationLang : store.get().interfaceLang, pd.availableLangs);
7758
+ if (match !== null && !exec.signal.aborted) {
7759
+ const fetched = await fetchTranslatedContent(pd.owner, pd.repo, pd.branch, match.code, uniqueFiles.map((f) => ({
7760
+ path: f,
7761
+ title: f,
7762
+ md: ""
7763
+ })), signalFetch(exec.signal, baseFetch));
7764
+ if (fetched.size > 0) translations = new Map([...fetched].map(([path, v]) => [path, {
7765
+ lang: match.code,
7766
+ content: v.content
7767
+ }]));
7768
+ }
7769
+ }
7770
+ const parsed = buildCourseFromDesign(pd.part !== void 0 && pd.partCount !== void 0 && pd.partCount > 1 ? `${pd.courseTitle} (part ${pd.part})` : pd.courseTitle, validated, contents, translations);
7525
7771
  requireParsedLessons(parsed);
7526
7772
  const languageTarget = typeof args.languageTarget === "string" && args.languageTarget.trim() !== "" ? args.languageTarget.trim().slice(0, 35) : null;
7527
7773
  const value = mutate((state) => {
@@ -7533,9 +7779,11 @@ ${p.text}`));
7533
7779
  });
7534
7780
  setPendingDesign(null);
7535
7781
  pendingPart = 1;
7782
+ const translatedLessons = parsed.sections.reduce((n, s) => n + s.lessons.filter((l) => l.translation !== void 0).length, 0);
7536
7783
  return {
7537
7784
  ...value,
7538
7785
  droppedLessons: validated.droppedLessons,
7786
+ translations: translatedLessons,
7539
7787
  ...languageTarget !== null ? { languageTarget } : {}
7540
7788
  };
7541
7789
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-lookatstudy",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "packageManager": "pnpm@11.7.0",
5
5
  "description": "Turn any markdown, local folder, or GitHub learning repo into a guided course inside DeepSeek Harness: gated skill-tree progression, BKT mastery tracking, SM-2 spaced repetition.",
6
6
  "type": "module",