diff --git a/nextjs/package.json b/nextjs/package.json index 75b95a2..f998faa 100644 --- a/nextjs/package.json +++ b/nextjs/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test": "vitest" }, "dependencies": { "@tanstack/react-query": "^5.52.0", diff --git a/nextjs/src/hooks/cavnasCouresHooks.ts b/nextjs/src/hooks/cavnasCouresHooks.ts index b4096b7..a9f3e12 100644 --- a/nextjs/src/hooks/cavnasCouresHooks.ts +++ b/nextjs/src/hooks/cavnasCouresHooks.ts @@ -1,11 +1,12 @@ +import { canvasService } from "@/services/canvas/canvasService"; import { useSuspenseQuery } from "@tanstack/react-query"; export const canvasCourseKeys = { courseDetails: (canavasId: number) => ["canvas course", canavasId] as const, }; - -export const useCanvasCourseQuery =(canvasId: number) => useSuspenseQuery({ - queryKey: canvasCourseKeys.courseDetails(canvasId), - queryFn: async () => canvasserv -}) +export const useCanvasCourseQuery = (canvasId: number) => + useSuspenseQuery({ + queryKey: canvasCourseKeys.courseDetails(canvasId), + queryFn: async () => await canvasService.getCourse(canvasId), + }); diff --git a/nextjs/src/models/local/assignmnet/assignmentSubmissionType.ts b/nextjs/src/models/local/assignmnet/assignmentSubmissionType.ts new file mode 100644 index 0000000..30a0582 --- /dev/null +++ b/nextjs/src/models/local/assignmnet/assignmentSubmissionType.ts @@ -0,0 +1,8 @@ +export enum AssignmentSubmissionType { + OnlineTextEntry = "online_text_entry", + OnlineUpload = "online_upload", + OnlineQuiz = "online_quiz", + DiscussionTopic = "discussion_topic", + OnlineUrl = "online_url", + None = "none" +} diff --git a/nextjs/src/models/local/assignmnet/localAssignment.ts b/nextjs/src/models/local/assignmnet/localAssignment.ts new file mode 100644 index 0000000..339f0a4 --- /dev/null +++ b/nextjs/src/models/local/assignmnet/localAssignment.ts @@ -0,0 +1,13 @@ +import { AssignmentSubmissionType } from "./assignmentSubmissionType"; +import { RubricItem } from "./rubricItem"; + +export interface LocalAssignment { + name: string; + description: string; + lockAt?: string; // ISO 8601 date string + dueAt: string; // ISO 8601 date string + localAssignmentGroupName?: string; + submissionTypes: AssignmentSubmissionType[]; + allowedFileUploadExtensions: string[]; + rubric: RubricItem[]; +} diff --git a/nextjs/src/models/local/assignmnet/rubricItem.ts b/nextjs/src/models/local/assignmnet/rubricItem.ts new file mode 100644 index 0000000..0cd5423 --- /dev/null +++ b/nextjs/src/models/local/assignmnet/rubricItem.ts @@ -0,0 +1,4 @@ +export interface RubricItem { + label: string; + points: number; +} diff --git a/nextjs/src/models/local/assignmnet/utils/assignmentMarkdownParser.ts b/nextjs/src/models/local/assignmnet/utils/assignmentMarkdownParser.ts new file mode 100644 index 0000000..e26ae90 --- /dev/null +++ b/nextjs/src/models/local/assignmnet/utils/assignmentMarkdownParser.ts @@ -0,0 +1,48 @@ +import { AssignmentSubmissionType } from "../assignmentSubmissionType"; +import { LocalAssignment } from "../localAssignment"; +import { RubricItem } from "../rubricItem"; + +const assignmentRubricToMarkdown = (assignment: LocalAssignment) => { + return assignment.rubric + .map((item: RubricItem) => { + const pointLabel = item.points > 1 ? "pts" : "pt"; + return `- ${item.points}${pointLabel}: ${item.label}`; + }) + .join("\n"); +}; + +const settingsToMarkdown = (assignment: LocalAssignment) => { + const printableDueDate = assignment.dueAt.toString().replace("\u202F", " "); + const printableLockAt = + assignment.lockAt?.toString().replace("\u202F", " ") || ""; + + const submissionTypesMarkdown = assignment.submissionTypes + .map((submissionType: AssignmentSubmissionType) => `- ${submissionType}`) + .join("\n"); + + const allowedFileUploadExtensionsMarkdown = + assignment.allowedFileUploadExtensions + .map((fileExtension: string) => `- ${fileExtension}`) + .join("\n"); + + const settingsMarkdown = [ + `Name: ${assignment.name}`, + `LockAt: ${printableLockAt}`, + `DueAt: ${printableDueDate}`, + `AssignmentGroupName: ${assignment.localAssignmentGroupName}`, + `SubmissionTypes:\n${submissionTypesMarkdown}`, + `AllowedFileUploadExtensions:\n${allowedFileUploadExtensionsMarkdown}`, + ].join("\n"); + + return settingsMarkdown; +}; + +export const assignmentMarkdownStringifier = { + toMarkdown(assignment: LocalAssignment): string { + const settingsMarkdown = settingsToMarkdown(assignment); + const rubricMarkdown = assignmentRubricToMarkdown(assignment); + const assignmentMarkdown = `${settingsMarkdown}\n---\n\n${assignment.description}\n\n## Rubric\n\n${rubricMarkdown}`; + + return assignmentMarkdown; + }, +}; diff --git a/nextjs/src/models/local/assignmnet/utils/assignmentMarkdownStringifier.ts b/nextjs/src/models/local/assignmnet/utils/assignmentMarkdownStringifier.ts new file mode 100644 index 0000000..edbeaf8 --- /dev/null +++ b/nextjs/src/models/local/assignmnet/utils/assignmentMarkdownStringifier.ts @@ -0,0 +1,138 @@ +import { AssignmentSubmissionType } from "../assignmentSubmissionType"; +import { LocalAssignment } from "../localAssignment"; +import { RubricItem } from "../rubricItem"; +import { extractLabelValue } from "./markdownUtils"; + +const parseFileUploadExtensions = (input: string) => { + const allowedFileUploadExtensions: string[] = []; + const regex = /- (.+)/; + + const words = input.split("AllowedFileUploadExtensions:"); + if (words.length < 2) return allowedFileUploadExtensions; + + const inputAfterSubmissionTypes = words[1]; + const lines = inputAfterSubmissionTypes + .split("\n") + .map((line) => line.trim()); + + for (const line of lines) { + const match = regex.exec(line); + if (!match) break; + allowedFileUploadExtensions.push(match[1].trim()); + } + + return allowedFileUploadExtensions; +}; + +const parseIndividualRubricItemMarkdown = (rawMarkdown: string) => { + const pointsPattern = /\s*-\s*(-?\d+(?:\.\d+)?)\s*pt(s)?:/; + const match = pointsPattern.exec(rawMarkdown); + if (!match) { + throw new Error(`Points not found: ${rawMarkdown}`); + } + + const points = parseFloat(match[1]); + const label = rawMarkdown.split(": ").slice(1).join(": "); + + const item: RubricItem = { points, label }; + return item; +}; + +const parseSettings = (input: string) => { + const name = extractLabelValue(input, "Name"); + const rawLockAt = extractLabelValue(input, "LockAt"); + const rawDueAt = extractLabelValue(input, "DueAt"); + const assignmentGroupName = extractLabelValue(input, "AssignmentGroupName"); + const submissionTypes = parseSubmissionTypes(input); + const fileUploadExtensions = parseFileUploadExtensions(input); + + const lockAt = (rawLockAt ? new Date(rawLockAt) : undefined)?.toISOString(); + const dueAt = new Date(rawDueAt).toISOString(); + + if (isNaN(new Date(dueAt).getTime())) { + throw new Error(`Error with DueAt: ${rawDueAt}`); + } + + return { + name, + assignmentGroupName, + submissionTypes, + fileUploadExtensions, + dueAt, + lockAt, + }; +}; + +const parseSubmissionTypes = (input: string): AssignmentSubmissionType[] => { + const submissionTypes: AssignmentSubmissionType[] = []; + const regex = /- (.+)/; + + const words = input.split("SubmissionTypes:"); + if (words.length < 2) return submissionTypes; + + const inputAfterSubmissionTypes = words[1]; + const lines = inputAfterSubmissionTypes + .split("\n") + .map((line) => line.trim()); + + for (const line of lines) { + const match = regex.exec(line); + if (!match) break; + + const typeString = match[1].trim(); + const type = Object.values(AssignmentSubmissionType).find( + (t) => t === typeString + ); + + if (type) { + submissionTypes.push(type); + } else { + console.warn(`Unknown submission type: ${typeString}`); + } + } + + return submissionTypes; +}; + +const parseRubricMarkdown = (rawMarkdown: string) => { + if (!rawMarkdown.trim()) return []; + + const lines = rawMarkdown.trim().split("\n"); + return lines.map(parseIndividualRubricItemMarkdown); +}; + +export const assignmentMarkdownParser = { + parseMarkdown(input: string): LocalAssignment { + const settingsString = input.split("---")[0]; + const { + name, + assignmentGroupName, + submissionTypes, + fileUploadExtensions, + dueAt, + lockAt, + } = parseSettings(settingsString); + + const description = input + .split("---\n") + .slice(1) + .join("---\n") + .split("## Rubric")[0] + .trim(); + + const rubricString = input.split("## Rubric\n")[1]; + const rubric = parseRubricMarkdown(rubricString); + + const assignment: LocalAssignment = { + name: name.trim(), + localAssignmentGroupName: assignmentGroupName.trim(), + submissionTypes: submissionTypes, + allowedFileUploadExtensions: fileUploadExtensions, + dueAt: dueAt, + lockAt: lockAt, + rubric: rubric, + description: description, + }; + return assignment; + }, +}; diff --git a/nextjs/src/models/local/assignmnet/utils/markdownUtils.ts b/nextjs/src/models/local/assignmnet/utils/markdownUtils.ts new file mode 100644 index 0000000..a65cd23 --- /dev/null +++ b/nextjs/src/models/local/assignmnet/utils/markdownUtils.ts @@ -0,0 +1,10 @@ +export const extractLabelValue = (input: string, label: string) => { + const pattern = new RegExp(`${label}: (.*?)\n`); + const match = pattern.exec(input); + + if (match && match[1]) { + return match[1].trim(); + } + + return ""; +}; diff --git a/nextjs/src/models/local/quiz/localQuiz.ts b/nextjs/src/models/local/quiz/localQuiz.ts new file mode 100644 index 0000000..4c6bf05 --- /dev/null +++ b/nextjs/src/models/local/quiz/localQuiz.ts @@ -0,0 +1,15 @@ +import { LocalQuizQuestion } from "./localQuizQuestion"; + +export interface LocalQuiz { + name: string; + description: string; + password?: string; + lockAt?: string; // ISO 8601 date string + dueAt: string; // ISO 8601 date string + shuffleAnswers: boolean; + showCorrectAnswers: boolean; + oneQuestionAtATime: boolean; + localAssignmentGroupName?: string; + allowedAttempts: number; + questions: LocalQuizQuestion[]; +} diff --git a/nextjs/src/models/local/quiz/localQuizQuestion.ts b/nextjs/src/models/local/quiz/localQuizQuestion.ts new file mode 100644 index 0000000..d4a814d --- /dev/null +++ b/nextjs/src/models/local/quiz/localQuizQuestion.ts @@ -0,0 +1,16 @@ +import { LocalQuizQuestionAnswer } from "./localQuizQuestionAnswer"; + +export interface LocalQuizQuestion { + text: string; + questionType: QuestionType; + points: number; + answers: LocalQuizQuestionAnswer[]; +} + +export enum QuestionType { + MultipleAnswers = "multiple_answers", + MultipleChoice = "multiple_choice", + Essay = "essay", + ShortAnswer = "short_answer", + Matching = "matching" +} diff --git a/nextjs/src/models/local/quiz/localQuizQuestionAnswer.ts b/nextjs/src/models/local/quiz/localQuizQuestionAnswer.ts new file mode 100644 index 0000000..6f46ef8 --- /dev/null +++ b/nextjs/src/models/local/quiz/localQuizQuestionAnswer.ts @@ -0,0 +1,6 @@ +export interface LocalQuizQuestionAnswer { + correct: boolean; + text: string; + matchedText?: string; + htmlText: string; +} diff --git a/nextjs/src/models/local/tests/markdown/assignmentMarkdown.test.ts b/nextjs/src/models/local/tests/markdown/assignmentMarkdown.test.ts new file mode 100644 index 0000000..d740c3a --- /dev/null +++ b/nextjs/src/models/local/tests/markdown/assignmentMarkdown.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from 'vitest'; +import { LocalAssignment } from '../../assignmnet/localAssignment'; +import { AssignmentSubmissionType } from '../../assignmnet/assignmentSubmissionType'; +import { assignmentMarkdownStringifier } from '../../assignmnet/utils/assignmentMarkdownParser'; +import { assignmentMarkdownParser } from '../../assignmnet/utils/assignmentMarkdownStringifier'; + +describe('AssignmentMarkdownTests', () => { + it('can parse assignment settings', () => { + const assignment: LocalAssignment ={ + name: 'test assignment', + description: 'here is the description', + dueAt: new Date().toISOString(), + lockAt: new Date().toISOString(), + submissionTypes: [AssignmentSubmissionType.OnlineUpload], + localAssignmentGroupName: 'Final Project', + rubric: [ + { points: 4, label: 'do task 1' }, + { points: 2, label: 'do task 2' }, + ], + allowedFileUploadExtensions: [], + }; + + const assignmentMarkdown = assignmentMarkdownStringifier.toMarkdown(assignment); + const parsedAssignment = assignmentMarkdownParser.parseMarkdown(assignmentMarkdown); + + expect(parsedAssignment).toEqual(assignment); + }); + + // it('assignment with empty rubric can be parsed', () => { + // const assignment = new LocalAssignment({ + // name: 'test assignment', + // description: 'here is the description', + // dueAt: new Date(), + // lockAt: new Date(), + // submissionTypes: [AssignmentSubmissionType.ONLINE_UPLOAD], + // localAssignmentGroupName: 'Final Project', + // rubric: [], + // }); + + // const assignmentMarkdown = assignment.toMarkdown(); + // const parsedAssignment = LocalAssignment.parseMarkdown(assignmentMarkdown); + + // expect(parsedAssignment).toEqual(assignment); + // }); + + // it('assignment with empty submission types can be parsed', () => { + // const assignment = new LocalAssignment({ + // name: 'test assignment', + // description: 'here is the description', + // dueAt: new Date(), + // lockAt: new Date(), + // submissionTypes: [], + // localAssignmentGroupName: 'Final Project', + // rubric: [ + // new RubricItem({ points: 4, label: 'do task 1' }), + // new RubricItem({ points: 2, label: 'do task 2' }), + // ], + // }); + + // const assignmentMarkdown = assignment.toMarkdown(); + // const parsedAssignment = LocalAssignment.parseMarkdown(assignmentMarkdown); + + // expect(parsedAssignment).toEqual(assignment); + // }); + + // it('assignment without lockAt date can be parsed', () => { + // const assignment = new LocalAssignment({ + // name: 'test assignment', + // description: 'here is the description', + // dueAt: new Date(), + // lockAt: null, + // submissionTypes: [], + // localAssignmentGroupName: 'Final Project', + // rubric: [ + // new RubricItem({ points: 4, label: 'do task 1' }), + // new RubricItem({ points: 2, label: 'do task 2' }), + // ], + // }); + + // const assignmentMarkdown = assignment.toMarkdown(); + // const parsedAssignment = LocalAssignment.parseMarkdown(assignmentMarkdown); + + // expect(parsedAssignment).toEqual(assignment); + // }); + + // it('assignment without description can be parsed', () => { + // const assignment = new LocalAssignment({ + // name: 'test assignment', + // description: '', + // dueAt: new Date(), + // lockAt: new Date(), + // submissionTypes: [], + // localAssignmentGroupName: 'Final Project', + // rubric: [ + // new RubricItem({ points: 4, label: 'do task 1' }), + // new RubricItem({ points: 2, label: 'do task 2' }), + // ], + // }); + + // const assignmentMarkdown = assignment.toMarkdown(); + // const parsedAssignment = LocalAssignment.parseMarkdown(assignmentMarkdown); + + // expect(parsedAssignment).toEqual(assignment); + // }); + + // it('assignments can have three dashes', () => { + // const assignment = new LocalAssignment({ + // name: 'test assignment', + // description: 'test assignment\n---\nsomestuff', + // dueAt: new Date(), + // lockAt: new Date(), + // submissionTypes: [], + // localAssignmentGroupName: 'Final Project', + // rubric: [], + // }); + + // const assignmentMarkdown = assignment.toMarkdown(); + // const parsedAssignment = LocalAssignment.parseMarkdown(assignmentMarkdown); + + // expect(parsedAssignment).toEqual(assignment); + // }); + + // it('assignments can restrict upload types', () => { + // const assignment = new LocalAssignment({ + // name: 'test assignment', + // description: 'here is the description', + // dueAt: new Date(), + // lockAt: new Date(), + // submissionTypes: [AssignmentSubmissionType.ONLINE_UPLOAD], + // allowedFileUploadExtensions: ['pdf', 'txt'], + // localAssignmentGroupName: 'Final Project', + // rubric: [], + // }); + + // const assignmentMarkdown = assignment.toMarkdown(); + // const parsedAssignment = LocalAssignment.parseMarkdown(assignmentMarkdown); + + // expect(parsedAssignment).toEqual(assignment); + // }); +}); diff --git a/nextjs/src/services/canvas/canvasService.ts b/nextjs/src/services/canvas/canvasService.ts index ca89c60..c972664 100644 --- a/nextjs/src/services/canvas/canvasService.ts +++ b/nextjs/src/services/canvas/canvasService.ts @@ -16,6 +16,7 @@ const getTerms = async () => { }; export const canvasService = { + getTerms, async getCourses(termId: number) { const url = `courses`; const coursesResponse = diff --git a/nextjs/vitest.config.ts b/nextjs/vitest.config.ts new file mode 100644 index 0000000..f9a60e6 --- /dev/null +++ b/nextjs/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + }, +}) \ No newline at end of file