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:
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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user