more refactor

This commit is contained in:
2025-07-23 09:54:11 -06:00
parent 3e371247d6
commit 1885431574
69 changed files with 158 additions and 142 deletions

View File

@@ -1,7 +1,7 @@
import publicProcedure from "../../../services/serverFunctions/procedures/public";
import publicProcedure from "../../../services/serverFunctions/publicProcedure";
import { z } from "zod";
import { router } from "../../../services/serverFunctions/trpcSetup";
import { fileStorageService } from "@/services/fileStorage/fileStorageService";
import { fileStorageService } from "@/features/local/utils/fileStorageService";
import { zodLocalAssignment } from "@/features/local/assignments/models/localAssignment";
export const assignmentRouter = router({

View File

@@ -6,7 +6,7 @@ import { assignmentMarkdownSerializer } from "@/features/local/assignments/model
import path from "path";
import { promises as fs } from "fs";
import { courseItemFileStorageService } from "@/features/local/course/courseItemFileStorageService";
import { getCoursePathByName } from "@/services/fileStorage/globalSettingsFileStorageService";
import { getCoursePathByName } from "@/features/local/globalSettings/globalSettingsFileStorageService";
import { directoryOrFileExists } from "@/services/fileStorage/utils/fileSystemUtils";
const getAssignmentNames = async (courseName: string, moduleName: string) => {

View File

@@ -1,7 +1,7 @@
import {
verifyDateOrThrow,
verifyDateStringOrUndefined,
} from "../../../../../models/local/utils/timeUtils";
} from "../../../utils/timeUtils";
import { AssignmentSubmissionType } from "../assignmentSubmissionType";
import { LocalAssignment } from "../localAssignment";
import { RubricItem } from "../rubricItem";

View File

@@ -11,7 +11,7 @@ import {
CourseItemType,
typeToFolder,
} from "@/features/local/course/courseItemTypes";
import { getCoursePathByName } from "../../../services/fileStorage/globalSettingsFileStorageService";
import { getCoursePathByName } from "../globalSettings/globalSettingsFileStorageService";
import {
localPageMarkdownUtils,
LocalCoursePage,

View File

@@ -8,12 +8,12 @@ import { AssignmentSubmissionType } from "@/features/local/assignments/models/as
import {
getCoursePathByName,
getGlobalSettings,
} from "../../../services/fileStorage/globalSettingsFileStorageService";
import { GlobalSettingsCourse } from "@/models/local/globalSettings";
} from "../globalSettings/globalSettingsFileStorageService";
import {
LocalCourseSettings,
localCourseYamlUtils,
} from "@/features/local/course/localCourseSettings";
import { GlobalSettingsCourse } from "../globalSettings/globalSettingsModels";
const getCourseSettings = async (
course: GlobalSettingsCourse

View File

@@ -0,0 +1,173 @@
import publicProcedure from "../../../services/serverFunctions/publicProcedure";
import { z } from "zod";
import { router } from "../../../services/serverFunctions/trpcSetup";
import { fileStorageService } from "@/features/local/utils/fileStorageService";
import {
prepAssignmentForNewSemester,
prepLectureForNewSemester,
prepPageForNewSemester,
prepQuizForNewSemester,
} from "@/features/local/utils/semesterTransferUtils";
import {
getGlobalSettings,
updateGlobalSettings,
} from "@/features/local/globalSettings/globalSettingsFileStorageService";
import {
getLectures,
updateLecture,
} from "@/features/local/lectures/lectureFileStorageService";
import { zodLocalCourseSettings } from "@/features/local/course/localCourseSettings";
export const settingsRouter = router({
allCoursesSettings: publicProcedure.query(async () => {
return await fileStorageService.settings.getAllCoursesSettings();
}),
courseSettings: publicProcedure
.input(
z.object({
courseName: z.string(),
})
)
.query(async ({ input: { courseName } }) => {
const settingsList =
await fileStorageService.settings.getAllCoursesSettings();
const s = settingsList.find((s) => s.name === courseName);
if (!s) {
console.log(courseName, settingsList);
throw Error("Could not find settings for course " + courseName);
}
return s;
}),
createCourse: publicProcedure
.input(
z.object({
name: z.string(),
directory: z.string(),
settings: zodLocalCourseSettings,
settingsFromCourseToImport: zodLocalCourseSettings.optional(),
})
)
.mutation(
async ({
input: { settings, settingsFromCourseToImport, name, directory },
}) => {
console.log("creating in directory", directory);
await fileStorageService.settings.createCourseSettings(
settings,
directory
);
const globalSettings = await getGlobalSettings();
await updateGlobalSettings({
...globalSettings,
courses: [
...globalSettings.courses,
{
name,
path: directory,
},
],
});
if (settingsFromCourseToImport) {
const oldCourseName = settingsFromCourseToImport.name;
const newCourseName = settings.name;
const oldModules = await fileStorageService.modules.getModuleNames(
oldCourseName
);
await Promise.all(
oldModules.map(async (moduleName) => {
await fileStorageService.modules.createModule(
newCourseName,
moduleName
);
const [oldAssignments, oldQuizzes, oldPages, oldLecturesByWeek] =
await Promise.all([
fileStorageService.assignments.getAssignments(
oldCourseName,
moduleName
),
await fileStorageService.quizzes.getQuizzes(
oldCourseName,
moduleName
),
await fileStorageService.pages.getPages(
oldCourseName,
moduleName
),
await getLectures(oldCourseName),
]);
await Promise.all([
...oldAssignments.map(async (oldAssignment) => {
const newAssignment = prepAssignmentForNewSemester(
oldAssignment,
settingsFromCourseToImport.startDate,
settings.startDate
);
await fileStorageService.assignments.updateOrCreateAssignment(
{
courseName: newCourseName,
moduleName,
assignmentName: newAssignment.name,
assignment: newAssignment,
}
);
}),
...oldQuizzes.map(async (oldQuiz) => {
const newQuiz = prepQuizForNewSemester(
oldQuiz,
settingsFromCourseToImport.startDate,
settings.startDate
);
await fileStorageService.quizzes.updateQuiz({
courseName: newCourseName,
moduleName,
quizName: newQuiz.name,
quiz: newQuiz,
});
}),
...oldPages.map(async (oldPage) => {
const newPage = prepPageForNewSemester(
oldPage,
settingsFromCourseToImport.startDate,
settings.startDate
);
await fileStorageService.pages.updatePage({
courseName: newCourseName,
moduleName,
pageName: newPage.name,
page: newPage,
});
}),
...oldLecturesByWeek.flatMap(async (oldLectureByWeek) =>
oldLectureByWeek.lectures.map(async (oldLecture) => {
const newLecture = prepLectureForNewSemester(
oldLecture,
settingsFromCourseToImport.startDate,
settings.startDate
);
await updateLecture(newCourseName, settings, newLecture);
})
),
]);
})
);
}
}
),
updateSettings: publicProcedure
.input(
z.object({
settings: zodLocalCourseSettings,
})
)
.mutation(async ({ input: { settings } }) => {
await fileStorageService.settings.updateCourseSettings(
settings.name,
settings
);
}),
});

View File

@@ -0,0 +1,50 @@
import { promises as fs } from "fs";
import path from "path";
import { basePath } from "../../../services/fileStorage/utils/fileSystemUtils";
import {
GlobalSettings,
zodGlobalSettings,
} from "@/features/local/globalSettings/globalSettingsModels";
import {
parseGlobalSettingsYaml,
globalSettingsToYaml,
} from "@/features/local/globalSettings/globalSettingsUtils";
const SETTINGS_FILE_PATH =
process.env.SETTINGS_FILE_PATH || "./globalSettings.yml";
export const getGlobalSettings = async (): Promise<GlobalSettings> => {
try {
await fs.access(SETTINGS_FILE_PATH);
} catch (err) {
console.log(err);
throw new Error(
`Global Settings file does not exist at path: ${SETTINGS_FILE_PATH}`
);
}
const globalSettingsString = process.env.GLOBAL_SETTINGS
? process.env.GLOBAL_SETTINGS
: await fs.readFile(SETTINGS_FILE_PATH, "utf-8");
const globalSettings = parseGlobalSettingsYaml(globalSettingsString);
return globalSettings;
};
export const getCoursePathByName = async (courseName: string) => {
const globalSettings = await getGlobalSettings();
const course = globalSettings.courses.find((c) => c.name === courseName);
if (!course) {
throw new Error(
`Course with name ${courseName} not found in global settings`
);
}
return path.join(basePath, course.path);
};
export const updateGlobalSettings = async (globalSettings: GlobalSettings) => {
const globalSettingsString = globalSettingsToYaml(
zodGlobalSettings.parse(globalSettings)
);
await fs.writeFile(SETTINGS_FILE_PATH, globalSettingsString, "utf-8");
};

View File

@@ -0,0 +1,28 @@
import { useTRPC } from "@/services/serverFunctions/trpcClient";
import {
useMutation,
useQueryClient,
useSuspenseQuery,
} from "@tanstack/react-query";
export const useGlobalSettingsQuery = () => {
const trpc = useTRPC();
return useSuspenseQuery(trpc.globalSettings.getGlobalSettings.queryOptions());
};
export const useUpdateGlobalSettingsMutation = () => {
const trpc = useTRPC();
const queryClient = useQueryClient();
return useMutation(
trpc.globalSettings.updateGlobalSettings.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: trpc.globalSettings.getGlobalSettings.queryKey(),
});
queryClient.invalidateQueries({
queryKey: trpc.settings.allCoursesSettings.queryKey(),
});
},
})
);
};

