moving v2 to top level

This commit is contained in:
2024-12-17 09:19:21 -07:00
parent 5f0b3554dc
commit 576ee02afb
468 changed files with 79 additions and 15430 deletions

View File

@@ -0,0 +1,59 @@
import { isServer } from "@tanstack/react-query";
import axios, { AxiosInstance, AxiosError, AxiosHeaders } from "axios";
import toast from "react-hot-toast";
const canvasBaseUrl = "https://snow.instructure.com/api/v1/";
export const axiosClient: AxiosInstance = axios.create();
if (!isServer) {
axiosClient.interceptors.request.use((config) => {
if (config.url && config.url.startsWith(canvasBaseUrl)) {
const newUrl = config.url.replace(canvasBaseUrl, "/api/canvas/");
config.url = newUrl;
}
return config;
});
} else {
const token = process.env.CANVAS_TOKEN;
if (!token) {
console.error("CANVAS_TOKEN not in environment")
// throw new Error("CANVAS_TOKEN not in environment");
} else {
axiosClient.interceptors.request.use((config) => {
if (config.url && config.url.startsWith(canvasBaseUrl)) {
config.headers.set("Authorization", `Bearer ${token}`);
}
return config;
});
}
}
axiosClient.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
if (error.response) {
// console.log("response error", error.response);
const responseErrorText =
typeof error.response.data === "object"
? (error.response.data as any).error
: error.response.data;
if (!isServer) {
toast.error(
`Error: ${error.response.status} - ${responseErrorText}, ${decodeURI(
error.response.config.url ?? ""
)}`
);
}
} else if (error.request) {
if (!isServer) {
toast.error("Error: No response from server");
}
} else {
if (!isServer) {
toast.error(`Error: ${error.message}`);
}
}
return Promise.reject(error);
}
);

View File

@@ -0,0 +1,65 @@
import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
import { axiosClient } from "../axiosUtils";
import { CanvasAssignmentGroup } from "@/models/canvas/assignments/canvasAssignmentGroup";
import { LocalAssignmentGroup } from "@/models/local/assignment/localAssignmentGroup";
import { rateLimitAwareDelete } from "./canvasWebRequestor";
export const canvasAssignmentGroupService = {
async getAll(courseId: number): Promise<CanvasAssignmentGroup[]> {
console.log("Requesting assignment groups");
const url = `${canvasApi}/courses/${courseId}/assignment_groups`;
const assignmentGroups = await paginatedRequest<
CanvasAssignmentGroup[]
>({
url,
});
return assignmentGroups.flatMap((groupList) => groupList);
},
async create(
canvasCourseId: number,
localAssignmentGroup: LocalAssignmentGroup
): Promise<LocalAssignmentGroup> {
console.log(`Creating assignment group: ${localAssignmentGroup.name}`);
const url = `${canvasApi}/courses/${canvasCourseId}/assignment_groups`;
const body = {
name: localAssignmentGroup.name,
group_weight: localAssignmentGroup.weight,
};
const { data: canvasAssignmentGroup } =
await axiosClient.post<CanvasAssignmentGroup>(url, body);
return {
...localAssignmentGroup,
canvasId: canvasAssignmentGroup.id,
};
},
async update(
canvasCourseId: number,
localAssignmentGroup: LocalAssignmentGroup
): Promise<void> {
console.log(`Updating assignment group: ${localAssignmentGroup.name}, ${localAssignmentGroup.canvasId}`);
if (!localAssignmentGroup.canvasId) {
throw new Error("Cannot update assignment group if canvas ID is null");
}
const url = `${canvasApi}/courses/${canvasCourseId}/assignment_groups/${localAssignmentGroup.canvasId}`;
const body = {
name: localAssignmentGroup.name,
group_weight: localAssignmentGroup.weight,
};
await axiosClient.put(url, body);
},
async delete(
canvasCourseId: number,
canvasAssignmentGroupId: number,
assignmentGroupName: string
): Promise<void> {
console.log(`Deleting assignment group: ${assignmentGroupName}`);
const url = `${canvasApi}/courses/${canvasCourseId}/assignment_groups/${canvasAssignmentGroupId}`;
await rateLimitAwareDelete(url);
},
};

View File

@@ -0,0 +1,148 @@
import { CanvasAssignment } from "@/models/canvas/assignments/canvasAssignment";
import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
import { LocalAssignment } from "@/models/local/assignment/localAssignment";
import { axiosClient } from "../axiosUtils";
import { markdownToHTMLSafe } from "../htmlMarkdownUtils";
import { CanvasRubricCreationResponse } from "@/models/canvas/assignments/canvasRubricCreationResponse";
import { assignmentPoints } from "@/models/local/assignment/utils/assignmentPointsUtils";
import { getDateFromString } from "@/models/local/utils/timeUtils";
import { getRubricCriterion } from "./canvasRubricUtils";
import { LocalCourseSettings } from "@/models/local/localCourseSettings";
export const canvasAssignmentService = {
async getAll(courseId: number): Promise<CanvasAssignment[]> {
console.log("getting canvas assignments");
const url = `${canvasApi}/courses/${courseId}/assignments`; //per_page=100
const assignments = await paginatedRequest<CanvasAssignment[]>({ url });
return assignments.map((a) => ({
...a,
due_at: a.due_at ? new Date(a.due_at).toLocaleString() : undefined, // timezones?
lock_at: a.lock_at ? new Date(a.lock_at).toLocaleString() : undefined, // timezones?
}));
},
async create(
canvasCourseId: number,
localAssignment: LocalAssignment,
settings: LocalCourseSettings,
canvasAssignmentGroupId?: number
) {
console.log(`Creating assignment: ${localAssignment.name}`);
const url = `${canvasApi}/courses/${canvasCourseId}/assignments`;
const body = {
assignment: {
name: localAssignment.name,
submission_types: localAssignment.submissionTypes.map((t) =>
t.toString()
),
allowed_extensions: localAssignment.allowedFileUploadExtensions.map(
(e) => e.toString()
),
description: markdownToHTMLSafe(localAssignment.description, settings),
due_at: getDateFromString(localAssignment.dueAt)?.toISOString(),
lock_at:
localAssignment.lockAt &&
getDateFromString(localAssignment.lockAt)?.toISOString(),
points_possible: assignmentPoints(localAssignment.rubric),
assignment_group_id: canvasAssignmentGroupId,
},
};
const response = await axiosClient.post<CanvasAssignment>(url, body);
const canvasAssignment = response.data;
await createRubric(canvasCourseId, canvasAssignment.id, localAssignment);
return canvasAssignment.id;
},
async update(
courseId: number,
canvasAssignmentId: number,
localAssignment: LocalAssignment,
settings: LocalCourseSettings,
canvasAssignmentGroupId?: number
) {
console.log(`Updating assignment: ${localAssignment.name}`);
const url = `${canvasApi}/courses/${courseId}/assignments/${canvasAssignmentId}`;
const body = {
assignment: {
name: localAssignment.name,
submission_types: localAssignment.submissionTypes.map((t) =>
t.toString()
),
allowed_extensions: localAssignment.allowedFileUploadExtensions.map(
(e) => e.toString()
),
description: markdownToHTMLSafe(localAssignment.description, settings),
due_at: getDateFromString(localAssignment.dueAt)?.toISOString(),
lock_at:
localAssignment.lockAt &&
getDateFromString(localAssignment.lockAt)?.toISOString(),
points_possible: assignmentPoints(localAssignment.rubric),
assignment_group_id: canvasAssignmentGroupId,
},
};
await axiosClient.put(url, body);
await createRubric(courseId, canvasAssignmentId, localAssignment);
},
async delete(
courseId: number,
assignmentCanvasId: number,
assignmentName: string
) {
console.log(`Deleting assignment from Canvas: ${assignmentName}`);
const url = `${canvasApi}/courses/${courseId}/assignments/${assignmentCanvasId}`;
const response = await axiosClient.delete(url);
if (!response.status.toString().startsWith("2")) {
console.error(`Failed to delete assignment: ${assignmentName}`);
throw new Error("Failed to delete assignment");
}
},
};
const createRubric = async (
courseId: number,
assignmentCanvasId: number,
localAssignment: LocalAssignment
) => {
const criterion = getRubricCriterion(localAssignment.rubric);
const rubricBody = {
rubric_association_id: assignmentCanvasId,
rubric: {
title: `Rubric for Assignment: ${localAssignment.name}`,
association_id: assignmentCanvasId,
association_type: "Assignment",
use_for_grading: true,
criteria: criterion,
},
rubric_association: {
association_id: assignmentCanvasId,
association_type: "Assignment",
purpose: "grading",
use_for_grading: true,
},
};
const rubricUrl = `${canvasApi}/courses/${courseId}/rubrics`;
const rubricResponse = await axiosClient.post<CanvasRubricCreationResponse>(
rubricUrl,
rubricBody
);
if (!rubricResponse.data) throw new Error("Failed to create rubric");
const assignmentPointAdjustmentUrl = `${canvasApi}/courses/${courseId}/assignments/${assignmentCanvasId}`;
const assignmentPointAdjustmentBody = {
assignment: { points_possible: assignmentPoints(localAssignment.rubric) },
};
await axiosClient.put(
assignmentPointAdjustmentUrl,
assignmentPointAdjustmentBody
);
};

View File

