diff --git a/packages/api/src/app.module.ts b/packages/api/src/app.module.ts index 6ca2109b..955b0c0e 100644 --- a/packages/api/src/app.module.ts +++ b/packages/api/src/app.module.ts @@ -11,6 +11,7 @@ import { MajorModule } from "./major/major.module"; import { EmailModule } from "./email/email.module"; import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler"; import { MetaModule } from "./meta/meta.module"; +import { MinorModule } from "./minor/minor.module"; @Module({ imports: [ @@ -29,6 +30,7 @@ import { MetaModule } from "./meta/meta.module"; AuthModule, PlanModule, MajorModule, + MinorModule, EmailModule, MetaModule, ], diff --git a/packages/api/src/minor/minor-collator.ts b/packages/api/src/minor/minor-collator.ts new file mode 100644 index 00000000..19f696c2 --- /dev/null +++ b/packages/api/src/minor/minor-collator.ts @@ -0,0 +1,125 @@ +import { Minor } from "@graduate/common"; +// year minor name minor data +const MINORS: Record> = {}; +const MINOR_YEARS = new Set(); + +const rootDir = "./src/minor/minors"; + +interface YearData { + year: string; +} + +interface YearCollegeData { + year: string; + college: string; +} + +interface YearCollegeMinorData { + year: string; + college: string; + minor: string; +} + +async function fileExists( + fs: typeof import("fs/promises"), + path: string +): Promise { + return await fs.access(path, fs.constants.F_OK).then( + () => true, + () => false + ); +} + +// TODO: this code is quick and dirty but works. this should be replaced with some dry-er code later. +/** + * Iterates over the ./minors directory, collecting minors and adding them to + * the exported MINORS and MINOR_YEARS object/set respectively. It prioritizes + * parsed.commit.json files over parsed.initial.json files because _.commit._ + * files have been human-reviewed and _.initial._ files are raw scraper output. + */ +async function collateMinors() { + // TODO: determine why these needed to be runtime imports (normal import statements didn't work here). + const fs = await import("fs/promises"); + const path = await import("path"); + const years = ( + await fs.readdir(path.resolve(rootDir), { + withFileTypes: true, + }) + ) + .filter((dirent) => dirent.isDirectory()) + .map( + (dirent): YearData => ({ + year: dirent.name, + }) + ); + + const colleges = ( + await Promise.all( + years.map(async ({ year }) => { + const colleges = await fs.readdir(path.join(rootDir, year), { + withFileTypes: true, + }); + return colleges + .filter((dirent) => dirent.isDirectory()) + .map( + (college): YearCollegeData => ({ + year: year, + college: college.name, + }) + ); + }) + ) + ).flat(); + + const minors = ( + await Promise.all( + colleges.map(async ({ year, college }) => { + const minors = await fs.readdir(path.join(rootDir, year, college), { + withFileTypes: true, + }); + return minors + .filter((dirent) => dirent.isDirectory()) + .map( + (minor): YearCollegeMinorData => ({ + year: year, + college: college, + minor: minor.name, + }) + ); + }) + ) + ).flat(); + + years.forEach(({ year }) => { + MINOR_YEARS.add(year); + MINORS[year] = {}; + }); + + const done = await Promise.all( + minors.map(async ({ year, college, minor }) => { + const basePath = path.join(rootDir, year, college, minor); + const commitFile = path.join(basePath, "parsed.commit.json"); + const initialFile = path.join(basePath, "parsed.initial.json"); + + if (await fileExists(fs, commitFile)) { + const fileData = JSON.parse( + (await fs.readFile(commitFile)).toString() + ) as Minor; + MINORS[year][fileData.name] = fileData; + } else if (await fileExists(fs, initialFile)) { + const fileData = JSON.parse( + (await fs.readFile(initialFile)).toString() + ) as Minor; + if (MINORS[year]) MINORS[year][fileData.name] = fileData; + } + }) + ); + + console.log( + `Successfully loaded ${done.length} minors across ${MINOR_YEARS.size} years!` + ); +} + +collateMinors(); + +export { MINORS, MINOR_YEARS }; diff --git a/packages/api/src/minor/minor.controller.ts b/packages/api/src/minor/minor.controller.ts new file mode 100644 index 00000000..02ad778a --- /dev/null +++ b/packages/api/src/minor/minor.controller.ts @@ -0,0 +1,32 @@ +import { GetSupportedMinorsResponse, Minor } from "@graduate/common"; +import { MinorService } from "./minor.service"; +import { + Controller, + Get, + NotFoundException, + Param, + ParseIntPipe, +} from "@nestjs/common"; + +@Controller("minors") +export class MinorController { + constructor(private readonly minorService: MinorService) {} + @Get("/:catalogYear/:minorName") + getMinor( + @Param("catalogYear", ParseIntPipe) catalogYear: number, + @Param("minorName") minorName: string + ): Minor { + const minor = this.minorService.findByMinorAndYear(minorName, catalogYear); + if (!minor) { + throw new NotFoundException(); + } + + return minor; + } + + @Get("supportedMinors") + getSupportedMinors(): GetSupportedMinorsResponse { + const supportedMinors = this.minorService.getSupportedMinors(); + return { supportedMinors }; + } +} diff --git a/packages/api/src/minor/minor.module.ts b/packages/api/src/minor/minor.module.ts new file mode 100644 index 00000000..cfb7fabf --- /dev/null +++ b/packages/api/src/minor/minor.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { MinorService } from "./minor.service"; +import { MinorController } from "./minor.controller"; + +@Module({ + controllers: [MinorController], + providers: [MinorService], + exports: [MinorService], +}) +export class MinorModule {} diff --git a/packages/api/src/minor/minor.service.ts b/packages/api/src/minor/minor.service.ts new file mode 100644 index 00000000..8061b5bf --- /dev/null +++ b/packages/api/src/minor/minor.service.ts @@ -0,0 +1,35 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { formatServiceCtx } from "src/utils"; +import { MINOR_YEARS, MINORS } from "./minor-collator"; +import { Minor, SupportedMinors } from "@graduate/common"; + +@Injectable() +export class MinorService { + private readonly logger: Logger = new Logger(); + findByMinorAndYear(minorName: string, catalogYear: number): Minor | null { + if (!MINOR_YEARS.has(String(catalogYear))) { + this.logger.debug( + { message: "Minor year not found", catalogYear }, + MinorService.formatMinorServiceCtx("findByMinorAndYear") + ); + return null; + } + + if (!MINORS[catalogYear][minorName]) { + this.logger.debug( + { message: "Minor within year not found", minorName, catalogYear }, + MinorService.formatMinorServiceCtx("findByMinorAndYear") + ); + return null; + } + return MINORS[catalogYear][minorName]; + } + + getSupportedMinors(): SupportedMinors { + return MINORS; + } + + private static formatMinorServiceCtx(methodName: string): string { + return formatServiceCtx(MinorService.name, methodName); + } +} diff --git a/packages/api/src/minor/minors/2022/science/mathematics/parsed.initial.json b/packages/api/src/minor/minors/2022/science/mathematics/parsed.initial.json new file mode 100644 index 00000000..1c03cfa9 --- /dev/null +++ b/packages/api/src/minor/minors/2022/science/mathematics/parsed.initial.json @@ -0,0 +1,58 @@ +{ + "name": "Mathematics", + "totalCreditsRequired": 24, + "yearVersion": 2022, + "requirementSections": [ + { + "type": "SECTION", + "title": "Required Courses", + "requirements": [ + { + "type": "COURSE", + "classId": 1341, + "subject": "MATH" + }, + { + "type": "COURSE", + "classId": 1341, + "subject": "MATH" + } + ] + }, + { + "type": "SECTION", + "title": "Intermediate-Level Courses", + "requirements": [ + { + "type": "COURSE", + "classId": 2321, + "subject": "MATH" + }, + { + "type": "COURSE", + "classId": 2341, + "subject": "MATH" + }, + { + "type": "COURSE", + "classId": 2331, + "subject": "MATH" + } + ], + "minRequirementCount": 2 + }, + { + "type": "RANGE", + "subject": "MATH", + "idRangeStart": 3001, + "idRangeEnd": 4699, + "exceptions": [ + { + "subject": "MATH", + "classId": 4000, + "type": "COURSE" + } + ] + } + ] +} diff --git a/packages/common/src/api-response-types.ts b/packages/common/src/api-response-types.ts index 7cb2c1de..4f92b036 100644 --- a/packages/common/src/api-response-types.ts +++ b/packages/common/src/api-response-types.ts @@ -5,6 +5,7 @@ import { ScheduleCourse2, MetaInfo, Maybe, + SupportedMinors, } from "./types"; /** Types our API responds with. */ @@ -54,6 +55,9 @@ export class GetSupportedMajorsResponse { // { year => { majorName => {concentrations, minRequiredConcentrations} }} supportedMajors: SupportedMajors; } +export class GetSupportedMinorsResponse { + supportedMinors: SupportedMinors; +} export class GetMetaInfoResponse implements MetaInfo { commit: Maybe; diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts index a6bc85b8..73977114 100644 --- a/packages/common/src/types.ts +++ b/packages/common/src/types.ts @@ -146,6 +146,34 @@ export interface Major2 { metadata?: MajorMetadata; } +/** + * A Minor, containing all the requirements. + * + * @param name The name of the minor. + * @param requirementSections A list of the sections of requirements. + * @param totalCreditsRequired Total credits required to graduate with this minor. + * @param yearVersion The catalog version year of this minor. + * @param metadata Metadata for the minor. + */ + +export interface Minor { + name: string; + requirementSections: Section[]; + totalCreditsRequired: number; + yearVersion: number; + metadata?: MinorMetaData; +} +/** + * Metadata for a minor. + * + * @param verified Whether the major has been manually verified. + * @param lastEdited The last time the major was edited MM/DD/YYYY. + */ +export interface MinorMetaData { + verified: boolean; + lastEdited: string; +} + /** * Metadata for a major. * @@ -473,9 +501,11 @@ export type SupportedConcentrations = { // { majorName => { concentration, minRequiredConcentrations, verified} } export type SupportedMajorsForYear = Record; +export type SupportedMinorsForYear = Record; // { year => supported majors } export type SupportedMajors = Record; +export type SupportedMinors = Record; /** * Types for a some result from an algorithim. Currently used for the result of