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/client.js +221 -21
- package/lib/client.js.map +1 -1
- package/lib/index.mjs +288 -40
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
6448
|
-
|
|
6449
|
-
|
|
6450
|
-
const
|
|
6451
|
-
|
|
6452
|
-
|
|
6453
|
-
|
|
6454
|
-
|
|
6455
|
-
|
|
6456
|
-
|
|
6457
|
-
|
|
6458
|
-
|
|
6459
|
-
|
|
6460
|
-
|
|
6461
|
-
|
|
6462
|
-
|
|
6463
|
-
|
|
6464
|
-
|
|
6465
|
-
|
|
6466
|
-
|
|
6467
|
-
|
|
6468
|
-
|
|
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:
|
|
6478
|
-
anchor: slugAnchor(
|
|
6479
|
-
|
|
6480
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|