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/client.js +6 -12
- package/lib/client.js.map +1 -1
- package/lib/index.mjs +82 -27
- package/package.json +1 -1
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 `["']/i.exec(match)?.[1] ?? ""}](${replacement})`;
|
|
6641
|
+
return ``;
|
|
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 (
|
|
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.
|
|
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",
|