From 446889206f36ad78620d6620cae34b679bfb1b7b Mon Sep 17 00:00:00 2001 From: Alex Mickelson Date: Sat, 24 Aug 2024 15:31:43 -0600 Subject: [PATCH] workign on test folders --- nextjs/.env.test | 1 + nextjs/.gitignore | 4 + nextjs/package-lock.json | 4 +- nextjs/package.json | 3 +- .../local/assignmnet/localAssignment.ts | 7 + nextjs/src/models/local/localCourse.ts | 20 +- .../src/models/local/page/localCoursePage.ts | 31 ++ .../models/local/page/pageMarkdownUtils.ts | 32 -- nextjs/src/models/local/quiz/localQuiz.ts | 6 + nextjs/src/models/local/timeUtils.ts | 2 +- .../fileStorage/fileStorageService.ts | 55 ++++ .../fileStorage/utils/couresMarkdownLoader.ts | 181 +++++++++++ .../{ => utils}/courseDifferences.ts | 0 .../fileStorage/utils/courseMarkdownSaver.ts | 245 ++++++++++++++ .../tests/courseDifferenceChanges.test.ts | 18 +- .../tests/courseDifferencesDeletions.test.ts | 67 ++-- nextjs/src/services/tests/fileStorage.test.ts | 298 ++++++++++++++++++ 17 files changed, 899 insertions(+), 75 deletions(-) create mode 100644 nextjs/.env.test delete mode 100644 nextjs/src/models/local/page/pageMarkdownUtils.ts create mode 100644 nextjs/src/services/fileStorage/fileStorageService.ts create mode 100644 nextjs/src/services/fileStorage/utils/couresMarkdownLoader.ts rename nextjs/src/services/fileStorage/{ => utils}/courseDifferences.ts (100%) create mode 100644 nextjs/src/services/fileStorage/utils/courseMarkdownSaver.ts create mode 100644 nextjs/src/services/tests/fileStorage.test.ts diff --git a/nextjs/.env.test b/nextjs/.env.test new file mode 100644 index 0000000..ba9058c --- /dev/null +++ b/nextjs/.env.test @@ -0,0 +1 @@ +STORAGE_DIRECTORY="/tmp/canvasManagerStorage" \ No newline at end of file diff --git a/nextjs/.gitignore b/nextjs/.gitignore index fd3dbb5..f73c6c9 100644 --- a/nextjs/.gitignore +++ b/nextjs/.gitignore @@ -34,3 +34,7 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + + +storage/ +temp/ \ No newline at end of file diff --git a/nextjs/package-lock.json b/nextjs/package-lock.json index 4e48e53..51cdf3a 100644 --- a/nextjs/package-lock.json +++ b/nextjs/package-lock.json @@ -12,7 +12,8 @@ "next": "14.2.5", "react": "^18", "react-dom": "^18", - "react-hot-toast": "^2.4.1" + "react-hot-toast": "^2.4.1", + "yaml": "^2.5.0" }, "devDependencies": { "@testing-library/dom": "^10.4.0", @@ -7257,7 +7258,6 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.0.tgz", "integrity": "sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==", - "dev": true, "bin": { "yaml": "bin.mjs" }, diff --git a/nextjs/package.json b/nextjs/package.json index f998faa..df3e583 100644 --- a/nextjs/package.json +++ b/nextjs/package.json @@ -14,7 +14,8 @@ "next": "14.2.5", "react": "^18", "react-dom": "^18", - "react-hot-toast": "^2.4.1" + "react-hot-toast": "^2.4.1", + "yaml": "^2.5.0" }, "devDependencies": { "@testing-library/dom": "^10.4.0", diff --git a/nextjs/src/models/local/assignmnet/localAssignment.ts b/nextjs/src/models/local/assignmnet/localAssignment.ts index 143e16f..0235d44 100644 --- a/nextjs/src/models/local/assignmnet/localAssignment.ts +++ b/nextjs/src/models/local/assignmnet/localAssignment.ts @@ -1,5 +1,7 @@ import { AssignmentSubmissionType } from "./assignmentSubmissionType"; import { RubricItem } from "./rubricItem"; +import { assignmentMarkdownParser } from "./utils/assignmentMarkdownParser"; +import { assignmentMarkdownSerializer } from "./utils/assignmentMarkdownSerializer"; export interface LocalAssignment { name: string; @@ -11,3 +13,8 @@ export interface LocalAssignment { allowedFileUploadExtensions: string[]; rubric: RubricItem[]; } + +export const localAssignmentMarkdown = { + parseMarkdown: assignmentMarkdownParser.parseMarkdown, + toMarkdown: assignmentMarkdownSerializer.toMarkdown, +}; diff --git a/nextjs/src/models/local/localCourse.ts b/nextjs/src/models/local/localCourse.ts index 2249c3e..c506526 100644 --- a/nextjs/src/models/local/localCourse.ts +++ b/nextjs/src/models/local/localCourse.ts @@ -1,5 +1,6 @@ import { LocalAssignmentGroup } from "./assignmnet/localAssignmentGroup"; import { LocalModule } from "./localModules"; +import { parse, stringify } from "yaml"; export interface LocalCourse { modules: LocalModule[]; @@ -30,14 +31,11 @@ export enum DayOfWeek { Friday = "Friday", Saturday = "Saturday", } - -// export const LocalCourseSettingsUtils = { -// toYaml(settings: LocalCourseSettings): string { -// return dump(settings, { noRefs: true }); -// }, - -// parseYaml(rawText: string): LocalCourseSettings { -// const settings = load(rawText) as LocalCourseSettings; -// return createLocalCourseSettings(settings); -// }, -// }; +export const localCourseYamlUtils = { + parseSettingYaml: (settingsString: string): LocalCourseSettings => { + return parse(settingsString); + }, + settingsToYaml: (settings: LocalCourseSettings) => { + return stringify(settings); + }, +}; diff --git a/nextjs/src/models/local/page/localCoursePage.ts b/nextjs/src/models/local/page/localCoursePage.ts index ead366e..0fec520 100644 --- a/nextjs/src/models/local/page/localCoursePage.ts +++ b/nextjs/src/models/local/page/localCoursePage.ts @@ -1,3 +1,4 @@ +import { extractLabelValue } from "../assignmnet/utils/markdownUtils"; import { IModuleItem } from "../IModuleItem"; export interface LocalCoursePage extends IModuleItem { @@ -5,3 +6,33 @@ export interface LocalCoursePage extends IModuleItem { text: string; dueAt: string; } + +export const localPageMarkdownUtils = { + toMarkdown: (page: LocalCoursePage) => { + const printableDueDate = new Date(page.dueAt) + .toISOString() + .replace("\u202F", " "); + const settingsMarkdown = `Name: ${page.name}\nDueDateForOrdering: ${printableDueDate}\n---\n`; + return settingsMarkdown + page.text; + }, + + parseMarkdown: (pageMarkdown: string) => { + const rawSettings = pageMarkdown.split("---")[0]; + const name = extractLabelValue(rawSettings, "Name"); + const rawDate = extractLabelValue(rawSettings, "DueDateForOrdering"); + + const parsedDate = new Date(rawDate); + if (isNaN(parsedDate.getTime())) { + throw new Error(`could not parse due date: ${rawDate}`); + } + + const text = pageMarkdown.split("---\n")[1]; + + const page: LocalCoursePage = { + name, + dueAt: parsedDate.toISOString(), + text, + }; + return page; + }, +}; diff --git a/nextjs/src/models/local/page/pageMarkdownUtils.ts b/nextjs/src/models/local/page/pageMarkdownUtils.ts deleted file mode 100644 index cc7c5eb..0000000 --- a/nextjs/src/models/local/page/pageMarkdownUtils.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { extractLabelValue } from "../assignmnet/utils/markdownUtils"; -import { LocalCoursePage } from "./localCoursePage"; - -export const pageMarkdownUtils = { - toMarkdown: (page: LocalCoursePage) => { - const printableDueDate = new Date(page.dueAt) - .toISOString() - .replace("\u202F", " "); - const settingsMarkdown = `Name: ${page.name}\nDueDateForOrdering: ${printableDueDate}\n---\n`; - return settingsMarkdown + page.text; - }, - - parseMarkdown: (pageMarkdown: string) => { - const rawSettings = pageMarkdown.split("---")[0]; - const name = extractLabelValue(rawSettings, "Name"); - const rawDate = extractLabelValue(rawSettings, "DueDateForOrdering"); - - const parsedDate = new Date(rawDate); - if (isNaN(parsedDate.getTime())) { - throw new Error(`could not parse due date: ${rawDate}`); - } - - const text = pageMarkdown.split("---\n")[1]; - - const page: LocalCoursePage = { - name, - dueAt: parsedDate.toISOString(), - text, - }; - return page; - }, -}; diff --git a/nextjs/src/models/local/quiz/localQuiz.ts b/nextjs/src/models/local/quiz/localQuiz.ts index 4c6bf05..192c462 100644 --- a/nextjs/src/models/local/quiz/localQuiz.ts +++ b/nextjs/src/models/local/quiz/localQuiz.ts @@ -1,4 +1,5 @@ import { LocalQuizQuestion } from "./localQuizQuestion"; +import { quizMarkdownUtils } from "./utils/quizMarkdownUtils"; export interface LocalQuiz { name: string; @@ -13,3 +14,8 @@ export interface LocalQuiz { allowedAttempts: number; questions: LocalQuizQuestion[]; } + +export const localQuizMarkdownUtils = { + parseMarkdown: quizMarkdownUtils.parseMarkdown, + toMarkdown: quizMarkdownUtils.toMarkdown, +}; diff --git a/nextjs/src/models/local/timeUtils.ts b/nextjs/src/models/local/timeUtils.ts index ec17d00..2e0fd6b 100644 --- a/nextjs/src/models/local/timeUtils.ts +++ b/nextjs/src/models/local/timeUtils.ts @@ -3,7 +3,7 @@ export const getDateFromString = (value: string) => { // may need to check for other formats const validDateRegex = - /([1-9][1-9]|[0-2])\/(0[1-9]|[1-2][0-9]|3[01])\/\d{4} (0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])/; + /\d{2}\/\d{2}\/\d{4} [0-2][0-9]|[0-5][0-9]|[0-2][0-9]:[0-5][0-9]:[0-5][0-9]/; if (!validDateRegex.test(value)) { return undefined; } diff --git a/nextjs/src/services/fileStorage/fileStorageService.ts b/nextjs/src/services/fileStorage/fileStorageService.ts new file mode 100644 index 0000000..1ddec86 --- /dev/null +++ b/nextjs/src/services/fileStorage/fileStorageService.ts @@ -0,0 +1,55 @@ +import { promises as fs } from "fs"; +import path from "path"; +import { LocalCourse } from "@/models/local/localCourse"; +import { courseMarkdownLoader } from "./utils/couresMarkdownLoader"; +import { courseMarkdownSaver } from "./utils/courseMarkdownSaver"; + +const basePath = process.env.STORAGE_DIRECTORY ?? "./storage"; + +export const fileStorageService = { + async saveCourseAsync( + course: LocalCourse, + previouslyStoredCourse?: LocalCourse + ) { + await courseMarkdownSaver.save(course, previouslyStoredCourse); + }, + + async loadSavedCourses(): Promise { + console.log("loading pages from file system"); + return (await courseMarkdownLoader.loadSavedCourses()) || []; + }, + + async getEmptyDirectories(): Promise { + if (!(await this.directoryExists(basePath))) { + throw new Error( + `Cannot get empty directories, ${basePath} does not exist` + ); + } + + const directories = await fs.readdir(basePath, { withFileTypes: true }); + const emptyDirectories = directories + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => path.join(basePath, dirent.name)) + .filter(async (dir) => !(await this.hasFileSystemEntries(dir))); + + return emptyDirectories; + }, + + async directoryExists(directoryPath: string): Promise { + try { + const stat = await fs.stat(directoryPath); + return stat.isDirectory(); + } catch { + return false; + } + }, + + async hasFileSystemEntries(directoryPath: string): Promise { + try { + const entries = await fs.readdir(directoryPath); + return entries.length > 0; + } catch { + return false; + } + }, +}; diff --git a/nextjs/src/services/fileStorage/utils/couresMarkdownLoader.ts b/nextjs/src/services/fileStorage/utils/couresMarkdownLoader.ts new file mode 100644 index 0000000..2e1e6d3 --- /dev/null +++ b/nextjs/src/services/fileStorage/utils/couresMarkdownLoader.ts @@ -0,0 +1,181 @@ +import { LocalAssignment, localAssignmentMarkdown } from "@/models/local/assignmnet/localAssignment"; +import { LocalCourse, LocalCourseSettings, localCourseYamlUtils } from "@/models/local/localCourse"; +import { LocalModule } from "@/models/local/localModules"; +import { LocalCoursePage, localPageMarkdownUtils } from "@/models/local/page/localCoursePage"; +import { LocalQuiz, localQuizMarkdownUtils } from "@/models/local/quiz/localQuiz"; +import { promises as fs } from "fs"; +import path from "path"; + +const basePath = process.env.STORAGE_DIRECTORY ?? "./storage"; + +export const courseMarkdownLoader = { + async loadSavedCourses(): Promise { + const courseDirectories = await fs.readdir(basePath, { + withFileTypes: true, + }); + const coursePromises = courseDirectories + .filter((dirent) => dirent.isDirectory()) + .map(async (dirent) => { + const coursePath = path.join(basePath, dirent.name); + const settingsPath = path.join(coursePath, "settings.yml"); + if (await this.fileExists(settingsPath)) { + return this.loadCourseByPath(coursePath); + } + return null; + }); + + const courses = (await Promise.all(coursePromises)).filter( + (course) => course !== null + ) as LocalCourse[]; + return courses.sort((a, b) => + a.settings.name.localeCompare(b.settings.name) + ); + }, + + async loadCourseByPath(courseDirectory: string): Promise { + if (!(await this.directoryExists(courseDirectory))) { + const errorMessage = `Error loading course by name, could not find folder ${courseDirectory}`; + console.log(errorMessage); + throw new Error(errorMessage); + } + + const settings = await this.loadCourseSettings(courseDirectory); + const modules = await this.loadCourseModules(courseDirectory); + + return { + settings, + modules, + }; + }, + + async loadCourseSettings( + courseDirectory: string + ): Promise { + const settingsPath = path.join(courseDirectory, "settings.yml"); + if (!(await this.fileExists(settingsPath))) { + const errorMessage = `Error loading course by name, settings file ${settingsPath}`; + console.log(errorMessage); + throw new Error(errorMessage); + } + + const settingsString = await fs.readFile(settingsPath, "utf-8"); + const settings = localCourseYamlUtils.parseSettingYaml(settingsString); + + const folderName = path.basename(courseDirectory); + return { ...settings, name: folderName }; + }, + + async loadCourseModules(courseDirectory: string): Promise { + const moduleDirectories = await fs.readdir(courseDirectory, { + withFileTypes: true, + }); + const modulePromises = moduleDirectories + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => + this.loadModuleFromPath(path.join(courseDirectory, dirent.name)) + ); + + const modules = await Promise.all(modulePromises); + return modules.sort((a, b) => a.name.localeCompare(b.name)); + }, + + async loadModuleFromPath(modulePath: string): Promise { + const moduleName = path.basename(modulePath); + const assignments = await this.loadAssignmentsFromPath(modulePath); + const quizzes = await this.loadQuizzesFromPath(modulePath); + const pages = await this.loadModulePagesFromPath(modulePath); + + return { + name: moduleName, + assignments, + quizzes, + pages, + }; + }, + + async loadAssignmentsFromPath( + modulePath: string + ): Promise { + const assignmentsPath = path.join(modulePath, "assignments"); + if (!(await this.directoryExists(assignmentsPath))) { + console.log( + `Error loading course by name, assignments folder does not exist in ${modulePath}` + ); + await fs.mkdir(assignmentsPath); + } + + const assignmentFiles = await fs.readdir(assignmentsPath); + const assignmentPromises = assignmentFiles.map(async (file) => { + const filePath = path.join(assignmentsPath, file); + const rawFile = (await fs.readFile(filePath, "utf-8")).replace( + /\r\n/g, + "\n" + ); + return localAssignmentMarkdown.parseMarkdown(rawFile); + }); + + return await Promise.all(assignmentPromises); + }, + + async loadQuizzesFromPath(modulePath: string): Promise { + const quizzesPath = path.join(modulePath, "quizzes"); + if (!(await this.directoryExists(quizzesPath))) { + console.log( + `Quizzes folder does not exist in ${modulePath}, creating now` + ); + await fs.mkdir(quizzesPath); + } + + const quizFiles = await fs.readdir(quizzesPath); + const quizPromises = quizFiles.map(async (file) => { + const filePath = path.join(quizzesPath, file); + const rawQuiz = (await fs.readFile(filePath, "utf-8")).replace( + /\r\n/g, + "\n" + ); + return localQuizMarkdownUtils.parseMarkdown(rawQuiz); + }); + + return await Promise.all(quizPromises); + }, + + async loadModulePagesFromPath( + modulePath: string + ): Promise { + const pagesPath = path.join(modulePath, "pages"); + if (!(await this.directoryExists(pagesPath))) { + console.log(`Pages folder does not exist in ${modulePath}, creating now`); + await fs.mkdir(pagesPath); + } + + const pageFiles = await fs.readdir(pagesPath); + const pagePromises = pageFiles.map(async (file) => { + const filePath = path.join(pagesPath, file); + const rawPage = (await fs.readFile(filePath, "utf-8")).replace( + /\r\n/g, + "\n" + ); + return localPageMarkdownUtils.parseMarkdown(rawPage); + }); + + return await Promise.all(pagePromises); + }, + + async fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } + }, + + async directoryExists(directoryPath: string): Promise { + try { + const stat = await fs.stat(directoryPath); + return stat.isDirectory(); + } catch { + return false; + } + }, +}; diff --git a/nextjs/src/services/fileStorage/courseDifferences.ts b/nextjs/src/services/fileStorage/utils/courseDifferences.ts similarity index 100% rename from nextjs/src/services/fileStorage/courseDifferences.ts rename to nextjs/src/services/fileStorage/utils/courseDifferences.ts diff --git a/nextjs/src/services/fileStorage/utils/courseMarkdownSaver.ts b/nextjs/src/services/fileStorage/utils/courseMarkdownSaver.ts new file mode 100644 index 0000000..1f79e02 --- /dev/null +++ b/nextjs/src/services/fileStorage/utils/courseMarkdownSaver.ts @@ -0,0 +1,245 @@ +import { localAssignmentMarkdown } from "@/models/local/assignmnet/localAssignment"; +import { LocalCourse, localCourseYamlUtils } from "@/models/local/localCourse"; +import { LocalModule } from "@/models/local/localModules"; +import { localPageMarkdownUtils } from "@/models/local/page/localCoursePage"; +import { quizMarkdownUtils } from "@/models/local/quiz/utils/quizMarkdownUtils"; +import { promises as fs } from "fs"; +import path from "path"; + +const basePath = process.env.STORAGE_DIRECTORY ?? "./storage"; + +const directoryExists = async (directoryPath: string): Promise => { + try { + const stat = await fs.stat(directoryPath); + return stat.isDirectory(); + } catch { + return false; + } +}; + +const saveSettings = async (course: LocalCourse, courseDirectory: string) => { + const settingsFilePath = path.join(courseDirectory, "settings.yml"); + const settingsYaml = localCourseYamlUtils.settingsToYaml(course.settings); + await fs.writeFile(settingsFilePath, settingsYaml); +}; + +const saveModules = async ( + course: LocalCourse, + courseDirectory: string, + previouslyStoredCourse?: LocalCourse +) => { + for (const localModule of course.modules) { + const moduleDirectory = path.join(courseDirectory, localModule.name); + if (!(await directoryExists(moduleDirectory))) { + await fs.mkdir(moduleDirectory, { recursive: true }); + } + + await saveQuizzes(course, localModule, previouslyStoredCourse); + await saveAssignments(course, localModule, previouslyStoredCourse); + await savePages(course, localModule, previouslyStoredCourse); + } + + const moduleNames = course.modules.map((m) => m.name); + const moduleDirectories = await fs.readdir(courseDirectory, { + withFileTypes: true, + }); + + for (const dirent of moduleDirectories) { + if (dirent.isDirectory() && !moduleNames.includes(dirent.name)) { + const moduleDirPath = path.join(courseDirectory, dirent.name); + console.log( + `Deleting extra module directory, it was probably renamed ${moduleDirPath}` + ); + await fs.rmdir(moduleDirPath, { recursive: true }); + } + } +}; + +const saveQuizzes = async ( + course: LocalCourse, + module: LocalModule, + previouslyStoredCourse?: LocalCourse +) => { + const quizzesDirectory = path.join( + basePath, + course.settings.name, + module.name, + "quizzes" + ); + if (!(await directoryExists(quizzesDirectory))) { + await fs.mkdir(quizzesDirectory, { recursive: true }); + } + + for (const quiz of module.quizzes) { + const previousModule = previouslyStoredCourse?.modules.find( + (m) => m.name === module.name + ); + const previousQuiz = previousModule?.quizzes.find((q) => q === quiz); + + if (!previousQuiz) { + const markdownPath = path.join(quizzesDirectory, `${quiz.name}.md`); + const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz); + console.log(`Saving quiz ${markdownPath}`); + await fs.writeFile(markdownPath, quizMarkdown); + } + } + + await removeOldQuizzes(quizzesDirectory, module); +}; + +const saveAssignments = async ( + course: LocalCourse, + module: LocalModule, + previouslyStoredCourse?: LocalCourse +) => { + const assignmentsDirectory = path.join( + basePath, + course.settings.name, + module.name, + "assignments" + ); + if (!(await directoryExists(assignmentsDirectory))) { + await fs.mkdir(assignmentsDirectory, { recursive: true }); + } + + for (const assignment of module.assignments) { + const previousModule = previouslyStoredCourse?.modules.find( + (m) => m.name === module.name + ); + const previousAssignment = previousModule?.assignments.find( + (a) => a === assignment + ); + + if (!previousAssignment) { + const assignmentMarkdown = localAssignmentMarkdown.toMarkdown(assignment); + const filePath = path.join(assignmentsDirectory, `${assignment.name}.md`); + console.log(`Saving assignment ${filePath}`); + await fs.writeFile(filePath, assignmentMarkdown); + } + } + + await removeOldAssignments(assignmentsDirectory, module); +}; + +const savePages = async ( + course: LocalCourse, + module: LocalModule, + previouslyStoredCourse?: LocalCourse +) => { + const pagesDirectory = path.join( + basePath, + course.settings.name, + module.name, + "pages" + ); + if (!(await directoryExists(pagesDirectory))) { + await fs.mkdir(pagesDirectory, { recursive: true }); + } + + for (const page of module.pages) { + const previousModule = previouslyStoredCourse?.modules.find( + (m) => m.name === module.name + ); + const previousPage = previousModule?.pages.find((p) => p === page); + + if (!previousPage) { + const pageMarkdown = localPageMarkdownUtils.toMarkdown(page); + const filePath = path.join(pagesDirectory, `${page.name}.md`); + console.log(`Saving page ${filePath}`); + await fs.writeFile(filePath, pageMarkdown); + } + } + + await removeOldPages(pagesDirectory, module); +}; + +const removeOldQuizzes = async ( + quizzesDirectory: string, + module: LocalModule +) => { + const existingFiles = await fs.readdir(quizzesDirectory); + const quizFilesToDelete = existingFiles.filter((file) => { + const quizMarkdownPath = path.join( + quizzesDirectory, + `${file.replace(".md", "")}.md` + ); + return !module.quizzes.some( + (quiz) => + path.join(quizzesDirectory, `${quiz.name}.md`) === quizMarkdownPath + ); + }); + + for (const file of quizFilesToDelete) { + console.log( + `Removing old quiz, it has probably been renamed ${path.join( + quizzesDirectory, + file + )}` + ); + await fs.unlink(path.join(quizzesDirectory, file)); + } +}; + +const removeOldAssignments = async ( + assignmentsDirectory: string, + module: LocalModule +) => { + const existingFiles = await fs.readdir(assignmentsDirectory); + const assignmentFilesToDelete = existingFiles.filter((file) => { + const assignmentMarkdownPath = path.join( + assignmentsDirectory, + `${file.replace(".md", "")}.md` + ); + return !module.assignments.some( + (assignment) => + path.join(assignmentsDirectory, `${assignment.name}.md`) === + assignmentMarkdownPath + ); + }); + + for (const file of assignmentFilesToDelete) { + console.log( + `Removing old assignment, it has probably been renamed ${path.join( + assignmentsDirectory, + file + )}` + ); + await fs.unlink(path.join(assignmentsDirectory, file)); + } +}; + +const removeOldPages = async (pagesDirectory: string, module: LocalModule) => { + const existingFiles = await fs.readdir(pagesDirectory); + const pageFilesToDelete = existingFiles.filter((file) => { + const pageMarkdownPath = path.join( + pagesDirectory, + `${file.replace(".md", "")}.md` + ); + return !module.pages.some( + (page) => + path.join(pagesDirectory, `${page.name}.md`) === pageMarkdownPath + ); + }); + + for (const file of pageFilesToDelete) { + console.log( + `Removing old page, it has probably been renamed ${path.join( + pagesDirectory, + file + )}` + ); + await fs.unlink(path.join(pagesDirectory, file)); + } +}; + +export const courseMarkdownSaver = { + async save(course: LocalCourse, previouslyStoredCourse?: LocalCourse) { + const courseDirectory = path.join(basePath, course.settings.name); + if (!(await directoryExists(courseDirectory))) { + await fs.mkdir(courseDirectory, { recursive: true }); + } + + await saveSettings(course, courseDirectory); + await saveModules(course, courseDirectory, previouslyStoredCourse); + }, +}; diff --git a/nextjs/src/services/tests/courseDifferenceChanges.test.ts b/nextjs/src/services/tests/courseDifferenceChanges.test.ts index c8adad9..67dae87 100644 --- a/nextjs/src/services/tests/courseDifferenceChanges.test.ts +++ b/nextjs/src/services/tests/courseDifferenceChanges.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { LocalCourse } from "@/models/local/localCourse"; -import { CourseDifferences } from "../fileStorage/courseDifferences"; +import { CourseDifferences } from "../fileStorage/utils/courseDifferences"; import { AssignmentSubmissionType } from "@/models/local/assignmnet/assignmentSubmissionType"; describe("CourseDifferencesChangesTests", () => { @@ -124,7 +124,9 @@ describe("CourseDifferencesChangesTests", () => { expect(differences.modules).not.toBeNull(); expect(differences.modules).toHaveLength(1); - expect(differences.modules?.[0].assignments?.[0].description).toBe("new description"); + expect(differences.modules?.[0].assignments?.[0].description).toBe( + "new description" + ); }); it("can properly ignore unchanged modules", () => { @@ -241,7 +243,9 @@ describe("CourseDifferencesChangesTests", () => { expect(differences.modules).toHaveLength(1); expect(differences.modules?.[0].assignments).toHaveLength(1); - expect(differences.modules?.[0].assignments?.[0].name).toBe("test assignment 2 with a new name"); + expect(differences.modules?.[0].assignments?.[0].name).toBe( + "test assignment 2 with a new name" + ); }); it("identical quizzes ignored", () => { @@ -349,7 +353,9 @@ describe("CourseDifferencesChangesTests", () => { expect(differences.modules).toHaveLength(1); expect(differences.modules?.[0].quizzes).toHaveLength(1); - expect(differences.modules?.[0].quizzes?.[0].lockAt).toBe("12/31/9999 23:59:59"); + expect(differences.modules?.[0].quizzes?.[0].lockAt).toBe( + "12/31/9999 23:59:59" + ); }); it("can detect only different quiz when other quizzes stay", () => { @@ -515,7 +521,9 @@ describe("CourseDifferencesChangesTests", () => { expect(differences.modules).toHaveLength(1); expect(differences.modules?.[0].pages).toHaveLength(1); - expect(differences.modules?.[0].pages?.[0].text).toBe("test description changed"); + expect(differences.modules?.[0].pages?.[0].text).toBe( + "test description changed" + ); }); it("different page detected but not same page", () => { diff --git a/nextjs/src/services/tests/courseDifferencesDeletions.test.ts b/nextjs/src/services/tests/courseDifferencesDeletions.test.ts index 739dcbd..dad4b20 100644 --- a/nextjs/src/services/tests/courseDifferencesDeletions.test.ts +++ b/nextjs/src/services/tests/courseDifferencesDeletions.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { LocalCourse } from "@/models/local/localCourse"; -import { CourseDifferences } from "../fileStorage/courseDifferences"; +import { CourseDifferences } from "../fileStorage/utils/courseDifferences"; describe("CourseDifferencesDeletionsTests", () => { it("same module does not get deleted", () => { @@ -37,7 +37,10 @@ describe("CourseDifferencesDeletionsTests", () => { ], }; - const differences = CourseDifferences.getDeletedChanges(newCourse, oldCourse); + const differences = CourseDifferences.getDeletedChanges( + newCourse, + oldCourse + ); expect(differences.namesOfModulesToDeleteCompletely).toHaveLength(0); }); @@ -76,7 +79,10 @@ describe("CourseDifferencesDeletionsTests", () => { ], }; - const differences = CourseDifferences.getDeletedChanges(newCourse, oldCourse); + const differences = CourseDifferences.getDeletedChanges( + newCourse, + oldCourse + ); expect(differences.namesOfModulesToDeleteCompletely).toHaveLength(1); expect(differences.namesOfModulesToDeleteCompletely[0]).toBe("test module"); @@ -105,7 +111,7 @@ describe("CourseDifferencesDeletionsTests", () => { dueAt: "09/07/2024 23:59:00", submissionTypes: [], allowedFileUploadExtensions: [], - rubric: [] + rubric: [], }, ], quizzes: [], @@ -125,7 +131,7 @@ describe("CourseDifferencesDeletionsTests", () => { dueAt: "09/07/2024 23:59:00", submissionTypes: [], allowedFileUploadExtensions: [], - rubric: [] + rubric: [], }, ], quizzes: [], @@ -134,7 +140,10 @@ describe("CourseDifferencesDeletionsTests", () => { ], }; - const differences = CourseDifferences.getDeletedChanges(newCourse, oldCourse); + const differences = CourseDifferences.getDeletedChanges( + newCourse, + oldCourse + ); expect(differences.namesOfModulesToDeleteCompletely).toHaveLength(0); expect(differences.deleteContentsOfModule).toHaveLength(1); @@ -167,7 +176,7 @@ describe("CourseDifferencesDeletionsTests", () => { dueAt: "09/07/2024 23:59:00", submissionTypes: [], allowedFileUploadExtensions: [], - rubric: [] + rubric: [], }, ], quizzes: [], @@ -187,7 +196,7 @@ describe("CourseDifferencesDeletionsTests", () => { dueAt: "09/07/2024 23:59:00", submissionTypes: [], allowedFileUploadExtensions: [], - rubric: [] + rubric: [], }, ], quizzes: [], @@ -196,7 +205,10 @@ describe("CourseDifferencesDeletionsTests", () => { ], }; - const differences = CourseDifferences.getDeletedChanges(newCourse, oldCourse); + const differences = CourseDifferences.getDeletedChanges( + newCourse, + oldCourse + ); expect(differences.deleteContentsOfModule).toHaveLength(0); }); @@ -224,7 +236,7 @@ describe("CourseDifferencesDeletionsTests", () => { dueAt: "09/07/2024 23:59:00", submissionTypes: [], allowedFileUploadExtensions: [], - rubric: [] + rubric: [], }, { name: "test assignment 2", @@ -232,7 +244,7 @@ describe("CourseDifferencesDeletionsTests", () => { dueAt: "09/07/2024 23:59:00", submissionTypes: [], allowedFileUploadExtensions: [], - rubric: [] + rubric: [], }, ], quizzes: [], @@ -252,7 +264,7 @@ describe("CourseDifferencesDeletionsTests", () => { dueAt: "09/07/2024 23:59:00", submissionTypes: [], allowedFileUploadExtensions: [], - rubric: [] + rubric: [], }, { name: "test assignment 2 changed", @@ -260,7 +272,7 @@ describe("CourseDifferencesDeletionsTests", () => { dueAt: "09/07/2024 23:59:00", submissionTypes: [], allowedFileUploadExtensions: [], - rubric: [] + rubric: [], }, ], quizzes: [], @@ -269,7 +281,10 @@ describe("CourseDifferencesDeletionsTests", () => { ], }; - const differences = CourseDifferences.getDeletedChanges(newCourse, oldCourse); + const differences = CourseDifferences.getDeletedChanges( + newCourse, + oldCourse + ); expect(differences.deleteContentsOfModule).toHaveLength(1); expect(differences.deleteContentsOfModule[0].assignments).toHaveLength(1); @@ -304,7 +319,7 @@ describe("CourseDifferencesDeletionsTests", () => { showCorrectAnswers: false, oneQuestionAtATime: false, allowedAttempts: 0, - questions: [] + questions: [], }, { name: "Test Quiz 2", @@ -314,7 +329,7 @@ describe("CourseDifferencesDeletionsTests", () => { showCorrectAnswers: false, oneQuestionAtATime: false, allowedAttempts: 0, - questions: [] + questions: [], }, ], pages: [], @@ -336,7 +351,7 @@ describe("CourseDifferencesDeletionsTests", () => { showCorrectAnswers: false, oneQuestionAtATime: false, allowedAttempts: 0, - questions: [] + questions: [], }, { name: "Test Quiz 3", @@ -346,7 +361,7 @@ describe("CourseDifferencesDeletionsTests", () => { showCorrectAnswers: false, oneQuestionAtATime: false, allowedAttempts: 0, - questions: [] + questions: [], }, ], pages: [], @@ -354,7 +369,10 @@ describe("CourseDifferencesDeletionsTests", () => { ], }; - const differences = CourseDifferences.getDeletedChanges(newCourse, oldCourse); + const differences = CourseDifferences.getDeletedChanges( + newCourse, + oldCourse + ); expect(differences.deleteContentsOfModule).toHaveLength(1); expect(differences.deleteContentsOfModule[0].quizzes).toHaveLength(1); @@ -390,7 +408,7 @@ describe("CourseDifferencesDeletionsTests", () => { { name: "Test Page 2", text: "test contents", - dueAt: "09/07/2024 23:59:00" + dueAt: "09/07/2024 23:59:00", }, ], }, @@ -407,19 +425,22 @@ describe("CourseDifferencesDeletionsTests", () => { { name: "Test Page", text: "test contents", - dueAt: "09/07/2024 23:59:00" + dueAt: "09/07/2024 23:59:00", }, { name: "Test Page 3", text: "test contents", - dueAt: "09/07/2024 23:59:00" + dueAt: "09/07/2024 23:59:00", }, ], }, ], }; - const differences = CourseDifferences.getDeletedChanges(newCourse, oldCourse); + const differences = CourseDifferences.getDeletedChanges( + newCourse, + oldCourse + ); expect(differences.deleteContentsOfModule).toHaveLength(1); expect(differences.deleteContentsOfModule[0].pages).toHaveLength(1); diff --git a/nextjs/src/services/tests/fileStorage.test.ts b/nextjs/src/services/tests/fileStorage.test.ts new file mode 100644 index 0000000..353ffd9 --- /dev/null +++ b/nextjs/src/services/tests/fileStorage.test.ts @@ -0,0 +1,298 @@ +import path from "path"; +import { describe, it, expect, beforeEach } from "vitest"; +import fs from "fs"; +import { DayOfWeek, LocalCourse } from "@/models/local/localCourse"; +import { AssignmentSubmissionType } from "@/models/local/assignmnet/assignmentSubmissionType"; +import { QuestionType } from "@/models/local/quiz/localQuizQuestion"; +import { fileStorageService } from "../fileStorage/fileStorageService"; + +describe("FileStorageTests", () => { + let storageDirectory: string; + + beforeEach(() => { + const tempDirectory = path.resolve("./temp"); + storageDirectory = path.join(tempDirectory, "fileStorageTests"); + console.log(storageDirectory); + + if (fs.existsSync(storageDirectory)) { + fs.rmdirSync(storageDirectory, { recursive: true }); + } + fs.mkdirSync(storageDirectory, { recursive: true }); + }); + + it("empty course can be saved and loaded", async () => { + const testCourse: LocalCourse = { + settings: { + name: "test empty course", + assignmentGroups: [], + daysOfWeek: [], + startDate: "09/07/2024 23:59:00", + endDate: "09/07/2024 23:59:00", + defaultDueTime: { hour: 1, minute: 59 }, + }, + modules: [], + }; + + await fileStorageService.saveCourseAsync(testCourse); + + const loadedCourses = await fileStorageService.loadSavedCourses(); + const loadedCourse = loadedCourses.find( + (c) => c.settings.name === testCourse.settings.name + ); + + expect(loadedCourse).toEqual(testCourse); + }); + + it("course settings can be saved and loaded", async () => { + const testCourse: LocalCourse = { + settings: { + assignmentGroups: [], + name: "Test Course with settings", + daysOfWeek: [DayOfWeek.Monday, DayOfWeek.Wednesday], + startDate: "09/07/2024 23:59:00", + endDate: "09/07/2024 23:59:00", + defaultDueTime: { hour: 1, minute: 59 }, + }, + modules: [], + }; + + await fileStorageService.saveCourseAsync(testCourse); + + const loadedCourses = await fileStorageService.loadSavedCourses(); + const loadedCourse = loadedCourses.find( + (c) => c.settings.name === testCourse.settings.name + ); + + expect(loadedCourse?.settings).toEqual(testCourse.settings); + }); + + it("empty course modules can be saved and loaded", async () => { + const testCourse: LocalCourse = { + settings: { + name: "Test Course with modules", + assignmentGroups: [], + daysOfWeek: [], + startDate: "09/07/2024 23:59:00", + endDate: "09/07/2024 23:59:00", + defaultDueTime: { hour: 1, minute: 59 }, + }, + modules: [ + { + name: "test module 1", + assignments: [], + quizzes: [], + pages: [], + }, + ], + }; + + await fileStorageService.saveCourseAsync(testCourse); + + const loadedCourses = await fileStorageService.loadSavedCourses(); + const loadedCourse = loadedCourses.find( + (c) => c.settings.name === testCourse.settings.name + ); + + expect(loadedCourse?.modules).toEqual(testCourse.modules); + }); + + it("course modules with assignments can be saved and loaded", async () => { + const testCourse: LocalCourse = { + settings: { + name: "Test Course with modules and assignments", + assignmentGroups: [], + daysOfWeek: [], + startDate: "09/07/2024 23:59:00", + endDate: "09/07/2024 23:59:00", + defaultDueTime: { hour: 1, minute: 59 }, + }, + modules: [ + { + name: "test module 1 with assignments", + assignments: [ + { + name: "test assignment", + description: "here is the description", + dueAt: "09/07/2024 23:59:00", + lockAt: "09/07/2024 23:59:00", + submissionTypes: [AssignmentSubmissionType.ONLINE_UPLOAD], + localAssignmentGroupName: "Final Project", + rubric: [ + { points: 4, label: "do task 1" }, + { points: 2, label: "do task 2" }, + ], + allowedFileUploadExtensions: [] + }, + ], + quizzes: [], + pages: [], + }, + ], + }; + + await fileStorageService.saveCourseAsync(testCourse); + + const loadedCourses = await fileStorageService.loadSavedCourses(); + const loadedCourse = loadedCourses.find( + (c) => c.settings.name === testCourse.settings.name + ); + + expect(loadedCourse?.modules[0].assignments).toEqual( + testCourse.modules[0].assignments + ); + }); + + it("course modules with quizzes can be saved and loaded", async () => { + const testCourse: LocalCourse = { + settings: { + name: "Test Course with modules and quiz", + assignmentGroups: [], + daysOfWeek: [], + startDate: "09/07/2024 23:59:00", + endDate: "09/07/2024 23:59:00", + defaultDueTime: { hour: 1, minute: 59 }, + }, + modules: [ + { + name: "test module 1 with quiz", + assignments: [], + quizzes: [ + { + name: "Test Quiz", + description: "quiz description", + lockAt: "09/07/2024 12:05:00", + dueAt: "09/07/2024 12:05:00", + shuffleAnswers: true, + oneQuestionAtATime: true, + localAssignmentGroupName: "Assignments", + questions: [ + { + text: "test essay", + questionType: QuestionType.ESSAY, + points: 1, + answers: [], + matchDistractors: [] + }, + ], + showCorrectAnswers: false, + allowedAttempts: 0 + }, + ], + pages: [], + }, + ], + }; + + await fileStorageService.saveCourseAsync(testCourse); + + const loadedCourses = await fileStorageService.loadSavedCourses(); + const loadedCourse = loadedCourses.find( + (c) => c.settings.name === testCourse.settings.name + ); + + expect(loadedCourse?.modules[0].quizzes).toEqual( + testCourse.modules[0].quizzes + ); + }); + + it("markdown storage fully populated does not lose data", async () => { + const testCourse: LocalCourse = { + settings: { + name: "Test Course with lots of data", + assignmentGroups: [], + daysOfWeek: [DayOfWeek.Monday, DayOfWeek.Wednesday], + startDate: "09/07/2024 23:59:00", + endDate: "09/07/2024 23:59:00", + defaultDueTime: { hour: 1, minute: 59 }, + }, + modules: [ + { + name: "new test module", + assignments: [ + { + name: "test assignment", + description: "here is the description", + dueAt: "09/07/2024 23:59:00", + lockAt: "09/07/2024 23:59:00", + submissionTypes: [AssignmentSubmissionType.ONLINE_UPLOAD], + localAssignmentGroupName: "Final Project", + rubric: [ + { points: 4, label: "do task 1" }, + { points: 2, label: "do task 2" }, + ], + allowedFileUploadExtensions: [] + }, + ], + quizzes: [ + { + name: "Test Quiz", + description: "quiz description", + lockAt: "09/07/2024 23:59:00", + dueAt: "09/07/2024 23:59:00", + shuffleAnswers: true, + oneQuestionAtATime: false, + localAssignmentGroupName: "someId", + allowedAttempts: -1, + questions: [ + { + text: "test short answer", + questionType: QuestionType.SHORT_ANSWER, + points: 1, + answers: [], + matchDistractors: [] + }, + ], + showCorrectAnswers: false + }, + ], + pages: [], + }, + ], + }; + + await fileStorageService.saveCourseAsync(testCourse); + + const loadedCourses = await fileStorageService.loadSavedCourses(); + const loadedCourse = loadedCourses.find( + (c) => c.settings.name === testCourse.settings.name + ); + + expect(loadedCourse).toEqual(testCourse); + }); + + it("markdown storage can persist pages", async () => { + const testCourse: LocalCourse = { + settings: { + name: "Test Course with page", + assignmentGroups: [], + daysOfWeek: [DayOfWeek.Monday, DayOfWeek.Wednesday], + startDate: "09/07/2024 23:59:00", + endDate: "09/07/2024 23:59:00", + defaultDueTime: { hour: 1, minute: 59 }, + }, + modules: [ + { + name: "page test module", + assignments: [], + quizzes: [], + pages: [ + { + name: "test page persistence", + dueAt: "09/07/2024 23:59:00", + text: "this is some\n## markdown\n", + }, + ], + }, + ], + }; + + await fileStorageService.saveCourseAsync(testCourse); + + const loadedCourses = await fileStorageService.loadSavedCourses(); + const loadedCourse = loadedCourses.find( + (c) => c.settings.name === testCourse.settings.name + ); + + expect(loadedCourse).toEqual(testCourse); + }); +});