more refactoring by feature

This commit is contained in:
2025-07-23 09:46:35 -06:00
parent d5a40e52d9
commit 3e371247d6
92 changed files with 159 additions and 158 deletions

View File

@@ -14,7 +14,7 @@ import {
import {
useLocalCourseSettingsQuery,
useUpdateLocalCourseSettingsMutation,
} from "@/hooks/localCourse/localCoursesHooks";
} from "@/features/local/course/localCoursesHooks";
export const useAssignmentQuery = (
moduleName: string,

View File

@@ -0,0 +1,100 @@
import {
localAssignmentMarkdown,
LocalAssignment,
} from "@/features/local/assignments/models/localAssignment";
import { assignmentMarkdownSerializer } from "@/features/local/assignments/models/utils/assignmentMarkdownSerializer";
import path from "path";
import { promises as fs } from "fs";
import { courseItemFileStorageService } from "@/features/local/course/courseItemFileStorageService";
import { getCoursePathByName } from "@/services/fileStorage/globalSettingsFileStorageService";
import { directoryOrFileExists } from "@/services/fileStorage/utils/fileSystemUtils";
const getAssignmentNames = async (courseName: string, moduleName: string) => {
const courseDirectory = await getCoursePathByName(courseName);
const filePath = path.join(courseDirectory, moduleName, "assignments");
if (!(await directoryOrFileExists(filePath))) {
console.log(
`Error loading course by name, assignments folder does not exist in ${filePath}`
);
// await fs.mkdir(filePath);
return [];
}
const assignmentFiles = await fs.readdir(filePath);
return assignmentFiles.map((f) => f.replace(/\.md$/, ""));
};
const getAssignment = async (
courseName: string,
moduleName: string,
assignmentName: string
) => {
const courseDirectory = await getCoursePathByName(courseName);
const filePath = path.join(
courseDirectory,
moduleName,
"assignments",
assignmentName + ".md"
);
const rawFile = (await fs.readFile(filePath, "utf-8")).replace(/\r\n/g, "\n");
return localAssignmentMarkdown.parseMarkdown(rawFile, assignmentName);
};
export const assignmentsFileStorageService = {
getAssignmentNames,
getAssignment,
async getAssignments(courseName: string, moduleName: string) {
return await courseItemFileStorageService.getItems(
courseName,
moduleName,
"Assignment"
);
},
async updateOrCreateAssignment({
courseName,
moduleName,
assignmentName,
assignment,
}: {
courseName: string;
moduleName: string;
assignmentName: string;
assignment: LocalAssignment;
}) {
const courseDirectory = await getCoursePathByName(courseName);
const folder = path.join(courseDirectory, moduleName, "assignments");
await fs.mkdir(folder, { recursive: true });
const filePath = path.join(
courseDirectory,
moduleName,
"assignments",
assignmentName + ".md"
);
const assignmentMarkdown =
assignmentMarkdownSerializer.toMarkdown(assignment);
console.log(`Saving assignment ${filePath}`);
await fs.writeFile(filePath, assignmentMarkdown);
},
async delete({
courseName,
moduleName,
assignmentName,
}: {
courseName: string;
moduleName: string;
assignmentName: string;
}) {
const courseDirectory = await getCoursePathByName(courseName);
const filePath = path.join(
courseDirectory,
moduleName,
"assignments",
assignmentName + ".md"
);
console.log("removing assignment", filePath);
await fs.unlink(filePath);
},
};

View File

@@ -1,4 +1,4 @@
import { IModuleItem } from "../../../../models/local/IModuleItem";
import { IModuleItem } from "../../modules/IModuleItem";
import {
AssignmentSubmissionType,
zodAssignmentSubmissionType,

View File

@@ -0,0 +1,134 @@
import path from "path";
import { directoryOrFileExists } from "../../../services/fileStorage/utils/fileSystemUtils";
import fs from "fs/promises";
import {
LocalAssignment,
localAssignmentMarkdown,
} from "@/features/local/assignments/models/localAssignment";
import { assignmentMarkdownSerializer } from "@/features/local/assignments/models/utils/assignmentMarkdownSerializer";
import {
CourseItemReturnType,
CourseItemType,
typeToFolder,
} from "@/features/local/course/courseItemTypes";
import { getCoursePathByName } from "../../../services/fileStorage/globalSettingsFileStorageService";
import {
localPageMarkdownUtils,
LocalCoursePage,
} from "@/features/local/pages/localCoursePageModels";
import {
LocalQuiz,
localQuizMarkdownUtils,
} from "@/features/local/quizzes/models/localQuiz";
import { quizMarkdownUtils } from "@/features/local/quizzes/models/utils/quizMarkdownUtils";
const getItemFileNames = async (
courseName: string,
moduleName: string,
type: CourseItemType
) => {
const courseDirectory = await getCoursePathByName(courseName);
const folder = typeToFolder[type];
const filePath = path.join(courseDirectory, moduleName, folder);
if (!(await directoryOrFileExists(filePath))) {
console.log(
`Error loading ${type}, ${folder} folder does not exist in ${filePath}`
);
await fs.mkdir(filePath);
}
const itemFiles = await fs.readdir(filePath);
return itemFiles.map((f) => f.replace(/\.md$/, ""));
};
const getItem = async <T extends CourseItemType>(
courseName: string,
moduleName: string,
name: string,
type: T
): Promise<CourseItemReturnType<T>> => {
const courseDirectory = await getCoursePathByName(courseName);
const folder = typeToFolder[type];
const filePath = path.join(courseDirectory, moduleName, folder, name + ".md");
const rawFile = (await fs.readFile(filePath, "utf-8")).replace(/\r\n/g, "\n");
if (type === "Assignment") {
return localAssignmentMarkdown.parseMarkdown(
rawFile,
name
) as CourseItemReturnType<T>;
} else if (type === "Quiz") {
return localQuizMarkdownUtils.parseMarkdown(
rawFile,
name
) as CourseItemReturnType<T>;
} else if (type === "Page") {
return localPageMarkdownUtils.parseMarkdown(
rawFile,
name
) as CourseItemReturnType<T>;
}
throw Error(`cannot read item, invalid type: ${type} in ${filePath}`);
};
export const courseItemFileStorageService = {
getItem,
getItems: async <T extends CourseItemType>(
courseName: string,
moduleName: string,
type: T
): Promise<CourseItemReturnType<T>[]> => {
const fileNames = await getItemFileNames(courseName, moduleName, type);
const items = (
await Promise.all(
fileNames.map(async (name) => {
try {
const item = await getItem(courseName, moduleName, name, type);
return item;
} catch {
return null;
}
})
)
).filter((a) => a !== null);
return items;
},
async updateOrCreateAssignment({
courseName,
moduleName,
name,
item,
type,
}: {
courseName: string;
moduleName: string;
name: string;
item: LocalAssignment | LocalQuiz | LocalCoursePage;
type: CourseItemType;
}) {
const courseDirectory = await getCoursePathByName(courseName);
const typeFolder = typeToFolder[type];
const folder = path.join(courseDirectory, moduleName, typeFolder);
await fs.mkdir(folder, { recursive: true });
const filePath = path.join(
courseDirectory,
moduleName,
typeFolder,
name + ".md"
);
const markdownDictionary: {
[_key in CourseItemType]: () => string;
} = {
Assignment: () =>
assignmentMarkdownSerializer.toMarkdown(item as LocalAssignment),
Quiz: () => quizMarkdownUtils.toMarkdown(item as LocalQuiz),
Page: () => localPageMarkdownUtils.toMarkdown(item as LocalCoursePage),
};
const itemMarkdown = markdownDictionary[type]();
console.log(`Saving ${type} ${filePath}`);
await fs.writeFile(filePath, itemMarkdown);
},
};

View File

@@ -0,0 +1,17 @@
import { LocalCoursePage } from "@/features/local/pages/localCoursePageModels";
import { LocalAssignment } from "../assignments/models/localAssignment";
import { LocalQuiz } from "@/features/local/quizzes/models/localQuiz";
export type CourseItemType = "Assignment" | "Quiz" | "Page";
export type CourseItemReturnType<T extends CourseItemType> =
T extends "Assignment"
? LocalAssignment
: T extends "Quiz"
? LocalQuiz
: LocalCoursePage;
export const typeToFolder = {
Assignment: "assignments",
Quiz: "quizzes",
Page: "pages",
} as const;

View File

@@ -0,0 +1,120 @@
import { z } from "zod";
import { parse, stringify } from "yaml";
import {
AssignmentSubmissionType,
zodAssignmentSubmissionType,
} from "../assignments/models/assignmentSubmissionType";
import {
LocalAssignmentGroup,
zodLocalAssignmentGroup,
} from "../assignments/models/localAssignmentGroup";
export interface SimpleTimeOnly {
hour: number;
minute: number;
}
export const zodSimpleTimeOnly = z.object({
hour: z.number().int().min(0).max(23), // hour should be an integer between 0 and 23
minute: z.number().int().min(0).max(59), // minute should be an integer between 0 and 59
});
export enum DayOfWeek {
Sunday = "Sunday",
Monday = "Monday",
Tuesday = "Tuesday",
Wednesday = "Wednesday",
Thursday = "Thursday",
Friday = "Friday",
Saturday = "Saturday",
}
export const zodDayOfWeek = z.enum([
DayOfWeek.Sunday,
DayOfWeek.Monday,
DayOfWeek.Tuesday,
DayOfWeek.Wednesday,
DayOfWeek.Thursday,
DayOfWeek.Friday,
DayOfWeek.Saturday,
]);
export interface LocalCourseSettings {
name: string;
assignmentGroups: LocalAssignmentGroup[];
daysOfWeek: DayOfWeek[];
canvasId: number;
startDate: string;
endDate: string;
defaultDueTime: SimpleTimeOnly;
defaultLockHoursOffset?: number;
defaultAssignmentSubmissionTypes: AssignmentSubmissionType[];
defaultFileUploadTypes: string[];
holidays: {
name: string;
days: string[];
}[];
assets: {
sourceUrl: string;
canvasUrl: string;
}[];
}
export const zodLocalCourseSettings = z.object({
name: z.string(),
assignmentGroups: zodLocalAssignmentGroup.array(),
daysOfWeek: zodDayOfWeek.array(),
canvasId: z.number(),
startDate: z.string(),
endDate: z.string(),
defaultDueTime: zodSimpleTimeOnly,
defaultLockHoursOffset: z.number().int().optional(),
defaultAssignmentSubmissionTypes: zodAssignmentSubmissionType.array(),
defaultFileUploadTypes: z.string().array(),
holidays: z
.object({
name: z.string(),
days: z.string().array(),
})
.array(),
assets: z
.object({
sourceUrl: z.string(),
canvasUrl: z.string(),
})
.array(),
});
export function getDayOfWeek(date: Date): DayOfWeek {
const dayIndex = date.getDay(); // Returns 0 for Sunday, 1 for Monday, etc.
return DayOfWeek[Object.keys(DayOfWeek)[dayIndex] as keyof typeof DayOfWeek];
}
export const localCourseYamlUtils = {
parseSettingYaml: (settingsString: string): LocalCourseSettings => {
const settings = parse(settingsString, {});
return lowercaseFirstLetter(settings);
},
settingsToYaml: (settings: Omit<LocalCourseSettings, "name">) => {
return stringify(settings);
},
};
function lowercaseFirstLetter<T>(obj: T): T {
if (obj === null || typeof obj !== "object") return obj as T;
if (Array.isArray(obj)) return obj.map(lowercaseFirstLetter) as unknown as T;
const result: Record<string, unknown> = {};
Object.keys(obj).forEach((key) => {
const value = (obj as Record<string, unknown>)[key];
const newKey = key.charAt(0).toLowerCase() + key.slice(1);
if (value && typeof value === "object") {
result[newKey] = lowercaseFirstLetter(value);
} else {
result[newKey] = value;
}
});
return result as T;
}

View File

@@ -0,0 +1,60 @@
"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 useLocalCoursesSettingsQuery = () => {
const trpc = useTRPC();
return useSuspenseQuery(trpc.settings.allCoursesSettings.queryOptions());
};
export const useLocalCourseSettingsQuery = () => {
const { courseName } = useCourseContext();
const trpc = useTRPC();
return useSuspenseQuery(
trpc.settings.courseSettings.queryOptions({ courseName })
);
};
export const useCreateLocalCourseMutation = () => {
const trpc = useTRPC();
const queryClient = useQueryClient();
return useMutation(
trpc.settings.createCourse.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: trpc.settings.allCoursesSettings.queryKey(),
});
queryClient.invalidateQueries({
queryKey: trpc.directories.getEmptyDirectories.queryKey(),
});
queryClient.invalidateQueries({
queryKey: trpc.globalSettings.getGlobalSettings.queryKey(),
});
},
})
);
};
export const useUpdateLocalCourseSettingsMutation = () => {
const { courseName } = useCourseContext();
const trpc = useTRPC();
const queryClient = useQueryClient();
return useMutation(
trpc.settings.updateSettings.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: trpc.settings.allCoursesSettings.queryKey(),
});
queryClient.invalidateQueries({
queryKey: trpc.settings.courseSettings.queryKey({ courseName }),
});
},
})
);
};

