Merge pull request #4 from teichert/main

latex
This commit is contained in:
2025-01-16 14:24:23 -07:00
committed by GitHub
8 changed files with 8768 additions and 30 deletions

8552
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,7 @@
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"form-data": "^4.0.1", "form-data": "^4.0.1",
"jsdom": "^25.0.0", "jsdom": "^25.0.0",
"marked-katex-extension": "^5.1.4",
"next": "^15.0.2", "next": "^15.0.2",
"react": "^19", "react": "^19",
"react-dom": "^19", "react-dom": "^19",

View File

@@ -11,6 +11,7 @@ export enum QuestionType {
SHORT_ANSWER = "short_answer", SHORT_ANSWER = "short_answer",
MATCHING = "matching", MATCHING = "matching",
NONE = "", NONE = "",
SHORT_ANSWER_WITH_ANSWERS = "short_answer=",
} }
export const zodQuestionType = z.enum([ export const zodQuestionType = z.enum([
@@ -20,6 +21,7 @@ export const zodQuestionType = z.enum([
QuestionType.SHORT_ANSWER, QuestionType.SHORT_ANSWER,
QuestionType.MATCHING, QuestionType.MATCHING,
QuestionType.NONE, QuestionType.NONE,
QuestionType.SHORT_ANSWER_WITH_ANSWERS,
]); ]);
export interface LocalQuizQuestion { export interface LocalQuizQuestion {

View File

@@ -69,6 +69,11 @@ const getQuestionType = (
"short_answer" "short_answer"
) )
return QuestionType.SHORT_ANSWER; return QuestionType.SHORT_ANSWER;
if (
linesWithoutPoints[linesWithoutPoints.length - 1].toLowerCase().trim() ===
"short_answer="
)
return QuestionType.SHORT_ANSWER_WITH_ANSWERS;
const answerLines = getAnswerStringsWithMultilineSupport( const answerLines = getAnswerStringsWithMultilineSupport(
linesWithoutPoints, linesWithoutPoints,
@@ -97,6 +102,7 @@ const getAnswers = (
questionIndex: number, questionIndex: number,
questionType: string questionType: string
): LocalQuizQuestionAnswer[] => { ): LocalQuizQuestionAnswer[] => {
if (questionType == QuestionType.SHORT_ANSWER_WITH_ANSWERS) linesWithoutPoints = linesWithoutPoints.slice(0, linesWithoutPoints.length - 1);
const answerLines = getAnswerStringsWithMultilineSupport( const answerLines = getAnswerStringsWithMultilineSupport(
linesWithoutPoints, linesWithoutPoints,
questionIndex questionIndex
@@ -149,6 +155,8 @@ export const quizQuestionMarkdownUtils = {
question.questionType === "essay" || question.questionType === "essay" ||
question.questionType === "short_answer" question.questionType === "short_answer"
? question.questionType ? question.questionType
: question.questionType === QuestionType.SHORT_ANSWER_WITH_ANSWERS
? `\n${QuestionType.SHORT_ANSWER_WITH_ANSWERS}`
: ""; : "";
return `Points: ${question.points}\n${question.text}\n${answersText}${distractorText}${questionTypeIndicator}`; return `Points: ${question.points}\n${question.text}\n${answersText}${distractorText}${questionTypeIndicator}`;
@@ -214,6 +222,7 @@ export const quizQuestionMarkdownUtils = {
"multiple_choice", "multiple_choice",
"multiple_answers", "multiple_answers",
"matching", "matching",
"short_answer=",
]; ];
const answers = typesWithAnswers.includes(questionType) const answers = typesWithAnswers.includes(questionType)
? getAnswers(linesWithoutPoints, questionIndex, questionType) ? getAnswers(linesWithoutPoints, questionIndex, questionType)

View File

@@ -3,6 +3,7 @@ import { LocalQuiz } from "../../quiz/localQuiz";
import { quizMarkdownUtils } from "../../quiz/utils/quizMarkdownUtils"; import { quizMarkdownUtils } from "../../quiz/utils/quizMarkdownUtils";
import { QuestionType } from "@/models/local/quiz/localQuizQuestion"; import { QuestionType } from "@/models/local/quiz/localQuizQuestion";
import { quizQuestionMarkdownUtils } from "@/models/local/quiz/utils/quizQuestionMarkdownUtils"; import { quizQuestionMarkdownUtils } from "@/models/local/quiz/utils/quizQuestionMarkdownUtils";
import { markdownToHtmlNoImages, markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
// Test suite for QuizMarkdown // Test suite for QuizMarkdown
describe("QuizMarkdownTests", () => { describe("QuizMarkdownTests", () => {
@@ -253,4 +254,33 @@ short answer
const firstQuestion = quiz.questions[0]; const firstQuestion = quiz.questions[0];
expect(firstQuestion.points).toBe(4.56); expect(firstQuestion.points).toBe(4.56);
}); });
it("can parse quiz with latex in a question", () => {
const name = "Test Quiz";
const rawMarkdownQuiz = `
ShuffleAnswers: true
OneQuestionAtATime: false
DueAt: 08/21/2023 23:59:00
LockAt: 08/21/2023 23:59:00
AssignmentGroup: Assignments
AllowedAttempts: -1
Description: this is the
multi line
description
---
Points: 2
This is latex: $x_2$
*a) true
b) false
endline`;
const quizHtml = markdownToHtmlNoImages(rawMarkdownQuiz);
expect(quizHtml).not.toContain("$");
expect(quizHtml).toContain("<mi>x</mi>");
expect(quizHtml).not.toContain("x_2");
});
}); });

View File

@@ -1,7 +1,9 @@
import { QuestionType } from "../../quiz/localQuizQuestion"; import { getAnswers, getQuestionType } from "@/services/canvas/canvasQuizService";
import { QuestionType, zodQuestionType } from "../../quiz/localQuizQuestion";
import { quizMarkdownUtils } from "../../quiz/utils/quizMarkdownUtils"; import { quizMarkdownUtils } from "../../quiz/utils/quizMarkdownUtils";
import { quizQuestionMarkdownUtils } from "../../quiz/utils/quizQuestionMarkdownUtils"; import { quizQuestionMarkdownUtils } from "../../quiz/utils/quizQuestionMarkdownUtils";
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { LocalCourseSettings } from "../../localCourseSettings";
describe("TextAnswerTests", () => { describe("TextAnswerTests", () => {
it("can parse essay", () => { it("can parse essay", () => {
@@ -83,6 +85,39 @@ short_answer`;
expect(questionMarkdown).toContain(expectedMarkdown); expect(questionMarkdown).toContain(expectedMarkdown);
}); });
it("short_answer= to markdown is correct", () => {
const name = "Test Quiz"
const rawMarkdownQuiz = `
ShuffleAnswers: true
OneQuestionAtATime: false
DueAt: 08/21/2023 23:59:00
LockAt: 08/21/2023 23:59:00
AssignmentGroup: Assignments
AllowedAttempts: -1
Description: this is the
multi line
description
---
Which events are triggered when the user clicks on an input field?
*a) yes
*b) Yes
short_answer=
`;
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
const firstQuestion = quiz.questions[0];
const questionMarkdown =
quizQuestionMarkdownUtils.toMarkdown(firstQuestion);
const expectedMarkdown = `Points: 1
Which events are triggered when the user clicks on an input field?
*a) yes
*b) Yes
short_answer=`;
expect(questionMarkdown).toContain(expectedMarkdown);
});
it("essay question to markdown is correct", () => { it("essay question to markdown is correct", () => {
const name = "Test Quiz" const name = "Test Quiz"
const rawMarkdownQuiz = ` const rawMarkdownQuiz = `
@@ -111,28 +146,128 @@ essay`;
expect(questionMarkdown).toContain(expectedMarkdown); expect(questionMarkdown).toContain(expectedMarkdown);
}); });
// it("Can parse short answer with auto graded answers", () => { it("Can parse short answer with auto graded answers", () => {
// const rawMarkdownQuiz = ` const name = "Test Quiz"
// Name: Test Quiz const rawMarkdownQuiz = `
// ShuffleAnswers: true ShuffleAnswers: true
// OneQuestionAtATime: false OneQuestionAtATime: false
// DueAt: 08/21/2023 23:59:00 DueAt: 08/21/2023 23:59:00
// LockAt: 08/21/2023 23:59:00 LockAt: 08/21/2023 23:59:00
// AssignmentGroup: Assignments AssignmentGroup: Assignments
// AllowedAttempts: -1 AllowedAttempts: -1
// Description: this is the Description: this is the
// multi line multi line
// description description
// --- ---
// Which events are triggered when the user clicks on an input field? Which events are triggered when the user clicks on an input field?
// *a) test *a) test
// short_answer= *b) other
// `; short_answer=
`;
// const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name); const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
// const firstQuestion = quiz.questions[0]; const firstQuestion = quiz.questions[0];
expect(firstQuestion.questionType).toBe(QuestionType.SHORT_ANSWER_WITH_ANSWERS)
expect(firstQuestion.answers.length).toBe(2);
expect(firstQuestion.answers[0].text).toBe("test");
expect(firstQuestion.answers[1].text).toBe("other");
});
it("Can parse short answer with auto graded answers", () => {
const name = "Test Quiz"
const rawMarkdownQuiz = `
ShuffleAnswers: true
OneQuestionAtATime: false
DueAt: 08/21/2023 23:59:00
LockAt: 08/21/2023 23:59:00
AssignmentGroup: Assignments
AllowedAttempts: -1
Description: this is the
multi line
description
---
Which events are triggered when the user clicks on an input field?
*a) test
*b) other
short_answer=
`;
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
const firstQuestion = quiz.questions[0];
expect(firstQuestion.questionType).toBe(QuestionType.SHORT_ANSWER_WITH_ANSWERS)
expect(firstQuestion.answers.length).toBe(2);
expect(firstQuestion.answers[0].text).toBe("test");
expect(firstQuestion.answers[1].text).toBe("other");
});
it("Has short_answer= type at the same position in types and zod types", () => {
expect(Object.values(zodQuestionType.Enum)).toEqual(Object.values(QuestionType));
});
it("Associates short_answer= questions with short_answer_question canvas question type", () => {
const name = "Test Quiz"
const rawMarkdownQuiz = `
ShuffleAnswers: true
OneQuestionAtATime: false
DueAt: 08/21/2023 23:59:00
LockAt: 08/21/2023 23:59:00
AssignmentGroup: Assignments
AllowedAttempts: -1
Description: this is the
multi line
description
---
Which events are triggered when the user clicks on an input field?
*a) test
*b) other
short_answer=
`;
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
const firstQuestion = quiz.questions[0];
expect(getQuestionType(firstQuestion)).toBe("short_answer_question");
});
it("Includes answer_text in answers sent to canvas", () => {
const name = "Test Quiz"
const rawMarkdownQuiz = `
ShuffleAnswers: true
OneQuestionAtATime: false
DueAt: 08/21/2023 23:59:00
LockAt: 08/21/2023 23:59:00
AssignmentGroup: Assignments
AllowedAttempts: -1
Description: this is the
multi line
description
---
Which events are triggered when the user clicks on an input field?
*a) test
*b) other
short_answer=
`;
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
const firstQuestion = quiz.questions[0];
const answers = getAnswers(firstQuestion, {
name: "",
assignmentGroups: [],
daysOfWeek: [],
canvasId: 0,
startDate: "",
endDate: "",
defaultDueTime: {
hour: 0,
minute: 0
},
defaultAssignmentSubmissionTypes: [],
defaultFileUploadTypes: [],
holidays: [],
assets: []
})
expect(answers).toHaveLength(2);
const firstAnswer = answers[0];
expect(firstAnswer).toHaveProperty("answer_text");
});
// expect(firstQuestion.questionType).toBe(QuestionType.SHORT_ANSWER_WITH_ANSWERS)
// });
}); });

View File

@@ -12,7 +12,7 @@ import {
import { CanvasQuizQuestion } from "@/models/canvas/quizzes/canvasQuizQuestionModel"; import { CanvasQuizQuestion } from "@/models/canvas/quizzes/canvasQuizQuestionModel";
import { LocalCourseSettings } from "@/models/local/localCourseSettings"; import { LocalCourseSettings } from "@/models/local/localCourseSettings";
const getAnswers = ( export const getAnswers = (
question: LocalQuizQuestion, question: LocalQuizQuestion,
settings: LocalCourseSettings settings: LocalCourseSettings
) => { ) => {
@@ -25,9 +25,16 @@ const getAnswers = (
return question.answers.map((answer) => ({ return question.answers.map((answer) => ({
answer_html: markdownToHTMLSafe(answer.text, settings), answer_html: markdownToHTMLSafe(answer.text, settings),
answer_weight: answer.correct ? 100 : 0, answer_weight: answer.correct ? 100 : 0,
answer_text: answer.text,
})); }));
}; };
export const getQuestionType = (
question: LocalQuizQuestion
) => {
return `${question.questionType.replace("=", "")}_question`;
}
const createQuestionOnly = async ( const createQuestionOnly = async (
canvasCourseId: number, canvasCourseId: number,
canvasQuizId: number, canvasQuizId: number,
@@ -41,7 +48,7 @@ const createQuestionOnly = async (
const body = { const body = {
question: { question: {
question_text: markdownToHTMLSafe(question.text, settings), question_text: markdownToHTMLSafe(question.text, settings),
question_type: `${question.questionType}_question`, question_type: getQuestionType(question),
points_possible: question.points, points_possible: question.points,
position, position,
answers: getAnswers(question, settings), answers: getAnswers(question, settings),

View File

@@ -2,6 +2,12 @@
import { marked } from "marked"; import { marked } from "marked";
import DOMPurify from "isomorphic-dompurify"; import DOMPurify from "isomorphic-dompurify";
import { LocalCourseSettings } from "@/models/local/localCourseSettings"; import { LocalCourseSettings } from "@/models/local/localCourseSettings";
import markedKatex from "marked-katex-extension";
marked.use(markedKatex({
throwOnError: false,
output: "mathml"
}));
export function extractImageSources(htmlString: string) { export function extractImageSources(htmlString: string) {
const srcUrls = []; const srcUrls = [];
@@ -40,16 +46,12 @@ export function markdownToHTMLSafe(
markdownString: string, markdownString: string,
settings: LocalCourseSettings settings: LocalCourseSettings
) { ) {
const clean = DOMPurify.sanitize( return markdownToHtmlNoImages(markdownString);
marked.parse(markdownString, { async: false, pedantic: false, gfm: true })
);
// return convertImagesToCanvasImages(clean, settings);
return clean;
} }
export function markdownToHtmlNoImages(markdownString: string) { export function markdownToHtmlNoImages(markdownString: string) {
const clean = DOMPurify.sanitize( const clean = DOMPurify.sanitize(
marked.parse(markdownString, { async: false, pedantic: false, gfm: true }) marked.parse(markdownString, { async: false, pedantic: false, gfm: true })
); ).replaceAll(/>[^<>]*<\/math>/g, "></math>");
return clean; return clean;
} }