mirror of
https://github.com/alexmickelson/canvasManagement.git
synced 2026-03-26 07:38:33 -06:00
more refactoring by feature
This commit is contained in:
@@ -14,7 +14,7 @@ import {
|
||||
import {
|
||||
useLocalCourseSettingsQuery,
|
||||
useUpdateLocalCourseSettingsMutation,
|
||||
} from "@/hooks/localCourse/localCoursesHooks";
|
||||
} from "@/features/local/course/localCoursesHooks";
|
||||
|
||||
export const useAssignmentQuery = (
|
||||
moduleName: string,
|
||||
|
||||
100
src/features/local/assignments/assignmentsFileStorageService.ts
Normal file
100
src/features/local/assignments/assignmentsFileStorageService.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IModuleItem } from "../../../../models/local/IModuleItem";
|
||||
import { IModuleItem } from "../../modules/IModuleItem";
|
||||
import {
|
||||
AssignmentSubmissionType,
|
||||
zodAssignmentSubmissionType,
|
||||
|
||||
134
src/features/local/course/courseItemFileStorageService.ts
Normal file
134
src/features/local/course/courseItemFileStorageService.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
17
src/features/local/course/courseItemTypes.ts
Normal file
17
src/features/local/course/courseItemTypes.ts
Normal 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;
|
||||
120
src/features/local/course/localCourseSettings.ts
Normal file
120
src/features/local/course/localCourseSettings.ts
Normal 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;
|
||||
}
|
||||
60
src/features/local/course/localCoursesHooks.ts
Normal file
60
src/features/local/course/localCoursesHooks.ts
Normal 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 }),
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
119
src/features/local/course/settingsFileStorageService.ts
Normal file
119
src/features/local/course/settingsFileStorageService.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
121
src/features/local/lectures/lectureFileStorageService.ts
Normal file
121
src/features/local/lectures/lectureFileStorageService.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
43
src/features/local/lectures/lectureHooks.ts
Normal file
43
src/features/local/lectures/lectureHooks.ts
Normal 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(),
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
13
src/features/local/lectures/lectureModel.ts
Normal file
13
src/features/local/lectures/lectureModel.ts
Normal 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(),
|
||||
});
|
||||
51
src/features/local/lectures/lectureRouter.ts
Normal file
51
src/features/local/lectures/lectureRouter.ts
Normal 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);
|
||||
}),
|
||||
});
|
||||
4
src/features/local/modules/IModuleItem.ts
Normal file
4
src/features/local/modules/IModuleItem.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface IModuleItem {
|
||||
name: string;
|
||||
dueAt: string;
|
||||
}
|
||||
173
src/features/local/modules/localCourseModuleHooks.ts
Normal file
173
src/features/local/modules/localCourseModuleHooks.ts
Normal 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;
|
||||
};
|
||||
75
src/features/local/modules/localModules.ts
Normal file
75
src/features/local/modules/localModules.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
27
src/features/local/modules/moduleFileStorageService.ts
Normal file
27
src/features/local/modules/moduleFileStorageService.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
26
src/features/local/modules/moduleRouter.ts
Normal file
26
src/features/local/modules/moduleRouter.ts
Normal 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);
|
||||
}),
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
63
src/features/local/quizzes/quizFileStorageService.ts
Normal file
63
src/features/local/quizzes/quizFileStorageService.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
107
src/features/local/quizzes/quizHooks.ts
Normal file
107
src/features/local/quizzes/quizHooks.ts
Normal 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,
|
||||
}),
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
110
src/features/local/quizzes/quizRouter.ts
Normal file
110
src/features/local/quizzes/quizRouter.ts
Normal 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,
|
||||
});
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user