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

@@ -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[];
}
);
}