moving to a global config

This commit is contained in:
2025-07-22 10:05:55 -06:00
parent cea6aef453
commit 01d137efcf
20 changed files with 190 additions and 99 deletions

View File

@@ -1,7 +1,7 @@
#!/bin/bash
MAJOR_VERSION="2"
MINOR_VERSION="8"
MAJOR_VERSION="3"
MINOR_VERSION="0"
VERSION="$MAJOR_VERSION.$MINOR_VERSION"
TAG_FLAG=false

View File

@@ -15,6 +15,7 @@ services:
- NEXT_PUBLIC_ENABLE_FILE_SYNC=true
- REDIS_URL=redis://redis:6379
volumes:
- ./globalSettings.yml:/app/globalSettings.yml
- .:/app
- ~/projects/faculty/1810/2025-spring-alex/in-person:/app/storage/intro_to_web_old
- ~/projects/faculty/1810/2025-fall-alex/modules:/app/storage/intro_to_web

3
globalSettings.yml Normal file
View File

@@ -0,0 +1,3 @@
courses:
- path: "./intro_to_web_old"
name: "Intro to Web (Old)"

View File

@@ -3,8 +3,6 @@ import { trpcAppRouter } from "@/services/serverFunctions/router/app";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
const handler = async (request: Request) => {
// await new Promise(r => setTimeout(r, 1000)); // delay for testing
return fetchRequestHandler({
endpoint: "/api/trpc",
req: request,

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,17 @@
import { GlobalSettings, zodGlobalSettings } from "./globalSettings";
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

@@ -0,0 +1,26 @@
import { describe, it, expect } from "vitest";
import { GlobalSettings } from "../globalSettings";
import {
globalSettingsToYaml,
parseGlobalSettingsYaml,
} from "../globalSettingsUtils";
describe("GlobalSettingsMarkdownTests", () => {
it("can parse global settings", () => {
const globalSettings: GlobalSettings = {
courses: [
{
path: "./distributed/2025-alex/modules",
name: "distributed",
},
],
};
const globalSettingsMarkdown = globalSettingsToYaml(globalSettings);
const parsedGlobalSettings = parseGlobalSettingsYaml(
globalSettingsMarkdown
);
expect(parsedGlobalSettings).toEqual(globalSettings);
});
});

View File

@@ -4,12 +4,14 @@ import {
} from "@/models/local/assignment/localAssignment";
import { assignmentMarkdownSerializer } from "@/models/local/assignment/utils/assignmentMarkdownSerializer";
import path from "path";
import { basePath, directoryOrFileExists } from "./utils/fileSystemUtils";
import { directoryOrFileExists } from "./utils/fileSystemUtils";
import { promises as fs } from "fs";
import { courseItemFileStorageService } from "./courseItemFileStorageService";
import { getCoursePathByName } from "./globalSettingsFileStorageService";
const getAssignmentNames = async (courseName: string, moduleName: string) => {
const filePath = path.join(basePath, courseName, moduleName, "assignments");
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}`
@@ -26,9 +28,9 @@ const getAssignment = async (
moduleName: string,
assignmentName: string
) => {
const courseDirectory = await getCoursePathByName(courseName);
const filePath = path.join(
basePath,
courseName,
courseDirectory,
moduleName,
"assignments",
assignmentName + ".md"
@@ -58,12 +60,12 @@ export const assignmentsFileStorageService = {
assignmentName: string;
assignment: LocalAssignment;
}) {
const folder = path.join(basePath, courseName, moduleName, "assignments");
const courseDirectory = await getCoursePathByName(courseName);
const folder = path.join(courseDirectory, moduleName, "assignments");
await fs.mkdir(folder, { recursive: true });
const filePath = path.join(
basePath,
courseName,
courseDirectory,
moduleName,
"assignments",
assignmentName + ".md"
@@ -75,7 +77,7 @@ export const assignmentsFileStorageService = {
await fs.writeFile(filePath, assignmentMarkdown);
},
async delete({
courseName,
moduleName,
@@ -85,9 +87,9 @@ export const assignmentsFileStorageService = {
moduleName: string;
assignmentName: string;
}) {
const courseDirectory = await getCoursePathByName(courseName);
const filePath = path.join(
basePath,
courseName,
courseDirectory,
moduleName,
"assignments",
assignmentName + ".md"

View File

@@ -1,5 +1,5 @@
import path from "path";
import { basePath, directoryOrFileExists } from "./utils/fileSystemUtils";
import { directoryOrFileExists } from "./utils/fileSystemUtils";
import fs from "fs/promises";
import {
LocalAssignment,
@@ -20,14 +20,16 @@ import {
CourseItemType,
typeToFolder,
} from "@/models/local/courseItemTypes";
import { getCoursePathByName } from "./globalSettingsFileStorageService";
const getItemFileNames = async (
courseName: string,
moduleName: string,
type: CourseItemType
) => {
const courseDirectory = await getCoursePathByName(courseName);
const folder = typeToFolder[type];
const filePath = path.join(basePath, courseName, moduleName, folder);
const filePath = path.join(courseDirectory, moduleName, folder);
if (!(await directoryOrFileExists(filePath))) {
console.log(
`Error loading ${type}, ${folder} folder does not exist in ${filePath}`
@@ -45,14 +47,9 @@ const getItem = async <T extends CourseItemType>(
name: string,
type: T
): Promise<CourseItemReturnType<T>> => {
const courseDirectory = await getCoursePathByName(courseName);
const folder = typeToFolder[type];
const filePath = path.join(
basePath,
courseName,
moduleName,
folder,
name + ".md"
);
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(
@@ -61,11 +58,13 @@ const getItem = async <T extends CourseItemType>(
) as CourseItemReturnType<T>;
} else if (type === "Quiz") {
return localQuizMarkdownUtils.parseMarkdown(
rawFile, name
rawFile,
name
) as CourseItemReturnType<T>;
} else if (type === "Page") {
return localPageMarkdownUtils.parseMarkdown(
rawFile, name
rawFile,
name
) as CourseItemReturnType<T>;
}
@@ -107,13 +106,13 @@ export const courseItemFileStorageService = {
item: LocalAssignment | LocalQuiz | LocalCoursePage;
type: CourseItemType;
}) {
const courseDirectory = await getCoursePathByName(courseName);
const typeFolder = typeToFolder[type];
const folder = path.join(basePath, courseName, moduleName, typeFolder);
const folder = path.join(courseDirectory, moduleName, typeFolder);
await fs.mkdir(folder, { recursive: true });
const filePath = path.join(
basePath,
courseName,
courseDirectory,
moduleName,
typeFolder,
name + ".md"

View File

@@ -6,6 +6,7 @@ import { quizFileStorageService } from "./quizFileStorageService";
import { pageFileStorageService } from "./pageFileStorageService";
import { moduleFileStorageService } from "./moduleFileStorageService";
import { settingsFileStorageService } from "./settingsFileStorageService";
import { getCoursePathByName } from "./globalSettingsFileStorageService";
export const fileStorageService = {
settings: settingsFileStorageService,
@@ -42,7 +43,7 @@ export const fileStorageService = {
},
async createCourseFolderForTesting(courseName: string) {
const courseDirectory = path.join(basePath, courseName);
const courseDirectory = await getCoursePathByName(courseName);
await fs.mkdir(courseDirectory, { recursive: true });
},

View File

@@ -0,0 +1,36 @@
import { GlobalSettings } from "@/models/local/globalSettings";
import { parseGlobalSettingsYaml } from "@/models/local/globalSettingsUtils";
import { promises as fs } from "fs";
import path from "path";
import { basePath } from "./utils/fileSystemUtils";
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) {
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);
};

View File

@@ -1,6 +1,5 @@
import path from "path";
import { basePath } from "./utils/fileSystemUtils";
import fs from "fs/promises";
import {
getLectureWeekName,
@@ -14,9 +13,11 @@ import {
LocalCourseSettings,
} from "@/models/local/localCourseSettings";
import { getDateFromStringOrThrow } from "@/models/local/utils/timeUtils";
import { getCoursePathByName } from "./globalSettingsFileStorageService";
export async function getLectures(courseName: string) {
const courseLectureRoot = path.join(basePath, courseName, lectureFolderName);
const courseDirectory = await getCoursePathByName(courseName);
const courseLectureRoot = path.join(courseDirectory, lectureFolderName);
if (!(await directoryExists(courseLectureRoot))) {
return [];
}
@@ -53,7 +54,8 @@ export async function updateLecture(
courseSettings: LocalCourseSettings,
lecture: Lecture
) {
const courseLectureRoot = path.join(basePath, courseName, lectureFolderName);
const courseDirectory = await getCoursePathByName(courseName);
const courseLectureRoot = path.join(courseDirectory, lectureFolderName);
const lectureDate = getDateFromStringOrThrow(
lecture.date,
"lecture start date in update lecture"
@@ -92,7 +94,8 @@ export async function deleteLecture(
dayAsString
);
const courseLectureRoot = path.join(basePath, courseName, lectureFolderName);
const courseDirectory = await getCoursePathByName(courseName);
const courseLectureRoot = path.join(courseDirectory, lectureFolderName);
const weekPath = path.join(courseLectureRoot, weekFolderName);
const lecturePath = path.join(
weekPath,

View File

@@ -1,11 +1,11 @@
import { promises as fs } from "fs";
import path from "path";
import { basePath } from "./utils/fileSystemUtils";
import { lectureFolderName } from "./utils/lectureUtils";
import { getCoursePathByName } from "./globalSettingsFileStorageService";
export const moduleFileStorageService = {
async getModuleNames(courseName: string) {
const courseDirectory = path.join(basePath, courseName);
const courseDirectory = await getCoursePathByName(courseName);
const moduleDirectories = await fs.readdir(courseDirectory, {
withFileTypes: true,
});
@@ -21,7 +21,7 @@ export const moduleFileStorageService = {
return modulesWithoutLectures.sort((a, b) => a.localeCompare(b));
},
async createModule(courseName: string, moduleName: string) {
const courseDirectory = path.join(basePath, courseName);
const courseDirectory = await getCoursePathByName(courseName);
await fs.mkdir(courseDirectory + "/" + moduleName, { recursive: true });
},

View File

@@ -4,8 +4,8 @@ import {
} from "@/models/local/page/localCoursePage";
import { promises as fs } from "fs";
import path from "path";
import { basePath } from "./utils/fileSystemUtils";
import { courseItemFileStorageService } from "./courseItemFileStorageService";
import { getCoursePathByName } from "./globalSettingsFileStorageService";
export const pageFileStorageService = {
getPage: async (courseName: string, moduleName: string, name: string) =>
@@ -29,12 +29,12 @@ export const pageFileStorageService = {
pageName: string;
page: LocalCoursePage;
}) {
const folder = path.join(basePath, courseName, moduleName, "pages");
const courseDirectory = await getCoursePathByName(courseName);
const folder = path.join(courseDirectory, moduleName, "pages");
await fs.mkdir(folder, { recursive: true });
const filePath = path.join(
basePath,
courseName,
courseDirectory,
moduleName,
"pages",
pageName + ".md"
@@ -53,9 +53,9 @@ export const pageFileStorageService = {
moduleName: string;
pageName: string;
}) {
const courseDirectory = await getCoursePathByName(courseName);
const filePath = path.join(
basePath,
courseName,
courseDirectory,
moduleName,
"pages",
pageName + ".md"

View File

@@ -1,9 +1,9 @@
import { LocalQuiz } from "@/models/local/quiz/localQuiz";
import { quizMarkdownUtils } from "@/models/local/quiz/utils/quizMarkdownUtils";
import path from "path";
import { basePath } from "./utils/fileSystemUtils";
import { promises as fs } from "fs";
import { courseItemFileStorageService } from "./courseItemFileStorageService";
import { getCoursePathByName } from "./globalSettingsFileStorageService";
export const quizFileStorageService = {
getQuiz: async (courseName: string, moduleName: string, quizName: string) =>
@@ -27,11 +27,11 @@ export const quizFileStorageService = {
quizName: string;
quiz: LocalQuiz;
}) {
const folder = path.join(basePath, courseName, moduleName, "quizzes");
const courseDirectory = await getCoursePathByName(courseName);
const folder = path.join(courseDirectory, moduleName, "quizzes");
await fs.mkdir(folder, { recursive: true });
const filePath = path.join(
basePath,
courseName,
courseDirectory,
moduleName,
"quizzes",
quizName + ".md"
@@ -50,9 +50,9 @@ export const quizFileStorageService = {
moduleName: string;
quizName: string;
}) {
const courseDirectory = await getCoursePathByName(courseName);
const filePath = path.join(
basePath,
courseName,
courseDirectory,
moduleName,
"quizzes",
quizName + ".md"

View File

@@ -4,20 +4,21 @@ import {
} from "@/models/local/localCourseSettings";
import { promises as fs } from "fs";
import path from "path";
import {
basePath,
directoryOrFileExists,
getCourseNames,
} from "./utils/fileSystemUtils";
import { basePath, directoryOrFileExists } from "./utils/fileSystemUtils";
import { AssignmentSubmissionType } from "@/models/local/assignment/assignmentSubmissionType";
import {
getCoursePathByName,
getGlobalSettings,
} from "./globalSettingsFileStorageService";
import { GlobalSettingsCourse } from "@/models/local/globalSettings";
const getCourseSettings = async (
courseName: string
course: GlobalSettingsCourse
): Promise<LocalCourseSettings> => {
const courseDirectory = path.join(basePath, courseName);
const courseDirectory = await getCoursePathByName(course.name);
const settingsPath = path.join(courseDirectory, "settings.yml");
if (!(await directoryOrFileExists(settingsPath))) {
const errorMessage = `could not find settings for ${courseName}, settings file ${settingsPath}`;
const errorMessage = `could not find settings for ${course.name}, settings file ${settingsPath}`;
console.log(errorMessage);
throw new Error(errorMessage);
}
@@ -29,8 +30,7 @@ const getCourseSettings = async (
const settings: LocalCourseSettings = populateDefaultValues(settingsFromFile);
const folderName = path.basename(courseDirectory);
return { ...settings, name: folderName };
return { ...settings, name: course.name };
};
const populateDefaultValues = (settingsFromFile: LocalCourseSettings) => {
@@ -60,7 +60,10 @@ const populateDefaultValues = (settingsFromFile: LocalCourseSettings) => {
export const settingsFileStorageService = {
getCourseSettings,
async getAllCoursesSettings() {
const courses = await getCourseNames();
const globalSettings = await getGlobalSettings();
// const courses = await getCourseNames();
const courses = globalSettings.courses;
const courseSettings = await Promise.all(
courses.map(async (c) => await getCourseSettings(c))
@@ -72,7 +75,7 @@ export const settingsFileStorageService = {
courseName: string,
settings: LocalCourseSettings
) {
const courseDirectory = path.join(basePath, courseName);
const courseDirectory = await getCoursePathByName(courseName);
const settingsPath = path.join(courseDirectory, "settings.yml");
const { name: _, ...settingsWithoutName } = settings;

View File

@@ -1,17 +1,10 @@
import { promises as fs } from "fs";
import path from "path";
import { getGlobalSettings } from "../globalSettingsFileStorageService";
export const hasFileSystemEntries = async (
export const directoryOrFileExists = async (
directoryPath: string
): Promise<boolean> => {
try {
const entries = await fs.readdir(directoryPath);
return entries.length > 0;
} catch {
return false;
}
};
export const directoryOrFileExists = async (directoryPath: string): Promise<boolean> => {
try {
await fs.access(directoryPath);
return true;
@@ -22,31 +15,9 @@ export const directoryOrFileExists = async (directoryPath: string): Promise<bool
export async function getCourseNames() {
console.log("loading course ids");
const courseDirectories = await fs.readdir(basePath, {
withFileTypes: true,
});
const coursePromises = await Promise.all(
courseDirectories
.filter((dirent) => dirent.isDirectory())
.map(async (dirent) => {
const coursePath = path.join(basePath, dirent.name);
const settingsPath = path.join(coursePath, "settings.yml");
const hasSettings = await directoryOrFileExists(settingsPath);
return {
dirent,
hasSettings,
};
})
);
const courseNamesFromDirectories = coursePromises
.filter(({ hasSettings }) => hasSettings)
.map(({ dirent }) => dirent.name);
return courseNamesFromDirectories;
const globalSettings = await getGlobalSettings();
return globalSettings.courses.map((course) => course.name);
}
export const basePath = process.env.STORAGE_DIRECTORY ?? "./storage";
console.log("base path", basePath);
console.log("base path", basePath);

View File

@@ -10,6 +10,9 @@ describe("FileStorageTests", () => {
beforeEach(async () => {
const storageDirectory =
process.env.STORAGE_DIRECTORY ?? "/tmp/canvasManagerTests";
process.env.GLOBAL_SETTINGS = `courses:
- path: test empty course
name: test empty course`;
try {
await fs.access(storageDirectory);
await fs.rm(storageDirectory, { recursive: true });
@@ -31,14 +34,15 @@ describe("FileStorageTests", () => {
defaultAssignmentSubmissionTypes: [],
defaultFileUploadTypes: [],
holidays: [],
assets: []
assets: [],
};
await fileStorageService.settings.updateCourseSettings(name, settings);
const loadedSettings = await fileStorageService.settings.getCourseSettings(
name
);
const loadedSettings = await fileStorageService.settings.getCourseSettings({
name,
path: name,
});
expect(loadedSettings).toEqual(settings);
});

View File

@@ -7,6 +7,9 @@ describe("FileStorageTests", () => {
beforeEach(async () => {
const storageDirectory =
process.env.STORAGE_DIRECTORY ?? "/tmp/canvasManagerTests";
process.env.GLOBAL_SETTINGS = `courses:
- path: testCourse
name: testCourse`;
try {
await fs.access(storageDirectory);
await fs.rm(storageDirectory, { recursive: true });

View File

@@ -1,5 +1,7 @@
import toast, { CheckmarkIcon } from "react-hot-toast";
import { ReactNode } from "react";
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
import { TRPCError } from "@trpc/server";
// const addErrorAsToast = async (error: unknown) => {
// console.error("error from toast", error);
@@ -41,6 +43,12 @@ import { ReactNode } from "react";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function getErrorMessage(error: any) {
if (error instanceof TRPCError) {
const httpCode = getHTTPStatusCodeFromError(error);
console.log("trpc error", httpCode, error); // 400
return `TRPC Error: ${error.message} (HTTP ${httpCode})`;
}
if (error?.response?.status === 422) {
console.log(error.response.data.detail);
const serializationMessages = error.response.data.detail.map(
@@ -59,7 +67,9 @@ export function getErrorMessage(error: any) {
return error.response?.data.detail;
} else return JSON.stringify(error.response?.data.detail);
}
console.log(error);
console.log("error message: ", error);
if(error.message )
return error.message;
return "Error With Request";
}