@@ -0,0 +1,66 @@
import { CanvasModuleItem } from "@/models/canvas/modules/canvasModuleItems";
import { CanvasPage } from "@/models/canvas/pages/canvasPageModel";
import { axiosClient } from "../axiosUtils";
import { canvasApi } from "./canvasServiceUtils";
import { CanvasModule } from "@/models/canvas/modules/canvasModule";
export const canvasModuleService = {
async updateModuleItem(
canvasCourseId: number,
canvasModuleId: number,
item: CanvasModuleItem
) {
console.log(`Updating module item ${item.title}`);
const url = `${canvasApi}/courses/${canvasCourseId}/modules/${canvasModuleId}/items/${item.id}`;
const body = {
module_item: { title: item.title, position: item.position },
};
const { data } = await axiosClient.put<CanvasModuleItem>(url, body);
if (!data) throw new Error("Something went wrong updating module item");
},
async createModuleItem(
canvasCourseId: number,
canvasModuleId: number,
title: string,
type: "Assignment" | "Quiz",
contentId: number | string
) {
console.log(`Creating new module item ${title}`);
const url = `${canvasApi}/courses/${canvasCourseId}/modules/${canvasModuleId}/items`;
const body = { module_item: { title, type, content_id: contentId } };
await axiosClient.post(url, body);
},
async createPageModuleItem(
canvasCourseId: number,
canvasModuleId: number,
title: string,
canvasPage: CanvasPage
) {
console.log(`Creating new module item ${title}`);
const url = `${canvasApi}/courses/${canvasCourseId}/modules/${canvasModuleId}/items`;
const body = {
module_item: { title, type: "Page", page_url: canvasPage.url },
};
await axiosClient.post<CanvasModuleItem>(url, body);
},
async getCourseModules(canvasCourseId: number) {
const url = `${canvasApi}/courses/${canvasCourseId}/modules`;
const response = await axiosClient.get<CanvasModule[]>(url);
return response.data;
},
async createModule(canvasCourseId: number, moduleName: string) {
const url = `${canvasApi}/courses/${canvasCourseId}/modules`;
const body = {
module: {
name: moduleName,
},
};
const response = await axiosClient.post<CanvasModule>(url, body);
return response.data.id;
},
};

View File

@@ -0,0 +1,62 @@
import { CanvasPage } from "@/models/canvas/pages/canvasPageModel";
import { LocalCoursePage } from "@/models/local/page/localCoursePage";
import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
import { markdownToHTMLSafe } from "../htmlMarkdownUtils";
import { axiosClient } from "../axiosUtils";
import { rateLimitAwareDelete } from "./canvasWebRequestor";
import { LocalCourseSettings } from "@/models/local/localCourseSettings";
export const canvasPageService = {
async getAll(courseId: number): Promise<CanvasPage[]> {
console.log("requesting pages");
const url = `${canvasApi}/courses/${courseId}/pages`;
const pages = await paginatedRequest<CanvasPage[]>({
url,
});
return pages.flatMap((pageList) => pageList);
},
async create(
canvasCourseId: number,
page: LocalCoursePage,
settings: LocalCourseSettings
): Promise<CanvasPage> {
console.log(`Creating course page: ${page.name}`);
const url = `${canvasApi}/courses/${canvasCourseId}/pages`;
const body = {
wiki_page: {
title: page.name,
body: markdownToHTMLSafe(page.text, settings),
},
};
const { data: canvasPage } = await axiosClient.post<CanvasPage>(url, body);
if (!canvasPage) {
throw new Error("Created canvas course page was null");
}
return canvasPage;
},
async update(
courseId: number,
canvasPageId: number,
page: LocalCoursePage,
settings: LocalCourseSettings
): Promise<void> {
console.log(`Updating course page: ${page.name}`);
const url = `${canvasApi}/courses/${courseId}/pages/${canvasPageId}`;
const body = {
wiki_page: {
title: page.name,
body: markdownToHTMLSafe(page.text, settings),
},
};
await axiosClient.put(url, body);
},
async delete(courseId: number, canvasPageId: number): Promise<void> {
console.log(`Deleting page from canvas ${canvasPageId}`);
const url = `${canvasApi}/courses/${courseId}/pages/${canvasPageId}`;
await rateLimitAwareDelete(url);
},
};

View File

@@ -0,0 +1,182 @@
import { CanvasQuiz } from "@/models/canvas/quizzes/canvasQuizModel";
import { axiosClient } from "../axiosUtils";
import { canvasApi } from "./canvasServiceUtils";
import { LocalQuiz } from "@/models/local/quiz/localQuiz";
import { markdownToHTMLSafe } from "../htmlMarkdownUtils";
import { getDateFromStringOrThrow } from "@/models/local/utils/timeUtils";
import { canvasAssignmentService } from "./canvasAssignmentService";
import {
LocalQuizQuestion,
QuestionType,
} from "@/models/local/quiz/localQuizQuestion";
import { CanvasQuizQuestion } from "@/models/canvas/quizzes/canvasQuizQuestionModel";
import { LocalCourseSettings } from "@/models/local/localCourseSettings";
const getAnswers = (
question: LocalQuizQuestion,
settings: LocalCourseSettings
) => {
if (question.questionType === QuestionType.MATCHING)
return question.answers.map((a) => ({
answer_match_left: a.text,
answer_match_right: a.matchedText,
}));
return question.answers.map((answer) => ({
answer_html: markdownToHTMLSafe(answer.text, settings),
answer_weight: answer.correct ? 100 : 0,
}));
};
const createQuestionOnly = async (
canvasCourseId: number,
canvasQuizId: number,
question: LocalQuizQuestion,
position: number,
settings: LocalCourseSettings
) => {
console.log("Creating individual question"); //, question);
const url = `${canvasApi}/courses/${canvasCourseId}/quizzes/${canvasQuizId}/questions`;
const body = {
question: {
question_text: markdownToHTMLSafe(question.text, settings),
question_type: `${question.questionType}_question`,
points_possible: question.points,
position,
answers: getAnswers(question, settings),
},
};
const response = await axiosClient.post<CanvasQuizQuestion>(url, body);
const newQuestion = response.data;
if (!newQuestion) throw new Error("Created question is null");
return { question: newQuestion, position };
};
const hackFixQuestionOrdering = async (
canvasCourseId: number,
canvasQuizId: number,
questionAndPositions: Array<{ question: any; position: number }>
) => {
console.log("Fixing question order");
const order = questionAndPositions.map((qp) => ({
type: "question",
id: qp.question.id.toString(),
}));
const url = `${canvasApi}/courses/${canvasCourseId}/quizzes/${canvasQuizId}/reorder`;
await axiosClient.post(url, { order });
};
const hackFixRedundantAssignments = async (canvasCourseId: number) => {
console.log("hack fixing redundant quiz assignments that are auto-created");
const assignments = await canvasAssignmentService.getAll(canvasCourseId);
const assignmentsToDelete = assignments.filter(
(assignment) =>
!assignment.is_quiz_assignment &&
assignment.submission_types.includes("online_quiz")
);
await Promise.all(
assignmentsToDelete.map(
async (assignment) =>
await canvasAssignmentService.delete(
canvasCourseId,
assignment.id,
assignment.name
)
)
);
console.log(`Deleted ${assignmentsToDelete.length} redundant assignments`);
};
const createQuizQuestions = async (
canvasCourseId: number,
canvasQuizId: number,
localQuiz: LocalQuiz,
settings: LocalCourseSettings
) => {
console.log("Creating quiz questions"); //, localQuiz);
const tasks = localQuiz.questions.map(
async (question, index) =>
await createQuestionOnly(
canvasCourseId,
canvasQuizId,
question,
index,
settings
)
);
const questionAndPositions = await Promise.all(tasks);
await hackFixQuestionOrdering(
canvasCourseId,
canvasQuizId,
questionAndPositions
);
await hackFixRedundantAssignments(canvasCourseId);
};
export const canvasQuizService = {
async getAll(canvasCourseId: number): Promise<CanvasQuiz[]> {
const url = `${canvasApi}/courses/${canvasCourseId}/quizzes`;
const response = await axiosClient.get<CanvasQuiz[]>(url);
return response.data.map((quiz) => ({
...quiz,
due_at: quiz.due_at ? new Date(quiz.due_at).toLocaleString() : undefined,
lock_at: quiz.lock_at
? new Date(quiz.lock_at).toLocaleString()
: undefined,
}));
},
async create(
canvasCourseId: number,
localQuiz: LocalQuiz,
settings: LocalCourseSettings,
canvasAssignmentGroupId?: number
) {
console.log("Creating quiz", localQuiz);
const url = `${canvasApi}/courses/${canvasCourseId}/quizzes`;
const body = {
quiz: {
title: localQuiz.name,
description: markdownToHTMLSafe(localQuiz.description, settings),
shuffle_answers: localQuiz.shuffleAnswers,
access_code: localQuiz.password,
show_correct_answers: localQuiz.showCorrectAnswers,
allowed_attempts: localQuiz.allowedAttempts,
one_question_at_a_time: localQuiz.oneQuestionAtATime,
cant_go_back: false,
due_at: localQuiz.dueAt
? getDateFromStringOrThrow(
localQuiz.dueAt,
"creating quiz"
).toISOString()
: undefined,
lock_at: localQuiz.lockAt
? getDateFromStringOrThrow(
localQuiz.lockAt,
"creating quiz"
).toISOString()
: undefined,
assignment_group_id: canvasAssignmentGroupId,
},
};
const { data: canvasQuiz } = await axiosClient.post<CanvasQuiz>(url, body);
await createQuizQuestions(canvasCourseId, canvasQuiz.id, localQuiz, settings);
return canvasQuiz.id;
},
async delete(canvasCourseId: number, canvasQuizId: number) {
const url = `${canvasApi}/courses/${canvasCourseId}/quizzes/${canvasQuizId}`;
await axiosClient.delete(url);
},
};

View File

@@ -0,0 +1,22 @@
import { RubricItem } from "@/models/local/assignment/rubricItem";
export const getRubricCriterion = (rubric: RubricItem[]) => {
const criterion = rubric
.map((rubricItem) => ({
description: rubricItem.label,
points: rubricItem.points,
ratings: {
0: { description: "Full Marks", points: rubricItem.points },
1: { description: "No Marks", points: 0 },
},
}))
.reduce((acc, item, index) => {
return {
...acc,
[index]: item,
};
}, {} as { [key: number]: { description: string; points: number; ratings: { [key: number]: { description: string; points: number } } } });
return criterion
}

View File

