dsh-plugin-lookatstudy 0.21.0 → 0.21.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
@@ -229,6 +229,7 @@ const ZH = {
229
229
  "settings.budget.on": "开启历史预算",
230
230
  "settings.budget.off": "关闭历史预算",
231
231
  "attach.sent": "我发了一个文件:{name}(工作区路径 {path})——请读取它再继续。",
232
+ "attach.remove": "移除待发附件",
232
233
  "attach.fail": "附件上传失败",
233
234
  "attach.label": "附件(≤20MiB,存入学习工作区)",
234
235
  "rail.mastered": "毕业 {mastered}/{total} 课",
@@ -575,6 +576,7 @@ const EN = {
575
576
  "settings.budget.on": "Enable history budget",
576
577
  "settings.budget.off": "Disable history budget",
577
578
  "attach.sent": "I attached a file: {name} (workspace path {path}) — read it before continuing.",
579
+ "attach.remove": "Remove pending attachment",
578
580
  "attach.fail": "Attachment upload failed",
579
581
  "attach.label": "Attach (≤20MiB, lands in the study workspace)",
580
582
  "rail.mastered": "{mastered}/{total} lessons graduated",
@@ -778,10 +780,13 @@ function escapeHtml(text) {
778
780
  }
779
781
  /** Render inline markup (code, bold, italic, links, images) over escaped text.
780
782
  * Image src allowlist: https?:// or data:image/ — folder imports inline local
781
- * images as data URLs (capped), GitHub imports reference jsDelivr; anything
782
- * else stays literal text (no broken relative images, no exotic schemes). */
783
+ * images as data URLs (capped), GitHub imports reference jsDelivr; the
784
+ * plugin's own attachment route (/lookatstudy/api/attachment/<name>, issue #7
785
+ * — same-origin, server-side validated) renders workspace attachments;
786
+ * anything else stays literal text (no broken relative images, no exotic
787
+ * schemes). */
783
788
  function inline(escaped) {
784
- return escaped.replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>").replace(/!\[([^\]]*)\]\((https?:\/\/[^)\s]+|data:image\/[^)\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>");
789
+ 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>");
785
790
  }
786
791
  /** Interleave one body with its translation, paragraph by paragraph (upstream
787
792
  * translation-layout "microsoft" semantics: original paragraph then its
@@ -1496,9 +1501,11 @@ function freshLesson(title, anchor, body, kind = "study") {
1496
1501
  * @returns the imported (or pre-existing) course.
1497
1502
  */
1498
1503
  function importCourse(state, parsed, source, sourceRef, languageTarget) {
1499
- const id = slugify(parsed.title);
1500
- const existing = state.courses.find((c) => c.id === id);
1501
- if (existing) return existing;
1504
+ const base = slugify(parsed.title);
1505
+ const sameTitle = state.courses.find((c) => c.id === base && c.title === parsed.title);
1506
+ if (sameTitle !== void 0) return sameTitle;
1507
+ let id = base;
1508
+ for (let n = 2; state.courses.some((c) => c.id === id); n++) id = `${base}-${String(n)}`;
1502
1509
  const course = {
1503
1510
  id,
1504
1511
  title: parsed.title,
@@ -3188,6 +3195,36 @@ function registerDashboard(webServer, deps) {
3188
3195
  }
3189
3196
  return;
3190
3197
  }
3198
+ if (req.method === "GET" && pathname.startsWith("/lookatstudy/api/attachment/")) {
3199
+ let name = "";
3200
+ try {
3201
+ name = decodeURIComponent(pathname.slice(28));
3202
+ } catch {
3203
+ name = "";
3204
+ }
3205
+ if (name === "" || name.includes("..") || /[\\/]/.test(name) || name.length > 250) {
3206
+ sendJson(res, 404, {
3207
+ ok: false,
3208
+ error: "not found"
3209
+ });
3210
+ return;
3211
+ }
3212
+ try {
3213
+ const buf = readFileSync(join(deps.studyAreaPath, "attachments", name));
3214
+ const ext = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
3215
+ const mime = ext === "png" ? "image/png" : ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "gif" ? "image/gif" : ext === "webp" ? "image/webp" : ext === "bmp" ? "image/bmp" : "application/octet-stream";
3216
+ res.writeHead(200, {
3217
+ "content-type": mime,
3218
+ "cache-control": "public, max-age=31536000, immutable"
3219
+ }).end(buf);
3220
+ } catch {
3221
+ sendJson(res, 404, {
3222
+ ok: false,
3223
+ error: "not found"
3224
+ });
3225
+ }
3226
+ return;
3227
+ }
3191
3228
  sendJson(res, 404, {
3192
3229
  ok: false,
3193
3230
  error: "not found"
@@ -6223,6 +6260,11 @@ function studyTools(store, deps = {}) {
6223
6260
  type: "integer",
6224
6261
  required: true
6225
6262
  },
6263
+ created: {
6264
+ type: "boolean",
6265
+ required: true,
6266
+ description: "False when an existing course short-circuited the import (same title) — the design did NOT land as a new course."
6267
+ },
6226
6268
  languageTarget: {
6227
6269
  type: "string",
6228
6270
  description: "Present only when the course teaches a language itself (BCP-47); the tutor then keeps quiz material in the target language."
@@ -6231,7 +6273,7 @@ function studyTools(store, deps = {}) {
6231
6273
  },
6232
6274
  render: (_args, value) => [{
6233
6275
  type: "text",
6234
- text: `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.`
6276
+ 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.`
6235
6277
  }]
6236
6278
  },
6237
6279
  async execute(args, exec) {
@@ -6261,7 +6303,13 @@ function studyTools(store, deps = {}) {
6261
6303
  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);
6262
6304
  requireParsedLessons(parsed);
6263
6305
  const languageTarget = typeof args.languageTarget === "string" && args.languageTarget.trim() !== "" ? args.languageTarget.trim().slice(0, 35) : null;
6264
- const value = mutate((state) => toImportValue(importCourse(state, parsed, pd.source, pd.url, languageTarget)));
6306
+ const value = mutate((state) => {
6307
+ const before = state.courses.length;
6308
+ return {
6309
+ ...toImportValue(importCourse(state, parsed, pd.source, pd.url, languageTarget)),
6310
+ created: state.courses.length > before
6311
+ };
6312
+ });
6265
6313
  pendingDesign = null;
6266
6314
  pendingPart = 1;
6267
6315
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-lookatstudy",
3
- "version": "0.21.0",
3
+ "version": "0.21.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",