dsh-plugin-lookatstudy 0.24.0 → 0.25.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.
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",
@@ -265,7 +266,6 @@ const ZH = {
265
266
  "chat.reasoning": "思考过程({n} 字)",
266
267
  "chat.think": "思考",
267
268
  "langview.original": "原文",
268
- "langview.bilingual": "对照",
269
269
  "langview.translation": "译文",
270
270
  "msg.speak": "朗读",
271
271
  "note.pin": "置顶笔记",
@@ -534,6 +534,7 @@ const EN = {
534
534
  "toast.close": "Close",
535
535
  "composer.busy": "The tutor is replying…",
536
536
  "rail.empty.demo": "Import the demo course",
537
+ "import.session.title": "Course import",
537
538
  "import.cta": "Import a new course",
538
539
  "import.tab.url": "URL",
539
540
  "import.tab.md": "MD",
@@ -636,7 +637,6 @@ const EN = {
636
637
  "chat.reasoning": "Thinking ({n} chars)",
637
638
  "chat.think": "Think",
638
639
  "langview.original": "Original",
639
- "langview.bilingual": "Both",
640
640
  "langview.translation": "Translation",
641
641
  "msg.speak": "Speak",
642
642
  "note.pin": "Pin note",
@@ -836,21 +836,6 @@ function escapeHtml(text) {
836
836
  function inline(escaped) {
837
837
  return escaped.replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>").replace(/!\[([^\]]*)\]\((https?:\/\/[^)\s]+|data:image\/[^)\s]+|\/lookatstudy\/api\/attachment\/[^)\s]+)\)/g, (_m, alt, src) => `<img src="${src}" alt="${alt}" loading="lazy">`).replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, "<a href=\"$2\" target=\"_blank\" rel=\"noreferrer\">$1</a>");
838
838
  }
839
- /** Interleave one body with its translation, paragraph by paragraph (upstream
840
- * translation-layout "microsoft" semantics: original paragraph then its
841
- * translation as a quote, tight pairing for bilingual reading; length
842
- * mismatches append the remainder in order). Pure. */
843
- function renderBilingual(bodyMd, translationMd) {
844
- const par = (md) => md.split(/\n\s*\n/).map((p) => p.trim()).filter((p) => p !== "");
845
- const a = par(bodyMd);
846
- const b = par(translationMd);
847
- const out = [];
848
- for (let i = 0; i < Math.max(a.length, b.length); i++) {
849
- if (a[i] !== void 0) out.push(a[i]);
850
- if (b[i] !== void 0) out.push(`> ${b[i].split("\n").join("\n> ")}`);
851
- }
852
- return out.join("\n\n");
853
- }
854
839
  /** True when the line opens a GFM table row (pipes with a delimiter row next). */
855
840
  function isTableRow(line) {
856
841
  return line.trim().startsWith("|") && line.trim().endsWith("|") && line.includes("|", 1);
@@ -2353,7 +2338,18 @@ function recordAnswer(state, lessonId, correct, concept, now) {
2353
2338
  function setCourseThreadScope(state, courseId, scope) {
2354
2339
  const course = state.courses.find((c) => c.id === courseId);
2355
2340
  if (course === void 0) throw new Error(`lookatstudy-plugin: unknown course id ${JSON.stringify(courseId)}`);
2341
+ if ((course.threadScope === "course" ? "course" : "lesson") === scope) return;
2356
2342
  course.threadScope = scope;
2343
+ const courseKey = `course:${courseId}`;
2344
+ const focusLessonId = state.focus?.lessonId ?? null;
2345
+ if (focusLessonId === null || !course.sections.some((s) => s.lessons.some((l) => l.id === focusLessonId))) return;
2346
+ const lessonKey = focusLessonId;
2347
+ const src = scope === "course" ? state.lessonThreads[lessonKey] : state.lessonThreads[courseKey];
2348
+ const dst = scope === "course" ? state.lessonThreads[courseKey] : state.lessonThreads[lessonKey];
2349
+ if (src === void 0 || src.threads.length === 0) return;
2350
+ if (dst !== void 0 && (dst.active !== null || dst.threads.length > 0)) return;
2351
+ state.lessonThreads[scope === "course" ? courseKey : lessonKey] = src;
2352
+ delete state.lessonThreads[scope === "course" ? lessonKey : courseKey];
2357
2353
  }
2358
2354
  /**
2359
2355
  * Resolve the thread-group map key a send from `lessonId` belongs to — the
@@ -3125,7 +3121,6 @@ function workbenchState(state, now) {
3125
3121
  markdown: ref.lesson.body,
3126
3122
  speechText: normalizeSpeechText(normalizeMathNotation(ref.lesson.body)),
3127
3123
  ...ref.lesson.translation !== void 0 ? {
3128
- bilingualHtml: renderMarkdown(normalizeMathNotation(renderBilingual(ref.lesson.body, ref.lesson.translation))),
3129
3124
  translationHtml: renderMarkdown(normalizeMathNotation(ref.lesson.translation)),
3130
3125
  translationLang: ref.lesson.translationLang ?? ""
3131
3126
  } : {}
@@ -6586,6 +6581,50 @@ function validateDesign(design, validFiles) {
6586
6581
  droppedLessons
6587
6582
  };
6588
6583
  }
6584
+ /**
6585
+ * Heading similarity (word-level overlap) — the translation misalignment
6586
+ * guard. Ported verbatim from upstream import-pipeline.ts (their audit F16,
6587
+ * 2026-09-13): ordinal alignment assumes both heading lists share structure;
6588
+ * a machine-translated file with a dropped header shifts every ordinal, and
6589
+ * A's translation would silently land on B's node — overlap too low skips the
6590
+ * segment (宁缺毋错). Empty titles cannot be checked and pass conservatively.
6591
+ */
6592
+ function headingsSimilar(a, b) {
6593
+ const norm = (s) => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim().split(/\s+/).filter(Boolean);
6594
+ const A = norm(a);
6595
+ const B = norm(b);
6596
+ if (A.length === 0 || B.length === 0) return true;
6597
+ if (A.join(" ") === B.join(" ")) return true;
6598
+ const setB = new Set(B);
6599
+ return A.filter((w) => setB.has(w)).length / A.length >= .3;
6600
+ }
6601
+ /**
6602
+ * Replace a translation body's image references BY POSITION with the
6603
+ * original lesson's images (upstream method, import-pipeline.ts): translated
6604
+ * figure i ← original figure i (the original's refs are already absolute
6605
+ * CDN URLs or inlined data URLs); extras beyond the original count drop
6606
+ * (machine translation adding figures is rare — dropping is cleanest);
6607
+ * HTML imgs convert to markdown. One unified regex pass so HTML→markdown
6608
+ * conversion is never re-scanned with a shared index.
6609
+ */
6610
+ function replaceImagesByPosition(content, originalImgs) {
6611
+ let imgIdx = 0;
6612
+ return content.replace(/!\[([^\]]*)\]\(([^)]+)\)|<img[^>]+src=["']([^"']+)["'][^>]*>/gi, (match, mdAlt, _mdSrc, htmlSrc) => {
6613
+ if (imgIdx >= originalImgs.length) return "";
6614
+ const replacement = originalImgs[imgIdx++];
6615
+ if (htmlSrc !== void 0) return `![${/alt=["']([^"']*)["']/i.exec(match)?.[1] ?? ""}](${replacement})`;
6616
+ return `![${mdAlt}](${replacement})`;
6617
+ });
6618
+ }
6619
+ /** The original lesson body's image refs in order (the position-mapping source). */
6620
+ function originalImagesOf(body) {
6621
+ const out = [];
6622
+ for (const m of body.matchAll(/!\[([^\]]*)\]\(([^)]+)\)|<img[^>]+src=["']([^"']+)["'][^>]*>/gi)) {
6623
+ const src = m[2] ?? m[3] ?? "";
6624
+ if (src !== "") out.push(src);
6625
+ }
6626
+ return out;
6627
+ }
6589
6628
  /** GitHub-style anchor slug for ParsedLesson/ParsedSection anchors. */
6590
6629
  function slugAnchor(title) {
6591
6630
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -6622,13 +6661,6 @@ function buildCourseFromDesign(courseTitle, validated, contents, translations) {
6622
6661
  const titleIndex = lesson.anchor === null ? -1 : findTitleIndex(headings, lesson.anchor);
6623
6662
  const isFirst = !firstLessonSeen.has(lesson.file);
6624
6663
  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
6664
  const body = sliceLessonBody(content, headings, titleIndex, isFirst);
6633
6665
  if (body.length > 8e3 && splittableHeadingCount(headings, titleIndex) > 0) pacingViolations.push({
6634
6666
  title: lesson.title,
@@ -6636,6 +6668,16 @@ function buildCourseFromDesign(courseTitle, validated, contents, translations) {
6636
6668
  chars: body.length,
6637
6669
  splittable: splittableHeadingCount(headings, titleIndex)
6638
6670
  });
6671
+ const pair = translations?.get(lesson.file);
6672
+ let translation;
6673
+ if (pair !== void 0) {
6674
+ const tHeadings = extractHeadings(pair.content);
6675
+ let tIndex = titleIndex;
6676
+ let keep = true;
6677
+ if (lesson.anchor === null || titleIndex === -1) tIndex = -1;
6678
+ else if (titleIndex >= tHeadings.length || !headingsSimilar(lesson.title, tHeadings[titleIndex].title)) keep = false;
6679
+ if (keep) translation = replaceImagesByPosition(sliceLessonBody(pair.content, tHeadings, tIndex, isFirst), originalImagesOf(body));
6680
+ }
6639
6681
  return {
6640
6682
  title: lesson.title,
6641
6683
  anchor: slugAnchor(lesson.title),
@@ -7366,7 +7408,7 @@ function studyTools(store, deps = {}) {
7366
7408
  };
7367
7409
  const designOrImportedRender = (_args, value) => [{
7368
7410
  type: "text",
7369
- 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}).`
7411
+ 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}).`
7370
7412
  }];
7371
7413
  const designOrImportedPresentationMeta = (_args, value) => value.status === "design_required" ? designBriefLines(value) : importLines(value);
7372
7414
  const designOrImportedPresent = { presentResult: (_args, result) => ({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-lookatstudy",
3
- "version": "0.24.0",
3
+ "version": "0.25.1",
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",