diff --git a/src/app/course/[courseName]/modules/[moduleName]/quiz/[quizName]/EditQuiz.tsx b/src/app/course/[courseName]/modules/[moduleName]/quiz/[quizName]/EditQuiz.tsx index ba289f0..235ce6b 100644 --- a/src/app/course/[courseName]/modules/[moduleName]/quiz/[quizName]/EditQuiz.tsx +++ b/src/app/course/[courseName]/modules/[moduleName]/quiz/[quizName]/EditQuiz.tsx @@ -15,6 +15,9 @@ import { useAuthoritativeUpdates } from "../../../../utils/useAuthoritativeUpdat import { extractLabelValue } from "@/features/local/assignments/models/utils/markdownUtils"; import EditQuizHeader from "./EditQuizHeader"; import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks"; +import { useGlobalSettingsQuery } from "@/features/local/globalSettings/globalSettingsHooks"; +import { getFeedbackDelimitersFromSettings } from "@/features/local/globalSettings/globalSettingsUtils"; +import type { GlobalSettings } from "@/features/local/globalSettings/globalSettingsModels"; import { EditLayout } from "@/components/EditLayout"; import { quizMarkdownUtils } from "@/features/local/quizzes/models/utils/quizMarkdownUtils"; import { LocalCourseSettings } from "@/features/local/course/localCourseSettings"; @@ -111,10 +114,15 @@ export default function EditQuiz({ isFetching, } = useQuizQuery(moduleName, quizName); const updateQuizMutation = useUpdateQuizMutation(); + const { data: globalSettings } = useGlobalSettingsQuery(); + const feedbackDelimiters = getFeedbackDelimitersFromSettings( + (globalSettings ?? ({} as GlobalSettings)) as GlobalSettings + ); + const { clientIsAuthoritative, text, textUpdate, monacoKey } = useAuthoritativeUpdates({ serverUpdatedAt: serverDataUpdatedAt, - startingText: quizMarkdownUtils.toMarkdown(quiz), + startingText: quizMarkdownUtils.toMarkdown(quiz, feedbackDelimiters), }); const [error, setError] = useState(""); @@ -130,13 +138,18 @@ export default function EditQuiz({ try { const name = extractLabelValue(text, "Name"); if ( - quizMarkdownUtils.toMarkdown(quiz) !== + quizMarkdownUtils.toMarkdown(quiz, feedbackDelimiters) !== quizMarkdownUtils.toMarkdown( - quizMarkdownUtils.parseMarkdown(text, name) + quizMarkdownUtils.parseMarkdown(text, name, feedbackDelimiters), + feedbackDelimiters ) ) { if (clientIsAuthoritative) { - const updatedQuiz = quizMarkdownUtils.parseMarkdown(text, quizName); + const updatedQuiz = quizMarkdownUtils.parseMarkdown( + text, + quizName, + feedbackDelimiters + ); await updateQuizMutation.mutateAsync({ quiz: updatedQuiz, moduleName, diff --git a/src/features/local/course/courseItemFileStorageService.ts b/src/features/local/course/courseItemFileStorageService.ts index e0ad9ad..811fe17 100644 --- a/src/features/local/course/courseItemFileStorageService.ts +++ b/src/features/local/course/courseItemFileStorageService.ts @@ -9,11 +9,15 @@ import { CourseItemType, typeToFolder, } from "@/features/local/course/courseItemTypes"; -import { getCoursePathByName } from "../globalSettings/globalSettingsFileStorageService"; +import { + getCoursePathByName, + getGlobalSettings, +} from "../globalSettings/globalSettingsFileStorageService"; import { localPageMarkdownUtils, } from "@/features/local/pages/localCoursePageModels"; import { quizMarkdownUtils } from "../quizzes/models/utils/quizMarkdownUtils"; +import { getFeedbackDelimitersFromSettings } from "../globalSettings/globalSettingsUtils"; const getItemFileNames = async ({ @@ -60,9 +64,12 @@ const getItem = async ({ name ) as CourseItemReturnType; } else if (type === "Quiz") { + const globalSettings = await getGlobalSettings(); + const delimiters = getFeedbackDelimitersFromSettings(globalSettings); return quizMarkdownUtils.parseMarkdown( rawFile, - name + name, + delimiters ) as CourseItemReturnType; } else if (type === "Page") { return localPageMarkdownUtils.parseMarkdown( diff --git a/src/features/local/globalSettings/globalSettingsModels.ts b/src/features/local/globalSettings/globalSettingsModels.ts index f8f7e2b..90df7a8 100644 --- a/src/features/local/globalSettings/globalSettingsModels.ts +++ b/src/features/local/globalSettings/globalSettingsModels.ts @@ -7,6 +7,7 @@ export const zodGlobalSettingsCourse = z.object({ export const zodGlobalSettings = z.object({ courses: z.array(zodGlobalSettingsCourse), + feedbackDelims: z.record(z.string()).optional(), }); diff --git a/src/features/local/globalSettings/globalSettingsUtils.test.ts b/src/features/local/globalSettings/globalSettingsUtils.test.ts new file mode 100644 index 0000000..350a96b --- /dev/null +++ b/src/features/local/globalSettings/globalSettingsUtils.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; +import { getFeedbackDelimitersFromSettings, overriddenDefaults } from "./globalSettingsUtils"; +import { defaultFeedbackDelimiters } from "../quizzes/models/utils/quizFeedbackMarkdownUtils"; +import { GlobalSettings } from "./globalSettingsModels"; + +describe("overriddenDefaults", () => { + it("uses defaults when overrides are missing", () => { + const defaults = { a: 1, b: 2 }; + const overrides = {}; + expect(overriddenDefaults(defaults, overrides)).toEqual({ a: 1, b: 2 }); + }); + + it("uses overrides when present", () => { + const defaults = { a: 1, b: 2 }; + const overrides = { a: 3 }; + expect(overriddenDefaults(defaults, overrides)).toEqual({ a: 3, b: 2 }); + }); + + it("ignores extra keys in overrides", () => { + const defaults = { a: 1 }; + const overrides = { a: 2, c: 3 }; + expect(overriddenDefaults(defaults, overrides)).toEqual({ a: 2 }); + }); +}); + +describe("getFeedbackDelimitersFromSettings", () => { + it("returns default delimiters if options are missing", () => { + const settings: GlobalSettings = { + courses: [], + }; + expect(getFeedbackDelimitersFromSettings(settings)).toEqual( + defaultFeedbackDelimiters + ); + }); + + it("returns custom delimiters if options are present", () => { + const settings: GlobalSettings = { + courses: [], + feedbackDelims: { + neutral: ":|", + correct: ":)", + incorrect: ":(", + }, + }; + const expected = { + correct: ":)", + incorrect: ":(", + neutral: ":|", + }; + expect(getFeedbackDelimitersFromSettings(settings)).toEqual(expected); + }); + + it("returns mixed delimiters if some options are missing", () => { + const settings: GlobalSettings = { + courses: [], + feedbackDelims: { + correct: ":)", + }, + }; + const expected = { + ...defaultFeedbackDelimiters, + correct: ":)", + }; + expect(getFeedbackDelimitersFromSettings(settings)).toEqual(expected); + }); +}); diff --git a/src/features/local/globalSettings/globalSettingsUtils.ts b/src/features/local/globalSettings/globalSettingsUtils.ts index 6c87037..6a401a8 100644 --- a/src/features/local/globalSettings/globalSettingsUtils.ts +++ b/src/features/local/globalSettings/globalSettingsUtils.ts @@ -1,5 +1,9 @@ import { GlobalSettings, zodGlobalSettings } from "./globalSettingsModels"; import { parse, stringify } from "yaml"; +import { + FeedbackDelimiters, + defaultFeedbackDelimiters, +} from "../quizzes/models/utils/quizFeedbackMarkdownUtils"; export const globalSettingsToYaml = (settings: GlobalSettings) => { return stringify(settings); @@ -14,3 +18,22 @@ export const parseGlobalSettingsYaml = (yaml: string): GlobalSettings => { throw new Error(`Error parsing global settings, got ${yaml}, ${e}`); } }; + +export function overriddenDefaults( + defaults: T, + overrides: Record +): T { + return Object.fromEntries( + Object.entries(defaults as Record).map(([k, v]) => [k, overrides[k] ?? v]) + ) as T; +} + +export const getFeedbackDelimitersFromSettings = ( + settings: GlobalSettings +): FeedbackDelimiters => { + return overriddenDefaults( + defaultFeedbackDelimiters, + settings.feedbackDelims ?? ({} as Record) + ); +}; + diff --git a/src/features/local/parsingTests/quizMarkdown/customFeedbackDelimiters.test.ts b/src/features/local/parsingTests/quizMarkdown/customFeedbackDelimiters.test.ts new file mode 100644 index 0000000..0fbcd45 --- /dev/null +++ b/src/features/local/parsingTests/quizMarkdown/customFeedbackDelimiters.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { quizQuestionMarkdownUtils } from "../../quizzes/models/utils/quizQuestionMarkdownUtils"; +import { FeedbackDelimiters } from "../../quizzes/models/utils/quizFeedbackMarkdownUtils"; +import { QuestionType } from "../../quizzes/models/localQuizQuestion"; + +describe("Custom Feedback Delimiters", () => { + const customDelimiters: FeedbackDelimiters = { + correct: ":)", + incorrect: ":(", + neutral: ":|", + }; + + it("can parse question with custom feedback delimiters", () => { + const input = `Points: 1 +Question text +:) Correct feedback +:( Incorrect feedback +:| Neutral feedback +*a) Answer 1 +b) Answer 2`; + + const question = quizQuestionMarkdownUtils.parseMarkdown(input, 0, customDelimiters); + + expect(question.correctComments).toBe("Correct feedback"); + expect(question.incorrectComments).toBe("Incorrect feedback"); + expect(question.neutralComments).toBe("Neutral feedback"); + }); + + it("can serialize question with custom feedback delimiters", () => { + const question = { + points: 1, + text: "Question text", + questionType: "multiple_choice_question" as QuestionType, + answers: [ + { text: "Answer 1", correct: true, weight: 100 }, + { text: "Answer 2", correct: false, weight: 0 }, + ], + correctComments: "Correct feedback", + incorrectComments: "Incorrect feedback", + neutralComments: "Neutral feedback", + matchDistractors: [], + }; + + const markdown = quizQuestionMarkdownUtils.toMarkdown(question, customDelimiters); + + expect(markdown).toContain(":) Correct feedback"); + expect(markdown).toContain(":( Incorrect feedback"); + expect(markdown).toContain(":| Neutral feedback"); + }); +}); diff --git a/src/features/local/parsingTests/quizMarkdown/feedbackSpacing.test.ts b/src/features/local/parsingTests/quizMarkdown/feedbackSpacing.test.ts new file mode 100644 index 0000000..a93296f --- /dev/null +++ b/src/features/local/parsingTests/quizMarkdown/feedbackSpacing.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { quizQuestionMarkdownUtils } from "@/features/local/quizzes/models/utils/quizQuestionMarkdownUtils"; +import { QuestionType, LocalQuizQuestion } from "@/features/local/quizzes/models/localQuizQuestion"; + +describe("feedback spacing", () => { + it("adds a blank line after feedback before answers", () => { + const question = { + text: "What is 2+2?", + questionType: QuestionType.MULTIPLE_CHOICE, + points: 1, + answers: [ + { correct: true, text: "4" }, + ], + matchDistractors: [], + correctComments: "Good", + incorrectComments: "No", + neutralComments: "Note", + } as LocalQuizQuestion; + + const md = quizQuestionMarkdownUtils.toMarkdown(question); + + // look for double newline separating feedback block and answer marker + expect(md).toMatch(/\n\n\*?a\)/); + }); +}); diff --git a/src/features/local/quizzes/models/utils/quizFeedbackMarkdownUtils.ts b/src/features/local/quizzes/models/utils/quizFeedbackMarkdownUtils.ts index 01b380f..5938205 100644 --- a/src/features/local/quizzes/models/utils/quizFeedbackMarkdownUtils.ts +++ b/src/features/local/quizzes/models/utils/quizFeedbackMarkdownUtils.ts @@ -1,7 +1,22 @@ +export interface FeedbackDelimiters { + correct: string; + incorrect: string; + neutral: string; +} + +export const defaultFeedbackDelimiters: FeedbackDelimiters = { + correct: "+", + incorrect: "-", + neutral: "...", +}; + type feedbackTypeOptions = "correct" | "incorrect" | "neutral" | "none"; export const quizFeedbackMarkdownUtils = { - extractFeedback(lines: string[]): { + extractFeedback( + lines: string[], + delimiters: FeedbackDelimiters = defaultFeedbackDelimiters + ): { correctComments?: string; incorrectComments?: string; neutralComments?: string; @@ -15,20 +30,18 @@ export const quizFeedbackMarkdownUtils = { const otherLines: string[] = []; - const feedbackIndicators = { - correct: "+", - incorrect: "-", - neutral: "...", - }; + const feedbackIndicators = delimiters; let currentFeedbackType: feedbackTypeOptions = "none"; for (const line of lines.map((l) => l)) { - const lineFeedbackType: feedbackTypeOptions = line.startsWith("+") + const lineFeedbackType: feedbackTypeOptions = line.startsWith( + feedbackIndicators.correct + ) ? "correct" - : line.startsWith("-") + : line.startsWith(feedbackIndicators.incorrect) ? "incorrect" - : line.startsWith("...") + : line.startsWith(feedbackIndicators.neutral) ? "neutral" : "none"; @@ -37,15 +50,12 @@ export const quizFeedbackMarkdownUtils = { .replace(feedbackIndicators[currentFeedbackType], "") .trim(); comments[currentFeedbackType].push(lineWithoutIndicator); - } else if (lineFeedbackType !== "none") { - const lineWithoutIndicator = line .replace(feedbackIndicators[lineFeedbackType], "") .trim(); currentFeedbackType = lineFeedbackType; comments[lineFeedbackType].push(lineWithoutIndicator); - } else { otherLines.push(line); } @@ -66,18 +76,21 @@ export const quizFeedbackMarkdownUtils = { formatFeedback( correctComments?: string, incorrectComments?: string, - neutralComments?: string + neutralComments?: string, + delimiters: FeedbackDelimiters = defaultFeedbackDelimiters ): string { let feedbackText = ""; if (correctComments) { - feedbackText += `+ ${correctComments}\n`; + feedbackText += `${delimiters.correct} ${correctComments}\n`; } if (incorrectComments) { - feedbackText += `- ${incorrectComments}\n`; + feedbackText += `${delimiters.incorrect} ${incorrectComments}\n`; } if (neutralComments) { - feedbackText += `... ${neutralComments}\n`; + feedbackText += `${delimiters.neutral} ${neutralComments}\n`; } + // Ensure there's a blank line after feedback block so answers are separated + if (feedbackText) feedbackText += "\n"; return feedbackText; }, }; diff --git a/src/features/local/quizzes/models/utils/quizMarkdownUtils.ts b/src/features/local/quizzes/models/utils/quizMarkdownUtils.ts index 445e25c..1d46998 100644 --- a/src/features/local/quizzes/models/utils/quizMarkdownUtils.ts +++ b/src/features/local/quizzes/models/utils/quizMarkdownUtils.ts @@ -4,6 +4,7 @@ import { } from "@/features/local/utils/timeUtils"; import { LocalQuiz } from "../localQuiz"; import { quizQuestionMarkdownUtils } from "./quizQuestionMarkdownUtils"; +import { FeedbackDelimiters } from "./quizFeedbackMarkdownUtils"; const extractLabelValue = (input: string, label: string): string => { const pattern = new RegExp(`${label}: (.*?)\n`); @@ -103,7 +104,7 @@ const getQuizWithOnlySettings = (settings: string, name: string): LocalQuiz => { }; export const quizMarkdownUtils = { - toMarkdown(quiz: LocalQuiz): string { + toMarkdown(quiz: LocalQuiz, delimiters?: FeedbackDelimiters): string { if (!quiz) { throw Error(`quiz was undefined, cannot parse markdown`); } @@ -115,7 +116,7 @@ export const quizMarkdownUtils = { throw Error(`quiz ${quiz.name} is probably not a quiz`); } const questionMarkdownArray = quiz.questions.map((q) => - quizQuestionMarkdownUtils.toMarkdown(q) + quizQuestionMarkdownUtils.toMarkdown(q, delimiters) ); const questionDelimiter = "\n\n---\n\n"; const questionMarkdown = questionMarkdownArray.join(questionDelimiter); @@ -133,7 +134,11 @@ Description: ${quiz.description} ${questionMarkdown}`; }, - parseMarkdown(input: string, name: string): LocalQuiz { + parseMarkdown( + input: string, + name: string, + delimiters?: FeedbackDelimiters + ): LocalQuiz { const splitInput = input.split("---\n"); const settings = splitInput[0]; const quizWithoutQuestions = getQuizWithOnlySettings(settings, name); @@ -141,7 +146,7 @@ ${questionMarkdown}`; const rawQuestions = splitInput.slice(1); const questions = rawQuestions .filter((str) => str.trim().length > 0) - .map((q, i) => quizQuestionMarkdownUtils.parseMarkdown(q, i)); + .map((q, i) => quizQuestionMarkdownUtils.parseMarkdown(q, i, delimiters)); return { ...quizWithoutQuestions, diff --git a/src/features/local/quizzes/models/utils/quizQuestionMarkdownUtils.ts b/src/features/local/quizzes/models/utils/quizQuestionMarkdownUtils.ts index 7aac245..fddfc6d 100644 --- a/src/features/local/quizzes/models/utils/quizQuestionMarkdownUtils.ts +++ b/src/features/local/quizzes/models/utils/quizQuestionMarkdownUtils.ts @@ -1,5 +1,8 @@ import { LocalQuizQuestion, QuestionType } from "../localQuizQuestion"; -import { quizFeedbackMarkdownUtils } from "./quizFeedbackMarkdownUtils"; +import { + quizFeedbackMarkdownUtils, + FeedbackDelimiters, +} from "./quizFeedbackMarkdownUtils"; import { quizQuestionAnswerMarkdownUtils } from "./quizQuestionAnswerMarkdownUtils"; const splitLinesAndPoints = (input: string[]) => { @@ -58,7 +61,10 @@ const removeQuestionTypeFromDescriptionLines = ( }; export const quizQuestionMarkdownUtils = { - toMarkdown(question: LocalQuizQuestion): string { + toMarkdown( + question: LocalQuizQuestion, + delimiters?: FeedbackDelimiters + ): string { const answerArray = question.answers.map((a, i) => quizQuestionAnswerMarkdownUtils.getAnswerMarkdown(question, a, i) ); @@ -72,7 +78,8 @@ export const quizQuestionMarkdownUtils = { const feedbackText = quizFeedbackMarkdownUtils.formatFeedback( question.correctComments, question.incorrectComments, - question.neutralComments + question.neutralComments, + delimiters ); const answersText = answerArray.join("\n"); @@ -87,7 +94,11 @@ export const quizQuestionMarkdownUtils = { return `Points: ${question.points}\n${question.text}\n${feedbackText}${answersText}${distractorText}${questionTypeIndicator}`; }, - parseMarkdown(input: string, questionIndex: number): LocalQuizQuestion { + parseMarkdown( + input: string, + questionIndex: number, + delimiters?: FeedbackDelimiters + ): LocalQuizQuestion { const { points, lines } = splitLinesAndPoints(input.trim().split("\n")); const linesWithoutAnswers = getLinesBeforeAnswerLines(lines); @@ -107,7 +118,10 @@ export const quizQuestionMarkdownUtils = { incorrectComments, neutralComments, otherLines: descriptionLines, - } = quizFeedbackMarkdownUtils.extractFeedback(linesWithoutAnswersAndTypes); + } = quizFeedbackMarkdownUtils.extractFeedback( + linesWithoutAnswersAndTypes, + delimiters + ); const { answers, distractors } = quizQuestionAnswerMarkdownUtils.getAnswers( lines, diff --git a/src/features/local/quizzes/quizRouter.ts b/src/features/local/quizzes/quizRouter.ts index c82b33a..120013f 100644 --- a/src/features/local/quizzes/quizRouter.ts +++ b/src/features/local/quizzes/quizRouter.ts @@ -5,11 +5,15 @@ import { LocalQuiz, zodLocalQuiz, } from "@/features/local/quizzes/models/localQuiz"; -import { getCoursePathByName } from "../globalSettings/globalSettingsFileStorageService"; +import { + getCoursePathByName, + getGlobalSettings, +} from "../globalSettings/globalSettingsFileStorageService"; import path from "path"; import { promises as fs } from "fs"; import { quizMarkdownUtils } from "./models/utils/quizMarkdownUtils"; import { courseItemFileStorageService } from "../course/courseItemFileStorageService"; +import { getFeedbackDelimitersFromSettings } from "../globalSettings/globalSettingsUtils"; import { assertValidFileName } from "@/services/fileNameValidation"; export const quizRouter = router({ @@ -161,7 +165,9 @@ export async function updateQuizFile({ quizName + ".md" ); - const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz); + const globalSettings = await getGlobalSettings(); + const delimiters = getFeedbackDelimitersFromSettings(globalSettings); + const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz, delimiters); console.log(`Saving quiz ${filePath}`); await fs.writeFile(filePath, quizMarkdown); }