mirror of
https://github.com/alexmickelson/canvasManagement.git
synced 2026-03-25 23:28:33 -06:00
refactoring files to be located by feature
This commit is contained in:
214
src/features/local/assignments/assignmentHooks.ts
Normal file
214
src/features/local/assignments/assignmentHooks.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
import { useTRPC } from "@/services/serverFunctions/trpcClient";
|
||||
import { useCourseContext } from "@/app/course/[courseName]/context/courseContext";
|
||||
import {
|
||||
extractImageSources,
|
||||
markdownToHtmlNoImages,
|
||||
} from "@/services/htmlMarkdownUtils";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
useSuspenseQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
useLocalCourseSettingsQuery,
|
||||
useUpdateLocalCourseSettingsMutation,
|
||||
} from "@/hooks/localCourse/localCoursesHooks";
|
||||
|
||||
export const useAssignmentQuery = (
|
||||
moduleName: string,
|
||||
assignmentName: string
|
||||
) => {
|
||||
const { courseName } = useCourseContext();
|
||||
const trpc = useTRPC();
|
||||
return useSuspenseQuery(
|
||||
trpc.assignment.getAssignment.queryOptions({
|
||||
moduleName,
|
||||
courseName,
|
||||
assignmentName,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const enable_images = process.env.NEXT_PUBLIC_ENABLE_FILE_SYNC === "true";
|
||||
|
||||
export const useUpdateImageSettingsForAssignment = ({
|
||||
moduleName,
|
||||
assignmentName,
|
||||
}: {
|
||||
moduleName: string;
|
||||
assignmentName: string;
|
||||
}) => {
|
||||
const { data: assignment } = useAssignmentQuery(moduleName, assignmentName);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const addNewImagesToCanvasMutation = useAddNewImagesToCanvasMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enable_images) {
|
||||
console.log(
|
||||
"not uploading images, NEXT_PUBLIC_ENABLE_FILE_SYNC is not set to true"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
console.log("not updating image assets, still loading");
|
||||
return;
|
||||
}
|
||||
setIsPending(true);
|
||||
const assignmentMarkdown = markdownToHtmlNoImages(assignment.description);
|
||||
|
||||
addNewImagesToCanvasMutation
|
||||
.mutateAsync({
|
||||
markdownString: assignmentMarkdown,
|
||||
})
|
||||
.then(() => setIsPending(false));
|
||||
// not sure why mutation reference changes...
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [assignment.description, isPending]);
|
||||
return { isPending };
|
||||
};
|
||||
|
||||
export const useAddNewImagesToCanvasMutation = () => {
|
||||
const { data: settings } = useLocalCourseSettingsQuery();
|
||||
const trpc = useTRPC();
|
||||
const createCanvasUrlMutation = useMutation(
|
||||
trpc.canvasFile.getCanvasFileUrl.mutationOptions()
|
||||
);
|
||||
const updateSettings = useUpdateLocalCourseSettingsMutation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ markdownString }: { markdownString: string }) => {
|
||||
const imageSources = extractImageSources(markdownString);
|
||||
// console.log("original image urls", imageSources);
|
||||
const newImages = imageSources.filter((source) =>
|
||||
settings.assets.every((a) => a.sourceUrl !== source)
|
||||
);
|
||||
|
||||
if (newImages.length === 0) {
|
||||
console.log("no new images to upload");
|
||||
return;
|
||||
}
|
||||
|
||||
const newAssets = await Promise.all(
|
||||
newImages.map(async (source) => {
|
||||
console.log("uploading image to canvas", source);
|
||||
const canvasUrl = await createCanvasUrlMutation.mutateAsync({
|
||||
sourceUrl: source,
|
||||
canvasCourseId: settings.canvasId,
|
||||
});
|
||||
console.log("got canvas url", source, canvasUrl);
|
||||
return { sourceUrl: source, canvasUrl };
|
||||
})
|
||||
);
|
||||
await updateSettings.mutateAsync({
|
||||
settings: {
|
||||
...settings,
|
||||
assets: [...settings.assets, ...newAssets],
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useAssignmentNamesQuery = (moduleName: string) => {
|
||||
const { courseName } = useCourseContext();
|
||||
const trpc = useTRPC();
|
||||
return useSuspenseQuery({
|
||||
...trpc.assignment.getAllAssignments.queryOptions({
|
||||
moduleName,
|
||||
courseName,
|
||||
}),
|
||||
select: (assignments) => assignments.map((a) => a.name),
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateAssignmentMutation = () => {
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
trpc.assignment.updateAssignment.mutationOptions({
|
||||
onSuccess: (
|
||||
_,
|
||||
{
|
||||
courseName,
|
||||
moduleName,
|
||||
assignmentName,
|
||||
previousAssignmentName,
|
||||
previousModuleName,
|
||||
}
|
||||
) => {
|
||||
if (moduleName !== previousModuleName) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: trpc.assignment.getAllAssignments.queryKey({
|
||||
courseName,
|
||||
moduleName: previousModuleName,
|
||||
}),
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: trpc.assignment.getAllAssignments.queryKey({
|
||||
courseName,
|
||||
moduleName,
|
||||
}),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: trpc.assignment.getAssignment.queryKey({
|
||||
courseName,
|
||||
moduleName,
|
||||
assignmentName,
|
||||
}),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: trpc.assignment.getAssignment.queryKey({
|
||||
courseName,
|
||||
moduleName,
|
||||
assignmentName: previousAssignmentName,
|
||||
}),
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export const useCreateAssignmentMutation = () => {
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
trpc.assignment.createAssignment.mutationOptions({
|
||||
onSuccess: (_result, { courseName, moduleName }) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: trpc.assignment.getAllAssignments.queryKey({
|
||||
courseName,
|
||||
moduleName,
|
||||
}),
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export const useDeleteAssignmentMutation = () => {
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
trpc.assignment.deleteAssignment.mutationOptions({
|
||||
onSuccess: (_result, { courseName, moduleName, assignmentName }) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: trpc.assignment.getAllAssignments.queryKey({
|
||||
courseName,
|
||||
moduleName,
|
||||
}),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: trpc.assignment.getAssignment.queryKey({
|
||||
courseName,
|
||||
moduleName,
|
||||
assignmentName,
|
||||
}),
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
116
src/features/local/assignments/assignmentRouter.ts
Normal file
116
src/features/local/assignments/assignmentRouter.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import publicProcedure from "../../../services/serverFunctions/procedures/public";
|
||||
import { z } from "zod";
|
||||
import { router } from "../../../services/serverFunctions/trpcSetup";
|
||||
import { fileStorageService } from "@/services/fileStorage/fileStorageService";
|
||||
import { zodLocalAssignment } from "@/features/local/assignments/models/localAssignment";
|
||||
|
||||
export const assignmentRouter = router({
|
||||
getAssignment: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
courseName: z.string(),
|
||||
moduleName: z.string(),
|
||||
assignmentName: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input: { courseName, moduleName, assignmentName } }) => {
|
||||
const assignment = await fileStorageService.assignments.getAssignment(
|
||||
courseName,
|
||||
moduleName,
|
||||
assignmentName
|
||||
);
|
||||
// console.log(assignment);
|
||||
return assignment;
|
||||
}),
|
||||
getAllAssignments: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
courseName: z.string(),
|
||||
moduleName: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input: { courseName, moduleName } }) => {
|
||||
const assignments = await fileStorageService.assignments.getAssignments(
|
||||
courseName,
|
||||
moduleName
|
||||
);
|
||||
return assignments;
|
||||
}),
|
||||
createAssignment: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
courseName: z.string(),
|
||||
moduleName: z.string(),
|
||||
assignmentName: z.string(),
|
||||
assignment: zodLocalAssignment,
|
||||
})
|
||||
)
|
||||
.mutation(
|
||||
async ({
|
||||
input: { courseName, moduleName, assignmentName, assignment },
|
||||
}) => {
|
||||
await fileStorageService.assignments.updateOrCreateAssignment({
|
||||
courseName,
|
||||
moduleName,
|
||||
assignmentName,
|
||||
assignment,
|
||||
});
|
||||
}
|
||||
),
|
||||
updateAssignment: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
courseName: z.string(),
|
||||
moduleName: z.string(),
|
||||
previousModuleName: z.string(),
|
||||
previousAssignmentName: z.string(),
|
||||
assignmentName: z.string(),
|
||||
assignment: zodLocalAssignment,
|
||||
})
|
||||
)
|
||||
.mutation(
|
||||
async ({
|
||||
input: {
|
||||
courseName,
|
||||
moduleName,
|
||||
assignmentName,
|
||||
assignment,
|
||||
previousModuleName,
|
||||
previousAssignmentName,
|
||||
},
|
||||
}) => {
|
||||
await fileStorageService.assignments.updateOrCreateAssignment({
|
||||
courseName,
|
||||
moduleName,
|
||||
assignmentName,
|
||||
assignment,
|
||||
});
|
||||
|
||||
if (
|
||||
assignmentName !== previousAssignmentName ||
|
||||
moduleName !== previousModuleName
|
||||
) {
|
||||
await fileStorageService.assignments.delete({
|
||||
courseName,
|
||||
moduleName: previousModuleName,
|
||||
assignmentName: previousAssignmentName,
|
||||
});
|
||||
}
|
||||
}
|
||||
),
|
||||
deleteAssignment: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
courseName: z.string(),
|
||||
moduleName: z.string(),
|
||||
assignmentName: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input: { courseName, moduleName, assignmentName } }) => {
|
||||
await fileStorageService.assignments.delete({
|
||||
courseName,
|
||||
moduleName,
|
||||
assignmentName,
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { z } from "zod";
|
||||
export enum AssignmentSubmissionType {
|
||||
ONLINE_TEXT_ENTRY = "online_text_entry",
|
||||
ONLINE_UPLOAD = "online_upload",
|
||||
ONLINE_QUIZ = "online_quiz",
|
||||
DISCUSSION_TOPIC = "discussion_topic",
|
||||
ONLINE_URL = "online_url",
|
||||
NONE = "none",
|
||||
}
|
||||
export const zodAssignmentSubmissionType = z.enum([
|
||||
AssignmentSubmissionType.ONLINE_TEXT_ENTRY,
|
||||
AssignmentSubmissionType.ONLINE_UPLOAD,
|
||||
AssignmentSubmissionType.ONLINE_QUIZ,
|
||||
AssignmentSubmissionType.DISCUSSION_TOPIC,
|
||||
AssignmentSubmissionType.ONLINE_URL,
|
||||
AssignmentSubmissionType.NONE,
|
||||
]);
|
||||
|
||||
export const AssignmentSubmissionTypeList: AssignmentSubmissionType[] = [
|
||||
AssignmentSubmissionType.ONLINE_TEXT_ENTRY,
|
||||
AssignmentSubmissionType.ONLINE_UPLOAD,
|
||||
AssignmentSubmissionType.ONLINE_QUIZ,
|
||||
AssignmentSubmissionType.DISCUSSION_TOPIC,
|
||||
AssignmentSubmissionType.ONLINE_URL,
|
||||
AssignmentSubmissionType.NONE,
|
||||
] as const;
|
||||
40
src/features/local/assignments/models/localAssignment.ts
Normal file
40
src/features/local/assignments/models/localAssignment.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { IModuleItem } from "../../../../models/local/IModuleItem";
|
||||
import {
|
||||
AssignmentSubmissionType,
|
||||
zodAssignmentSubmissionType,
|
||||
} from "./assignmentSubmissionType";
|
||||
import { RubricItem, zodRubricItem } from "./rubricItem";
|
||||
import { assignmentMarkdownParser } from "./utils/assignmentMarkdownParser";
|
||||
import { assignmentMarkdownSerializer } from "./utils/assignmentMarkdownSerializer";
|
||||
import { z } from "zod";
|
||||
|
||||
export interface LocalAssignment extends IModuleItem {
|
||||
name: string;
|
||||
description: string;
|
||||
lockAt?: string; // 08/21/2023 23:59:00
|
||||
dueAt: string; // 08/21/2023 23:59:00
|
||||
localAssignmentGroupName?: string;
|
||||
submissionTypes: AssignmentSubmissionType[];
|
||||
allowedFileUploadExtensions: string[];
|
||||
rubric: RubricItem[];
|
||||
githubClassroomAssignmentShareLink?: string;
|
||||
githubClassroomAssignmentLink?: string;
|
||||
}
|
||||
|
||||
export const zodLocalAssignment = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
lockAt: z.string().optional(),
|
||||
dueAt: z.string(),
|
||||
localAssignmentGroupName: z.string().optional(),
|
||||
submissionTypes: zodAssignmentSubmissionType.array(),
|
||||
allowedFileUploadExtensions: z.string().array(),
|
||||
rubric: zodRubricItem.array(),
|
||||
githubClassroomAssignmentShareLink: z.string().optional(),
|
||||
githubClassroomAssignmentLink: z.string().optional(),
|
||||
});
|
||||
|
||||
export const localAssignmentMarkdown = {
|
||||
parseMarkdown: assignmentMarkdownParser.parseMarkdown,
|
||||
toMarkdown: assignmentMarkdownSerializer.toMarkdown,
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export interface LocalAssignmentGroup {
|
||||
canvasId?: number;
|
||||
id: string;
|
||||
name: string;
|
||||
weight: number;
|
||||
}
|
||||
export const zodLocalAssignmentGroup = z.object({
|
||||
canvasId: z.optional(z.number()),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
weight: z.number(),
|
||||
});
|
||||
16
src/features/local/assignments/models/rubricItem.ts
Normal file
16
src/features/local/assignments/models/rubricItem.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export interface RubricItem {
|
||||
label: string;
|
||||
points: number;
|
||||
}
|
||||
|
||||
export const zodRubricItem = z.object({
|
||||
label: z.string(),
|
||||
points: z.number(),
|
||||
});
|
||||
|
||||
export const rubricItemIsExtraCredit = (item: RubricItem) => {
|
||||
const extraCredit = "(extra credit)";
|
||||
return item.label.toLowerCase().includes(extraCredit.toLowerCase());
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
verifyDateOrThrow,
|
||||
verifyDateStringOrUndefined,
|
||||
} from "../../../../../models/local/utils/timeUtils";
|
||||
import { AssignmentSubmissionType } from "../assignmentSubmissionType";
|
||||
import { LocalAssignment } from "../localAssignment";
|
||||
import { RubricItem } from "../rubricItem";
|
||||
import { extractLabelValue } from "./markdownUtils";
|
||||
|
||||
const parseFileUploadExtensions = (input: string) => {
|
||||
const allowedFileUploadExtensions: string[] = [];
|
||||
const regex = /- (.+)/;
|
||||
|
||||
const words = input.split("AllowedFileUploadExtensions:");
|
||||
if (words.length < 2) return allowedFileUploadExtensions;
|
||||
|
||||
const inputAfterSubmissionTypes = words[1];
|
||||
const lines = inputAfterSubmissionTypes
|
||||
.split("\n")
|
||||
.map((line) => line.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
const match = regex.exec(line);
|
||||
if (!match) {
|
||||
if (line === "") continue;
|
||||
else break;
|
||||
}
|
||||
|
||||
allowedFileUploadExtensions.push(match[1].trim());
|
||||
}
|
||||
|
||||
return allowedFileUploadExtensions;
|
||||
};
|
||||
|
||||
const parseIndividualRubricItemMarkdown = (rawMarkdown: string) => {
|
||||
const pointsPattern = /\s*-\s*(-?\d+(?:\.\d+)?)\s*pt(s)?:/;
|
||||
const match = pointsPattern.exec(rawMarkdown);
|
||||
if (!match) {
|
||||
throw new Error(`Points not found: ${rawMarkdown}`);
|
||||
}
|
||||
|
||||
const points = parseFloat(match[1]);
|
||||
const label = rawMarkdown.split(": ").slice(1).join(": ");
|
||||
|
||||
const item: RubricItem = { points, label };
|
||||
return item;
|
||||
};
|
||||
|
||||
const parseSettings = (input: string) => {
|
||||
const rawLockAt = extractLabelValue(input, "LockAt");
|
||||
const rawDueAt = extractLabelValue(input, "DueAt");
|
||||
const assignmentGroupName = extractLabelValue(input, "AssignmentGroupName");
|
||||
const submissionTypes = parseSubmissionTypes(input);
|
||||
const fileUploadExtensions = parseFileUploadExtensions(input);
|
||||
const githubClassroomAssignmentShareLink = extractLabelValue(
|
||||
input,
|
||||
"GithubClassroomAssignmentShareLink"
|
||||
);
|
||||
const githubClassroomAssignmentLink = extractLabelValue(
|
||||
input,
|
||||
"GithubClassroomAssignmentLink"
|
||||
);
|
||||
|
||||
const dueAt = verifyDateOrThrow(rawDueAt, "DueAt");
|
||||
const lockAt = verifyDateStringOrUndefined(rawLockAt);
|
||||
|
||||
return {
|
||||
assignmentGroupName,
|
||||
submissionTypes,
|
||||
fileUploadExtensions,
|
||||
dueAt,
|
||||
lockAt,
|
||||
githubClassroomAssignmentShareLink,
|
||||
githubClassroomAssignmentLink,
|
||||
};
|
||||
};
|
||||
|
||||
const parseSubmissionTypes = (input: string): AssignmentSubmissionType[] => {
|
||||
const submissionTypes: AssignmentSubmissionType[] = [];
|
||||
const regex = /- (.+)/;
|
||||
|
||||
const words = input.split("SubmissionTypes:");
|
||||
if (words.length < 2) return submissionTypes;
|
||||
|
||||
const inputAfterSubmissionTypes = words[1]; // doesn't consider other settings that follow...
|
||||
const lines = inputAfterSubmissionTypes
|
||||
.split("\n")
|
||||
.map((line) => line.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
const match = regex.exec(line);
|
||||
if (!match) {
|
||||
if (line === "") continue;
|
||||
else break;
|
||||
}
|
||||
|
||||
const typeString = match[1].trim();
|
||||
const type = Object.values(AssignmentSubmissionType).find(
|
||||
(t) => t === typeString
|
||||
);
|
||||
|
||||
if (type) {
|
||||
submissionTypes.push(type);
|
||||
} else {
|
||||
console.warn(`Unknown submission type: ${typeString}`);
|
||||
}
|
||||
}
|
||||
|
||||
return submissionTypes;
|
||||
};
|
||||
|
||||
const parseRubricMarkdown = (rawMarkdown: string) => {
|
||||
if (!rawMarkdown.trim()) return [];
|
||||
|
||||
const lines = rawMarkdown.trim().split("\n");
|
||||
return lines.map(parseIndividualRubricItemMarkdown);
|
||||
};
|
||||
|
||||
export const assignmentMarkdownParser = {
|
||||
parseRubricMarkdown,
|
||||
parseMarkdown(input: string, name: string): LocalAssignment {
|
||||
const settingsString = input.split("---")[0];
|
||||
const {
|
||||
assignmentGroupName,
|
||||
submissionTypes,
|
||||
fileUploadExtensions,
|
||||
dueAt,
|
||||
lockAt,
|
||||
githubClassroomAssignmentShareLink,
|
||||
githubClassroomAssignmentLink,
|
||||
} = parseSettings(settingsString);
|
||||
|
||||
const description = input
|
||||
.split("---\n")
|
||||
.slice(1)
|
||||
.join("---\n")
|
||||
.split("## Rubric")[0]
|
||||
.trim();
|
||||
|
||||
const rubricString = input.split("## Rubric\n")[1];
|
||||
const rubric = parseRubricMarkdown(rubricString);
|
||||
|
||||
const assignment: LocalAssignment = {
|
||||
name,
|
||||
localAssignmentGroupName: assignmentGroupName.trim(),
|
||||
submissionTypes: submissionTypes,
|
||||
allowedFileUploadExtensions: fileUploadExtensions,
|
||||
dueAt: dueAt,
|
||||
lockAt: lockAt,
|
||||
rubric: rubric,
|
||||
description: description,
|
||||
};
|
||||
if (githubClassroomAssignmentShareLink) {
|
||||
assignment.githubClassroomAssignmentShareLink =
|
||||
githubClassroomAssignmentShareLink;
|
||||
}
|
||||
if (githubClassroomAssignmentLink) {
|
||||
assignment.githubClassroomAssignmentLink = githubClassroomAssignmentLink;
|
||||
}
|
||||
return assignment;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { AssignmentSubmissionType } from "../assignmentSubmissionType";
|
||||
import { LocalAssignment } from "../localAssignment";
|
||||
import { RubricItem } from "../rubricItem";
|
||||
|
||||
const assignmentRubricToMarkdown = (assignment: LocalAssignment) => {
|
||||
return assignment.rubric
|
||||
.map((item: RubricItem) => {
|
||||
const pointLabel = item.points > 1 ? "pts" : "pt";
|
||||
return `- ${item.points}${pointLabel}: ${item.label}`;
|
||||
})
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
const settingsToMarkdown = (assignment: LocalAssignment) => {
|
||||
const printableDueDate = assignment.dueAt.toString().replace("\u202F", " ");
|
||||
const printableLockAt =
|
||||
assignment.lockAt?.toString().replace("\u202F", " ") || "";
|
||||
|
||||
const submissionTypesMarkdown = assignment.submissionTypes
|
||||
.map((submissionType: AssignmentSubmissionType) => `- ${submissionType}`)
|
||||
.join("\n");
|
||||
|
||||
const allowedFileUploadExtensionsMarkdown =
|
||||
assignment.allowedFileUploadExtensions
|
||||
.map((fileExtension: string) => `- ${fileExtension}`)
|
||||
.join("\n");
|
||||
|
||||
const settingsMarkdownArr = [
|
||||
`LockAt: ${printableLockAt}`,
|
||||
`DueAt: ${printableDueDate}`,
|
||||
`AssignmentGroupName: ${assignment.localAssignmentGroupName}`,
|
||||
`GithubClassroomAssignmentLink: ${assignment.githubClassroomAssignmentLink ?? ""}`,
|
||||
`GithubClassroomAssignmentShareLink: ${assignment.githubClassroomAssignmentShareLink ?? ""}`,
|
||||
`SubmissionTypes:\n${submissionTypesMarkdown}`,
|
||||
`AllowedFileUploadExtensions:\n${allowedFileUploadExtensionsMarkdown}`,
|
||||
];
|
||||
return settingsMarkdownArr.join("\n");
|
||||
};
|
||||
|
||||
export const assignmentMarkdownSerializer = {
|
||||
toMarkdown(assignment: LocalAssignment): string {
|
||||
try {
|
||||
const settingsMarkdown = settingsToMarkdown(assignment);
|
||||
const rubricMarkdown = assignmentRubricToMarkdown(assignment);
|
||||
const assignmentMarkdown = `${settingsMarkdown}\n---\n\n${assignment.description}\n\n## Rubric\n\n${rubricMarkdown}`;
|
||||
|
||||
return assignmentMarkdown;
|
||||
} catch (e) {
|
||||
console.log(assignment);
|
||||
console.log("Error converting assignment to markdown");
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { RubricItem } from "../rubricItem";
|
||||
|
||||
export const assignmentPoints = (rubric: RubricItem[]) => {
|
||||
const basePoints = rubric
|
||||
.map((r) => {
|
||||
if (r.label.toLowerCase().includes("(extra credit)")) return 0;
|
||||
if (r.points < 0) return 0; // don't count negative points towards the point totals
|
||||
return r.points;
|
||||
})
|
||||
.reduce((acc, current) => (current > 0 ? acc + current : acc), 0);
|
||||
return basePoints;
|
||||
};
|
||||
10
src/features/local/assignments/models/utils/markdownUtils.ts
Normal file
10
src/features/local/assignments/models/utils/markdownUtils.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const extractLabelValue = (input: string, label: string) => {
|
||||
const pattern = new RegExp(`${label}: (.*?)\n`);
|
||||
const match = pattern.exec(input);
|
||||
|
||||
if (match && match.length > 1 && match[1]) {
|
||||
return match[1].trim();
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
42
src/features/local/pages/localCoursePageModels.ts
Normal file
42
src/features/local/pages/localCoursePageModels.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { IModuleItem } from "@/models/local/IModuleItem";
|
||||
import { verifyDateOrThrow } from "@/models/local/utils/timeUtils";
|
||||
import { z } from "zod";
|
||||
import { extractLabelValue } from "../assignments/models/utils/markdownUtils";
|
||||
|
||||
export interface LocalCoursePage extends IModuleItem {
|
||||
name: string;
|
||||
text: string;
|
||||
dueAt: string;
|
||||
}
|
||||
|
||||
export const zodLocalCoursePage = z.object({
|
||||
name: z.string(),
|
||||
text: z.string(),
|
||||
dueAt: z.string(), // ISO 8601 date string
|
||||
});
|
||||
|
||||
export const localPageMarkdownUtils = {
|
||||
toMarkdown: (page: LocalCoursePage) => {
|
||||
const printableDueDate = verifyDateOrThrow(
|
||||
page.dueAt,
|
||||
"page DueDateForOrdering"
|
||||
);
|
||||
const settingsMarkdown = `DueDateForOrdering: ${printableDueDate}\n---\n`;
|
||||
return settingsMarkdown + page.text;
|
||||
},
|
||||
|
||||
parseMarkdown: (pageMarkdown: string, name: string) => {
|
||||
const rawSettings = pageMarkdown.split("---")[0];
|
||||
const rawDate = extractLabelValue(rawSettings, "DueDateForOrdering");
|
||||
const dueAt = verifyDateOrThrow(rawDate, "page DueDateForOrdering");
|
||||
|
||||
const text = pageMarkdown.split("---\n")[1];
|
||||
|
||||
const page: LocalCoursePage = {
|
||||
name,
|
||||
dueAt,
|
||||
text,
|
||||
};
|
||||
return page;
|
||||
},
|
||||
};
|
||||
66
src/features/local/pages/pageFileStorageService.ts
Normal file
66
src/features/local/pages/pageFileStorageService.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { courseItemFileStorageService } from "../../../services/fileStorage/courseItemFileStorageService";
|
||||
import { getCoursePathByName } from "../../../services/fileStorage/globalSettingsFileStorageService";
|
||||
import {
|
||||
LocalCoursePage,
|
||||
localPageMarkdownUtils,
|
||||
} from "@/features/local/pages/localCoursePageModels";
|
||||
|
||||
export const pageFileStorageService = {
|
||||
getPage: async (courseName: string, moduleName: string, name: string) =>
|
||||
await courseItemFileStorageService.getItem(
|
||||
courseName,
|
||||
moduleName,
|
||||
name,
|
||||
"Page"
|
||||
),
|
||||
getPages: async (courseName: string, moduleName: string) =>
|
||||
await courseItemFileStorageService.getItems(courseName, moduleName, "Page"),
|
||||
|
||||
async updatePage({
|
||||
courseName,
|
||||
moduleName,
|
||||
pageName,
|
||||
page,
|
||||
}: {
|
||||
courseName: string;
|
||||
moduleName: string;
|
||||
pageName: string;
|
||||
page: LocalCoursePage;
|
||||
}) {
|
||||
const courseDirectory = await getCoursePathByName(courseName);
|
||||
const folder = path.join(courseDirectory, moduleName, "pages");
|
||||
await fs.mkdir(folder, { recursive: true });
|
||||
|
||||
const filePath = path.join(
|
||||
courseDirectory,
|
||||
moduleName,
|
||||
"pages",
|
||||
pageName + ".md"
|
||||
);
|
||||
|
||||
const pageMarkdown = localPageMarkdownUtils.toMarkdown(page);
|
||||
console.log(`Saving page ${filePath}`);
|
||||
await fs.writeFile(filePath, pageMarkdown);
|
||||
},
|
||||
async delete({
|
||||
courseName,
|
||||
moduleName,
|
||||
pageName,
|
||||
}: {
|
||||
courseName: string;
|
||||
moduleName: string;
|
||||
pageName: string;
|
||||
}) {
|
||||
const courseDirectory = await getCoursePathByName(courseName);
|
||||
const filePath = path.join(
|
||||
courseDirectory,
|
||||
moduleName,
|
||||
"pages",
|
||||
pageName + ".md"
|
||||
);
|
||||
console.log("removing page", filePath);
|
||||
await fs.unlink(filePath);
|
||||
},
|
||||
};
|
||||
98
src/features/local/pages/pageHooks.ts
Normal file
98
src/features/local/pages/pageHooks.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
import { useCourseContext } from "@/app/course/[courseName]/context/courseContext";
|
||||
import { useTRPC } from "@/services/serverFunctions/trpcClient";
|
||||
import {
|
||||
useSuspenseQuery,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
export const usePageQuery = (moduleName: string, pageName: string) => {
|
||||
const { courseName } = useCourseContext();
|
||||
const trpc = useTRPC();
|
||||
return useSuspenseQuery(
|
||||
trpc.page.getPage.queryOptions({
|
||||
courseName,
|
||||
moduleName,
|
||||
pageName,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export const usePagesQueries = (moduleName: string) => {
|
||||
const { courseName } = useCourseContext();
|
||||
const trpc = useTRPC();
|
||||
return useSuspenseQuery(
|
||||
trpc.page.getAllPages.queryOptions({
|
||||
courseName,
|
||||
moduleName,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export const useUpdatePageMutation = () => {
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
trpc.page.updatePage.mutationOptions({
|
||||
onSuccess: (
|
||||
_,
|
||||
{ courseName, moduleName, pageName, previousModuleName }
|
||||
) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: trpc.page.getAllPages.queryKey({ courseName, moduleName }),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: trpc.page.getPage.queryKey({
|
||||
courseName,
|
||||
moduleName,
|
||||
pageName,
|
||||
}),
|
||||
});
|
||||
if (moduleName !== previousModuleName) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: trpc.page.getAllPages.queryKey({
|
||||
courseName,
|
||||
moduleName: previousModuleName,
|
||||
}),
|
||||
});
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export const useCreatePageMutation = () => {
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
trpc.page.createPage.mutationOptions({
|
||||
onSuccess: (_, { courseName, moduleName }) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: trpc.page.getAllPages.queryKey({ courseName, moduleName }),
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export const useDeletePageMutation = () => {
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
trpc.page.deletePage.mutationOptions({
|
||||
onSuccess: (_, { courseName, moduleName, pageName }) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: trpc.page.getAllPages.queryKey({ courseName, moduleName }),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: trpc.page.getPage.queryKey({
|
||||
courseName,
|
||||
moduleName,
|
||||
pageName,
|
||||
}),
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
107
src/features/local/pages/pageRouter.ts
Normal file
107
src/features/local/pages/pageRouter.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import publicProcedure from "../../../services/serverFunctions/procedures/public";
|
||||
import { z } from "zod";
|
||||
import { router } from "../../../services/serverFunctions/trpcSetup";
|
||||
import { fileStorageService } from "@/services/fileStorage/fileStorageService";
|
||||
import { zodLocalCoursePage } from "@/features/local/pages/localCoursePageModels";
|
||||
|
||||
export const pageRouter = router({
|
||||
getPage: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
courseName: z.string(),
|
||||
moduleName: z.string(),
|
||||
pageName: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input: { courseName, moduleName, pageName } }) => {
|
||||
return await fileStorageService.pages.getPage(
|
||||
courseName,
|
||||
moduleName,
|
||||
pageName
|
||||
);
|
||||
}),
|
||||
|
||||
getAllPages: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
courseName: z.string(),
|
||||
moduleName: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input: { courseName, moduleName } }) => {
|
||||
return await fileStorageService.pages.getPages(courseName, moduleName);
|
||||
}),
|
||||
createPage: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
courseName: z.string(),
|
||||
moduleName: z.string(),
|
||||
pageName: z.string(),
|
||||
page: zodLocalCoursePage,
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input: { courseName, moduleName, pageName, page } }) => {
|
||||
await fileStorageService.pages.updatePage({
|
||||
courseName,
|
||||
moduleName,
|
||||
pageName,
|
||||
page,
|
||||
});
|
||||
}),
|
||||
updatePage: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
courseName: z.string(),
|
||||
moduleName: z.string(),
|
||||
previousModuleName: z.string(),
|
||||
previousPageName: z.string(),
|
||||
pageName: z.string(),
|
||||
page: zodLocalCoursePage,
|
||||
})
|
||||
)
|
||||
.mutation(
|
||||
async ({
|
||||
input: {
|
||||
courseName,
|
||||
moduleName,
|
||||
pageName,
|
||||
page,
|
||||
previousModuleName,
|
||||
previousPageName,
|
||||
},
|
||||
}) => {
|
||||
await fileStorageService.pages.updatePage({
|
||||
courseName,
|
||||
moduleName,
|
||||
pageName,
|
||||
page,
|
||||
});
|
||||
|
||||
if (
|
||||
pageName !== previousPageName ||
|
||||
moduleName !== previousModuleName
|
||||
) {
|
||||
await fileStorageService.pages.delete({
|
||||
courseName,
|
||||
moduleName: previousModuleName,
|
||||
pageName: previousPageName,
|
||||
});
|
||||
}
|
||||
}
|
||||
),
|
||||
deletePage: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
courseName: z.string(),
|
||||
moduleName: z.string(),
|
||||
pageName: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input: { courseName, moduleName, pageName } }) => {
|
||||
await fileStorageService.pages.delete({
|
||||
courseName,
|
||||
moduleName,
|
||||
pageName,
|
||||
});
|
||||
}),
|
||||
});
|
||||
37
src/features/local/quizzes/models/localQuiz.ts
Normal file
37
src/features/local/quizzes/models/localQuiz.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { z } from "zod";
|
||||
import { LocalQuizQuestion, zodLocalQuizQuestion } from "./localQuizQuestion";
|
||||
import { quizMarkdownUtils } from "./utils/quizMarkdownUtils";
|
||||
import { IModuleItem } from "@/models/local/IModuleItem";
|
||||
|
||||
export interface LocalQuiz extends IModuleItem {
|
||||
name: string;
|
||||
description: string;
|
||||
password?: string;
|
||||
lockAt?: string; // ISO 8601 date string
|
||||
dueAt: string; // ISO 8601 date string
|
||||
shuffleAnswers: boolean;
|
||||
showCorrectAnswers: boolean;
|
||||
oneQuestionAtATime: boolean;
|
||||
localAssignmentGroupName?: string;
|
||||
allowedAttempts: number;
|
||||
questions: LocalQuizQuestion[];
|
||||
}
|
||||
|
||||
export const zodLocalQuiz = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
password: z.string().optional(),
|
||||
lockAt: z.string().optional(),
|
||||
dueAt: z.string(),
|
||||
shuffleAnswers: z.boolean(),
|
||||
showCorrectAnswers: z.boolean(),
|
||||
oneQuestionAtATime: z.boolean(),
|
||||
localAssignmentGroupName: z.string().optional(),
|
||||
allowedAttempts: z.number(),
|
||||
questions: zodLocalQuizQuestion.array(),
|
||||
});
|
||||
|
||||
export const localQuizMarkdownUtils = {
|
||||
parseMarkdown: quizMarkdownUtils.parseMarkdown,
|
||||
toMarkdown: quizMarkdownUtils.toMarkdown,
|
||||
};
|
||||
40
src/features/local/quizzes/models/localQuizQuestion.ts
Normal file
40
src/features/local/quizzes/models/localQuizQuestion.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
LocalQuizQuestionAnswer,
|
||||
zodLocalQuizQuestionAnswer,
|
||||
} from "./localQuizQuestionAnswer";
|
||||
|
||||
export enum QuestionType {
|
||||
MULTIPLE_ANSWERS = "multiple_answers",
|
||||
MULTIPLE_CHOICE = "multiple_choice",
|
||||
ESSAY = "essay",
|
||||
SHORT_ANSWER = "short_answer",
|
||||
MATCHING = "matching",
|
||||
NONE = "",
|
||||
SHORT_ANSWER_WITH_ANSWERS = "short_answer=",
|
||||
}
|
||||
|
||||
export const zodQuestionType = z.enum([
|
||||
QuestionType.MULTIPLE_ANSWERS,
|
||||
QuestionType.MULTIPLE_CHOICE,
|
||||
QuestionType.ESSAY,
|
||||
QuestionType.SHORT_ANSWER,
|
||||
QuestionType.MATCHING,
|
||||
QuestionType.NONE,
|
||||
QuestionType.SHORT_ANSWER_WITH_ANSWERS,
|
||||
]);
|
||||
|
||||
export interface LocalQuizQuestion {
|
||||
text: string;
|
||||
questionType: QuestionType;
|
||||
points: number;
|
||||
answers: LocalQuizQuestionAnswer[];
|
||||
matchDistractors: string[];
|
||||
}
|
||||
export const zodLocalQuizQuestion = z.object({
|
||||
text: z.string(),
|
||||
questionType: zodQuestionType,
|
||||
points: z.number(),
|
||||
answers: zodLocalQuizQuestionAnswer.array(),
|
||||
matchDistractors: z.array(z.string()),
|
||||
});
|
||||
13
src/features/local/quizzes/models/localQuizQuestionAnswer.ts
Normal file
13
src/features/local/quizzes/models/localQuizQuestionAnswer.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export interface LocalQuizQuestionAnswer {
|
||||
correct: boolean;
|
||||
text: string;
|
||||
matchedText?: string;
|
||||
}
|
||||
|
||||
export const zodLocalQuizQuestionAnswer = z.object({
|
||||
correct: z.boolean(),
|
||||
text: z.string(),
|
||||
matchedText: z.string().optional(),
|
||||
});
|
||||
149
src/features/local/quizzes/models/utils/quizMarkdownUtils.ts
Normal file
149
src/features/local/quizzes/models/utils/quizMarkdownUtils.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { verifyDateOrThrow, verifyDateStringOrUndefined } from "@/models/local/utils/timeUtils";
|
||||
import { LocalQuiz } from "../localQuiz";
|
||||
import { quizQuestionMarkdownUtils } from "./quizQuestionMarkdownUtils";
|
||||
|
||||
const extractLabelValue = (input: string, label: string): string => {
|
||||
const pattern = new RegExp(`${label}: (.*?)\n`);
|
||||
const match = pattern.exec(input);
|
||||
return match ? match[1].trim() : "";
|
||||
};
|
||||
|
||||
const extractDescription = (input: string): string => {
|
||||
const pattern = new RegExp("Description: (.*?)$", "s");
|
||||
const match = pattern.exec(input);
|
||||
return match ? match[1].trim() : "";
|
||||
};
|
||||
|
||||
const parseBooleanOrThrow = (value: string, label: string): boolean => {
|
||||
if (value.toLowerCase() === "true") return true;
|
||||
if (value.toLowerCase() === "false") return false;
|
||||
throw new Error(`Error with ${label}: ${value}`);
|
||||
};
|
||||
|
||||
const parseBooleanOrDefault = (
|
||||
value: string,
|
||||
label: string,
|
||||
defaultValue: boolean
|
||||
): boolean => {
|
||||
if (value.toLowerCase() === "true") return true;
|
||||
if (value.toLowerCase() === "false") return false;
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
const parseNumberOrThrow = (value: string, label: string): number => {
|
||||
const parsed = parseInt(value, 10);
|
||||
if (isNaN(parsed)) {
|
||||
throw new Error(`Error with ${label}: ${value}`);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
const getQuizWithOnlySettings = (settings: string, name: string): LocalQuiz => {
|
||||
|
||||
const rawShuffleAnswers = extractLabelValue(settings, "ShuffleAnswers");
|
||||
const shuffleAnswers = parseBooleanOrThrow(
|
||||
rawShuffleAnswers,
|
||||
"ShuffleAnswers"
|
||||
);
|
||||
|
||||
const password = extractLabelValue(settings, "Password") || undefined;
|
||||
|
||||
const rawShowCorrectAnswers = extractLabelValue(
|
||||
settings,
|
||||
"ShowCorrectAnswers"
|
||||
);
|
||||
const showCorrectAnswers = parseBooleanOrDefault(
|
||||
rawShowCorrectAnswers,
|
||||
"ShowCorrectAnswers",
|
||||
true
|
||||
);
|
||||
|
||||
const rawOneQuestionAtATime = extractLabelValue(
|
||||
settings,
|
||||
"OneQuestionAtATime"
|
||||
);
|
||||
const oneQuestionAtATime = parseBooleanOrThrow(
|
||||
rawOneQuestionAtATime,
|
||||
"OneQuestionAtATime"
|
||||
);
|
||||
|
||||
const rawAllowedAttempts = extractLabelValue(settings, "AllowedAttempts");
|
||||
const allowedAttempts = parseNumberOrThrow(
|
||||
rawAllowedAttempts,
|
||||
"AllowedAttempts"
|
||||
);
|
||||
|
||||
const rawDueAt = extractLabelValue(settings, "DueAt");
|
||||
const dueAt = verifyDateOrThrow(rawDueAt, "DueAt");
|
||||
|
||||
const rawLockAt = extractLabelValue(settings, "LockAt");
|
||||
const lockAt = verifyDateStringOrUndefined(rawLockAt);
|
||||
|
||||
const description = extractDescription(settings);
|
||||
const localAssignmentGroupName = extractLabelValue(
|
||||
settings,
|
||||
"AssignmentGroup"
|
||||
);
|
||||
|
||||
const quiz: LocalQuiz = {
|
||||
name,
|
||||
description,
|
||||
password,
|
||||
lockAt,
|
||||
dueAt,
|
||||
shuffleAnswers,
|
||||
showCorrectAnswers,
|
||||
oneQuestionAtATime,
|
||||
localAssignmentGroupName,
|
||||
allowedAttempts,
|
||||
questions: [],
|
||||
};
|
||||
return quiz;
|
||||
};
|
||||
|
||||
export const quizMarkdownUtils = {
|
||||
toMarkdown(quiz: LocalQuiz): string {
|
||||
if (!quiz) {
|
||||
throw Error(`quiz was undefined, cannot parse markdown`);
|
||||
}
|
||||
if (
|
||||
typeof quiz.questions === "undefined" ||
|
||||
typeof quiz.oneQuestionAtATime === "undefined"
|
||||
) {
|
||||
console.log("quiz is probably not a quiz", quiz);
|
||||
throw Error(`quiz ${quiz.name} is probably not a quiz`);
|
||||
}
|
||||
const questionMarkdownArray = quiz.questions.map((q) =>
|
||||
quizQuestionMarkdownUtils.toMarkdown(q)
|
||||
);
|
||||
const questionDelimiter = "\n\n---\n\n";
|
||||
const questionMarkdown = questionMarkdownArray.join(questionDelimiter);
|
||||
|
||||
return `LockAt: ${quiz.lockAt ?? ""}
|
||||
DueAt: ${quiz.dueAt}
|
||||
Password: ${quiz.password ?? ""}
|
||||
ShuffleAnswers: ${quiz.shuffleAnswers.toString().toLowerCase()}
|
||||
ShowCorrectAnswers: ${quiz.showCorrectAnswers.toString().toLowerCase()}
|
||||
OneQuestionAtATime: ${quiz.oneQuestionAtATime.toString().toLowerCase()}
|
||||
AssignmentGroup: ${quiz.localAssignmentGroupName}
|
||||
AllowedAttempts: ${quiz.allowedAttempts}
|
||||
Description: ${quiz.description}
|
||||
---
|
||||
${questionMarkdown}`;
|
||||
},
|
||||
|
||||
parseMarkdown(input: string, name: string): LocalQuiz {
|
||||
const splitInput = input.split("---\n");
|
||||
const settings = splitInput[0];
|
||||
const quizWithoutQuestions = getQuizWithOnlySettings(settings, name);
|
||||
|
||||
const rawQuestions = splitInput.slice(1);
|
||||
const questions = rawQuestions
|
||||
.filter((str) => str.trim().length > 0)
|
||||
.map((q, i) => quizQuestionMarkdownUtils.parseMarkdown(q, i));
|
||||
|
||||
return {
|
||||
...quizWithoutQuestions,
|
||||
questions,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { QuestionType } from "../localQuizQuestion";
|
||||
import { LocalQuizQuestionAnswer } from "../localQuizQuestionAnswer";
|
||||
|
||||
const parseMatchingAnswer = (input: string) => {
|
||||
const matchingPattern = /^\^?/;
|
||||
const textWithoutMatchDelimiter = input.replace(matchingPattern, "");
|
||||
const [text, ...matchedParts] = textWithoutMatchDelimiter.split(" - ");
|
||||
const answer: LocalQuizQuestionAnswer = {
|
||||
correct: true,
|
||||
text: text.trim(),
|
||||
matchedText: matchedParts.join("-").trim(),
|
||||
};
|
||||
return answer;
|
||||
};
|
||||
|
||||
export const quizQuestionAnswerMarkdownUtils = {
|
||||
// getHtmlText(): string {
|
||||
// return MarkdownService.render(this.text);
|
||||
// }
|
||||
|
||||
parseMarkdown(input: string, questionType: string): LocalQuizQuestionAnswer {
|
||||
const isCorrect = input.startsWith("*") || input[1] === "*";
|
||||
|
||||
if (questionType === QuestionType.MATCHING) {
|
||||
return parseMatchingAnswer(input);
|
||||
}
|
||||
|
||||
const startingQuestionPattern = /^(\*?[a-z]?\))|\[\s*\]|\[\*\]|\^ /;
|
||||
|
||||
let replaceCount = 0;
|
||||
const text = input
|
||||
.replace(startingQuestionPattern, (m) => (replaceCount++ === 0 ? "" : m))
|
||||
.trim();
|
||||
|
||||
const answer: LocalQuizQuestionAnswer = {
|
||||
correct: isCorrect,
|
||||
text: text,
|
||||
};
|
||||
return answer;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,253 @@
|
||||
import { LocalQuizQuestion, QuestionType } from "../localQuizQuestion";
|
||||
import { LocalQuizQuestionAnswer } from "../localQuizQuestionAnswer";
|
||||
import { quizQuestionAnswerMarkdownUtils } from "./quizQuestionAnswerMarkdownUtils";
|
||||
|
||||
const _validFirstAnswerDelimiters = [
|
||||
"*a)",
|
||||
"a)",
|
||||
"*)",
|
||||
")",
|
||||
"[ ]",
|
||||
"[]",
|
||||
"[*]",
|
||||
"^",
|
||||
];
|
||||
const _multipleChoicePrefix = ["a)", "*a)", "*)", ")"];
|
||||
const _multipleAnswerPrefix = ["[ ]", "[*]", "[]"];
|
||||
|
||||
const getAnswerStringsWithMultilineSupport = (
|
||||
linesWithoutPoints: string[],
|
||||
questionIndex: number
|
||||
) => {
|
||||
const indexOfAnswerStart = linesWithoutPoints.findIndex((l) =>
|
||||
_validFirstAnswerDelimiters.some((prefix) =>
|
||||
l.trimStart().startsWith(prefix)
|
||||
)
|
||||
);
|
||||
if (indexOfAnswerStart === -1) {
|
||||
const debugLine = linesWithoutPoints.find((l) => l.trim().length > 0);
|
||||
throw Error(
|
||||
`question ${
|
||||
questionIndex + 1
|
||||
}: no answers when detecting question type on ${debugLine}`
|
||||
);
|
||||
}
|
||||
|
||||
const answerLinesRaw = linesWithoutPoints.slice(indexOfAnswerStart);
|
||||
|
||||
const answerStartPattern = /^(\*?[a-z]?\))|(?<!\S)\[\s*\]|\[\*\]|\^/;
|
||||
const answerLines = answerLinesRaw.reduce((acc: string[], line: string) => {
|
||||
const isNewAnswer = answerStartPattern.test(line);
|
||||
if (isNewAnswer) {
|
||||
acc.push(line);
|
||||
} else if (acc.length !== 0) {
|
||||
acc[acc.length - 1] += "\n" + line;
|
||||
} else {
|
||||
acc.push(line);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
return answerLines;
|
||||
};
|
||||
const getQuestionType = (
|
||||
linesWithoutPoints: string[],
|
||||
questionIndex: number
|
||||
): QuestionType => {
|
||||
if (linesWithoutPoints.length === 0) return QuestionType.NONE;
|
||||
if (
|
||||
linesWithoutPoints[linesWithoutPoints.length - 1].toLowerCase() === "essay"
|
||||
)
|
||||
return QuestionType.ESSAY;
|
||||
if (
|
||||
linesWithoutPoints[linesWithoutPoints.length - 1].toLowerCase() ===
|
||||
"short answer"
|
||||
)
|
||||
return QuestionType.SHORT_ANSWER;
|
||||
if (
|
||||
linesWithoutPoints[linesWithoutPoints.length - 1].toLowerCase() ===
|
||||
"short_answer"
|
||||
)
|
||||
return QuestionType.SHORT_ANSWER;
|
||||
if (
|
||||
linesWithoutPoints[linesWithoutPoints.length - 1].toLowerCase().trim() ===
|
||||
"short_answer="
|
||||
)
|
||||
return QuestionType.SHORT_ANSWER_WITH_ANSWERS;
|
||||
|
||||
const answerLines = getAnswerStringsWithMultilineSupport(
|
||||
linesWithoutPoints,
|
||||
questionIndex
|
||||
);
|
||||
const firstAnswerLine = answerLines[0];
|
||||
const isMultipleChoice = _multipleChoicePrefix.some((prefix) =>
|
||||
firstAnswerLine.startsWith(prefix)
|
||||
);
|
||||
|
||||
if (isMultipleChoice) return QuestionType.MULTIPLE_CHOICE;
|
||||
|
||||
const isMultipleAnswer = _multipleAnswerPrefix.some((prefix) =>
|
||||
firstAnswerLine.startsWith(prefix)
|
||||
);
|
||||
if (isMultipleAnswer) return QuestionType.MULTIPLE_ANSWERS;
|
||||
|
||||
const isMatching = firstAnswerLine.startsWith("^");
|
||||
if (isMatching) return QuestionType.MATCHING;
|
||||
|
||||
return QuestionType.NONE;
|
||||
};
|
||||
|
||||
const getAnswers = (
|
||||
linesWithoutPoints: string[],
|
||||
questionIndex: number,
|
||||
questionType: string
|
||||
): LocalQuizQuestionAnswer[] => {
|
||||
if (questionType == QuestionType.SHORT_ANSWER_WITH_ANSWERS)
|
||||
linesWithoutPoints = linesWithoutPoints.slice(
|
||||
0,
|
||||
linesWithoutPoints.length - 1
|
||||
);
|
||||
const answerLines = getAnswerStringsWithMultilineSupport(
|
||||
linesWithoutPoints,
|
||||
questionIndex
|
||||
);
|
||||
|
||||
const answers = answerLines.map((a) =>
|
||||
quizQuestionAnswerMarkdownUtils.parseMarkdown(a, questionType)
|
||||
);
|
||||
return answers;
|
||||
};
|
||||
|
||||
const getAnswerMarkdown = (
|
||||
question: LocalQuizQuestion,
|
||||
answer: LocalQuizQuestionAnswer,
|
||||
index: number
|
||||
): string => {
|
||||
const multilineMarkdownCompatibleText = answer.text.startsWith("```")
|
||||
? "\n" + answer.text
|
||||
: answer.text;
|
||||
|
||||
if (question.questionType === "multiple_answers") {
|
||||
const correctIndicator = answer.correct ? "*" : " ";
|
||||
const questionTypeIndicator = `[${correctIndicator}] `;
|
||||
|
||||
return `${questionTypeIndicator}${multilineMarkdownCompatibleText}`;
|
||||
} else if (question.questionType === "matching") {
|
||||
return `^ ${answer.text} - ${answer.matchedText}`;
|
||||
} else {
|
||||
const questionLetter = String.fromCharCode(97 + index);
|
||||
const correctIndicator = answer.correct ? "*" : "";
|
||||
const questionTypeIndicator = `${correctIndicator}${questionLetter}) `;
|
||||
|
||||
return `${questionTypeIndicator}${multilineMarkdownCompatibleText}`;
|
||||
}
|
||||
};
|
||||
|
||||
export const quizQuestionMarkdownUtils = {
|
||||
toMarkdown(question: LocalQuizQuestion): string {
|
||||
const answerArray = question.answers.map((a, i) =>
|
||||
getAnswerMarkdown(question, a, i)
|
||||
);
|
||||
|
||||
const distractorText =
|
||||
question.questionType === QuestionType.MATCHING
|
||||
? question.matchDistractors?.map((d) => `\n^ - ${d}`).join("") ?? ""
|
||||
: "";
|
||||
|
||||
const answersText = answerArray.join("\n");
|
||||
const questionTypeIndicator =
|
||||
question.questionType === "essay" ||
|
||||
question.questionType === "short_answer"
|
||||
? 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}`;
|
||||
},
|
||||
|
||||
parseMarkdown(input: string, questionIndex: number): LocalQuizQuestion {
|
||||
const lines = input.trim().split("\n");
|
||||
const firstLineIsPoints = lines[0].toLowerCase().includes("points: ");
|
||||
|
||||
const textHasPoints =
|
||||
lines.length > 0 &&
|
||||
lines[0].includes(": ") &&
|
||||
lines[0].split(": ").length > 1 &&
|
||||
!isNaN(parseFloat(lines[0].split(": ")[1]));
|
||||
|
||||
const points =
|
||||
firstLineIsPoints && textHasPoints
|
||||
? parseFloat(lines[0].split(": ")[1])
|
||||
: 1;
|
||||
|
||||
const linesWithoutPoints = firstLineIsPoints ? lines.slice(1) : lines;
|
||||
|
||||
const { linesWithoutAnswers } = linesWithoutPoints.reduce(
|
||||
({ linesWithoutAnswers, taking }, currentLine) => {
|
||||
if (!taking)
|
||||
return { linesWithoutAnswers: linesWithoutAnswers, taking: false };
|
||||
|
||||
const lineIsAnswer = _validFirstAnswerDelimiters.some((prefix) =>
|
||||
currentLine.trimStart().startsWith(prefix)
|
||||
);
|
||||
if (lineIsAnswer)
|
||||
return { linesWithoutAnswers: linesWithoutAnswers, taking: false };
|
||||
|
||||
return {
|
||||
linesWithoutAnswers: [...linesWithoutAnswers, currentLine],
|
||||
taking: true,
|
||||
};
|
||||
},
|
||||
{ linesWithoutAnswers: [] as string[], taking: true }
|
||||
);
|
||||
const questionType = getQuestionType(linesWithoutPoints, questionIndex);
|
||||
|
||||
const questionTypesWithoutAnswers = [
|
||||
"essay",
|
||||
"short answer",
|
||||
"short_answer",
|
||||
];
|
||||
|
||||
const descriptionLines = questionTypesWithoutAnswers.includes(
|
||||
questionType.toLowerCase()
|
||||
)
|
||||
? linesWithoutAnswers
|
||||
.slice(0, linesWithoutPoints.length)
|
||||
.filter(
|
||||
(line) =>
|
||||
!questionTypesWithoutAnswers.includes(line.toLowerCase())
|
||||
)
|
||||
: linesWithoutAnswers;
|
||||
|
||||
const description = descriptionLines.join("\n");
|
||||
|
||||
const typesWithAnswers = [
|
||||
"multiple_choice",
|
||||
"multiple_answers",
|
||||
"matching",
|
||||
"short_answer=",
|
||||
];
|
||||
const answers = typesWithAnswers.includes(questionType)
|
||||
? getAnswers(linesWithoutPoints, questionIndex, questionType)
|
||||
: [];
|
||||
|
||||
const answersWithoutDistractors =
|
||||
questionType === QuestionType.MATCHING
|
||||
? answers.filter((a) => a.text)
|
||||
: answers;
|
||||
|
||||
const distractors =
|
||||
questionType === QuestionType.MATCHING
|
||||
? answers.filter((a) => !a.text).map((a) => a.matchedText ?? "")
|
||||
: [];
|
||||
|
||||
const question: LocalQuizQuestion = {
|
||||
text: description,
|
||||
questionType,
|
||||
points,
|
||||
answers: answersWithoutDistractors,
|
||||
matchDistractors: distractors,
|
||||
};
|
||||
return question;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user