View File

@@ -0,0 +1,14 @@
import z from "zod";
export const zodGlobalSettingsCourse = z.object({
path: z.string(),
name: z.string(),
});
export const zodGlobalSettings = z.object({
courses: z.array(zodGlobalSettingsCourse),
});
export type GlobalSettings = z.infer<typeof zodGlobalSettings>;
export type GlobalSettingsCourse = z.infer<typeof zodGlobalSettingsCourse>;

View File

@@ -0,0 +1,23 @@
import { router } from "../../../services/serverFunctions/trpcSetup";
import z from "zod";
import publicProcedure from "../../../services/serverFunctions/publicProcedure";
import {
getGlobalSettings,
updateGlobalSettings,
} from "@/features/local/globalSettings/globalSettingsFileStorageService";
import { zodGlobalSettings } from "./globalSettingsModels";
export const globalSettingsRouter = router({
getGlobalSettings: publicProcedure.query(async () => {
return await getGlobalSettings();
}),
updateGlobalSettings: publicProcedure
.input(
z.object({
globalSettings: zodGlobalSettings,
})
)
.mutation(async ({ input: { globalSettings } }) => {
return await updateGlobalSettings(globalSettings);
}),
});

View File

@@ -0,0 +1,16 @@
import { GlobalSettings, zodGlobalSettings } from "./globalSettingsModels";
import { parse, stringify } from "yaml";
export const globalSettingsToYaml = (settings: GlobalSettings) => {
return stringify(settings);
};
export const parseGlobalSettingsYaml = (yaml: string): GlobalSettings => {
const parsed = parse(yaml);
try {
return zodGlobalSettings.parse(parsed);
} catch (e) {
console.error("Error parsing global settings YAML:", e);
throw new Error(`Error parsing global settings, got ${yaml}, ${e}`);
}
};

