feat(learner): library, search, course outline with gating, lesson payload API
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a8a8e32bcb
commit
5f85ce1d4b
@@ -0,0 +1,115 @@
|
||||
import { statSync } from "node:fs";
|
||||
import type { Feature, ServerContext } from "@stc/contracts";
|
||||
import { extractHints, extractSection, type Lesson, type Manifest } from "@stc/course-format";
|
||||
import { progressRepo } from "@stc/db";
|
||||
import type { FileRef, LessonPayload } from "../shared/types";
|
||||
import { mediaUrl } from "../shared/types";
|
||||
import { courseFor } from "./access";
|
||||
import { createEstimator } from "./catalog";
|
||||
import { moduleLocks } from "./gating";
|
||||
import { json, notFound, param } from "./http";
|
||||
|
||||
const isMarkdown = (p: string) => /\.(md|markdown)$/i.test(p);
|
||||
|
||||
export function lessonFiles(m: Manifest): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const mod of m.modules) for (const l of mod.lessons) if (l.file && !l.anchor && isMarkdown(l.file) && !(l.file in out)) out[l.file] = l.id;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function lessonRoutes(ctx: ServerContext): Feature["routes"] {
|
||||
const minutesFor = createEstimator(ctx);
|
||||
|
||||
const read = async (courseId: string, rel: string): Promise<string | null> => {
|
||||
const abs = ctx.courses.resolvePath(courseId, rel);
|
||||
if (!abs) return null;
|
||||
try { return await Bun.file(abs).text(); } catch { return null; }
|
||||
};
|
||||
const fileRef = (courseId: string, file: string, title?: string): FileRef => {
|
||||
const abs = ctx.courses.resolvePath(courseId, file);
|
||||
let size: number | null = null;
|
||||
try { if (abs) size = statSync(abs).size; } catch { /* missing */ }
|
||||
return { title: title ?? file.split("/").pop()!, file, url: mediaUrl(courseId, file), size };
|
||||
};
|
||||
|
||||
return {
|
||||
"/api/courses/:courseId/lessons/:lessonId": {
|
||||
GET: async (_req, rc) => {
|
||||
const found = await courseFor(ctx, rc.user, param(rc, "courseId"));
|
||||
if (!found) return notFound();
|
||||
const { course, manifest: m } = found;
|
||||
const lessonId = param(rc, "lessonId");
|
||||
const entry = course.lessonIndex.get(lessonId);
|
||||
if (!entry) return notFound();
|
||||
const { lesson: l } = entry;
|
||||
const modIndex = m.modules.findIndex((x) => x.id === entry.moduleId);
|
||||
const mod = m.modules[modIndex]!;
|
||||
const flat: { l: Lesson; mi: number }[] = m.modules.flatMap((x, mi) => x.lessons.map((ll) => ({ l: ll, mi })));
|
||||
const pos = flat.findIndex((x) => x.l.id === l.id);
|
||||
const progress = rc.user ? await progressRepo(ctx.db).get(rc.user.id, m.id) : {};
|
||||
const locks = moduleLocks(m, progress, found.canEdit);
|
||||
const link = (x: { l: Lesson; mi: number } | undefined) => (x ? { id: x.l.id, title: x.l.title } : null);
|
||||
const est = await minutesFor(course);
|
||||
|
||||
const base: LessonPayload = {
|
||||
courseId: m.id, courseTitle: m.title,
|
||||
lesson: { id: l.id, title: l.title, type: l.type, minutes: est.get(l.id) ?? null },
|
||||
module: { id: mod.id, title: mod.title, index: modIndex },
|
||||
locked: locks[modIndex]!,
|
||||
markdown: null, baseFile: null, trackMarkdown: {}, tracks: m.tracks ?? [],
|
||||
video: null, transcript: null, hints: [], decks: [], code: null, attachments: [], browser: null,
|
||||
localCommand: null,
|
||||
prev: link(flat[pos - 1]), next: link(flat[pos + 1]),
|
||||
lessonFiles: lessonFiles(m),
|
||||
};
|
||||
if (base.locked) return json(base);
|
||||
|
||||
if (l.type === "video") {
|
||||
if (l.file) base.video = { url: mediaUrl(m.id, l.file), file: l.file, duration: l.duration ?? null };
|
||||
if (l.transcript && isMarkdown(l.transcript)) {
|
||||
base.transcript = await read(m.id, l.transcript);
|
||||
base.baseFile = l.transcript;
|
||||
}
|
||||
} else if (l.file && isMarkdown(l.file)) {
|
||||
const text = await read(m.id, l.file);
|
||||
base.markdown = text === null ? null : l.anchor ? extractSection(text, l.anchor) : text;
|
||||
base.baseFile = l.file;
|
||||
}
|
||||
for (const [track, t] of Object.entries(l.tracks ?? {})) {
|
||||
if (t.file && isMarkdown(t.file)) {
|
||||
const text = await read(m.id, t.file);
|
||||
if (text !== null) base.trackMarkdown[track] = l.anchor ? extractSection(text, l.anchor) ?? text : text;
|
||||
}
|
||||
}
|
||||
if (l.hints) {
|
||||
const text = await read(m.id, l.hints.file);
|
||||
if (text !== null) base.hints = extractHints(text, l.hints.match);
|
||||
}
|
||||
const first = mod.lessons[0]?.id === l.id;
|
||||
base.decks = (m.decks ?? [])
|
||||
.filter((d) => d.attachTo === l.id || (first && d.attachTo === mod.id))
|
||||
.filter((d) => ctx.courses.resolvePath(m.id, d.file))
|
||||
.map((d) => ({ id: d.id, title: d.title ?? d.file.split("/").pop()!.replace(/\.pdf$/i, ""), url: mediaUrl(m.id, d.file), attachTo: d.attachTo }));
|
||||
if (mod.code) base.code = { moduleId: mod.id, dir: mod.code.dir, command: mod.code.command ?? null };
|
||||
base.attachments = (m.attachments ?? [])
|
||||
.filter((a) => !a.module || a.module === mod.id)
|
||||
.filter((a) => ctx.courses.resolvePath(m.id, a.file))
|
||||
.map((a) => fileRef(m.id, a.file, a.title));
|
||||
if (l.browser) {
|
||||
const starter = await read(m.id, l.browser.starter);
|
||||
const tests = await read(m.id, l.browser.tests);
|
||||
if (starter !== null && tests !== null) {
|
||||
base.browser = {
|
||||
language: l.browser.language, starter, tests, starterFile: l.browser.starter.split("/").pop()!,
|
||||
// the solution is visible to authors only (spec section 5)
|
||||
solution: found.canEdit && l.browser.solution ? await read(m.id, l.browser.solution) : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
const trackCommands = Object.fromEntries(Object.entries(l.tracks ?? {}).filter(([, t]) => t.command).map(([k, t]) => [k, t.command!]));
|
||||
base.localCommand = l.localCommand ?? (Object.keys(trackCommands).length ? trackCommands : null);
|
||||
return json(base);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user