288 lines
9.1 KiB
TypeScript
288 lines
9.1 KiB
TypeScript
export const lessonContentTypes = ["VIDEO", "LECTURE", "ACTIVITY", "QUIZ", "FINAL_EXAM"] as const;
|
|
|
|
export type LessonContentType = (typeof lessonContentTypes)[number];
|
|
|
|
export const lessonQuestionKinds = ["TRUE_FALSE", "MULTIPLE_CHOICE"] as const;
|
|
|
|
export type LessonQuestionKind = (typeof lessonQuestionKinds)[number];
|
|
|
|
export type LessonQuestionOption = {
|
|
id: string;
|
|
text: string;
|
|
isCorrect: boolean;
|
|
};
|
|
|
|
export type LessonQuestion = {
|
|
id: string;
|
|
kind: LessonQuestionKind;
|
|
prompt: string;
|
|
explanation: string;
|
|
options: LessonQuestionOption[];
|
|
};
|
|
|
|
export type LessonActivityMeta = {
|
|
instructions: string;
|
|
passingScorePercent: number;
|
|
questions: LessonQuestion[];
|
|
};
|
|
|
|
type LessonDescriptionMeta = {
|
|
text: string;
|
|
contentType: LessonContentType;
|
|
materialUrl: string | null;
|
|
lectureContent: string;
|
|
activity: LessonActivityMeta | null;
|
|
};
|
|
|
|
const lessonTypeAliases: Record<string, LessonContentType> = {
|
|
VIDEO: "VIDEO",
|
|
LECTURE: "LECTURE",
|
|
LECTURA: "LECTURE",
|
|
READING: "LECTURE",
|
|
ACTIVITY: "ACTIVITY",
|
|
ACTIVIDAD: "ACTIVITY",
|
|
QUIZ: "QUIZ",
|
|
EVALUACION: "QUIZ",
|
|
EVALUACIÓN: "QUIZ",
|
|
FINAL_EXAM: "FINAL_EXAM",
|
|
EXAMEN_FINAL: "FINAL_EXAM",
|
|
EXAMENFINAL: "FINAL_EXAM",
|
|
EVALUACION_FINAL: "FINAL_EXAM",
|
|
EVALUACIÓN_FINAL: "FINAL_EXAM",
|
|
};
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function asString(value: unknown): string {
|
|
return typeof value === "string" ? value.trim() : "";
|
|
}
|
|
|
|
function asNumber(value: unknown): number | null {
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
if (typeof value === "string" && value.trim()) {
|
|
const parsed = Number(value);
|
|
if (Number.isFinite(parsed)) return parsed;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function normalizeType(value: string): LessonContentType {
|
|
const normalized = value.trim().toUpperCase();
|
|
return lessonTypeAliases[normalized] ?? "VIDEO";
|
|
}
|
|
|
|
function getDescriptionText(input: unknown): string {
|
|
if (typeof input === "string") return input.trim();
|
|
if (isRecord(input)) {
|
|
const direct = asString(input.text);
|
|
if (direct) return direct;
|
|
const es = asString(input.es);
|
|
if (es) return es;
|
|
const en = asString(input.en);
|
|
if (en) return en;
|
|
const summary = asString(input.summary);
|
|
if (summary) return summary;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function normalizeQuestionKind(value: unknown): LessonQuestionKind {
|
|
const raw = asString(value).toUpperCase();
|
|
return raw === "TRUE_FALSE" ? "TRUE_FALSE" : "MULTIPLE_CHOICE";
|
|
}
|
|
|
|
function parseQuestionOptions(value: unknown, kind: LessonQuestionKind): LessonQuestionOption[] {
|
|
if (!Array.isArray(value)) {
|
|
if (kind === "TRUE_FALSE") {
|
|
return [
|
|
{ id: "true", text: "Verdadero", isCorrect: true },
|
|
{ id: "false", text: "Falso", isCorrect: false },
|
|
];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
const options = value
|
|
.map((option, index) => {
|
|
if (!isRecord(option)) return null;
|
|
const id = asString(option.id) || `opt-${index + 1}`;
|
|
const text = asString(option.text);
|
|
const isCorrect = Boolean(option.isCorrect);
|
|
if (!text) return null;
|
|
return { id, text, isCorrect };
|
|
})
|
|
.filter((option): option is LessonQuestionOption => option !== null);
|
|
|
|
if (kind === "TRUE_FALSE") {
|
|
const hasTrue = options.find((option) => option.id === "true" || option.text.toLowerCase() === "verdadero");
|
|
const hasFalse = options.find((option) => option.id === "false" || option.text.toLowerCase() === "falso");
|
|
if (!hasTrue || !hasFalse) {
|
|
const correctIndex = options.findIndex((option) => option.isCorrect);
|
|
return [
|
|
{ id: "true", text: "Verdadero", isCorrect: correctIndex !== 1 },
|
|
{ id: "false", text: "Falso", isCorrect: correctIndex === 1 },
|
|
];
|
|
}
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
function parseQuestion(value: unknown, index: number): LessonQuestion | null {
|
|
if (!isRecord(value)) return null;
|
|
const kind = normalizeQuestionKind(value.kind ?? value.type);
|
|
const id = asString(value.id) || `q-${index + 1}`;
|
|
const prompt = asString(value.prompt ?? value.question);
|
|
const explanation = asString(value.explanation);
|
|
const options = parseQuestionOptions(value.options, kind);
|
|
|
|
if (!prompt) return null;
|
|
if (options.length < 2) return null;
|
|
|
|
const hasCorrect = options.some((option) => option.isCorrect);
|
|
const normalizedOptions = hasCorrect ? options : options.map((option, optIndex) => ({ ...option, isCorrect: optIndex === 0 }));
|
|
|
|
return {
|
|
id,
|
|
kind,
|
|
prompt,
|
|
explanation,
|
|
options: normalizedOptions,
|
|
};
|
|
}
|
|
|
|
function normalizePassingScore(value: unknown, fallback: number): number {
|
|
const parsed = asNumber(value);
|
|
if (parsed == null) return fallback;
|
|
return Math.max(0, Math.min(100, Math.round(parsed)));
|
|
}
|
|
|
|
function parseActivityMeta(description: Record<string, unknown>): LessonActivityMeta | null {
|
|
const activityRaw =
|
|
(isRecord(description.activity) && description.activity) ||
|
|
(isRecord(description.quiz) && description.quiz) ||
|
|
(isRecord(description.exercise) && description.exercise) ||
|
|
null;
|
|
|
|
if (!activityRaw) return null;
|
|
|
|
const instructions = asString(activityRaw.instructions ?? activityRaw.intro ?? description.es ?? description.text);
|
|
const passingScorePercent = normalizePassingScore(activityRaw.passingScorePercent, 70);
|
|
const questionsRaw = Array.isArray(activityRaw.questions) ? activityRaw.questions : [];
|
|
const questions = questionsRaw
|
|
.map((question, index) => parseQuestion(question, index))
|
|
.filter((question): question is LessonQuestion => question !== null);
|
|
|
|
if (questions.length === 0) return null;
|
|
|
|
return {
|
|
instructions,
|
|
passingScorePercent,
|
|
questions,
|
|
};
|
|
}
|
|
|
|
export function parseLessonDescriptionMeta(description: unknown): LessonDescriptionMeta {
|
|
if (!isRecord(description)) {
|
|
const fallbackText = getDescriptionText(description);
|
|
return {
|
|
text: fallbackText,
|
|
contentType: "VIDEO",
|
|
materialUrl: null,
|
|
lectureContent: fallbackText,
|
|
activity: null,
|
|
};
|
|
}
|
|
|
|
const contentTypeRaw = asString(description.contentType) || asString(description.kind) || asString(description.type);
|
|
const materialUrl =
|
|
asString(description.materialUrl) ||
|
|
asString(description.resourceUrl) ||
|
|
asString(description.pdfUrl) ||
|
|
asString(description.attachmentUrl) ||
|
|
"";
|
|
const text = getDescriptionText(description);
|
|
const lectureContent = asString(description.lectureContent) || asString(description.content) || text;
|
|
const activity = parseActivityMeta(description);
|
|
|
|
return {
|
|
text,
|
|
contentType: normalizeType(contentTypeRaw || "VIDEO"),
|
|
materialUrl: materialUrl || null,
|
|
lectureContent,
|
|
activity,
|
|
};
|
|
}
|
|
|
|
export function buildLessonDescriptionMeta(input: {
|
|
text: string;
|
|
contentType: LessonContentType;
|
|
materialUrl?: string | null;
|
|
lectureContent?: string | null;
|
|
activity?: LessonActivityMeta | null;
|
|
}): Record<string, unknown> {
|
|
const payload: Record<string, unknown> = {
|
|
contentType: input.contentType,
|
|
};
|
|
|
|
const text = input.text.trim();
|
|
if (text) payload.es = text;
|
|
|
|
const materialUrl = (input.materialUrl ?? "").trim();
|
|
if (materialUrl) payload.materialUrl = materialUrl;
|
|
|
|
const lectureContent = (input.lectureContent ?? "").trim();
|
|
if (lectureContent) payload.lectureContent = lectureContent;
|
|
|
|
const activity = input.activity;
|
|
if (activity && activity.questions.length > 0) {
|
|
payload.activity = {
|
|
instructions: activity.instructions.trim(),
|
|
passingScorePercent: Math.max(0, Math.min(100, Math.round(activity.passingScorePercent))),
|
|
questions: activity.questions.map((question, qIndex) => {
|
|
const questionId = question.id.trim() || `q-${qIndex + 1}`;
|
|
const prompt = question.prompt.trim();
|
|
const explanation = question.explanation.trim();
|
|
const kind: LessonQuestionKind = question.kind === "TRUE_FALSE" ? "TRUE_FALSE" : "MULTIPLE_CHOICE";
|
|
const options = question.options
|
|
.map((option, optionIndex) => {
|
|
const optionId = option.id.trim() || `opt-${optionIndex + 1}`;
|
|
const optionText = option.text.trim();
|
|
if (!optionText) return null;
|
|
return {
|
|
id: optionId,
|
|
text: optionText,
|
|
isCorrect: Boolean(option.isCorrect),
|
|
};
|
|
})
|
|
.filter((option): option is LessonQuestionOption => option !== null);
|
|
const hasCorrect = options.some((option) => option.isCorrect);
|
|
const normalizedOptions = hasCorrect ? options : options.map((option, optionIndex) => ({ ...option, isCorrect: optionIndex === 0 }));
|
|
return {
|
|
id: questionId,
|
|
kind,
|
|
prompt,
|
|
explanation,
|
|
options: normalizedOptions,
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
return payload;
|
|
}
|
|
|
|
export function getLessonContentTypeLabel(contentType: LessonContentType): string {
|
|
if (contentType === "LECTURE") return "Lectura";
|
|
if (contentType === "ACTIVITY") return "Actividad";
|
|
if (contentType === "QUIZ") return "Quiz";
|
|
if (contentType === "FINAL_EXAM") return "Evaluación final";
|
|
return "Video";
|
|
}
|
|
|
|
export function isFinalExam(contentType: LessonContentType): boolean {
|
|
return contentType === "FINAL_EXAM";
|
|
}
|