Contracts and core: - Feature gains auth and stop; FeatureFactory; core composeServer builds the context, runs the factories, then the job queue from the union of their jobs (R8/R14). startServer uses it; main.ts composes learner, import, studio, AI. - AppConfig/loadConfig own the layout and settings: courseDirs include <dataDir>/courses and <dataDir>/worktrees, studioReposDir, cacheDir, importRoots, uploadMaxMb, aiAllowedBaseUrls, transcribeUrl, python. resolveConfig derives the same for tests; cachePaths shares the importer's cache layout. - canView: the global author role views every course (R14); the learner, studio and AI packages all call it. - migrate(db, packages): core first, then packages in name order. - Media: nosniff on every response, a sandbox CSP on svg/html/js; a Range on an empty file is 416. - courseUrl/lessonUrl and the lesson route patterns live in @stc/contracts. Packages: - import, studio and AI read AppConfig instead of process.env; the importer refuses ids used by studio/repos. - learner migrations run through core migrate; library covers fall back to the importer's cached candidate behind a canView-checked route. - studio preview renders through the learner LessonView (anchors honoured, draft URLs); AI drafts go to the studio draft branch for studio courses (as the requesting user) and to <dataDir>/drafts otherwise, and the generator page says which. Web: tsconfig paths for @stc/<feature>/web browser entries; features.ts imports them by package name; nav.roles hides links by role. Tests: compose, config, canView, media headers, migrations order, covers, preview, nav, SPA bundle without server code, and an end-to-end flow on SQLite and Postgres. Co-Authored-By: Claude Opus 5.5 <[email protected]>
218 lines
10 KiB
TypeScript
218 lines
10 KiB
TypeScript
import { statSync } from "node:fs";
|
|
import type { AuthUser, Feature, ServerContext } from "@stc/contracts";
|
|
import type { LoadedCourse, Manifest } from "@stc/course-format";
|
|
import { libraryRepo, progressRepo } from "@stc/db";
|
|
import type {
|
|
ContinueItem, CourseCard, CoursePayload, Format, LessonProgress, LibraryPayload, OutlineModule, SearchHit,
|
|
} from "../shared/types";
|
|
import { mediaUrl } from "../shared/types";
|
|
import { accessIndex, courseFor, isOwner, visible } from "./access";
|
|
import { coverUrlFor } from "./covers";
|
|
import { moduleLocks } from "./gating";
|
|
import { json, notFound, param } from "./http";
|
|
|
|
const WPM = 220;
|
|
const EXERCISE_MINUTES = 15;
|
|
|
|
/** Lesson length estimates (minutes), cached per course and manifest mtime. */
|
|
export function createEstimator(ctx: ServerContext) {
|
|
const cache = new Map<string, { key: string; minutes: Map<string, number | null> }>();
|
|
return async function minutesFor(c: LoadedCourse): Promise<Map<string, number | null>> {
|
|
let key = c.manifestPath;
|
|
try { key += `@${statSync(c.manifestPath).mtimeMs}`; } catch { /* keep path */ }
|
|
const hit = cache.get(c.id);
|
|
if (hit && hit.key === key) return hit.minutes;
|
|
const minutes = new Map<string, number | null>();
|
|
for (const mod of c.manifest?.modules ?? []) {
|
|
for (const l of mod.lessons) {
|
|
if (l.type === "video") {
|
|
minutes.set(l.id, l.duration ? l.duration / 60 : null);
|
|
continue;
|
|
}
|
|
let words = 0;
|
|
try {
|
|
const md = await ctx.courses.readLessonMarkdown(c.id, l.id);
|
|
words = md ? md.split(/\s+/).filter(Boolean).length : 0;
|
|
} catch { /* unreadable: no estimate */ }
|
|
const read = words / WPM;
|
|
minutes.set(l.id, l.type === "exercise" ? Math.max(EXERCISE_MINUTES, read) : words ? Math.max(1, read) : null);
|
|
}
|
|
}
|
|
cache.set(c.id, { key, minutes });
|
|
return minutes;
|
|
};
|
|
}
|
|
|
|
export function formatsOf(m: Manifest): Format[] {
|
|
const types = new Set(m.modules.flatMap((x) => x.lessons.map((l) => l.type)));
|
|
const f: Format[] = [];
|
|
if (types.has("text")) f.push("text");
|
|
if (types.has("video")) f.push("video");
|
|
if (m.decks?.length) f.push("slides");
|
|
if (types.has("exercise")) f.push("exercises");
|
|
return f;
|
|
}
|
|
|
|
export function addedAt(c: LoadedCourse): string {
|
|
const imported = c.manifest?.source?.importedAt;
|
|
if (imported && !Number.isNaN(Date.parse(imported))) return new Date(imported).toISOString();
|
|
try { return statSync(c.manifestPath).mtime.toISOString(); } catch { return new Date(0).toISOString(); }
|
|
}
|
|
|
|
const slug = (s: string) => s.trim().toLowerCase().normalize("NFKD").replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-|-$/g, "");
|
|
const fold = (s: string) => s.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase();
|
|
|
|
type ProgressByCourse = Map<string, Record<string, LessonProgress>>;
|
|
|
|
async function allProgress(ctx: ServerContext, user: AuthUser | null): Promise<ProgressByCourse> {
|
|
const out: ProgressByCourse = new Map();
|
|
if (!user) return out;
|
|
const rows = await ctx.db.sql`SELECT course_id, lesson_id, state, position_seconds, updated_at FROM progress WHERE user_id = ${user.id}`;
|
|
for (const r of rows) {
|
|
const rec = out.get(r.course_id) ?? {};
|
|
rec[r.lesson_id] = { state: r.state, positionSeconds: r.position_seconds === null ? null : Number(r.position_seconds), updatedAt: r.updated_at };
|
|
out.set(r.course_id, rec);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function libraryRoutes(ctx: ServerContext): Feature["routes"] {
|
|
const minutesFor = createEstimator(ctx);
|
|
const lib = libraryRepo(ctx.db);
|
|
|
|
async function card(c: LoadedCourse, progress: Record<string, LessonProgress>): Promise<CourseCard> {
|
|
const m = c.manifest;
|
|
if (!m) {
|
|
return {
|
|
id: c.id, title: c.id, pitch: c.diagnostics[0]?.message ?? null, authors: [], coverUrl: null, accent: null, formats: [],
|
|
lessonCount: 0, minutes: 0, collections: [], tracks: [], source: null, addedAt: addedAt(c), status: "error", progress: 0, doneCount: 0, started: false,
|
|
};
|
|
}
|
|
const lessons = m.modules.flatMap((x) => x.lessons);
|
|
const est = await minutesFor(c);
|
|
const minutes = Math.round(lessons.reduce((s, l) => s + (est.get(l.id) ?? 0), 0));
|
|
const doneCount = lessons.filter((l) => progress[l.id]?.state === "done").length;
|
|
return {
|
|
id: m.id, title: m.title, pitch: m.pitch ?? null, authors: m.authors ?? [],
|
|
coverUrl: coverUrlFor(ctx, m),
|
|
accent: m.accent ?? null, formats: formatsOf(m), lessonCount: lessons.length, minutes,
|
|
collections: m.collections ?? [], tracks: (m.tracks ?? []).map((t) => t.label), source: m.source?.kind ?? null,
|
|
addedAt: addedAt(c), status: c.status, progress: lessons.length ? doneCount / lessons.length : 0, doneCount,
|
|
started: lessons.some((l) => progress[l.id]),
|
|
};
|
|
}
|
|
|
|
function continueItem(c: LoadedCourse, progress: Record<string, LessonProgress>, bypass: boolean): ContinueItem | null {
|
|
const m = c.manifest!;
|
|
const lessons = m.modules.flatMap((x) => x.lessons);
|
|
const done = lessons.filter((l) => progress[l.id]?.state === "done").length;
|
|
if (!lessons.some((l) => progress[l.id]) || done === lessons.length) return null;
|
|
const locks = moduleLocks(m, progress, bypass);
|
|
for (const [i, mod] of m.modules.entries()) {
|
|
if (locks[i]) break;
|
|
const next = mod.lessons.find((l) => progress[l.id]?.state !== "done");
|
|
if (!next) continue;
|
|
return {
|
|
courseId: m.id, lessonId: next.id, lessonTitle: next.title, moduleTitle: mod.title, moduleIndex: i,
|
|
moduleDone: mod.lessons.filter((l) => progress[l.id]?.state === "done").length, moduleTotal: mod.lessons.length,
|
|
progress: done / lessons.length,
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
"/api/library": {
|
|
GET: async (_req, rc) => {
|
|
const user = rc.user;
|
|
const idx = await accessIndex(ctx.db, user);
|
|
const courses = ctx.courses.list().filter((c) => visible(user, c, idx));
|
|
const progress = await allProgress(ctx, user);
|
|
const cards = await Promise.all(courses.map((c) => card(c, progress.get(c.id) ?? {})));
|
|
const byId = new Map(cards.map((c) => [c.id, c]));
|
|
|
|
const cont: (ContinueItem & { at: string })[] = [];
|
|
for (const c of courses) {
|
|
if (!c.manifest) continue;
|
|
const p = progress.get(c.id) ?? {};
|
|
const item = continueItem(c, p, isOwner(user));
|
|
if (item) cont.push({ ...item, at: Object.values(p).map((x) => x.updatedAt).sort().pop() ?? "" });
|
|
}
|
|
cont.sort((a, b) => (a.at < b.at ? 1 : -1));
|
|
|
|
const newest = [...cards].filter((c) => c.status === "ok" || c.lessonCount).sort((a, b) => (a.addedAt < b.addedAt ? 1 : a.addedAt > b.addedAt ? -1 : a.title.localeCompare(b.title))).map((c) => c.id);
|
|
|
|
const collections: LibraryPayload["collections"] = [];
|
|
for (const col of await lib.listCollections()) {
|
|
collections.push({ id: col.id, title: col.title, courseIds: col.courseIds.filter((id) => byId.has(id)) });
|
|
}
|
|
for (const c of cards) {
|
|
for (const name of c.collections) {
|
|
const id = slug(name);
|
|
let col = collections.find((x) => x.id === id || x.title.toLowerCase() === name.toLowerCase());
|
|
if (!col) collections.push((col = { id, title: name, courseIds: [] }));
|
|
if (!col.courseIds.includes(c.id)) col.courseIds.push(c.id);
|
|
}
|
|
}
|
|
|
|
const featuredId = await lib.getFeatured();
|
|
const featured = (featuredId && byId.get(featuredId)) || (cont[0] && byId.get(cont[0].courseId)) || byId.get(newest[0] ?? "") || null;
|
|
const payload: LibraryPayload = {
|
|
signedIn: !!user,
|
|
featured,
|
|
continue: cont.map(({ at: _at, ...rest }) => rest),
|
|
newest,
|
|
collections: collections.filter((c) => c.courseIds.length > 0),
|
|
courses: cards.sort((a, b) => a.title.localeCompare(b.title)),
|
|
};
|
|
return json(payload);
|
|
},
|
|
},
|
|
"/api/library/search": {
|
|
GET: async (_req, rc) => {
|
|
const q = fold(rc.url.searchParams.get("q") ?? "").trim();
|
|
if (!q) return json([]);
|
|
const terms = q.split(/\s+/);
|
|
const hit = (s: string | undefined) => !!s && terms.every((t) => fold(s).includes(t));
|
|
const idx = await accessIndex(ctx.db, rc.user);
|
|
const out: SearchHit[] = [];
|
|
for (const c of ctx.courses.list()) {
|
|
const m = c.manifest;
|
|
if (!m || !visible(rc.user, c, idx)) continue;
|
|
const course = hit(`${m.title} ${m.pitch ?? ""} ${(m.authors ?? []).join(" ")} ${(m.collections ?? []).join(" ")}`);
|
|
const lessons = m.modules.flatMap((mod) => mod.lessons.filter((l) => hit(l.title) || hit(`${mod.title} ${l.title}`)).map((l) => ({ id: l.id, title: l.title, moduleTitle: mod.title })));
|
|
if (course || lessons.length) out.push({ courseId: m.id, courseTitle: m.title, course, lessons: lessons.slice(0, 8) });
|
|
}
|
|
out.sort((a, b) => Number(b.course) - Number(a.course) || b.lessons.length - a.lessons.length);
|
|
return json(out.slice(0, 50));
|
|
},
|
|
},
|
|
"/api/courses/:courseId": {
|
|
GET: async (_req, rc) => {
|
|
const found = await courseFor(ctx, rc.user, param(rc, "courseId"));
|
|
if (!found) return notFound();
|
|
const { course, manifest: m } = found;
|
|
const progress = rc.user ? await progressRepo(ctx.db).get(rc.user.id, m.id) : {};
|
|
const est = await minutesFor(course);
|
|
const locks = moduleLocks(m, progress, found.canEdit);
|
|
const deckTargets = new Set((m.decks ?? []).map((d) => d.attachTo));
|
|
const modules: OutlineModule[] = m.modules.map((mod, i) => ({
|
|
id: mod.id, title: mod.title, index: i, locked: locks[i]!, hasCode: !!mod.code,
|
|
lessons: mod.lessons.map((l, li) => ({
|
|
id: l.id, title: l.title, type: l.type, minutes: est.get(l.id) ?? null,
|
|
hasDeck: deckTargets.has(l.id) || (li === 0 && deckTargets.has(mod.id)), browser: !!l.browser,
|
|
})),
|
|
}));
|
|
const payload: CoursePayload = {
|
|
id: m.id, title: m.title, pitch: m.pitch ?? null, authors: m.authors ?? [], accent: m.accent ?? null,
|
|
coverUrl: coverUrlFor(ctx, m),
|
|
tracks: m.tracks ?? [], gating: m.gating, source: m.source?.kind ?? null, status: course.status,
|
|
modules, progress, canEdit: found.canEdit, signedIn: !!rc.user,
|
|
diagnostics: found.canEdit ? course.diagnostics.map((d) => ({ level: d.level, lessonId: d.lessonId, path: d.path, message: d.message })) : [],
|
|
};
|
|
return json(payload);
|
|
},
|
|
},
|
|
};
|
|
}
|