@@ -0,0 +1,67 @@
import { CanvasEnrollmentTermModel } from "@/models/canvas/enrollmentTerms/canvasEnrollmentTermModel";
import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
import { CanvasCourseModel } from "@/models/canvas/courses/canvasCourseModel";
import { CanvasEnrollmentModel } from "@/models/canvas/enrollments/canvasEnrollmentModel";
import { axiosClient } from "../axiosUtils";
const getAllTerms = async () => {
const url = `${canvasApi}/accounts/10/terms?per_page=100`;
const data = await paginatedRequest<
{
enrollment_terms: CanvasEnrollmentTermModel[];
}[]
>({ url });
const terms = data.flatMap((t) => t.enrollment_terms);
return terms;
};
export const canvasService = {
getAllTerms,
async getCourses(termId: number) {
const url = `${canvasApi}/courses?per_page=100`;
const allCourses = await paginatedRequest<CanvasCourseModel[]>({ url });
const coursesInTerm = allCourses
.flatMap((l) => l)
.filter((c) => c.enrollment_term_id === termId);
return coursesInTerm;
},
async getCourse(courseId: number): Promise<CanvasCourseModel> {
const url = `${canvasApi}/courses/${courseId}`;
const { data } = await axiosClient.get<CanvasCourseModel>(url);
return data;
},
async getCurrentTermsFor(queryDate: Date = new Date()) {
const terms = await getAllTerms();
const currentTerms = terms
.filter(
(t) =>
t.end_at &&
new Date(t.end_at) > queryDate &&
new Date(t.end_at) <
new Date(queryDate.setFullYear(queryDate.getFullYear() + 1))
)
.sort(
(a, b) =>
new Date(a.start_at ?? "").getTime() -
new Date(b.start_at ?? "").getTime()
)
.slice(0, 3);
return currentTerms;
},
async getEnrolledStudents(canvasCourseId: number) {
console.log(`Getting students for course ${canvasCourseId}`);
const url = `${canvasApi}/courses/${canvasCourseId}/enrollments?enrollment_type=student`;
const { data } = await axiosClient.get<CanvasEnrollmentModel[]>(url);
if (!data)
throw new Error(
`Something went wrong getting enrollments for ${canvasCourseId}`
);
return data;
},
};

View File

@@ -0,0 +1,68 @@
// services/canvasServiceUtils.ts
import { AxiosResponseHeaders, RawAxiosResponseHeaders } from "axios";
import { axiosClient } from "../axiosUtils";
export const baseCanvasUrl = "https://snow.instructure.com";
export const canvasApi = baseCanvasUrl + "/api/v1";
const getNextUrl = (
headers: AxiosResponseHeaders | RawAxiosResponseHeaders
): string | undefined => {
const linkHeader: string | undefined =
typeof headers.get === "function"
? (headers.get("link") as string)
: ((headers as RawAxiosResponseHeaders)["link"] as string);
if (!linkHeader) {
console.log("could not find link header in the response");
return undefined;
}
const links = linkHeader.split(",").map((link) => link.trim());
const nextLink = links.find((link) => link.includes('rel="next"'));
if (!nextLink) {
// console.log(
// "could not find next url in link header, reached end of pagination"
// );
return undefined;
}
const nextUrl = nextLink.split(";")[0].trim().slice(1, -1);
return nextUrl;
};
export async function paginatedRequest<T extends any[]>(request: {
url: string;
}): Promise<T> {
var requestCount = 1;
const url = new URL(request.url);
url.searchParams.set("per_page", "100");
const { data: firstData, headers: firstHeaders } = await axiosClient.get<T>(
url.toString()
);
var returnData = Array.isArray(firstData) ? [...firstData] : [firstData]; // terms come across as nested objects {enrolmentTerms: terms[]}
var nextUrl = getNextUrl(firstHeaders);
while (nextUrl) {
requestCount += 1;
const { data, headers } = await axiosClient.get<T>(nextUrl);
if (data) {
returnData = returnData.concat(Array.isArray(data) ? [...data] : [data]);
}
nextUrl = getNextUrl(headers);
}
if (requestCount > 1) {
console.log(
`Requesting ${typeof returnData} took ${requestCount} requests`
);
}
return returnData as T;
}

View File

@@ -0,0 +1,66 @@
import { AxiosResponse } from "axios";
import { axiosClient } from "../axiosUtils";
type FetchOptions = Omit<RequestInit, "method">;
const rateLimitRetryCount = 6;
const rateLimitSleepInterval = 1000;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export const isRateLimited = async (
response: AxiosResponse
): Promise<boolean> => {
const content = await response.data;
return (
response.status === 403 &&
content.includes("403 Forbidden (Rate Limit Exceeded)")
);
};
const rateLimitAwarePost = async (url: string, body: any, retryCount = 0) => {
const response = await axiosClient.post(url, body);
if (await isRateLimited(response)) {
if (retryCount < rateLimitRetryCount) {
console.info(
`Hit rate limit on post, retry count is ${retryCount} / ${rateLimitRetryCount}, retrying`
);
await sleep(rateLimitSleepInterval);
return await rateLimitAwarePost(url, body, retryCount + 1);
}
}
return response;
};
export const rateLimitAwareDelete = async (
url: string,
retryCount = 0
): Promise<void> => {
try {
const response = await axiosClient.delete(url);
if (await isRateLimited(response)) {
console.info("After delete response in rate limited");
await sleep(rateLimitSleepInterval);
return await rateLimitAwareDelete(url, retryCount + 1);
}
} catch (e) {
const error = e as Error & { response?: Response };
if (error.response?.status === 403) {
if (retryCount < rateLimitRetryCount) {
console.info(
`Hit rate limit in delete, retry count is ${retryCount} / ${rateLimitRetryCount}, retrying`
);
await sleep(rateLimitSleepInterval);
return await rateLimitAwareDelete(url, retryCount + 1);
} else {
console.info(
`Hit rate limit in delete, ${rateLimitRetryCount} retries did not fix it`
);
}
}
throw e;
}
};

View File

@@ -0,0 +1,101 @@
import fs from "fs/promises";
import path from "path";
import axios from "axios";
import { canvasApi } from "../canvasServiceUtils";
import { axiosClient } from "@/services/axiosUtils";
import FormData from "form-data";
export const downloadUrlToTempDirectory = async (
sourceUrl: string
): Promise<string> => {
try {
const fileName =
path.basename(new URL(sourceUrl).pathname) || `tempfile-${Date.now()}`;
const tempFilePath = path.join("/tmp", fileName);
const response = await axios.get(sourceUrl, {
responseType: "arraybuffer",
});
await fs.writeFile(tempFilePath, response.data);
return tempFilePath;
} catch (error) {
console.error("Error downloading or saving the file:", error);
throw error;
}
};
const getFileSize = async (pathToFile: string): Promise<number> => {
try {
const stats = await fs.stat(pathToFile);
return stats.size;
} catch (error) {
console.error("Error reading file size:", error);
throw error;
}
};
export const uploadToCanvasPart1 = async (
pathToUpload: string,
canvasCourseId: number
) => {
try {
const url = `${canvasApi}/courses/${canvasCourseId}/files`;
const formData = new FormData();
formData.append("name", path.basename(pathToUpload));
formData.append("size", (await getFileSize(pathToUpload)).toString());
const response = await axiosClient.post(url, formData);
const upload_url = response.data.upload_url;
const upload_params = response.data.upload_params;
return { upload_url, upload_params };
} catch (error) {
console.error("Error uploading file to Canvas part 1:", error);
throw error;
}
};
export const uploadToCanvasPart2 = async ({
pathToUpload,
upload_url,
upload_params,
}: {
pathToUpload: string;
upload_url: string;
upload_params: { [key: string]: string };
}) => {
try {
const formData = new FormData();
Object.keys(upload_params).forEach((key) => {
formData.append(key, upload_params[key]);
});
const fileBuffer = await fs.readFile(pathToUpload);
const fileName = path.basename(pathToUpload);
formData.append("file", fileBuffer, fileName);
const response = await axiosClient.post(upload_url, formData, {
headers: formData.getHeaders(),
validateStatus: (status) => status < 500,
});
if (response.status === 301) {
const redirectUrl = response.headers.location;
if (!redirectUrl) {
throw new Error(
"Redirect URL not provided in the Location header on redirect from second part of canvas file upload"
);
}
const redirectResponse = await axiosClient.get(redirectUrl);
console.log("redirect response", redirectResponse.data);
}
return response.data.url;
} catch (error) {
console.error("Error uploading file to Canvas part 2:", error);
throw error;
}
};

View File

@@ -0,0 +1,93 @@
import { RubricItem } from "@/models/local/assignment/rubricItem";
import { describe, expect, it } from "vitest";
import { getRubricCriterion } from "./canvasRubricUtils";
import { assignmentPoints } from "@/models/local/assignment/utils/assignmentPointsUtils";
describe("can prepare rubric for canvas", () =>{
it("can parse normal rubric into criterion", () =>{
const rubric: RubricItem[] = [
{
label: "first",
points: 1
},
{
label: "second",
points: 2
},
]
const criterion = getRubricCriterion(rubric)
expect(criterion).toStrictEqual({
0: {
description: "first",
points: 1,
ratings: {
0: { description: "Full Marks", points: 1 },
1: { description: "No Marks", points: 0 },
}
},
1: {
description: "second",
points: 2,
ratings: {
0: { description: "Full Marks", points: 2 },
1: { description: "No Marks", points: 0 },
}
}
})
})
it("can parse negative rubric into criterion", () =>{
const rubric: RubricItem[] = [
{
label: "first",
points: 1
},
{
label: "second",
points: -2
},
]
const criterion = getRubricCriterion(rubric)
expect(criterion).toStrictEqual({
0: {
description: "first",
points: 1,
ratings: {
0: { description: "Full Marks", points: 1 },
1: { description: "No Marks", points: 0 },
}
},
1: {
description: "second",
points: -2,
ratings: {
0: { description: "Full Marks", points: -2 },
1: { description: "No Marks", points: 0 },
}
}
})
})
it("negative rubric items do not contribute to the total", () =>{
const rubric: RubricItem[] = [
{
label: "first",
points: 1
},
{
label: "second",
points: -2
},
{
label: "second",
points: 3
},
]
const points = assignmentPoints(rubric)
expect(points).toBe(4)
})
})

View File

