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

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