View File

@@ -1,15 +1,18 @@
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 { getDateFromStringOrThrow } from "@/features/local/utils/timeUtils";
import { getCoursePathByName } from "@/features/local/globalSettings/globalSettingsFileStorageService";
import {
lectureFolderName,
parseLecture,
getLectureWeekName,
lectureToString,
} from "@/services/fileStorage/utils/lectureUtils";
import { LocalCourseSettings, getDayOfWeek } from "../course/localCourseSettings";
import {
LocalCourseSettings,
getDayOfWeek,
} from "../course/localCourseSettings";
export async function getLectures(courseName: string) {
const courseDirectory = await getCoursePathByName(courseName);

View File

@@ -1,5 +1,5 @@
import { z } from "zod";
import publicProcedure from "../../../services/serverFunctions/procedures/public";
import publicProcedure from "../../../services/serverFunctions/publicProcedure";
import { router } from "../../../services/serverFunctions/trpcSetup";
import { zodLecture } from "@/features/local/lectures/lectureModel";
import {

View File

@@ -4,7 +4,7 @@ import { CalendarItemsInterface } from "@/app/course/[courseName]/context/calend
import {
getDateOnlyMarkdownString,
getDateFromStringOrThrow,
} from "@/models/local/utils/timeUtils";
} from "@/features/local/utils/timeUtils";
import {
useSuspenseQuery,
useMutation,
@@ -92,10 +92,7 @@ export const useCoursePagesByModuleByDateQuery = () => {
}
);
const pagesByModuleByDate = pagesAndModules.reduce(
(
previous,
{ page, moduleName }
) => {
(previous, { page, moduleName }) => {
const dueDay = getDateOnlyMarkdownString(
getDateFromStringOrThrow(page.dueAt, "due at for page in items context")
);
@@ -129,9 +126,7 @@ export const useCourseAssignmentsByModuleByDateQuery = () => {
trpc.assignment.getAllAssignments.queryOptions({ courseName, moduleName })
),
});
const assignments = assignmentsResults.map(
(result) => result.data
);
const assignments = assignmentsResults.map((result) => result.data);
const assignmentsAndModules = moduleNames.flatMap(
(moduleName: string, index: number) => {
return assignments[index].map((assignment) => ({
@@ -141,10 +136,7 @@ export const useCourseAssignmentsByModuleByDateQuery = () => {
}
);
const assignmentsByModuleByDate = assignmentsAndModules.reduce(
(
previous,
{ assignment, moduleName }
) => {
(previous, { assignment, moduleName }) => {
const dueDay = getDateOnlyMarkdownString(
getDateFromStringOrThrow(
assignment.dueAt,

View File

@@ -1,7 +1,7 @@
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 { getDateFromString } from "../utils/timeUtils";
import { LocalQuiz } from "@/features/local/quizzes/models/localQuiz";
export interface LocalModule {

View File

@@ -1,6 +1,6 @@
import { promises as fs } from "fs";
import { lectureFolderName } from "../../../services/fileStorage/utils/lectureUtils";
import { getCoursePathByName } from "../../../services/fileStorage/globalSettingsFileStorageService";
import { getCoursePathByName } from "../globalSettings/globalSettingsFileStorageService";
export const moduleFileStorageService = {
async getModuleNames(courseName: string) {

View File

@@ -1,7 +1,7 @@
import { z } from "zod";
import { fileStorageService } from "@/services/fileStorage/fileStorageService";
import { fileStorageService } from "@/features/local/utils/fileStorageService";
import { router } from "@/services/serverFunctions/trpcSetup";
import publicProcedure from "@/services/serverFunctions/procedures/public";
import publicProcedure from "@/services/serverFunctions/publicProcedure";
export const moduleRouter = router({
getModuleNames: publicProcedure

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import publicProcedure from "../../../services/serverFunctions/procedures/public";
import publicProcedure from "../../../services/serverFunctions/publicProcedure";
import { z } from "zod";
import { router } from "../../../services/serverFunctions/trpcSetup";
import { fileStorageService } from "@/services/fileStorage/fileStorageService";
import { fileStorageService } from "@/features/local/utils/fileStorageService";
import { zodLocalCoursePage } from "@/features/local/pages/localCoursePageModels";
export const pageRouter = router({

View File

@@ -1,4 +1,7 @@
import { verifyDateOrThrow, verifyDateStringOrUndefined } from "@/models/local/utils/timeUtils";
import {
verifyDateOrThrow,
verifyDateStringOrUndefined,
} from "@/features/local/utils/timeUtils";
import { LocalQuiz } from "../localQuiz";
import { quizQuestionMarkdownUtils } from "./quizQuestionMarkdownUtils";
@@ -38,7 +41,6 @@ const parseNumberOrThrow = (value: string, label: string): number => {
return parsed;
};
const getQuizWithOnlySettings = (settings: string, name: string): LocalQuiz => {
const rawShuffleAnswers = extractLabelValue(settings, "ShuffleAnswers");
const shuffleAnswers = parseBooleanOrThrow(
rawShuffleAnswers,

View File

@@ -1,7 +1,7 @@
import path from "path";
import { promises as fs } from "fs";
import { courseItemFileStorageService } from "../course/courseItemFileStorageService";
import { getCoursePathByName } from "../../../services/fileStorage/globalSettingsFileStorageService";
import { getCoursePathByName } from "../globalSettings/globalSettingsFileStorageService";
import { LocalQuiz } from "@/features/local/quizzes/models/localQuiz";
import { quizMarkdownUtils } from "@/features/local/quizzes/models/utils/quizMarkdownUtils";

View File

@@ -1,7 +1,7 @@
import publicProcedure from "../../../services/serverFunctions/procedures/public";
import publicProcedure from "../../../services/serverFunctions/publicProcedure";
import { z } from "zod";
import { router } from "../../../services/serverFunctions/trpcSetup";
import { fileStorageService } from "@/services/fileStorage/fileStorageService";
import { fileStorageService } from "@/features/local/utils/fileStorageService";
import { zodLocalQuiz } from "@/features/local/quizzes/models/localQuiz";
export const quizRouter = router({

View File

@@ -0,0 +1,28 @@
import z from "zod";
import publicProcedure from "../../../services/serverFunctions/publicProcedure";
import { router } from "../../../services/serverFunctions/trpcSetup";
import { fileStorageService } from "@/features/local/utils/fileStorageService";
export const directoriesRouter = router({
getEmptyDirectories: publicProcedure.query(async () => {
return await fileStorageService.getEmptyDirectories();
}),
getDirectoryContents: publicProcedure
.input(
z.object({
relativePath: z.string(),
})
)
.query(async ({ input: { relativePath } }) => {
return await fileStorageService.getDirectoryContents(relativePath);
}),
directoryIsCourse: publicProcedure
.input(
z.object({
folderPath: z.string(),
})
)
.query(async ({ input: { folderPath } }) => {
return await fileStorageService.settings.folderIsCourse(folderPath);
}),
});

View File

@@ -0,0 +1,86 @@
import { promises as fs } from "fs";
import path from "path";
import {
basePath,
directoryOrFileExists,
} from "../../../services/fileStorage/utils/fileSystemUtils";
import { quizFileStorageService } from "../quizzes/quizFileStorageService";
import { pageFileStorageService } from "../pages/pageFileStorageService";
import { moduleFileStorageService } from "../modules/moduleFileStorageService";
import { settingsFileStorageService } from "../course/settingsFileStorageService";
import { getCoursePathByName } from "../globalSettings/globalSettingsFileStorageService";
import { assignmentsFileStorageService } from "@/features/local/assignments/assignmentsFileStorageService";
export const fileStorageService = {
settings: settingsFileStorageService,
modules: moduleFileStorageService,
assignments: assignmentsFileStorageService,
quizzes: quizFileStorageService,
pages: pageFileStorageService,
async getEmptyDirectories(): Promise<string[]> {
if (!(await directoryOrFileExists(basePath))) {
throw new Error(
`Cannot get empty directories, ${basePath} does not exist`
);
}
const directories = await fs.readdir(basePath, { withFileTypes: true });
const emptyDirectories = (
await Promise.all(
directories
.filter((dirent) => dirent.isDirectory())
.map((dirent) => path.join(dirent.name))
.map(async (directory) => {
return {
directory,
files: await fs.readdir(path.join(basePath, directory)),
};
})
)
)
.filter(({ files }) => files.length === 0)
.map(({ directory }) => directory);
return emptyDirectories;
},
async createCourseFolderForTesting(courseName: string) {
const courseDirectory = await getCoursePathByName(courseName);
await fs.mkdir(courseDirectory, { recursive: true });
},
async createModuleFolderForTesting(courseName: string, moduleName: string) {
const courseDirectory = path.join(basePath, courseName, moduleName);
await fs.mkdir(courseDirectory, { recursive: true });
},
async getDirectoryContents(
relativePath: string
): Promise<{ files: string[]; folders: string[] }> {
const fullPath = path.join(basePath, relativePath);
// Security: ensure fullPath is inside basePath
const resolvedBase = path.resolve(basePath);
const resolvedFull = path.resolve(fullPath);
if (!resolvedFull.startsWith(resolvedBase)) {
return { files: [], folders: [] };
}
if (!(await directoryOrFileExists(fullPath))) {
throw new Error(`Directory ${fullPath} does not exist`);
}
const contents = await fs.readdir(fullPath, { withFileTypes: true });
const files: string[] = [];
const folders: string[] = [];
for (const dirent of contents) {
if (dirent.isDirectory()) {
folders.push(dirent.name);
} else if (dirent.isFile()) {
files.push(dirent.name);
}
}
return { files, folders };
},
};

View File

@@ -0,0 +1,11 @@
import { Lecture } from "../lectures/lectureModel";
import { getDateOnlyMarkdownString } from "./timeUtils";
export function getLectureForDay(
weeks: { weekName: string; lectures: Lecture[] }[],
dayAsDate: Date
) {
return weeks
.flatMap((w) => w.lectures)
.find((l) => l.date == getDateOnlyMarkdownString(dayAsDate));
}

View File

@@ -0,0 +1,124 @@
import { LocalCoursePage } from "@/features/local/pages/localCoursePageModels";
import { LocalAssignment } from "../assignments/models/localAssignment";
import { Lecture } from "../lectures/lectureModel";
import { LocalQuiz } from "@/features/local/quizzes/models/localQuiz";
import { getDateFromStringOrThrow, dateToMarkdownString } from "./timeUtils";
export const prepAssignmentForNewSemester = (
assignment: LocalAssignment,
oldSemesterStartDate: string,
newSemesterStartDate: string
): LocalAssignment => {
const descriptionWithoutGithubClassroom = replaceClassroomUrl(
assignment.description
);
return {
...assignment,
description: descriptionWithoutGithubClassroom,
dueAt:
newDateOffset(
assignment.dueAt,
oldSemesterStartDate,
newSemesterStartDate
) ?? assignment.dueAt,
lockAt: newDateOffset(
assignment.lockAt,
oldSemesterStartDate,
newSemesterStartDate
),
githubClassroomAssignmentLink: undefined,
githubClassroomAssignmentShareLink: undefined,
};
};
export const prepQuizForNewSemester = (
quiz: LocalQuiz,
oldSemesterStartDate: string,
newSemesterStartDate: string
): LocalQuiz => {
const descriptionWithoutGithubClassroom = replaceClassroomUrl(
quiz.description
);
return {
...quiz,
description: descriptionWithoutGithubClassroom,
dueAt:
newDateOffset(quiz.dueAt, oldSemesterStartDate, newSemesterStartDate) ??
quiz.dueAt,
lockAt: newDateOffset(
quiz.lockAt,
oldSemesterStartDate,
newSemesterStartDate
),
};
};
export const prepPageForNewSemester = (
page: LocalCoursePage,
oldSemesterStartDate: string,
newSemesterStartDate: string
): LocalCoursePage => {
const updatedText = replaceClassroomUrl(page.text);
return {
...page,
text: updatedText,
dueAt:
newDateOffset(page.dueAt, oldSemesterStartDate, newSemesterStartDate) ??
page.dueAt,
};
};
export const prepLectureForNewSemester = (
lecture: Lecture,
oldSemesterStartDate: string,
newSemesterStartDate: string
): Lecture => {
const updatedText = replaceClassroomUrl(lecture.content);
const newDate = newDateOffset(
lecture.date,
oldSemesterStartDate,
newSemesterStartDate
);
const newDateOnly = newDate?.split(" ")[0];
return {
...lecture,
content: updatedText,
date: newDateOnly ?? lecture.date,
};
};
const replaceClassroomUrl = (value: string) => {
const classroomPattern =
/https:\/\/classroom\.github\.com\/[a-zA-Z0-9\/._-]+/g;
const withoutGithubClassroom = value.replace(
classroomPattern,
"insert_github_classroom_url"
);
return withoutGithubClassroom;
};
const newDateOffset = (
dateString: string | undefined,
oldSemesterStartDate: string,
newSemesterStartDate: string
) => {
if (!dateString) return dateString;
const oldStart = getDateFromStringOrThrow(
oldSemesterStartDate,
"semester start date in new semester offset"
);
const newStart = getDateFromStringOrThrow(
newSemesterStartDate,
"new semester start date in new semester offset"
);
const date = getDateFromStringOrThrow(
dateString,
"date in new semester offset"
);
const offset = date.getTime() - oldStart.getTime();
const newUnixTime = offset + newStart.getTime();
const newDate = new Date(newUnixTime);
return dateToMarkdownString(newDate);
};

View File

@@ -0,0 +1,50 @@
import {
getDateFromStringOrThrow,
getDateOnlyMarkdownString,
} from "./timeUtils";
export const parseHolidays = (
inputText: string
): {
name: string;
days: string[];
}[] => {
let holidays: {
name: string;
days: string[];
}[] = [];
const lines = inputText.split("\n").filter((line) => line.trim() !== "");
let currentHoliday: string | null = null;
lines.forEach((line) => {
if (line.includes(":")) {
const holidayName = line.split(":")[0].trim();
currentHoliday = holidayName;
holidays = [...holidays, { name: holidayName, days: [] }];
} else if (currentHoliday && line.startsWith("-")) {
const date = line.replace("-", "").trim();
const dateObject = getDateFromStringOrThrow(date, "parsing holiday text");
const holiday = holidays.find((h) => h.name == currentHoliday);
holiday?.days.push(getDateOnlyMarkdownString(dateObject));
}
});
return holidays;
};
export const holidaysToString = (
holidays: {
name: string;
days: string[];
}[]
) => {
const entries = holidays.map((holiday) => {
const title = holiday.name + ":\n";
const days = holiday.days.map((d) => `- ${d}\n`);
return title + days.join("");
});
return entries.join("");
};

View File

@@ -0,0 +1,25 @@
import { useTRPC } from "@/services/serverFunctions/trpcClient";
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
export const directoryKeys = {
emptyFolders: ["empty folders"] as const,
};
export const useEmptyDirectoriesQuery = () => {
const trpc = useTRPC();
return useSuspenseQuery(trpc.directories.getEmptyDirectories.queryOptions());
};
export const useDirectoryContentsQuery = (relativePath: string) => {
const trpc = useTRPC();
return useQuery(
trpc.directories.getDirectoryContents.queryOptions({ relativePath })
);
};
export const useDirectoryIsCourseQuery = (folderPath: string) => {
const trpc = useTRPC();
return useQuery(
trpc.directories.directoryIsCourse.queryOptions({ folderPath })
);
};

View File

@@ -0,0 +1,138 @@
import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
const _getDateFromAMPM = (
datePart: string,
timePart: string,
amPmPart: string
): Date | undefined => {
const [month, day, year] = datePart.split("/").map(Number);
const [hours, minutes, seconds] = timePart.split(":").map(Number);
let adjustedHours = hours;
if (amPmPart) {
const upperMeridian = amPmPart.toUpperCase();
if (upperMeridian === "PM" && hours < 12) {
adjustedHours += 12;
} else if (upperMeridian === "AM" && hours === 12) {
adjustedHours = 0;
}
}
const date = new Date(year, month - 1, day, adjustedHours, minutes, seconds);
return isNaN(date.getTime()) ? undefined : date;
};
const _getDateFromMilitary = (
datePart: string,
timePart: string
): Date | undefined => {
const [month, day, year] = datePart.split("/").map(Number);
const [hours, minutes, seconds] = timePart.split(":").map(Number);
const date = new Date(year, month - 1, day, hours, minutes, seconds);
return isNaN(date.getTime()) ? undefined : date;
};
const _getDateFromISO = (value: string): Date | undefined => {
const date = new Date(value);
return isNaN(date.getTime()) ? undefined : date;
};
const _getDateFromDateOnly = (datePart: string): Date | undefined => {
const [month, day, year] = datePart.split("/").map(Number);
const date = new Date(year, month - 1, day);
return isNaN(date.getTime()) ? undefined : date;
};
export const getDateFromString = (value: string): Date | undefined => {
const ampmDateRegex =
/^\d{1,2}\/\d{1,2}\/\d{4},? \d{1,2}:\d{2}:\d{2}\s{1}[APap][Mm]$/; //"M/D/YYYY h:mm:ss AM/PM" or "M/D/YYYY, h:mm:ss AM/PM"
const militaryDateRegex = /^\d{1,2}\/\d{1,2}\/\d{4} \d{1,2}:\d{2}:\d{2}$/; //"MM/DD/YYYY HH:mm:ss"
const isoDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}((.\d+)|(Z))$/; //"2024-08-26T00:00:00.0000000"
const dateOnlyRegex = /^\d{1,2}\/\d{1,2}\/\d{4}$/; // "M/D/YYYY" or "MM/DD/YYYY"
if (isoDateRegex.test(value)) {
return _getDateFromISO(value);
} else if (ampmDateRegex.test(value)) {
const [datePart, timePart, amPmPart] = value.split(/,?[\s\u202F]+/);
return _getDateFromAMPM(datePart, timePart, amPmPart);
} else if (militaryDateRegex.test(value)) {
const [datePart, timePart] = value.split(" ");
return _getDateFromMilitary(datePart, timePart);
}
if (dateOnlyRegex.test(value)) {
return _getDateFromDateOnly(value);
} else {
if (value) console.log("invalid date format", value);
return undefined;
}
};
export const getDateFromStringOrThrow = (
value: string,
labelForError: string
): Date => {
const d = getDateFromString(value);
if (!d) throw Error(`Invalid date format for ${labelForError}, ${value}`);
return d;
};
export const verifyDateStringOrUndefined = (
value: string
): string | undefined => {
const date = getDateFromString(value);
return date ? dateToMarkdownString(date) : undefined;
};
export const verifyDateOrThrow = (
value: string,
labelForError: string
): string => {
const myDate = getDateFromString(value);
if (!myDate) throw new Error(`Invalid format for ${labelForError}: ${value}`);
return dateToMarkdownString(myDate);
};
export const dateToMarkdownString = (date: Date) => {
const stringDay = String(date.getDate()).padStart(2, "0");
const stringMonth = String(date.getMonth() + 1).padStart(2, "0"); // Months are zero-based
const stringYear = date.getFullYear();
const stringHours = String(date.getHours()).padStart(2, "0");
const stringMinutes = String(date.getMinutes()).padStart(2, "0");
const stringSeconds = String(date.getSeconds()).padStart(2, "0");
return `${stringMonth}/${stringDay}/${stringYear} ${stringHours}:${stringMinutes}:${stringSeconds}`;
};
export const getDateOnlyMarkdownString = (date: Date) => {
return dateToMarkdownString(date).split(" ")[0];
};
export function getTermName(startDate: string) {
const [year, month, ..._rest] = startDate.split("-");
if (month < "04") return "Spring " + year;
if (month < "07") return "Summer " + year;
return "Fall " + year;
}
export function getDateKey(dateString: string) {
return dateString.split("T")[0];
}
export function groupByStartDate(courses: LocalCourseSettings[]): {
[key: string]: LocalCourseSettings[];
} {
return courses.reduce(
(acc, course) => {
const { startDate } = course;
const key = getDateKey(startDate);
if (!acc[key]) {
acc[key] = [];
}
acc[key].push(course);
return acc;
},
{} as {
[key: string]: LocalCourseSettings[];
}
);
}