@@ -0,0 +1,97 @@
import {
localAssignmentMarkdown,
LocalAssignment,
} from "@/models/local/assignment/localAssignment";
import { assignmentMarkdownSerializer } from "@/models/local/assignment/utils/assignmentMarkdownSerializer";
import path from "path";
import { basePath, directoryOrFileExists } from "./utils/fileSystemUtils";
import { promises as fs } from "fs";
import { courseItemFileStorageService } from "./courseItemFileStorageService";
const getAssignmentNames = async (courseName: string, moduleName: string) => {
const filePath = path.join(basePath, courseName, moduleName, "assignments");
if (!(await directoryOrFileExists(filePath))) {
console.log(
`Error loading course by name, assignments folder does not exist in ${filePath}`
);
// await fs.mkdir(filePath);
return [];
}
const assignmentFiles = await fs.readdir(filePath);
return assignmentFiles.map((f) => f.replace(/\.md$/, ""));
};
const getAssignment = async (
courseName: string,
moduleName: string,
assignmentName: string
) => {
const filePath = path.join(
basePath,
courseName,
moduleName,
"assignments",
assignmentName + ".md"
);
const rawFile = (await fs.readFile(filePath, "utf-8")).replace(/\r\n/g, "\n");
return localAssignmentMarkdown.parseMarkdown(rawFile);
};
export const assignmentsFileStorageService = {
getAssignmentNames,
getAssignment,
async getAssignments(courseName: string, moduleName: string) {
return await courseItemFileStorageService.getItems(
courseName,
moduleName,
"Assignment"
);
},
async updateOrCreateAssignment({
courseName,
moduleName,
assignmentName,
assignment,
}: {
courseName: string;
moduleName: string;
assignmentName: string;
assignment: LocalAssignment;
}) {
const folder = path.join(basePath, courseName, moduleName, "assignments");
await fs.mkdir(folder, { recursive: true });
const filePath = path.join(
basePath,
courseName,
moduleName,
"assignments",
assignmentName + ".md"
);
const assignmentMarkdown =
assignmentMarkdownSerializer.toMarkdown(assignment);
console.log(`Saving assignment ${filePath}`);
await fs.writeFile(filePath, assignmentMarkdown);
},
async delete({
courseName,
moduleName,
assignmentName,
}: {
courseName: string;
moduleName: string;
assignmentName: string;
}) {
const filePath = path.join(
basePath,
courseName,
moduleName,
"assignments",
assignmentName + ".md"
);
console.log("removing assignment", filePath);
await fs.unlink(filePath);
},
};

View File

@@ -0,0 +1,134 @@
import path from "path";
import { basePath, directoryOrFileExists } from "./utils/fileSystemUtils";
import fs from "fs/promises";
import {
LocalAssignment,
localAssignmentMarkdown,
} from "@/models/local/assignment/localAssignment";
import {
LocalQuiz,
localQuizMarkdownUtils,
} from "@/models/local/quiz/localQuiz";
import {
LocalCoursePage,
localPageMarkdownUtils,
} from "@/models/local/page/localCoursePage";
import { assignmentMarkdownSerializer } from "@/models/local/assignment/utils/assignmentMarkdownSerializer";
import { quizMarkdownUtils } from "@/models/local/quiz/utils/quizMarkdownUtils";
import {
CourseItemReturnType,
CourseItemType,
typeToFolder,
} from "@/models/local/courseItemTypes";
const getItemFileNames = async (
courseName: string,
moduleName: string,
type: CourseItemType
) => {
const folder = typeToFolder[type];
const filePath = path.join(basePath, courseName, 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 folder = typeToFolder[type];
const filePath = path.join(
basePath,
courseName,
moduleName,
folder,
name + ".md"
);
const rawFile = (await fs.readFile(filePath, "utf-8")).replace(/\r\n/g, "\n");
if (type === "Assignment") {
return localAssignmentMarkdown.parseMarkdown(
rawFile
) as CourseItemReturnType<T>;
} else if (type === "Quiz") {
return localQuizMarkdownUtils.parseMarkdown(
rawFile
) as CourseItemReturnType<T>;
} else if (type === "Page") {
return localPageMarkdownUtils.parseMarkdown(
rawFile
) 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 typeFolder = typeToFolder[type];
const folder = path.join(basePath, courseName, moduleName, typeFolder);
await fs.mkdir(folder, { recursive: true });
const filePath = path.join(
basePath,
courseName,
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,54 @@
import { promises as fs } from "fs";
import path from "path";
import { basePath, directoryOrFileExists } from "./utils/fileSystemUtils";
import { assignmentsFileStorageService } from "./assignmentsFileStorageService";
import { quizFileStorageService } from "./quizFileStorageService";
import { pageFileStorageService } from "./pageFileStorageService";
import { moduleFileStorageService } from "./moduleFileStorageService";
import { settingsFileStorageService } from "./settingsFileStorageService";
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 = path.join(basePath, 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 });
},
};

View File

@@ -0,0 +1,121 @@
import path from "path";
import { basePath } from "./utils/fileSystemUtils";
import fs from "fs/promises";
import {
getLectureWeekName,
lectureFolderName,
lectureToString,
parseLecture,
} from "./utils/lectureUtils";
import { Lecture } from "@/models/local/lecture";
import {
getDayOfWeek,
LocalCourseSettings,
} from "@/models/local/localCourseSettings";
import { getDateFromStringOrThrow } from "@/models/local/utils/timeUtils";
export async function getLectures(courseName: string) {
const courseLectureRoot = path.join(basePath, courseName, lectureFolderName);
if (!(await directoryExists(courseLectureRoot))) {
return [];
}
const entries = await fs.readdir(courseLectureRoot, { withFileTypes: true });
const lectureWeekFolders = entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
const lecturesByWeek = await Promise.all(
lectureWeekFolders.map(async (weekName) => {
const weekBasePath = path.join(courseLectureRoot, weekName);
const fileNames = await fs.readdir(weekBasePath);
const lectures = await Promise.all(
fileNames.map(async (fileName) => {
const filePath = path.join(weekBasePath, fileName);
const fileContent = await fs.readFile(filePath, "utf-8");
const lecture = parseLecture(fileContent);
return lecture;
})
);
return {
weekName,
lectures,
};
})
);
return lecturesByWeek;
}
export async function updateLecture(
courseName: string,
courseSettings: LocalCourseSettings,
lecture: Lecture
) {
const courseLectureRoot = path.join(basePath, courseName, lectureFolderName);
const lectureDate = getDateFromStringOrThrow(
lecture.date,
"lecture start date in update lecture"
);
const weekFolderName = getLectureWeekName(
courseSettings.startDate,
lecture.date
);
const weekPath = path.join(courseLectureRoot, weekFolderName);
if (!(await directoryExists(weekPath))) {
await fs.mkdir(weekPath, { recursive: true });
}
const lecturePath = path.join(
weekPath,
`${lectureDate.getDay()}-${getDayOfWeek(lectureDate)}.md`
);
const lectureContents = lectureToString(lecture);
await fs.writeFile(lecturePath, lectureContents);
}
export async function deleteLecture(
courseName: string,
courseSettings: LocalCourseSettings,
dayAsString: string
) {
console.log("deleting lecture", courseName, dayAsString);
const lectureDate = getDateFromStringOrThrow(
dayAsString,
"lecture start date in update lecture"
);
const weekFolderName = getLectureWeekName(
courseSettings.startDate,
dayAsString
);
const courseLectureRoot = path.join(basePath, courseName, lectureFolderName);
const weekPath = path.join(courseLectureRoot, weekFolderName);
const lecturePath = path.join(
weekPath,
`${lectureDate.getDay()}-${getDayOfWeek(lectureDate)}.md`
);
try {
await fs.access(lecturePath); // throws error if no file
await fs.unlink(lecturePath);
console.log(`File deleted: ${lecturePath}`);
} catch (error: any) {
if (error?.code === "ENOENT") {
console.log(`Cannot delete lecture, file does not exist: ${lecturePath}`);
} else {
throw error;
}
}
}
const directoryExists = async (path: string): Promise<boolean> => {
try {
const stat = await fs.stat(path);
return stat.isDirectory();
} catch {
return false;
}
};

View File

@@ -0,0 +1,28 @@
import { promises as fs } from "fs";
import path from "path";
import { basePath } from "./utils/fileSystemUtils";
import { lectureFolderName } from "./utils/lectureUtils";
export const moduleFileStorageService = {
async getModuleNames(courseName: string) {
const courseDirectory = path.join(basePath, courseName);
const moduleDirectories = await fs.readdir(courseDirectory, {
withFileTypes: true,
});
const modulePromises = moduleDirectories
.filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name);
const modules = await Promise.all(modulePromises);
const modulesWithoutLectures = modules.filter(
(m) => m !== lectureFolderName
);
return modulesWithoutLectures.sort((a, b) => a.localeCompare(b));
},
async createModule(courseName: string, moduleName: string) {
const courseDirectory = path.join(basePath, courseName);
await fs.mkdir(courseDirectory + "/" + moduleName, { recursive: true });
},
};

View File

@@ -0,0 +1,79 @@
import {
localPageMarkdownUtils,
LocalCoursePage,
} from "@/models/local/page/localCoursePage";
import { promises as fs } from "fs";
import path from "path";
import { basePath } from "./utils/fileSystemUtils";
import { courseItemFileStorageService } from "./courseItemFileStorageService";
export const pageFileStorageService = {
getPage: async (courseName: string, moduleName: string, name: string) =>
await courseItemFileStorageService.getItem(
courseName,
moduleName,
name,
"Page"
),
getPages: async (courseName: string, moduleName: string) =>
await courseItemFileStorageService.getItems(courseName, moduleName, "Page"),
async updatePage({
courseName,
moduleName,
pageName,
page,
}: {
courseName: string;
moduleName: string;
pageName: string;
page: LocalCoursePage;
}) {
const folder = path.join(basePath, courseName, moduleName, "pages");
await fs.mkdir(folder, { recursive: true });
const filePath = path.join(
basePath,
courseName,
moduleName,
"pages",
page.name + ".md"
);
const pageMarkdown = localPageMarkdownUtils.toMarkdown(page);
console.log(`Saving page ${filePath}`);
await fs.writeFile(filePath, pageMarkdown);
const pageNameIsChanged = pageName !== page.name;
if (pageNameIsChanged) {
console.log("removing old page after name change " + pageName);
const oldFilePath = path.join(
basePath,
courseName,
moduleName,
"pages",
pageName + ".md"
);
await fs.unlink(oldFilePath);
}
},
async delete({
courseName,
moduleName,
pageName,
}: {
courseName: string;
moduleName: string;
pageName: string;
}) {
const filePath = path.join(
basePath,
courseName,
moduleName,
"pages",
pageName + ".md"
);
console.log("removing page", filePath);
await fs.unlink(filePath);
},
};

