Files
STCourses/packages/feature-learner/src/server/lesson.ts
T

116 lines
5.7 KiB
TypeScript

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);
},
},
};
}