dsh-plugin-lookatstudy 0.25.0 → 0.25.2

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
@@ -266,7 +266,6 @@ const ZH = {
266
266
  "chat.reasoning": "思考过程({n} 字)",
267
267
  "chat.think": "思考",
268
268
  "langview.original": "原文",
269
- "langview.bilingual": "对照",
270
269
  "langview.translation": "译文",
271
270
  "msg.speak": "朗读",
272
271
  "note.pin": "置顶笔记",
@@ -638,7 +637,6 @@ const EN = {
638
637
  "chat.reasoning": "Thinking ({n} chars)",
639
638
  "chat.think": "Think",
640
639
  "langview.original": "Original",
641
- "langview.bilingual": "Both",
642
640
  "langview.translation": "Translation",
643
641
  "msg.speak": "Speak",
644
642
  "note.pin": "Pin note",
@@ -838,21 +836,6 @@ function escapeHtml(text) {
838
836
  function inline(escaped) {
839
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>");
840
838
  }
841
- /** Interleave one body with its translation, paragraph by paragraph (upstream
842
- * translation-layout "microsoft" semantics: original paragraph then its
843
- * translation as a quote, tight pairing for bilingual reading; length
844
- * mismatches append the remainder in order). Pure. */
845
- function renderBilingual(bodyMd, translationMd) {
846
- const par = (md) => md.split(/\n\s*\n/).map((p) => p.trim()).filter((p) => p !== "");
847
- const a = par(bodyMd);
848
- const b = par(translationMd);
849
- const out = [];
850
- for (let i = 0; i < Math.max(a.length, b.length); i++) {
851
- if (a[i] !== void 0) out.push(a[i]);
852
- if (b[i] !== void 0) out.push(`> ${b[i].split("\n").join("\n> ")}`);
853
- }
854
- return out.join("\n\n");
855
- }
856
839
  /** True when the line opens a GFM table row (pipes with a delimiter row next). */
857
840
  function isTableRow(line) {
858
841
  return line.trim().startsWith("|") && line.trim().endsWith("|") && line.includes("|", 1);
@@ -3138,7 +3121,6 @@ function workbenchState(state, now) {
3138
3121
  markdown: ref.lesson.body,
3139
3122
  speechText: normalizeSpeechText(normalizeMathNotation(ref.lesson.body)),
3140
3123
  ...ref.lesson.translation !== void 0 ? {
3141
- bilingualHtml: renderMarkdown(normalizeMathNotation(renderBilingual(ref.lesson.body, ref.lesson.translation))),
3142
3124
  translationHtml: renderMarkdown(normalizeMathNotation(ref.lesson.translation)),
3143
3125
  translationLang: ref.lesson.translationLang ?? ""
3144
3126
  } : {}
@@ -6599,6 +6581,75 @@ function validateDesign(design, validFiles) {
6599
6581
  droppedLessons
6600
6582
  };
6601
6583
  }
6584
+ /**
6585
+ * Heading similarity — the translation misalignment guard. Ported from
6586
+ * upstream import-pipeline.ts (their audit F16, 2026-09-13): ordinal
6587
+ * alignment assumes both heading lists share structure; a machine-translated
6588
+ * file with a dropped header shifts every ordinal, and A's translation would
6589
+ * silently land on B's node — overlap too low skips the segment (宁缺毋错).
6590
+ * Empty titles cannot be checked and pass conservatively.
6591
+ *
6592
+ * 0.25.2 (owner real-import catch): upstream's word overlap normalizes on
6593
+ * whitespace, and a CJK title is ONE token — paraphrased Chinese headings
6594
+ * (课程总览与环境搭建指南 vs 课程总览) could never overlap and the guard
6595
+ * skipped every non-verbatim segment (38/110 paired on the owner's import;
6596
+ * the 38 were exact matches). CJK strings now compare by character
6597
+ * containment first, then character-bigram overlap ≥ 0.3.
6598
+ */
6599
+ function headingsSimilar(a, b) {
6600
+ const norm = (s) => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim().split(/\s+/).filter(Boolean);
6601
+ const A = norm(a);
6602
+ const B = norm(b);
6603
+ if (A.length === 0 || B.length === 0) return true;
6604
+ if (A.join(" ") === B.join(" ")) return true;
6605
+ const han = (s) => s.toLowerCase().replace(/[^\p{Script=Han}\p{N}]/gu, "");
6606
+ const ca = han(a);
6607
+ const cb = han(b);
6608
+ if (ca.length >= 2 || cb.length >= 2) {
6609
+ if (ca === "" || cb === "") return true;
6610
+ if (ca.includes(cb) || cb.includes(ca)) return true;
6611
+ const bigramsOf = (s) => {
6612
+ const out = /* @__PURE__ */ new Set();
6613
+ const chars = Array.from(s);
6614
+ for (let i = 0; i + 1 < chars.length; i++) out.add(chars[i] + chars[i + 1]);
6615
+ return out;
6616
+ };
6617
+ const ba = bigramsOf(ca);
6618
+ const bb = bigramsOf(cb);
6619
+ let hit = 0;
6620
+ for (const g of ba) if (bb.has(g)) hit++;
6621
+ return hit / Math.max(1, ba.size) >= .3;
6622
+ }
6623
+ const setB = new Set(B);
6624
+ return A.filter((w) => setB.has(w)).length / A.length >= .3;
6625
+ }
6626
+ /**
6627
+ * Replace a translation body's image references BY POSITION with the
6628
+ * original lesson's images (upstream method, import-pipeline.ts): translated
6629
+ * figure i ← original figure i (the original's refs are already absolute
6630
+ * CDN URLs or inlined data URLs); extras beyond the original count drop
6631
+ * (machine translation adding figures is rare — dropping is cleanest);
6632
+ * HTML imgs convert to markdown. One unified regex pass so HTML→markdown
6633
+ * conversion is never re-scanned with a shared index.
6634
+ */
6635
+ function replaceImagesByPosition(content, originalImgs) {
6636
+ let imgIdx = 0;
6637
+ return content.replace(/!\[([^\]]*)\]\(([^)]+)\)|<img[^>]+src=["']([^"']+)["'][^>]*>/gi, (match, mdAlt, _mdSrc, htmlSrc) => {
6638
+ if (imgIdx >= originalImgs.length) return "";
6639
+ const replacement = originalImgs[imgIdx++];
6640
+ if (htmlSrc !== void 0) return `![${/alt=["']([^"']*)["']/i.exec(match)?.[1] ?? ""}](${replacement})`;
6641
+ return `![${mdAlt}](${replacement})`;
6642
+ });
6643
+ }
6644
+ /** The original lesson body's image refs in order (the position-mapping source). */
6645
+ function originalImagesOf(body) {
6646
+ const out = [];
6647
+ for (const m of body.matchAll(/!\[([^\]]*)\]\(([^)]+)\)|<img[^>]+src=["']([^"']+)["'][^>]*>/gi)) {
6648
+ const src = m[2] ?? m[3] ?? "";
6649
+ if (src !== "") out.push(src);
6650
+ }
6651
+ return out;
6652
+ }
6602
6653
  /** GitHub-style anchor slug for ParsedLesson/ParsedSection anchors. */