View File

@@ -0,0 +1,63 @@
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";
export const quizFileStorageService = {
getQuiz: async (courseName: string, moduleName: string, quizName: string) =>
await courseItemFileStorageService.getItem(
courseName,
moduleName,
quizName,
"Quiz"
),
getQuizzes: async (courseName: string, moduleName: string) =>
await courseItemFileStorageService.getItems(courseName, moduleName, "Quiz"),
async updateQuiz({
courseName,
moduleName,
quizName,
quiz,
}: {
courseName: string;
moduleName: string;
quizName: string;
quiz: LocalQuiz;
}) {
const folder = path.join(basePath, courseName, moduleName, "quizzes");
await fs.mkdir(folder, { recursive: true });
const filePath = path.join(
basePath,
courseName,
moduleName,
"quizzes",
quizName + ".md"
);
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz);
console.log(`Saving quiz ${filePath}`);
await fs.writeFile(filePath, quizMarkdown);
},
async delete({
courseName,
moduleName,
quizName,
}: {
courseName: string;
moduleName: string;
quizName: string;
}) {
const filePath = path.join(
basePath,
courseName,
moduleName,
"quizzes",
quizName + ".md"
);
console.log("removing quiz", filePath);
await fs.unlink(filePath);
},
};

View File

@@ -0,0 +1,86 @@
import {
LocalCourseSettings,
localCourseYamlUtils,
} from "@/models/local/localCourseSettings";
import { promises as fs } from "fs";
import path from "path";
import {
basePath,
directoryOrFileExists,
getCourseNames,
} from "./utils/fileSystemUtils";
import { AssignmentSubmissionType } from "@/models/local/assignment/assignmentSubmissionType";
const getCourseSettings = async (
courseName: string
): Promise<LocalCourseSettings> => {
const courseDirectory = path.join(basePath, courseName);
const settingsPath = path.join(courseDirectory, "settings.yml");
if (!(await directoryOrFileExists(settingsPath))) {
const errorMessage = `could not find settings for ${courseName}, 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);
const folderName = path.basename(courseDirectory);
return { ...settings, name: folderName };
};
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 courses = await getCourseNames();
const courseSettings = await Promise.all(
courses.map(async (c) => await getCourseSettings(c))
);
return courseSettings;
},
async updateCourseSettings(
courseName: string,
settings: LocalCourseSettings
) {
const courseDirectory = path.join(basePath, 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);
},
};

View File

@@ -0,0 +1,52 @@
import { promises as fs } from "fs";
import path from "path";
export const hasFileSystemEntries = 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;
} catch {
return false;
}
};
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;
}
export const basePath = process.env.STORAGE_DIRECTORY ?? "./storage";
console.log("base path", basePath);

View File

@@ -0,0 +1,48 @@
import { getWeekNumber } from "@/app/course/[courseName]/calendar/calendarMonthUtils";
import { extractLabelValue } from "@/models/local/assignment/utils/markdownUtils";
import { Lecture } from "@/models/local/lecture";
import { getDateFromStringOrThrow } from "@/models/local/utils/timeUtils";
export function parseLecture(fileContent: string): Lecture {
try {
const settings = fileContent.split("---\n")[0];
const name = extractLabelValue(settings, "Name");
const date = extractLabelValue(settings, "Date");
const content = fileContent.split("---\n").slice(1).join("---\n").trim();
return {
name,
date,
content,
};
} catch (error) {
console.error("Error parsing lecture: ", fileContent);
throw error;
}
}
export function lectureToString(lecture: Lecture) {
return `Name: ${lecture.name}
Date: ${lecture.date}
---
${lecture.content}`;
}
export const lectureFolderName = "00 - lectures";
export function getLectureWeekName(semesterStart: string, lectureDate: string) {
const startDate = getDateFromStringOrThrow(
semesterStart,
"semester start date in update lecture"
);
const targetDate = getDateFromStringOrThrow(
lectureDate,
"lecture start date in update lecture"
);
const weekNumber = getWeekNumber(startDate, targetDate)
.toString()
.padStart(2, "0");
const weekName = `week-${weekNumber}`;
return weekName;
}

View File

@@ -0,0 +1,55 @@
"use client";
import { marked } from "marked";
import * as DOMPurify from "isomorphic-dompurify";
import { LocalCourseSettings } from "@/models/local/localCourseSettings";
export function extractImageSources(htmlString: string) {
const srcUrls = [];
const regex = /<img[^>]+src=["']?([^"'>]+)["']?/g;
let match;
while ((match = regex.exec(htmlString)) !== null) {
srcUrls.push(match[1]);
}
return srcUrls;
}
export function convertImagesToCanvasImages(
html: string,
settings: LocalCourseSettings
) {
const imageSources = extractImageSources(html);
let mutableHtml = html;
// console.log(imageSources);
const imageLookup = settings.assets.reduce((acc, asset) => {
return { ...acc, [asset.sourceUrl]: asset.canvasUrl };
}, {} as { [key: string]: string });
for (const imageSrc of imageSources) {
const destinationUrl = imageLookup[imageSrc];
if (typeof destinationUrl === "undefined") {
throw `cannot convert to html, no canvas url for ${imageSrc} in settings`;
}
mutableHtml = mutableHtml.replaceAll(imageSrc, destinationUrl);
}
return mutableHtml;
}
export function markdownToHTMLSafe(
markdownString: string,
settings: LocalCourseSettings
) {
const clean = DOMPurify.sanitize(
marked.parse(markdownString, { async: false, pedantic: false, gfm: true })
);
// return convertImagesToCanvasImages(clean, settings);
return clean;
}
export function markdownToHtmlNoImages(markdownString: string) {
const clean = DOMPurify.sanitize(
marked.parse(markdownString, { async: false, pedantic: false, gfm: true })
);
return clean;
}

View File

@@ -0,0 +1,33 @@
"use client";
import { useState } from "react";
import superjson from "superjson";
import { httpBatchLink } from "@trpc/client";
import { trpc } from "./trpcClient";
import { getQueryClient } from "@/app/providersQueryClientUtils";
import { isServer } from "@tanstack/react-query";
export default function TrpcProvider({
children,
}: {
children: React.ReactNode;
}) {
const url = isServer ? "http://localhost:3000/api/trpc/" : "/api/trpc";
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url,
transformer: superjson,
maxURLLength: 10_000, // limit number of batched requests
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={getQueryClient()}>
{children}
</trpc.Provider>
);
}

View File

@@ -0,0 +1,6 @@
export const createTrpcContext = async () => {
return {};
};
export type TrpcContext = typeof createTrpcContext;

View File

@@ -0,0 +1,5 @@
import { procedure } from "../trpcSetup";
const publicProcedure = procedure;
export default publicProcedure;

View File

@@ -0,0 +1,30 @@
import { createTrpcContext } from "../context";
import { createCallerFactory, router } from "../trpcSetup";
import { assignmentRouter } from "./assignmentRouter";
import { canvasFileRouter } from "./canvasFileRouter";
import { directoriesRouter } from "./directoriesRouter";
import { lectureRouter } from "./lectureRouter";
import { moduleRouter } from "./moduleRouter";
import { pageRouter } from "./pageRouter";
import { quizRouter } from "./quizRouter";
import { settingsRouter } from "./settingsRouter";
export const trpcAppRouter = router({
assignment: assignmentRouter,
lectures: lectureRouter,
settings: settingsRouter,
quiz: quizRouter,
page: pageRouter,
module: moduleRouter,
directories: directoriesRouter,
canvasFile: canvasFileRouter,
});
export const createCaller = createCallerFactory(trpcAppRouter);
export const createAsyncCaller = async () => {
const context = await createTrpcContext();
return createCaller(context);
};
export type AppRouter = typeof trpcAppRouter;

View File

@@ -0,0 +1,116 @@
import publicProcedure from "../procedures/public";
import { z } from "zod";
import { router } from "../trpcSetup";
import { fileStorageService } from "@/services/fileStorage/fileStorageService";
import { zodLocalAssignment } from "@/models/local/assignment/localAssignment";
export const assignmentRouter = router({
getAssignment: publicProcedure
.input(
z.object({
courseName: z.string(),
moduleName: z.string(),
assignmentName: z.string(),
})
)
.query(async ({ input: { courseName, moduleName, assignmentName } }) => {
const assignment = await fileStorageService.assignments.getAssignment(
courseName,
moduleName,
assignmentName
);
// console.log(assignment);
return assignment;
}),
getAllAssignments: publicProcedure
.input(
z.object({
courseName: z.string(),
moduleName: z.string(),
})
)
.query(async ({ input: { courseName, moduleName } }) => {
const assignments = await fileStorageService.assignments.getAssignments(
courseName,
moduleName
);
return assignments;
}),
createAssignment: publicProcedure
.input(
z.object({
courseName: z.string(),
moduleName: z.string(),
assignmentName: z.string(),
assignment: zodLocalAssignment,
})
)
.mutation(
async ({
input: { courseName, moduleName, assignmentName, assignment },
}) => {
await fileStorageService.assignments.updateOrCreateAssignment({
courseName,
moduleName,
assignmentName,
assignment,
});
}
),
updateAssignment: publicProcedure
.input(
z.object({
courseName: z.string(),
moduleName: z.string(),
previousModuleName: z.string(),
previousAssignmentName: z.string(),
assignmentName: z.string(),
assignment: zodLocalAssignment,
})
)
.mutation(
async ({
input: {
courseName,
moduleName,
assignmentName,
assignment,
previousModuleName,
previousAssignmentName,
},
}) => {
await fileStorageService.assignments.updateOrCreateAssignment({
courseName,
moduleName,
assignmentName,
assignment,
});
if (
assignment.name !== previousAssignmentName ||
moduleName !== previousModuleName
) {
fileStorageService.assignments.delete({
courseName,
moduleName: previousModuleName,
assignmentName: previousAssignmentName,
});
}
}
),
deleteAssignment: publicProcedure
.input(
z.object({
courseName: z.string(),
moduleName: z.string(),
assignmentName: z.string(),
})
)
.mutation(async ({ input: { courseName, moduleName, assignmentName } }) => {
await fileStorageService.assignments.delete({
courseName,
moduleName,
assignmentName,
});
}),
});

View File

@@ -0,0 +1,34 @@
import publicProcedure from "../procedures/public";
import { z } from "zod";
import { router } from "../trpcSetup";
import {
downloadUrlToTempDirectory,
uploadToCanvasPart1,
uploadToCanvasPart2,
} from "@/services/canvas/files/canvasFileService";
export const canvasFileRouter = router({
getCanvasFileUrl: publicProcedure
.input(
z.object({
sourceUrl: z.string(),
canvasCourseId: z.number(),
})
)
.mutation(async ({ input: { sourceUrl, canvasCourseId } }) => {
const localTempFile = await downloadUrlToTempDirectory(sourceUrl);
console.log("local temp file", localTempFile);
const { upload_url, upload_params } = await uploadToCanvasPart1(
localTempFile,
canvasCourseId
);
console.log("part 1 done", upload_url, upload_params);
const canvasUrl = await uploadToCanvasPart2({
pathToUpload: localTempFile,
upload_url,
upload_params,
});
console.log("canvas url done");
return canvasUrl;
}),
});

View File

@@ -0,0 +1,11 @@
import publicProcedure from "../procedures/public";
import { z } from "zod";
import { router } from "../trpcSetup";
import { fileStorageService } from "@/services/fileStorage/fileStorageService";
import { zodLocalAssignment } from "@/models/local/assignment/localAssignment";
export const directoriesRouter = router({
getEmptyDirectories: publicProcedure.query(async () => {
return await fileStorageService.getEmptyDirectories();
}),
});

View File

@@ -0,0 +1,51 @@
import { z } from "zod";
import publicProcedure from "../procedures/public";
import { router } from "../trpcSetup";
import {
deleteLecture,
getLectures,
updateLecture,
} from "@/services/fileStorage/lectureFileStorageService";
import { zodLecture } from "@/models/local/lecture";
import { zodLocalCourseSettings } from "@/models/local/localCourseSettings";
export const lectureRouter = router({
getLectures: publicProcedure
.input(
z.object({
courseName: z.string(),
})
)
.query(async ({ input: { courseName } }) => {
return await getLectures(courseName);
}),
updateLecture: publicProcedure
.input(
z.object({
courseName: z.string(),
lecture: zodLecture,
previousDay: z.string().optional(),
settings: zodLocalCourseSettings,
})
)
.mutation(
async ({ input: { courseName, settings, lecture, previousDay } }) => {
await updateLecture(courseName, settings, lecture);
if (previousDay && previousDay !== lecture.date) {
await deleteLecture(courseName, settings, previousDay);
}
}
),
deleteLecture: publicProcedure
.input(
z.object({
courseName: z.string(),
lectureDay: z.string(),
settings: zodLocalCourseSettings,
})
)
.mutation(async ({ input: { courseName, settings, lectureDay } }) => {
await deleteLecture(courseName, settings, lectureDay);
}),
});

View File

@@ -0,0 +1,26 @@
import { z } from "zod";
import publicProcedure from "../procedures/public";
import { router } from "../trpcSetup";
import { fileStorageService } from "@/services/fileStorage/fileStorageService";
export const moduleRouter = router({
getModuleNames: publicProcedure
.input(
z.object({
courseName: z.string(),
})
)
.query(async ({ input: { courseName } }) => {
return await fileStorageService.modules.getModuleNames(courseName);
}),
createModule: publicProcedure
.input(
z.object({
courseName: z.string(),
moduleName: z.string(),
})
)
.mutation(async ({ input: { courseName, moduleName } }) => {
await fileStorageService.modules.createModule(courseName, moduleName);
}),
});

View File

@@ -0,0 +1,107 @@
import publicProcedure from "../procedures/public";
import { z } from "zod";
import { router } from "../trpcSetup";
import { fileStorageService } from "@/services/fileStorage/fileStorageService";
import { zodLocalCoursePage } from "@/models/local/page/localCoursePage";
export const pageRouter = router({
getPage: publicProcedure
.input(
z.object({
courseName: z.string(),
moduleName: z.string(),
pageName: z.string(),
})
)
.query(async ({ input: { courseName, moduleName, pageName } }) => {
return await fileStorageService.pages.getPage(
courseName,
moduleName,
pageName
);
}),
getAllPages: publicProcedure
.input(
z.object({
courseName: z.string(),
moduleName: z.string(),
})
)
.query(async ({ input: { courseName, moduleName } }) => {
return await fileStorageService.pages.getPages(courseName, moduleName);
}),
createPage: publicProcedure
.input(
z.object({
courseName: z.string(),
moduleName: z.string(),
pageName: z.string(),
page: zodLocalCoursePage,
})
)
.mutation(async ({ input: { courseName, moduleName, pageName, page } }) => {
await fileStorageService.pages.updatePage({
courseName,
moduleName,
pageName,
page,
});
}),
updatePage: publicProcedure
.input(
z.object({
courseName: z.string(),
moduleName: z.string(),
previousModuleName: z.string(),
previousPageName: z.string(),
pageName: z.string(),
page: zodLocalCoursePage,
})
)
.mutation(
async ({
input: {
courseName,
moduleName,
pageName,
page,
previousModuleName,
previousPageName,
},
}) => {
await fileStorageService.pages.updatePage({
courseName,
moduleName,
pageName,
page,
});
if (
page.name !== previousPageName ||
moduleName !== previousModuleName
) {
fileStorageService.pages.delete({
courseName,
moduleName: previousModuleName,
pageName: previousPageName,
});
}
}
),
deletePage: publicProcedure
.input(
z.object({
courseName: z.string(),
moduleName: z.string(),
pageName: z.string(),
})
)
.mutation(async ({ input: { courseName, moduleName, pageName } }) => {
await fileStorageService.pages.delete({
courseName,
moduleName,
pageName,
});
}),
});

View File

@@ -0,0 +1,110 @@
import publicProcedure from "../procedures/public";
import { z } from "zod";
import { router } from "../trpcSetup";
import { fileStorageService } from "@/services/fileStorage/fileStorageService";
import { zodLocalQuiz } from "@/models/local/quiz/localQuiz";
export const quizRouter = router({
getQuiz: publicProcedure
.input(
z.object({
courseName: z.string(),
moduleName: z.string(),
quizName: z.string(),
})
)
.query(async ({ input: { courseName, moduleName, quizName } }) => {
return await fileStorageService.quizzes.getQuiz(
courseName,
moduleName,
quizName
);
}),
getAllQuizzes: publicProcedure
.input(
z.object({
courseName: z.string(),
moduleName: z.string(),
})
)
.query(async ({ input: { courseName, moduleName } }) => {
return await fileStorageService.quizzes.getQuizzes(
courseName,
moduleName
);
}),
createQuiz: publicProcedure
.input(
z.object({
courseName: z.string(),
moduleName: z.string(),
quizName: z.string(),
quiz: zodLocalQuiz,
})
)
.mutation(async ({ input: { courseName, moduleName, quizName, quiz } }) => {
await fileStorageService.quizzes.updateQuiz({
courseName,
moduleName,
quizName,
quiz,
});
}),
updateQuiz: publicProcedure
.input(
z.object({
courseName: z.string(),
moduleName: z.string(),
previousModuleName: z.string(),
previousQuizName: z.string(),
quizName: z.string(),
quiz: zodLocalQuiz,
})
)
.mutation(
async ({
input: {
courseName,
moduleName,
quizName,
quiz,
previousModuleName,
previousQuizName,
},
}) => {
await fileStorageService.quizzes.updateQuiz({
courseName,
moduleName,
quizName,
quiz,
});
if (
quiz.name !== previousQuizName ||
moduleName !== previousModuleName
) {
fileStorageService.quizzes.delete({
courseName,
moduleName: previousModuleName,
quizName: previousQuizName,
});
}
}
),
deleteQuiz: publicProcedure
.input(
z.object({
courseName: z.string(),
moduleName: z.string(),
quizName: z.string(),
})
)
.mutation(async ({ input: { courseName, moduleName, quizName } }) => {
await fileStorageService.quizzes.delete({
courseName,
moduleName,
quizName,
});
}),
});

View File

@@ -0,0 +1,148 @@
import publicProcedure from "../procedures/public";
import { z } from "zod";
import { router } from "../trpcSetup";
import { fileStorageService } from "@/services/fileStorage/fileStorageService";
import { zodLocalCourseSettings } from "@/models/local/localCourseSettings";
import { trpc } from "../trpcClient";
import {
getLectures,
updateLecture,
} from "@/services/fileStorage/lectureFileStorageService";
import {
prepAssignmentForNewSemester,
prepLectureForNewSemester,
prepPageForNewSemester,
prepQuizForNewSemester,
} from "@/models/local/utils/semesterTransferUtils";
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({
settings: zodLocalCourseSettings,
settingsFromCourseToImport: zodLocalCourseSettings.optional(),
})
)
.mutation(async ({ input: { settings, settingsFromCourseToImport } }) => {
await fileStorageService.settings.updateCourseSettings(
settings.name,
settings
);
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,4 @@
import { createTRPCReact } from "@trpc/react-query";
import { AppRouter } from "./router/app";
export const trpc = createTRPCReact<AppRouter>();

View File

@@ -0,0 +1,13 @@
import { initTRPC } from "@trpc/server";
import superjson from 'superjson';
const t = initTRPC.create({
transformer: superjson,
});
export const middleware = t.middleware;
export const createCallerFactory = t.createCallerFactory;
export const mergeRouters = t.mergeRouters;
export const router = t.router;
export const procedure = t.procedure;

View File

@@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach } from "vitest";
import { promises as fs } from "fs";
import {
DayOfWeek,
LocalCourseSettings,
} from "@/models/local/localCourseSettings";
import { fileStorageService } from "../fileStorage/fileStorageService";
describe("FileStorageTests", () => {
beforeEach(async () => {
const storageDirectory =
process.env.STORAGE_DIRECTORY ?? "/tmp/canvasManagerTests";
try {
await fs.access(storageDirectory);
await fs.rm(storageDirectory, { recursive: true });
} catch (error) {}
await fs.mkdir(storageDirectory, { recursive: true });
});
it("course settings can be saved and loaded", async () => {
const name = "test empty course";
await fileStorageService.createCourseFolderForTesting(name);
const settings: LocalCourseSettings = {
name,
assignmentGroups: [],
daysOfWeek: [DayOfWeek.Monday, DayOfWeek.Wednesday],
startDate: "07/09/2024 23:59:00",
endDate: "07/09/2024 23:59:00",
defaultDueTime: { hour: 1, minute: 59 },
canvasId: 0,
defaultAssignmentSubmissionTypes: [],
defaultFileUploadTypes: [],
holidays: [],
assets: []
};
await fileStorageService.settings.updateCourseSettings(name, settings);
const loadedSettings = await fileStorageService.settings.getCourseSettings(
name
);
expect(loadedSettings).toEqual(settings);
});
it("empty course modules can be created", async () => {
const courseName = "test empty course";
const moduleName = "test module 1";
await fileStorageService.modules.createModule(courseName, moduleName);
const moduleNames = await fileStorageService.modules.getModuleNames(
courseName
);
expect(moduleNames).toContain(moduleName);
});
});

View File

@@ -0,0 +1,167 @@
import { describe, it, expect, beforeEach } from "vitest";
import { promises as fs } from "fs";
import { fileStorageService } from "../fileStorage/fileStorageService";
import { basePath } from "../fileStorage/utils/fileSystemUtils";
describe("FileStorageTests", () => {
beforeEach(async () => {
const storageDirectory =
process.env.STORAGE_DIRECTORY ?? "/tmp/canvasManagerTests";
try {
await fs.access(storageDirectory);
await fs.rm(storageDirectory, { recursive: true });
} catch (error) {}
await fs.mkdir(storageDirectory, { recursive: true });
});
it("invalid quizzes do not get loaded", async () => {
const courseName = "testCourse";
const moduleName = "testModule";
const validQuizMarkdown = `Name: validQuiz
LockAt: 08/28/2024 23:59:00
DueAt: 08/28/2024 23:59:00
Password:
ShuffleAnswers: true
ShowCorrectAnswers: false
OneQuestionAtATime: false
AssignmentGroup: Assignments
AllowedAttempts: -1
Description: Repeat this quiz until you can complete it without notes/help.
---
Points: 0.25
An empty string is
a) truthy
*b) falsy
`;
const invalidQuizMarkdown = "name: testQuiz\n---\nnot a quiz";
await fileStorageService.createCourseFolderForTesting(courseName);
await fileStorageService.modules.createModule(courseName, moduleName);
await fs.mkdir(`${basePath}/${courseName}/${moduleName}/quizzes`, {
recursive: true,
});
await fs.writeFile(
`${basePath}/${courseName}/${moduleName}/quizzes/testQuiz.md`,
invalidQuizMarkdown
);
await fs.writeFile(
`${basePath}/${courseName}/${moduleName}/quizzes/validQuiz.md`,
validQuizMarkdown
);
const quizzes = await fileStorageService.quizzes.getQuizzes(
courseName,
moduleName
);
const quizNames = quizzes.map((q) => q.name);
expect(quizNames).not.includes("testQuiz");
expect(quizNames).include("validQuiz");
});
// it("invalid quizes give error messages", async () => {
// const courseName = "testCourse";
// const moduleName = "testModule";
// const invalidQuizMarkdown = "name: testQuiz\n---\nnot a quiz";
// await fileStorageService.createCourseFolderForTesting(courseName);
// await fileStorageService.modules.createModule(courseName, moduleName);
// await fs.mkdir(`${basePath}/${courseName}/${moduleName}/quizzes`, {
// recursive: true,
// });
// await fs.writeFile(
// `${basePath}/${courseName}/${moduleName}/quizzes/testQuiz.md`,
// invalidQuizMarkdown
// );
// const invalidReasons = await fileStorageService.quizzes.getInvalidQuizzes(
// courseName,
// moduleName
// );
// const invalidQuiz = invalidReasons.filter((q) => q.quizName === "testQuiz");
// expect(invalidQuiz.reason).is("testQuiz");
// });
it("invalid assignments dont get loaded", async () => {
const courseName = "testCourse";
const moduleName = "testModule";
const validAssignmentMarkdown = `Name: testAssignment
LockAt: 09/19/2024 23:59:00
DueAt: 09/19/2024 23:59:00
AssignmentGroupName: Assignments
SubmissionTypes:
- online_text_entry
- online_upload
AllowedFileUploadExtensions:
- pdf
---
description
## Rubric
- 2pts: animation has at least 5 transition states
`;
const invalidAssignment = "name: invalidAssignment\n---\nnot an assignment";
await fileStorageService.createCourseFolderForTesting(courseName);
await fileStorageService.modules.createModule(courseName, moduleName);
await fs.mkdir(`${basePath}/${courseName}/${moduleName}/assignments`, {
recursive: true,
});
await fs.writeFile(
`${basePath}/${courseName}/${moduleName}/assignments/testAssignment.md`,
validAssignmentMarkdown
);
await fs.writeFile(
`${basePath}/${courseName}/${moduleName}/assignments/invalidAssignment.md`,
invalidAssignment
);
const assignments = await fileStorageService.assignments.getAssignments(
courseName,
moduleName
);
const assignmentNames = assignments.map((q) => q.name);
expect(assignmentNames).not.includes("invalidAssignment");
expect(assignmentNames).include("testAssignment");
});
it("invalid pages dont get loaded", async () => {
const courseName = "testCourse";
const moduleName = "testModule";
const validPageMarkdown = `Name: validPage
DueDateForOrdering: 08/31/2024 23:59:00
---
# Deploying React
`;
const invalidPageMarkdown = `Name: invalidPage
DueDateFo59:00
---
# Deploying React`;
await fileStorageService.createCourseFolderForTesting(courseName);
await fileStorageService.modules.createModule(courseName, moduleName);
await fs.mkdir(`${basePath}/${courseName}/${moduleName}/pages`, {
recursive: true,
});
await fs.writeFile(
`${basePath}/${courseName}/${moduleName}/pages/validPage.md`,
validPageMarkdown
);
await fs.writeFile(
`${basePath}/${courseName}/${moduleName}/pages/invalidPage.md`,
invalidPageMarkdown
);
const pages = await fileStorageService.pages.getPages(
courseName,
moduleName
);
const assignmentNames = pages.map((q) => q.name);
expect(assignmentNames).include("validPage");
expect(assignmentNames).not.includes("invalidPage");
});
});

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { lectureToString } from "../fileStorage/utils/lectureUtils";
import { parseLecture } from "../fileStorage/utils/lectureUtils";
import { Lecture } from "@/models/local/lecture";
describe("can parse and stringify lectures", () => {
it("can parse lecture", () => {
const rawLecture = `
Name: some name
Date: 6/22/2024
---
this is the lecture
content`;
const parsed = parseLecture(rawLecture);
expect(parsed.name).toBe("some name");
expect(parsed.date).toBe("6/22/2024");
expect(parsed.content).toBe(`this is the lecture
content`);
});
it("parsing and stringification is deterministic", () => {
const lecture: Lecture = {
name: "test lecture",
date: "06/*22/2024",
content: `some content
- with
- a
- list`,
};
const rawLecture = lectureToString(lecture);
const parsedLecture = parseLecture(rawLecture);
expect(parsedLecture).toStrictEqual(lecture);
});
});

