Merge pull request #20 from teichert/feature-and-fix-allow-custom-feedback-delims

Feature and fix allow custom feedback delims
This commit is contained in:
2026-01-15 13:59:30 -07:00
committed by GitHub
11 changed files with 256 additions and 33 deletions

View File

@@ -15,6 +15,9 @@ import { useAuthoritativeUpdates } from "../../../../utils/useAuthoritativeUpdat
import { extractLabelValue } from "@/features/local/assignments/models/utils/markdownUtils"; import { extractLabelValue } from "@/features/local/assignments/models/utils/markdownUtils";
import EditQuizHeader from "./EditQuizHeader"; import EditQuizHeader from "./EditQuizHeader";
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks"; 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 { EditLayout } from "@/components/EditLayout";
import { quizMarkdownUtils } from "@/features/local/quizzes/models/utils/quizMarkdownUtils"; import { quizMarkdownUtils } from "@/features/local/quizzes/models/utils/quizMarkdownUtils";
import { LocalCourseSettings } from "@/features/local/course/localCourseSettings"; import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
@@ -111,10 +114,15 @@ export default function EditQuiz({
isFetching, isFetching,
} = useQuizQuery(moduleName, quizName); } = useQuizQuery(moduleName, quizName);
const updateQuizMutation = useUpdateQuizMutation(); const updateQuizMutation = useUpdateQuizMutation();
const { data: globalSettings } = useGlobalSettingsQuery();
const feedbackDelimiters = getFeedbackDelimitersFromSettings(
(globalSettings ?? ({} as GlobalSettings)) as GlobalSettings
);
const { clientIsAuthoritative, text, textUpdate, monacoKey } = const { clientIsAuthoritative, text, textUpdate, monacoKey } =
useAuthoritativeUpdates({ useAuthoritativeUpdates({
serverUpdatedAt: serverDataUpdatedAt, serverUpdatedAt: serverDataUpdatedAt,
startingText: quizMarkdownUtils.toMarkdown(quiz), startingText: quizMarkdownUtils.toMarkdown(quiz, feedbackDelimiters),
}); });
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -130,13 +138,18 @@ export default function EditQuiz({
try { try {
const name = extractLabelValue(text, "Name"); const name = extractLabelValue(text, "Name");
if ( if (
quizMarkdownUtils.toMarkdown(quiz) !== quizMarkdownUtils.toMarkdown(quiz, feedbackDelimiters) !==
quizMarkdownUtils.toMarkdown( quizMarkdownUtils.toMarkdown(
quizMarkdownUtils.parseMarkdown(text, name) quizMarkdownUtils.parseMarkdown(text, name, feedbackDelimiters),
feedbackDelimiters
) )
) { ) {
if (clientIsAuthoritative) { if (clientIsAuthoritative) {
const updatedQuiz = quizMarkdownUtils.parseMarkdown(text, quizName); const updatedQuiz = quizMarkdownUtils.parseMarkdown(
text,
quizName,
feedbackDelimiters
);
await updateQuizMutation.mutateAsync({ await updateQuizMutation.mutateAsync({
quiz: updatedQuiz, quiz: updatedQuiz,
moduleName, moduleName,

View File

@@ -9,11 +9,15 @@ import {
CourseItemType, CourseItemType,
typeToFolder, typeToFolder,
} from "@/features/local/course/courseItemTypes"; } from "@/features/local/course/courseItemTypes";
import { getCoursePathByName } from "../globalSettings/globalSettingsFileStorageService"; import {
getCoursePathByName,
getGlobalSettings,
} from "../globalSettings/globalSettingsFileStorageService";
import { import {
localPageMarkdownUtils, localPageMarkdownUtils,
} from "@/features/local/pages/localCoursePageModels"; } from "@/features/local/pages/localCoursePageModels";
import { quizMarkdownUtils } from "../quizzes/models/utils/quizMarkdownUtils"; import { quizMarkdownUtils } from "../quizzes/models/utils/quizMarkdownUtils";
import { getFeedbackDelimitersFromSettings } from "../globalSettings/globalSettingsUtils";
const getItemFileNames = async ({ const getItemFileNames = async ({
@@ -60,9 +64,12 @@ const getItem = async <T extends CourseItemType>({
name name
) as CourseItemReturnType<T>; ) as CourseItemReturnType<T>;
} else if (type === "Quiz") { } else if (type === "Quiz") {
const globalSettings = await getGlobalSettings();
const delimiters = getFeedbackDelimitersFromSettings(globalSettings);
return quizMarkdownUtils.parseMarkdown( return quizMarkdownUtils.parseMarkdown(
rawFile, rawFile,
name name,
delimiters
) as CourseItemReturnType<T>; ) as CourseItemReturnType<T>;
} else if (type === "Page") { } else if (type === "Page") {
return localPageMarkdownUtils.parseMarkdown( return localPageMarkdownUtils.parseMarkdown(

View File

@@ -7,6 +7,7 @@ export const zodGlobalSettingsCourse = z.object({
export const zodGlobalSettings = z.object({ export const zodGlobalSettings = z.object({
courses: z.array(zodGlobalSettingsCourse), courses: z.array(zodGlobalSettingsCourse),
feedbackDelims: z.record(z.string()).optional(),
}); });

View File

@@ -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);
});
});

View File

@@ -1,5 +1,9 @@
import { GlobalSettings, zodGlobalSettings } from "./globalSettingsModels"; import { GlobalSettings, zodGlobalSettings } from "./globalSettingsModels";
import { parse, stringify } from "yaml"; import { parse, stringify } from "yaml";
import {
FeedbackDelimiters,
defaultFeedbackDelimiters,
} from "../quizzes/models/utils/quizFeedbackMarkdownUtils";
export const globalSettingsToYaml = (settings: GlobalSettings) => { export const globalSettingsToYaml = (settings: GlobalSettings) => {
return stringify(settings); return stringify(settings);
@@ -14,3 +18,22 @@ export const parseGlobalSettingsYaml = (yaml: string): GlobalSettings => {
throw new Error(`Error parsing global settings, got ${yaml}, ${e}`); throw new Error(`Error parsing global settings, got ${yaml}, ${e}`);
} }
}; };
export function overriddenDefaults<T>(
defaults: T,
overrides: Record<string, unknown>
): T {
return Object.fromEntries(
Object.entries(defaults as Record<string, unknown>).map(([k, v]) => [k, overrides[k] ?? v])
) as T;
}
export const getFeedbackDelimitersFromSettings = (
settings: GlobalSettings
): FeedbackDelimiters => {
return overriddenDefaults(
defaultFeedbackDelimiters,
settings.feedbackDelims ?? ({} as Record<string, unknown>)
);
};

View File

@@ -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");
});
});

View File

@@ -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\)/);
});
});

View File

@@ -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"; type feedbackTypeOptions = "correct" | "incorrect" | "neutral" | "none";
export const quizFeedbackMarkdownUtils = { export const quizFeedbackMarkdownUtils = {
extractFeedback(lines: string[]): { extractFeedback(
lines: string[],
delimiters: FeedbackDelimiters = defaultFeedbackDelimiters
): {
correctComments?: string; correctComments?: string;
incorrectComments?: string; incorrectComments?: string;
neutralComments?: string; neutralComments?: string;
@@ -15,20 +30,18 @@ export const quizFeedbackMarkdownUtils = {
const otherLines: string[] = []; const otherLines: string[] = [];
const feedbackIndicators = { const feedbackIndicators = delimiters;
correct: "+",
incorrect: "-",
neutral: "...",
};
let currentFeedbackType: feedbackTypeOptions = "none"; let currentFeedbackType: feedbackTypeOptions = "none";
for (const line of lines.map((l) => l)) { for (const line of lines.map((l) => l)) {
const lineFeedbackType: feedbackTypeOptions = line.startsWith("+") const lineFeedbackType: feedbackTypeOptions = line.startsWith(
feedbackIndicators.correct
)
? "correct" ? "correct"
: line.startsWith("-") : line.startsWith(feedbackIndicators.incorrect)
? "incorrect" ? "incorrect"
: line.startsWith("...") : line.startsWith(feedbackIndicators.neutral)
? "neutral" ? "neutral"
: "none"; : "none";
@@ -37,15 +50,12 @@ export const quizFeedbackMarkdownUtils = {
.replace(feedbackIndicators[currentFeedbackType], "") .replace(feedbackIndicators[currentFeedbackType], "")
.trim(); .trim();
comments[currentFeedbackType].push(lineWithoutIndicator); comments[currentFeedbackType].push(lineWithoutIndicator);
} else if (lineFeedbackType !== "none") { } else if (lineFeedbackType !== "none") {
const lineWithoutIndicator = line const lineWithoutIndicator = line
.replace(feedbackIndicators[lineFeedbackType], "") .replace(feedbackIndicators[lineFeedbackType], "")
.trim(); .trim();
currentFeedbackType = lineFeedbackType; currentFeedbackType = lineFeedbackType;
comments[lineFeedbackType].push(lineWithoutIndicator); comments[lineFeedbackType].push(lineWithoutIndicator);
} else { } else {
otherLines.push(line); otherLines.push(line);
} }
@@ -66,18 +76,21 @@ export const quizFeedbackMarkdownUtils = {
formatFeedback( formatFeedback(
correctComments?: string, correctComments?: string,
incorrectComments?: string, incorrectComments?: string,
neutralComments?: string neutralComments?: string,
delimiters: FeedbackDelimiters = defaultFeedbackDelimiters
): string { ): string {
let feedbackText = ""; let feedbackText = "";
if (correctComments) { if (correctComments) {
feedbackText += `+ ${correctComments}\n`; feedbackText += `${delimiters.correct} ${correctComments}\n`;
} }
if (incorrectComments) { if (incorrectComments) {
feedbackText += `- ${incorrectComments}\n`; feedbackText += `${delimiters.incorrect} ${incorrectComments}\n`;
} }
if (neutralComments) { 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; return feedbackText;
}, },
}; };

View File

@@ -4,6 +4,7 @@ import {
} from "@/features/local/utils/timeUtils"; } from "@/features/local/utils/timeUtils";
import { LocalQuiz } from "../localQuiz"; import { LocalQuiz } from "../localQuiz";
import { quizQuestionMarkdownUtils } from "./quizQuestionMarkdownUtils"; import { quizQuestionMarkdownUtils } from "./quizQuestionMarkdownUtils";
import { FeedbackDelimiters } from "./quizFeedbackMarkdownUtils";
const extractLabelValue = (input: string, label: string): string => { const extractLabelValue = (input: string, label: string): string => {
const pattern = new RegExp(`${label}: (.*?)\n`); const pattern = new RegExp(`${label}: (.*?)\n`);
@@ -103,7 +104,7 @@ const getQuizWithOnlySettings = (settings: string, name: string): LocalQuiz => {
}; };
export const quizMarkdownUtils = { export const quizMarkdownUtils = {
toMarkdown(quiz: LocalQuiz): string { toMarkdown(quiz: LocalQuiz, delimiters?: FeedbackDelimiters): string {
if (!quiz) { if (!quiz) {
throw Error(`quiz was undefined, cannot parse markdown`); 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`); throw Error(`quiz ${quiz.name} is probably not a quiz`);
} }
const questionMarkdownArray = quiz.questions.map((q) => const questionMarkdownArray = quiz.questions.map((q) =>
quizQuestionMarkdownUtils.toMarkdown(q) quizQuestionMarkdownUtils.toMarkdown(q, delimiters)
); );
const questionDelimiter = "\n\n---\n\n"; const questionDelimiter = "\n\n---\n\n";
const questionMarkdown = questionMarkdownArray.join(questionDelimiter); const questionMarkdown = questionMarkdownArray.join(questionDelimiter);
@@ -133,7 +134,11 @@ Description: ${quiz.description}
${questionMarkdown}`; ${questionMarkdown}`;
}, },
parseMarkdown(input: string, name: string): LocalQuiz { parseMarkdown(
input: string,
name: string,
delimiters?: FeedbackDelimiters
): LocalQuiz {
const splitInput = input.split("---\n"); const splitInput = input.split("---\n");
const settings = splitInput[0]; const settings = splitInput[0];
const quizWithoutQuestions = getQuizWithOnlySettings(settings, name); const quizWithoutQuestions = getQuizWithOnlySettings(settings, name);
@@ -141,7 +146,7 @@ ${questionMarkdown}`;
const rawQuestions = splitInput.slice(1); const rawQuestions = splitInput.slice(1);
const questions = rawQuestions const questions = rawQuestions
.filter((str) => str.trim().length > 0) .filter((str) => str.trim().length > 0)
.map((q, i) => quizQuestionMarkdownUtils.parseMarkdown(q, i)); .map((q, i) => quizQuestionMarkdownUtils.parseMarkdown(q, i, delimiters));
return { return {
...quizWithoutQuestions, ...quizWithoutQuestions,

View File

@@ -1,5 +1,8 @@
import { LocalQuizQuestion, QuestionType } from "../localQuizQuestion"; import { LocalQuizQuestion, QuestionType } from "../localQuizQuestion";
import { quizFeedbackMarkdownUtils } from "./quizFeedbackMarkdownUtils"; import {
quizFeedbackMarkdownUtils,
FeedbackDelimiters,
} from "./quizFeedbackMarkdownUtils";
import { quizQuestionAnswerMarkdownUtils } from "./quizQuestionAnswerMarkdownUtils"; import { quizQuestionAnswerMarkdownUtils } from "./quizQuestionAnswerMarkdownUtils";
const splitLinesAndPoints = (input: string[]) => { const splitLinesAndPoints = (input: string[]) => {
@@ -58,7 +61,10 @@ const removeQuestionTypeFromDescriptionLines = (
}; };
export const quizQuestionMarkdownUtils = { export const quizQuestionMarkdownUtils = {
toMarkdown(question: LocalQuizQuestion): string { toMarkdown(
question: LocalQuizQuestion,
delimiters?: FeedbackDelimiters
): string {
const answerArray = question.answers.map((a, i) => const answerArray = question.answers.map((a, i) =>
quizQuestionAnswerMarkdownUtils.getAnswerMarkdown(question, a, i) quizQuestionAnswerMarkdownUtils.getAnswerMarkdown(question, a, i)
); );
@@ -72,7 +78,8 @@ export const quizQuestionMarkdownUtils = {
const feedbackText = quizFeedbackMarkdownUtils.formatFeedback( const feedbackText = quizFeedbackMarkdownUtils.formatFeedback(
question.correctComments, question.correctComments,
question.incorrectComments, question.incorrectComments,
question.neutralComments question.neutralComments,
delimiters
); );
const answersText = answerArray.join("\n"); const answersText = answerArray.join("\n");
@@ -87,7 +94,11 @@ export const quizQuestionMarkdownUtils = {
return `Points: ${question.points}\n${question.text}\n${feedbackText}${answersText}${distractorText}${questionTypeIndicator}`; 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 { points, lines } = splitLinesAndPoints(input.trim().split("\n"));
const linesWithoutAnswers = getLinesBeforeAnswerLines(lines); const linesWithoutAnswers = getLinesBeforeAnswerLines(lines);
@@ -107,7 +118,10 @@ export const quizQuestionMarkdownUtils = {
incorrectComments, incorrectComments,
neutralComments, neutralComments,
otherLines: descriptionLines, otherLines: descriptionLines,
} = quizFeedbackMarkdownUtils.extractFeedback(linesWithoutAnswersAndTypes); } = quizFeedbackMarkdownUtils.extractFeedback(
linesWithoutAnswersAndTypes,
delimiters
);
const { answers, distractors } = quizQuestionAnswerMarkdownUtils.getAnswers( const { answers, distractors } = quizQuestionAnswerMarkdownUtils.getAnswers(
lines, lines,

View File

@@ -5,11 +5,15 @@ import {
LocalQuiz, LocalQuiz,
zodLocalQuiz, zodLocalQuiz,
} from "@/features/local/quizzes/models/localQuiz"; } from "@/features/local/quizzes/models/localQuiz";
import { getCoursePathByName } from "../globalSettings/globalSettingsFileStorageService"; import {
getCoursePathByName,
getGlobalSettings,
} from "../globalSettings/globalSettingsFileStorageService";
import path from "path"; import path from "path";
import { promises as fs } from "fs"; import { promises as fs } from "fs";
import { quizMarkdownUtils } from "./models/utils/quizMarkdownUtils"; import { quizMarkdownUtils } from "./models/utils/quizMarkdownUtils";
import { courseItemFileStorageService } from "../course/courseItemFileStorageService"; import { courseItemFileStorageService } from "../course/courseItemFileStorageService";
import { getFeedbackDelimitersFromSettings } from "../globalSettings/globalSettingsUtils";
import { assertValidFileName } from "@/services/fileNameValidation"; import { assertValidFileName } from "@/services/fileNameValidation";
export const quizRouter = router({ export const quizRouter = router({
@@ -161,7 +165,9 @@ export async function updateQuizFile({
quizName + ".md" 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}`); console.log(`Saving quiz ${filePath}`);
await fs.writeFile(filePath, quizMarkdown); await fs.writeFile(filePath, quizMarkdown);
} }