View File

@@ -0,0 +1,119 @@
import { promises as fs } from "fs";
import path from "path";
import {
basePath,
directoryOrFileExists,
} from "../../../services/fileStorage/utils/fileSystemUtils";
import { AssignmentSubmissionType } from "@/features/local/assignments/models/assignmentSubmissionType";
import {
getCoursePathByName,
getGlobalSettings,
} from "../../../services/fileStorage/globalSettingsFileStorageService";
import { GlobalSettingsCourse } from "@/models/local/globalSettings";
import {
LocalCourseSettings,
localCourseYamlUtils,
} from "@/features/local/course/localCourseSettings";
const getCourseSettings = async (
course: GlobalSettingsCourse
): Promise<LocalCourseSettings> => {
const courseDirectory = await getCoursePathByName(course.name);
const settingsPath = path.join(courseDirectory, "settings.yml");
if (!(await directoryOrFileExists(settingsPath))) {
const errorMessage = `could not find settings for ${course.name}, settings file ${settingsPath}`;
console.log(errorMessage);
throw new Error(errorMessage);
}
const settingsString = await fs.readFile(settingsPath, "utf-8");
const settingsFromFile =
localCourseYamlUtils.parseSettingYaml(settingsString);
const settings: LocalCourseSettings = populateDefaultValues(settingsFromFile);
return { ...settings, name: course.name };
};
const populateDefaultValues = (settingsFromFile: LocalCourseSettings) => {
const defaultSubmissionType = [
AssignmentSubmissionType.ONLINE_TEXT_ENTRY,
AssignmentSubmissionType.ONLINE_UPLOAD,
];
const defaultFileUploadTypes = ["pdf", "jpg", "jpeg"];
const settings: LocalCourseSettings = {
...settingsFromFile,
defaultAssignmentSubmissionTypes:
settingsFromFile.defaultAssignmentSubmissionTypes ||
defaultSubmissionType,
defaultFileUploadTypes:
settingsFromFile.defaultFileUploadTypes || defaultFileUploadTypes,
holidays: Array.isArray(settingsFromFile.holidays)
? settingsFromFile.holidays
: [],
assets: Array.isArray(settingsFromFile.assets)
? settingsFromFile.assets
: [],
};
return settings;
};
export const settingsFileStorageService = {
getCourseSettings,
async getAllCoursesSettings() {
const globalSettings = await getGlobalSettings();
// const courses = await getCourseNames();
const courses = globalSettings.courses;
const courseSettings = await Promise.all(
courses.map(async (c) => await getCourseSettings(c))
);
return courseSettings;
},
async updateCourseSettings(
courseName: string,
settings: LocalCourseSettings
) {
const courseDirectory = await getCoursePathByName(courseName);
const settingsPath = path.join(courseDirectory, "settings.yml");
const { name: _, ...settingsWithoutName } = settings;
const settingsMarkdown =
localCourseYamlUtils.settingsToYaml(settingsWithoutName);
console.log(`Saving settings ${settingsPath}`);
await fs.writeFile(settingsPath, settingsMarkdown);
},
async createCourseSettings(settings: LocalCourseSettings, directory: string) {
const courseDirectory = path.join(basePath, directory);
if (await directoryOrFileExists(courseDirectory)) {
throw new Error(
`Course path "${courseDirectory}" already exists. Create course in a new folder.`
);
}
await fs.mkdir(courseDirectory, { recursive: true });
const settingsPath = path.join(courseDirectory, "settings.yml");
const { name: _, ...settingsWithoutName } = settings;
const settingsMarkdown =
localCourseYamlUtils.settingsToYaml(settingsWithoutName);
console.log(`Saving settings ${settingsPath}`);
await fs.writeFile(settingsPath, settingsMarkdown);
},
async folderIsCourse(folderPath: string) {
const settingsPath = path.join(basePath, folderPath, "settings.yml");
if (!(await directoryOrFileExists(settingsPath))) {
return false;
}
return true;
},
};

