refactoring files to be located by feature

This commit is contained in:
2025-07-23 09:23:44 -06:00
parent 46e0c36916
commit c95c40f9e7
75 changed files with 279 additions and 303 deletions

View 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,
}),
});
},
})
);
};

View 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,
});
}),
});

View File

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

View 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,
};

View File

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

View 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());
};

View File

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

View File

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

View File

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

View 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 "";
};

View 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;
},
};

View 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);
},
};

View 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,
}),
});
},
})
);
};

View 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,
});
}),
});

View 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,
};

View 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()),
});

View 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(),
});

View 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,
};
},
};

View File

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

View File

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