116 lines
5.7 KiB
TypeScript
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);
|
|
},
|
|
},
|
|
};
|
|
}
|