View File

@@ -0,0 +1,121 @@
import path from "path";
import fs from "fs/promises";
import { Lecture } from "@/features/local/lectures/lectureModel";
import { getDateFromStringOrThrow } from "@/models/local/utils/timeUtils";
import { getCoursePathByName } from "@/services/fileStorage/globalSettingsFileStorageService";
import {
lectureFolderName,
parseLecture,
getLectureWeekName,
lectureToString,
} from "@/services/fileStorage/utils/lectureUtils";
import { LocalCourseSettings, getDayOfWeek } from "../course/localCourseSettings";
export async function getLectures(courseName: string) {
const courseDirectory = await getCoursePathByName(courseName);
const courseLectureRoot = path.join(courseDirectory, lectureFolderName);
if (!(await directoryExists(courseLectureRoot))) {
return [];
}
const entries = await fs.readdir(courseLectureRoot, { withFileTypes: true });
const lectureWeekFolders = entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
const lecturesByWeek = await Promise.all(
lectureWeekFolders.map(async (weekName) => {
const weekBasePath = path.join(courseLectureRoot, weekName);
const fileNames = await fs.readdir(weekBasePath);
const lectures = await Promise.all(
fileNames.map(async (fileName) => {
const filePath = path.join(weekBasePath, fileName);
const fileContent = await fs.readFile(filePath, "utf-8");
const lecture = parseLecture(fileContent);
return lecture;
})
);
return {
weekName,
lectures,
};
})
);
return lecturesByWeek;
}
export async function updateLecture(
courseName: string,
courseSettings: LocalCourseSettings,
lecture: Lecture
) {
const courseDirectory = await getCoursePathByName(courseName);
const courseLectureRoot = path.join(courseDirectory, lectureFolderName);
const lectureDate = getDateFromStringOrThrow(
lecture.date,
"lecture start date in update lecture"
);
const weekFolderName = getLectureWeekName(
courseSettings.startDate,
lecture.date
);
const weekPath = path.join(courseLectureRoot, weekFolderName);
if (!(await directoryExists(weekPath))) {
await fs.mkdir(weekPath, { recursive: true });
}
const lecturePath = path.join(
weekPath,
`${lectureDate.getDay()}-${getDayOfWeek(lectureDate)}.md`
);
const lectureContents = lectureToString(lecture);
await fs.writeFile(lecturePath, lectureContents);
}
export async function deleteLecture(
courseName: string,
courseSettings: LocalCourseSettings,
dayAsString: string
) {
console.log("deleting lecture", courseName, dayAsString);
const lectureDate = getDateFromStringOrThrow(
dayAsString,
"lecture start date in update lecture"
);
const weekFolderName = getLectureWeekName(
courseSettings.startDate,
dayAsString
);
const courseDirectory = await getCoursePathByName(courseName);
const courseLectureRoot = path.join(courseDirectory, lectureFolderName);
const weekPath = path.join(courseLectureRoot, weekFolderName);
const lecturePath = path.join(
weekPath,
`${lectureDate.getDay()}-${getDayOfWeek(lectureDate)}.md`
);
try {
await fs.access(lecturePath); // throws error if no file
await fs.unlink(lecturePath);
console.log(`File deleted: ${lecturePath}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
if (error?.code === "ENOENT") {
console.log(`Cannot delete lecture, file does not exist: ${lecturePath}`);
} else {
throw error;
}
}
}
const directoryExists = async (path: string): Promise<boolean> => {
try {
const stat = await fs.stat(path);
return stat.isDirectory();
} catch {
return false;
}
};

View File

@@ -0,0 +1,43 @@
import { useCourseContext } from "@/app/course/[courseName]/context/courseContext";
import { useTRPC } from "@/services/serverFunctions/trpcClient";
import {
useSuspenseQuery,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
export const useLecturesSuspenseQuery = () => {
const { courseName } = useCourseContext();
const trpc = useTRPC();
return useSuspenseQuery(
trpc.lectures.getLectures.queryOptions({ courseName })
);
};
export const useLectureUpdateMutation = () => {
const trpc = useTRPC();
const queryClient = useQueryClient();
return useMutation(
trpc.lectures.updateLecture.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: trpc.lectures.getLectures.queryKey(),
});
},
})
);
};
export const useDeleteLectureMutation = () => {
const trpc = useTRPC();
const queryClient = useQueryClient();
return useMutation(
trpc.lectures.deleteLecture.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: trpc.lectures.getLectures.queryKey(),
});
},
})
);
};

View File

@@ -0,0 +1,13 @@
import { z } from "zod";
export interface Lecture {
name: string
date: string
content: string
}
export const zodLecture = z.object({
name: z.string(),
date: z.string(),
content: z.string(),
});

View File

@@ -0,0 +1,51 @@
import { z } from "zod";
import publicProcedure from "../../../services/serverFunctions/procedures/public";
import { router } from "../../../services/serverFunctions/trpcSetup";
import { zodLecture } from "@/features/local/lectures/lectureModel";
import {
getLectures,
updateLecture,
deleteLecture,
} from "./lectureFileStorageService";
import { zodLocalCourseSettings } from "../course/localCourseSettings";
export const lectureRouter = router({
getLectures: publicProcedure
.input(
z.object({
courseName: z.string(),
})
)
.query(async ({ input: { courseName } }) => {
return await getLectures(courseName);
}),
updateLecture: publicProcedure
.input(
z.object({
courseName: z.string(),
lecture: zodLecture,
previousDay: z.string().optional(),
settings: zodLocalCourseSettings,
})
)
.mutation(
async ({ input: { courseName, settings, lecture, previousDay } }) => {
await updateLecture(courseName, settings, lecture);
if (previousDay && previousDay !== lecture.date) {
await deleteLecture(courseName, settings, previousDay);
}
}
),
deleteLecture: publicProcedure
.input(
z.object({
courseName: z.string(),
lectureDay: z.string(),
settings: zodLocalCourseSettings,
})
)
.mutation(async ({ input: { courseName, settings, lectureDay } }) => {
await deleteLecture(courseName, settings, lectureDay);
}),
});

View File

@@ -0,0 +1,4 @@
export interface IModuleItem {
name: string;
dueAt: string;
}

View File

@@ -0,0 +1,173 @@
import { useCourseContext } from "@/app/course/[courseName]/context/courseContext";
import { useTRPC } from "@/services/serverFunctions/trpcClient";
import { CalendarItemsInterface } from "@/app/course/[courseName]/context/calendarItemsContext";
import {
getDateOnlyMarkdownString,
getDateFromStringOrThrow,
} from "@/models/local/utils/timeUtils";
import {
useSuspenseQuery,
useMutation,
useQueryClient,
useSuspenseQueries,
} from "@tanstack/react-query";
export const useModuleNamesQuery = () => {
const { courseName } = useCourseContext();
const trpc = useTRPC();
return useSuspenseQuery(
trpc.module.getModuleNames.queryOptions({ courseName })
);
};
export const useCreateModuleMutation = () => {
const { courseName } = useCourseContext();
const trpc = useTRPC();
const queryClient = useQueryClient();
return useMutation(
trpc.module.createModule.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: trpc.module.getModuleNames.queryKey({ courseName }),
});
},
})
);
};
export const useCourseQuizzesByModuleByDateQuery = () => {
const { courseName } = useCourseContext();
const { data: moduleNames } = useModuleNamesQuery();
const trpc = useTRPC();
const quizzesResults = useSuspenseQueries({
queries: moduleNames.map((moduleName: string) =>
trpc.quiz.getAllQuizzes.queryOptions({ courseName, moduleName })
),
});
const quizzes = quizzesResults.map((result) => result.data ?? []);
const quizzesAndModules = moduleNames.flatMap(
(moduleName: string, index: number) => {
return quizzes[index].map((quiz) => ({ moduleName, quiz }));
}
);
const quizzesByModuleByDate = quizzesAndModules.reduce(
(previous, { quiz, moduleName }) => {
const dueDay = getDateOnlyMarkdownString(
getDateFromStringOrThrow(quiz.dueAt, "due at for quiz in items context")
);
const previousModules = previous[dueDay] ?? {};
const previousModule = previousModules[moduleName] ?? {
quizzes: [],
};
const updatedModule = {
...previousModule,
quizzes: [...previousModule.quizzes, quiz],
};
return {
...previous,
[dueDay]: {
...previousModules,
[moduleName]: updatedModule,
},
};
},
{} as CalendarItemsInterface
);
return quizzesByModuleByDate;
};
export const useCoursePagesByModuleByDateQuery = () => {
const { courseName } = useCourseContext();
const { data: moduleNames } = useModuleNamesQuery();
const trpc = useTRPC();
const pagesResults = useSuspenseQueries({
queries: moduleNames.map((moduleName: string) =>
trpc.page.getAllPages.queryOptions({ courseName, moduleName })
),
});
const pages = pagesResults.map((result) => result.data ?? []);
const pagesAndModules = moduleNames.flatMap(
(moduleName: string, index: number) => {
return pages[index].map((page) => ({ moduleName, page }));
}
);
const pagesByModuleByDate = pagesAndModules.reduce(
(
previous,
{ page, moduleName }
) => {
const dueDay = getDateOnlyMarkdownString(
getDateFromStringOrThrow(page.dueAt, "due at for page in items context")
);
const previousModules = previous[dueDay] ?? {};
const previousModule = previousModules[moduleName] ?? {
pages: [],
};
const updatedModule = {
...previousModule,
pages: [...previousModule.pages, page],
};
return {
...previous,
[dueDay]: {
...previousModules,
[moduleName]: updatedModule,
},
};
},
{} as CalendarItemsInterface
);
return pagesByModuleByDate;
};
export const useCourseAssignmentsByModuleByDateQuery = () => {
const { courseName } = useCourseContext();
const { data: moduleNames } = useModuleNamesQuery();
const trpc = useTRPC();
const assignmentsResults = useSuspenseQueries({
queries: moduleNames.map((moduleName: string) =>
trpc.assignment.getAllAssignments.queryOptions({ courseName, moduleName })
),
});
const assignments = assignmentsResults.map(
(result) => result.data
);
const assignmentsAndModules = moduleNames.flatMap(
(moduleName: string, index: number) => {
return assignments[index].map((assignment) => ({
moduleName,
assignment,
}));
}
);
const assignmentsByModuleByDate = assignmentsAndModules.reduce(
(
previous,
{ assignment, moduleName }
) => {
const dueDay = getDateOnlyMarkdownString(
getDateFromStringOrThrow(
assignment.dueAt,
"due at for assignment in items context"
)
);
const previousModules = previous[dueDay] ?? {};
const previousModule = previousModules[moduleName] ?? {
assignments: [],
};
const updatedModule = {
...previousModule,
assignments: [...previousModule.assignments, assignment],
};
return {
...previous,
[dueDay]: {
...previousModules,
[moduleName]: updatedModule,
},
};
},
{} as CalendarItemsInterface
);
return assignmentsByModuleByDate;
};

View File

@@ -0,0 +1,75 @@
import { LocalCoursePage } from "@/features/local/pages/localCoursePageModels";
import { LocalAssignment } from "../assignments/models/localAssignment";
import { IModuleItem } from "./IModuleItem";
import { getDateFromString } from "../../../models/local/utils/timeUtils";
import { LocalQuiz } from "@/features/local/quizzes/models/localQuiz";
export interface LocalModule {
name: string;
assignments: LocalAssignment[];
quizzes: LocalQuiz[];
pages: LocalCoursePage[];
}
export const LocalModuleUtils = {
getSortedModuleItems(module: LocalModule): IModuleItem[] {
return [...module.assignments, ...module.quizzes, ...module.pages].sort(
(a, b) =>
(getDateFromString(a.dueAt)?.getTime() ?? 0) -
(getDateFromString(b.dueAt)?.getTime() ?? 0)
);
},
equals(module1: LocalModule, module2: LocalModule): boolean {
return (
module1.name.toLowerCase() === module2.name.toLowerCase() &&
LocalModuleUtils.compareCollections(
module1.assignments.sort((a, b) => a.name.localeCompare(b.name)),
module2.assignments.sort((a, b) => a.name.localeCompare(b.name))
) &&
LocalModuleUtils.compareCollections(
module1.quizzes.sort((a, b) => a.name.localeCompare(b.name)),
module2.quizzes.sort((a, b) => a.name.localeCompare(b.name))
) &&
LocalModuleUtils.compareCollections(
module1.pages.sort((a, b) => a.name.localeCompare(b.name)),
module2.pages.sort((a, b) => a.name.localeCompare(b.name))
)
);
},
compareCollections<T>(first: T[], second: T[]): boolean {
if (first.length !== second.length) return false;
for (let i = 0; i < first.length; i++) {
if (JSON.stringify(first[i]) !== JSON.stringify(second[i])) return false;
}
return true;
},
getHashCode(module: LocalModule): number {
const hash = new Map<string, number>();
hash.set(module.name.toLowerCase(), 1);
LocalModuleUtils.addRangeToHash(
hash,
module.assignments.sort((a, b) => a.name.localeCompare(b.name))
);
LocalModuleUtils.addRangeToHash(
hash,
module.quizzes.sort((a, b) => a.name.localeCompare(b.name))
);
LocalModuleUtils.addRangeToHash(
hash,
module.pages.sort((a, b) => a.name.localeCompare(b.name))
);
return Array.from(hash.values()).reduce((acc, val) => acc + val, 0);
},
addRangeToHash<T>(hash: Map<string, number>, items: T[]): void {
for (const item of items) {
hash.set(JSON.stringify(item), 1);
}
},
};

View File

@@ -0,0 +1,27 @@
import { promises as fs } from "fs";
import { lectureFolderName } from "../../../services/fileStorage/utils/lectureUtils";
import { getCoursePathByName } from "../../../services/fileStorage/globalSettingsFileStorageService";
export const moduleFileStorageService = {
async getModuleNames(courseName: string) {
const courseDirectory = await getCoursePathByName(courseName);
const moduleDirectories = await fs.readdir(courseDirectory, {
withFileTypes: true,
});
const modulePromises = moduleDirectories
.filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name);
const modules = await Promise.all(modulePromises);
const modulesWithoutLectures = modules.filter(
(m) => m !== lectureFolderName
);
return modulesWithoutLectures.sort((a, b) => a.localeCompare(b));
},
async createModule(courseName: string, moduleName: string) {
const courseDirectory = await getCoursePathByName(courseName);
await fs.mkdir(courseDirectory + "/" + moduleName, { recursive: true });
},
};

View File

@@ -0,0 +1,26 @@
import { z } from "zod";
import { fileStorageService } from "@/services/fileStorage/fileStorageService";
import { router } from "@/services/serverFunctions/trpcSetup";
import publicProcedure from "@/services/serverFunctions/procedures/public";
export const moduleRouter = router({
getModuleNames: publicProcedure
.input(
z.object({
courseName: z.string(),
})
)
.query(async ({ input: { courseName } }) => {
return await fileStorageService.modules.getModuleNames(courseName);
}),
createModule: publicProcedure
.input(
z.object({
courseName: z.string(),
moduleName: z.string(),
})
)
.mutation(async ({ input: { courseName, moduleName } }) => {
await fileStorageService.modules.createModule(courseName, moduleName);
}),
});

View File

@@ -1,4 +1,4 @@
import { IModuleItem } from "@/models/local/IModuleItem";
import { IModuleItem } from "@/features/local/modules/IModuleItem";
import { verifyDateOrThrow } from "@/models/local/utils/timeUtils";
import { z } from "zod";
import { extractLabelValue } from "../assignments/models/utils/markdownUtils";

View File

@@ -1,6 +1,6 @@
import { promises as fs } from "fs";
import path from "path";
import { courseItemFileStorageService } from "../../../services/fileStorage/courseItemFileStorageService";
import { courseItemFileStorageService } from "../course/courseItemFileStorageService";
import { getCoursePathByName } from "../../../services/fileStorage/globalSettingsFileStorageService";
import {
LocalCoursePage,

View File

@@ -1,7 +1,7 @@
import { z } from "zod";
import { LocalQuizQuestion, zodLocalQuizQuestion } from "./localQuizQuestion";
import { quizMarkdownUtils } from "./utils/quizMarkdownUtils";
import { IModuleItem } from "@/models/local/IModuleItem";
import { IModuleItem } from "@/features/local/modules/IModuleItem";
export interface LocalQuiz extends IModuleItem {
name: string;

View File

@@ -0,0 +1,63 @@
import path from "path";
import { promises as fs } from "fs";
import { courseItemFileStorageService } from "../course/courseItemFileStorageService";
import { getCoursePathByName } from "../../../services/fileStorage/globalSettingsFileStorageService";
import { LocalQuiz } from "@/features/local/quizzes/models/localQuiz";
import { quizMarkdownUtils } from "@/features/local/quizzes/models/utils/quizMarkdownUtils";
export const quizFileStorageService = {
getQuiz: async (courseName: string, moduleName: string, quizName: string) =>
await courseItemFileStorageService.getItem(
courseName,
moduleName,
quizName,
"Quiz"
),
getQuizzes: async (courseName: string, moduleName: string) =>
await courseItemFileStorageService.getItems(courseName, moduleName, "Quiz"),
async updateQuiz({
courseName,
moduleName,
quizName,
quiz,
}: {
courseName: string;
moduleName: string;
quizName: string;
quiz: LocalQuiz;
}) {
const courseDirectory = await getCoursePathByName(courseName);
const folder = path.join(courseDirectory, moduleName, "quizzes");
await fs.mkdir(folder, { recursive: true });
const filePath = path.join(
courseDirectory,
moduleName,
"quizzes",
quizName + ".md"
);
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz);
console.log(`Saving quiz ${filePath}`);
await fs.writeFile(filePath, quizMarkdown);
},
async delete({
courseName,
moduleName,
quizName,
}: {
courseName: string;
moduleName: string;
quizName: string;
}) {
const courseDirectory = await getCoursePathByName(courseName);
const filePath = path.join(
courseDirectory,
moduleName,
"quizzes",
quizName + ".md"
);
console.log("removing quiz", filePath);
await fs.unlink(filePath);
},
};

View File

@@ -0,0 +1,107 @@
"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 useQuizQuery = (moduleName: string, quizName: string) => {
const { courseName } = useCourseContext();
const trpc = useTRPC();
return useSuspenseQuery(
trpc.quiz.getQuiz.queryOptions({
courseName,
moduleName,
quizName,
})
);
};
export const useQuizzesQueries = (moduleName: string) => {
const { courseName } = useCourseContext();
const trpc = useTRPC();
return useSuspenseQuery(
trpc.quiz.getAllQuizzes.queryOptions({
courseName,
moduleName,
})
);
};
export const useUpdateQuizMutation = () => {
const trpc = useTRPC();
const queryClient = useQueryClient();
return useMutation(
trpc.quiz.updateQuiz.mutationOptions({
onSuccess: (
_,
{ courseName, moduleName, quizName, previousModuleName }
) => {
if (moduleName !== previousModuleName) {
queryClient.invalidateQueries({
queryKey: trpc.quiz.getAllQuizzes.queryKey({
courseName,
moduleName: previousModuleName,
}),
});
}
queryClient.invalidateQueries({
queryKey: trpc.quiz.getAllQuizzes.queryKey({
courseName,
moduleName,
}),
});
queryClient.invalidateQueries({
queryKey: trpc.quiz.getQuiz.queryKey({
courseName,
moduleName,
quizName,
}),
});
},
})
);
};
export const useCreateQuizMutation = () => {
const trpc = useTRPC();
const queryClient = useQueryClient();
return useMutation(
trpc.quiz.createQuiz.mutationOptions({
onSuccess: (_, { courseName, moduleName }) => {
queryClient.invalidateQueries({
queryKey: trpc.quiz.getAllQuizzes.queryKey({
courseName,
moduleName,
}),
});
},
})
);
};
export const useDeleteQuizMutation = () => {
const trpc = useTRPC();
const queryClient = useQueryClient();
return useMutation(
trpc.quiz.deleteQuiz.mutationOptions({
onSuccess: (_, { courseName, moduleName, quizName }) => {
queryClient.invalidateQueries({
queryKey: trpc.quiz.getAllQuizzes.queryKey({
courseName,
moduleName,
}),
});
queryClient.invalidateQueries({
queryKey: trpc.quiz.getQuiz.queryKey({
courseName,
moduleName,
quizName,
}),
});
},
})
);
};

View File

@@ -0,0 +1,110 @@
import publicProcedure from "../../../services/serverFunctions/procedures/public";
import { z } from "zod";
import { router } from "../../../services/serverFunctions/trpcSetup";
import { fileStorageService } from "@/services/fileStorage/fileStorageService";
import { zodLocalQuiz } from "@/features/local/quizzes/models/localQuiz";
export const quizRouter = router({
getQuiz: publicProcedure
.input(
z.object({
courseName: z.string(),
moduleName: z.string(),
quizName: z.string(),
})
)
.query(async ({ input: { courseName, moduleName, quizName } }) => {
return await fileStorageService.quizzes.getQuiz(
courseName,
moduleName,
quizName
);
}),
getAllQuizzes: publicProcedure
.input(
z.object({
courseName: z.string(),
moduleName: z.string(),
})
)
.query(async ({ input: { courseName, moduleName } }) => {
return await fileStorageService.quizzes.getQuizzes(
courseName,
moduleName
);
}),
createQuiz: publicProcedure
.input(
z.object({
courseName: z.string(),
moduleName: z.string(),
quizName: z.string(),
quiz: zodLocalQuiz,
})
)
.mutation(async ({ input: { courseName, moduleName, quizName, quiz } }) => {
await fileStorageService.quizzes.updateQuiz({
courseName,
moduleName,
quizName,
quiz,
});
}),
updateQuiz: publicProcedure
.input(
z.object({
courseName: z.string(),
moduleName: z.string(),
previousModuleName: z.string(),
previousQuizName: z.string(),
quizName: z.string(),
quiz: zodLocalQuiz,
})
)
.mutation(
async ({
input: {
courseName,
moduleName,
quizName,
quiz,
previousModuleName,
previousQuizName,
},
}) => {
await fileStorageService.quizzes.updateQuiz({
courseName,
moduleName,
quizName,
quiz,
});
if (
quizName !== previousQuizName ||
moduleName !== previousModuleName
) {
await fileStorageService.quizzes.delete({
courseName,
moduleName: previousModuleName,
quizName: previousQuizName,
});
}
}
),
deleteQuiz: publicProcedure
.input(
z.object({
courseName: z.string(),
moduleName: z.string(),
quizName: z.string(),
})
)
.mutation(async ({ input: { courseName, moduleName, quizName } }) => {
await fileStorageService.quizzes.delete({
courseName,
moduleName,
quizName,
});
}),
});