34
src/services/urlUtils.ts Normal file
View File

@@ -0,0 +1,34 @@
export function getModuleItemUrl(
courseName: string,
moduleName: string,
type: "assignment" | "page" | "quiz",
itemName: string
) {
return (
"/course/" +
encodeURIComponent(courseName) +
"/modules/" +
encodeURIComponent(moduleName) +
`/${type}/` +
encodeURIComponent(itemName)
);
}
export function getLectureUrl(courseName: string, lectureDate: string) {
return (
"/course/" +
encodeURIComponent(courseName) +
"/lecture/" +
encodeURIComponent(lectureDate)
);
}
export function getLecturePreviewUrl(courseName: string, lectureDate: string) {
return getLectureUrl(courseName, lectureDate) + "/preview";
}
export function getCourseUrl(courseName: string) {
return "/course/" + encodeURIComponent(courseName);
}
export function getCourseSettingsUrl(courseName: string) {
return "/course/" + encodeURIComponent(courseName) + "/settings";
}

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { removeHtmlDetails } from "./htmlIsCloseEnough";
describe("html can be close enough", () => {
it("scenario 1", () => {
const localHTML = `<h2>Local Development Environment</h2><p>Integrate your api </p><ol><li>Set up a docker compose file for local development inside your react frontend git repo.<ul><li>Have two services, one for the client and the other for the api</li><li>Reference the <code>:latest</code> image for your api container</li></ul></li><li>Be sure to mount your react client code as a volume to enable hot reloading</li><li>Configure vite to proxy <code>/api</code> requests to the api container</li><li>Your api should still persist the data, even if the container gets re-created (use a volume)</li></ol><h2>Calling Your API from React</h2><p>Create an <code>apiService.ts</code> file to store all functions that make api calls. Have your inventory item context load your items from the api on startup. Use the api calls for all CRUD operations on your app. When modifying the list of items, ensure the item is modified on the api before you show the modified value on the client.</p><h2>Submission</h2><p>Submit screenshots of code relating to each rubric item. Include 1+ questions you would like to ask a the beginning of class</p>`;
const canvasHTML = `<link rel="stylesheet" href="https://instructure-uploads-2.s3.amazonaws.com/account_20000000000010/attachments/162497727/DesignPlus%20Mobile%20%25282024%20May%2013%2529.css"><h2>Local Development Environment</h2><p>Integrate your api </p><ol><li>Set up a docker compose file for local development inside your react frontend git repo.<ul><li>Have two services, one for the client and the other for the api</li><li>Reference the <code>:latest</code> image for your api container</li></ul></li><li>Be sure to mount your react client code as a volume to enable hot reloading</li><li>Configure vite to proxy <code>/api</code> requests to the api container</li><li>Your api should still persist the data, even if the container gets re-created (use a volume)</li></ol><h2>Calling Your API from React</h2><p>Create an <code>apiService.ts</code> file to store all functions that make api calls. Have your inventory item context load your items from the api on startup. Use the api calls for all CRUD operations on your app. When modifying the list of items, ensure the item is modified on the api before you show the modified value on the client.</p><h2>Submission</h2><p>Submit screenshots of code relating to each rubric item. Include 1+ questions you would like to ask a the beginning of class</p><script src="https://instructure-uploads-2.s3.amazonaws.com/account_20000000000010/attachments/162497726/DesignPlus%20Mobile%20Java%20%25282024%20May%2013%2529.js"></script>`;
expect(removeHtmlDetails(localHTML)).toBe(removeHtmlDetails(canvasHTML));
});
it("scenario 2", () => {
const localHTML = `<p>the changesasdf</p>`;
const canvasHTML = `<link rel="stylesheet" href="https://instructure-uploads-2.s3.amazonaws.com/account_20000000000010/attachments/162497727/DesignPlus%20Mobile%20%25282024%20May%2013%2529.css"><p>the changes\nasdf</p>\n<h2>Local Development Environment</h2>\n<p>Integrate your api </p>\n<ol>\n<li>Set up a docker compose file for local development inside your react frontend git repo.<ul>\n<li>Have two services, one for the client and the other for the api</li>\n<li>Reference the <code>:latest</code> image for your api container</li>\n</ul>\n</li>\n<li>Be sure to mount your react client code as a volume to enable hot reloading</li>\n<li>Configure vite to proxy <code>/api</code> requests to the api container</li>\n<li>Your api should still persist the data, even if the container gets re-created (use a volume)</li>\n</ol>\n<h2>Calling Your API from React</h2>\n<p>Create an <code>apiService.ts</code> file to store all functions that make api calls. Have your inventory item context load your items from the api on startup. Use the api calls for all CRUD operations on your app. When modifying the list of items, ensure the item is modified on the api before you show the modified value on the client.</p>\n<h2>Submission</h2>\n<p>Submit screenshots of code relating to each rubric item. Include 1+ questions you would like to ask a the beginning of class</p>\n<script src="https://instructure-uploads-2.s3.amazonaws.com/account_20000000000010/attachments/162497726/DesignPlus%20Mobile%20Java%20%25282024%20May%2013%2529.js"></script>"`;
expect(removeHtmlDetails(localHTML)).not.toBe(removeHtmlDetails(canvasHTML));
});
it("scenario 3", () => {
const localHTML = `<p>Create a custom component to properly style, format, and validate text input elements. Replace all text inputs in your list detail app with this new text input component. You may write addition custom hooks to add functionality. This component should include the "supporting" html elements to the input, like the label and the feedback.</p><p>Requirements:</p><ol><li>Have a way for have the parent specify validation rules for the input<ul><li>Support at least required, min/max length, regex pattern</li></ul></li><li>The parent can set and read the data from the input</li><li>The component looks good in any layout (e.g. looks good for any width given to it by the parent)</li><li>The component informs the parent if the value is valid or invalid</li><li>The component visually indicates if the value is valid or invalid after the input has been touched by the user.</li></ol><p>Push your code to the same repo</p><p>Submit a text entry about how it went and a question you would like to bring up with the class. Submit a screenshot of your re-usable input component's code and your component running on your webpage.</p><h2>Extra Credit</h2><p>Add debouncing to your validation. Indicate in your submission if you did this.</p>`
const canvasHTML = "\u003clink rel=\"stylesheet\" href=\"https://instructure-uploads-2.s3.amazonaws.com/account_20000000000010/attachments/162497727/DesignPlus%20Mobile%20%25282024%20May%2013%2529.css\"\u003e\u003cp\u003eCreate a custom component to properly style, format, and validate text input elements. Replace all text inputs in your list detail app with this new text input component. You may write addition custom hooks to add functionality. This component should include the \"supporting\" html elements to the input, like the label and the feedback.\u003c/p\u003e\n\u003cp\u003eRequirements:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003eHave a way for have the parent specify validation rules for the input\n\u003cul\u003e\n\u003cli\u003eSupport at least required, min/max length, regex pattern\u003c/li\u003e\n\u003c/ul\u003e\n\u003c/li\u003e\n\u003cli\u003eThe parent can set and read the data from the input\u003c/li\u003e\n\u003cli\u003eThe component looks good in any layout (e.g. looks good for any width given to it by the parent)\u003c/li\u003e\n\u003cli\u003eThe component informs the parent if the value is valid or invalid\u003c/li\u003e\n\u003cli\u003eThe component visually indicates if the value is valid or invalid after the input has been touched by the user.\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003ePush your code to the same repo\u003c/p\u003e\n\u003cp\u003eSubmit a text entry about how it went and a question you would like to bring up with the class. Submit a screenshot of your re-usable input component's code and your component running on your webpage.\u003c/p\u003e\n\u003ch2 id=\"extra-credit\"\u003eExtra Credit\u003c/h2\u003e\n\u003cp\u003eAdd debouncing to your validation. Indicate in your submission if you did this.\u003c/p\u003e\n\u003cscript src=\"https://instructure-uploads-2.s3.amazonaws.com/account_20000000000010/attachments/162497726/DesignPlus%20Mobile%20Java%20%25282024%20May%2013%2529.js\"\u003e\u003c/script\u003e";
expect(removeHtmlDetails(localHTML)).toBe(removeHtmlDetails(canvasHTML));
});
});