6603
6654
  function slugAnchor(title) {
6604
6655
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -6635,13 +6686,6 @@ function buildCourseFromDesign(courseTitle, validated, contents, translations) {
6635
6686
  const titleIndex = lesson.anchor === null ? -1 : findTitleIndex(headings, lesson.anchor);
6636
6687
  const isFirst = !firstLessonSeen.has(lesson.file);
6637
6688
  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
6689
  const body = sliceLessonBody(content, headings, titleIndex, isFirst);
6646
6690
  if (body.length > 8e3 && splittableHeadingCount(headings, titleIndex) > 0) pacingViolations.push({
6647
6691
  title: lesson.title,
@@ -6649,6 +6693,17 @@ function buildCourseFromDesign(courseTitle, validated, contents, translations) {
6649
6693
  chars: body.length,
6650
6694
  splittable: splittableHeadingCount(headings, titleIndex)
6651
6695
  });
6696
+ const pair = translations?.get(lesson.file);
6697
+ let translation;
6698
+ if (pair !== void 0) {
6699
+ const tHeadings = extractHeadings(pair.content);
6700
+ let tIndex = titleIndex;
6701
+ let keep = true;
6702
+ if (lesson.anchor === null || titleIndex === -1) tIndex = -1;
6703
+ else if (titleIndex >= tHeadings.length) keep = false;
6704
+ else if (!(tHeadings.length === headings.length && tHeadings.every((h, i) => h.level === headings[i].level)) && !headingsSimilar(lesson.title, tHeadings[titleIndex].title)) keep = false;
6705
+ if (keep) translation = replaceImagesByPosition(sliceLessonBody(pair.content, tHeadings, tIndex, isFirst), originalImagesOf(body));
6706
+ }
6652
6707
  return {
6653
6708
  title: lesson.title,
6654
6709
  anchor: slugAnchor(lesson.title),
@@ -7710,7 +7765,7 @@ ${p.text}`));
7710
7765
  translations: {
7711
7766
  type: "integer",
7712
7767
  required: true,
7713
- description: "Lessons that landed with a paired translation (the interface-language mirror existed and its anchor matched) — their teach tab offers 原文/对照/译文."
7768
+ description: "Lessons that landed with a paired translation (the interface-language mirror existed and its anchor matched) — their teach tab offers 原文/译文."
7714
7769
  },
7715
7770
  created: {
7716
7771
  type: "boolean",
@@ -7725,7 +7780,7 @@ ${p.text}`));
7725
7780
  },
7726
7781
  render: (_args, value) => [{
7727
7782
  type: "text",
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.`
7783
+ 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.`
7729
7784
  }]
7730
7785
  },
7731
7786
  async execute(args, exec) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-lookatstudy",
3
- "version": "0.25.0",
3
+ "version": "0.25.2",
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",