View File

@@ -0,0 +1,36 @@
const scriptRegex = /<script.*?<\/script>/g;
const linkTagRegex = /<link\s+rel="[^"]*"\s+href="[^"]*"[^>]*>/g;
const htmlAttribute = /\s+\w+="[^"]*"|\s+\w+='[^']*'|\s+\w+=[^\s>]+/g;
function replaceUnicodeEscapes(input: string) {
return input.replace(/\\u[\dA-Fa-f]{4}/g, (match) => {
// Extract the hexadecimal part and convert it to a character
return String.fromCharCode(parseInt(match.slice(2), 16));
});
}
export const removeHtmlDetails = (html: string) => {
const withoutUnicode = replaceUnicodeEscapes(html);
return withoutUnicode
.replaceAll(scriptRegex, "")
.replaceAll(linkTagRegex, "")
.replaceAll(htmlAttribute, "")
.replaceAll(/\\"/g, '"')
.replaceAll(/\s/g, "")
.replaceAll(/<hr\s*\/?>/g, "<hr>")
.replaceAll(/<br\s*\/?>/g, "<br>")
.replaceAll(/&gt;/g, "")
.replaceAll(/&lt;/g, "")
.replaceAll(/>/g, "")
.replaceAll(/</g, "")
.replaceAll(/&quot;/g, "")
.replaceAll(/"/g, "")
.replaceAll(/&amp;/g, "")
.replaceAll(/&/g, "");
};
export const htmlIsCloseEnough = (html1: string, html2: string) => {
const simple1 = removeHtmlDetails(html1);
const simple2 = removeHtmlDetails(html2);
return simple1 === simple2;
};

View File

@@ -0,0 +1,124 @@
import toast, { ErrorIcon, CheckmarkIcon } from "react-hot-toast";
import { ReactNode } from "react";
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
const addErrorAsToast = async (error: any) => {
console.error("error from toast", error);
const message = getErrorMessage(error);
toast(
(t: any) => (
<div className="row">
<div className="col-auto my-auto">
<ErrorIcon />
</div>
<div className="col my-auto">
<div className="white-space">{message}</div>
<div>
<a
href="https://snow.kualibuild.com/app/651eeebc32976c013a4c4739/run"
target="_blank"
rel="noreferrer"
>
Report Bug
</a>
</div>
</div>
<div className="col-auto my-auto">
<button
onClick={() => toast.dismiss(t.id)}
className="btn btn-outline-secondary btn-sm"
>
<i className="bi bi-x"></i>
</button>
</div>
</div>
),
{
duration: Infinity,
}
);
};
export function getErrorMessage(error: any) {
if (error?.response?.status === 422) {
console.log(error.response.data.detail);
const serializationMessages = error.response.data.detail.map(
(d: any) => `${d.type} - ${d.loc[1]}`
);
return `Deserialization error on request:\n${serializationMessages.join(
"\n"
)}`;
}
if (typeof error === "string") {
return error;
}
if (error.response?.data.detail) {
if (typeof error.response?.data.detail === "string") {
return error.response?.data.detail;
} else return JSON.stringify(error.response?.data.detail);
}
console.log(error);
return "Error With Request";
}
export function createInfoToast(
children: ReactNode,
onClose: () => void = () => {}
) {
const closeHandler = (t: any) => {
toast.dismiss(t.id);
onClose();
};
toast(
(t: any) => (
<div className="row">
<div className="col-auto my-auto">
<i className="bi bi-info-circle-fill"></i>
</div>
<div className="col my-auto">{children}</div>
<div className="col-auto my-auto">
<button
onClick={() => closeHandler(t)}
className="btn btn-outline-secondary py-1"
>
<i className="bi-x-lg" />
</button>
</div>
</div>
),
{
duration: Infinity,
style: {
maxWidth: "75em",
},
}
);
}
export function createSuccessToast(message: string) {
toast(
(t: any) => (
<div className="row">
<div className="col-auto my-auto">
<CheckmarkIcon />
</div>
<div className="col my-auto"> {message}</div>
<div className="col-auto my-auto">
<button
onClick={() => toast.dismiss(t.id)}
className="btn btn-outline-secondary py-1"
>
<i className="bi-x-lg" />
</button>
</div>
</div>
),
{
duration: Infinity,
style: {
maxWidth: "75em",
},
}
);
}

View File

@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
export async function withErrorHandling(handler: () => Promise<Response | NextResponse>) {
try {
return await handler();
} catch (error) {
console.error("Error caught in centralized handler:", error);
return NextResponse.json(
{ error: (error as Error).message || "Internal Server Error" },
{ status: 500 }
);
}
}