diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/FuzzySearch.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/FuzzySearch.tsx index 26e353daa..8b70b8b61 100644 --- a/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/FuzzySearch.tsx +++ b/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/FuzzySearch.tsx @@ -15,6 +15,7 @@ const emojiMap: Record = { GE_CATEGORY: '🏫', // U+1F3EB :school: DEPARTMENT: '🏢', // U+1F3E2 :office: COURSE: '📚', // U+1F4DA :books: + SECTION: '📝', // U+1F4DD :memo: }; const romanArr = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII']; @@ -79,6 +80,10 @@ class FuzzySearch extends PureComponent { RightPaneStore.updateFormValue('courseNumber', ident[0].split(' ').slice(-1)[0]); break; } + case emojiMap.SECTION: { + RightPaneStore.updateFormValue('sectionCode', ident[0].split(' ').slice(0)[0]); + break; + } default: break; } @@ -106,6 +111,8 @@ class FuzzySearch extends PureComponent { return `${emojiMap.DEPARTMENT} ${option}: ${object.name}`; case 'COURSE': return `${emojiMap.COURSE} ${object.metadata.department} ${object.metadata.number}: ${object.name}`; + case 'SECTION': + return `${emojiMap.SECTION} ${object.sectionCode} ${object.sectionType} ${object.sectionNum}: ${object.department} ${object.courseNumber}`; default: return ''; } @@ -121,7 +128,7 @@ class FuzzySearch extends PureComponent { maybeDoSearchFactory = (requestTimestamp: number) => () => { if (!this.requestIsCurrent(requestTimestamp)) return; trpc.search.doSearch - .query({ query: this.state.value }) + .query({ query: this.state.value, term: RightPaneStore.getFormData().term }) .then((result) => { if (!this.requestIsCurrent(requestTimestamp)) return; this.setState({ diff --git a/apps/backend/scripts/get-search-data.ts b/apps/backend/scripts/get-search-data.ts index a4d438f63..3ec171e9b 100644 --- a/apps/backend/scripts/get-search-data.ts +++ b/apps/backend/scripts/get-search-data.ts @@ -1,7 +1,9 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; -import { mkdir, writeFile } from 'node:fs/promises'; +import { mkdir, writeFile, appendFile} from 'node:fs/promises'; import {Course, CourseSearchResult, DepartmentSearchResult} from '@packages/antalmanac-types'; +import { queryGraphQL } from 'src/lib/helpers'; +import { parseSectionCodes, SectionCodesGraphQLResponse, termData } from 'src/lib/term-section-codes'; import "dotenv/config"; @@ -50,12 +52,46 @@ async function main() { }); } console.log(`Fetched ${deptMap.size} departments.`); + + const QUERY_TEMPLATE = `{ + websoc(query: {year: "$$YEAR$$", quarter: $$QUARTER$$}) { + schools { + departments { + deptCode + courses { + courseTitle + courseNumber + sections { + sectionCode + sectionType + sectionNum + } + } + } + } + } + }`; await mkdir(join(__dirname, "../src/generated/"), { recursive: true }); await writeFile(join(__dirname, "../src/generated/searchData.ts"), ` - import type { CourseSearchResult, DepartmentSearchResult } from "@packages/antalmanac-types"; + import type { CourseSearchResult, DepartmentSearchResult, SectionSearchResult } from "@packages/antalmanac-types"; export const departments: Array = ${JSON.stringify(Array.from(deptMap.values()))}; export const courses: Array = ${JSON.stringify(Array.from(courseMap.values()))}; `) + let count = 0; + for (const term of termData){ + const [year, quarter] = term.shortName.split(" "); + const query = QUERY_TEMPLATE.replace("$$YEAR$$", year).replace("$$QUARTER$$", quarter); + const res = await queryGraphQL(query); + if (!res) { + throw new Error("Error fetching section codes."); + } + const parsedSectionData = parseSectionCodes(res); + console.log(`Fetched ${Object.keys(parsedSectionData).length} course codes for ${term.shortName} from Anteater API.`); + count += Object.keys(parsedSectionData).length; + await appendFile(join(__dirname, "../src/generated/searchData.ts"), `export const ${quarter}${year}: Record = ${JSON.stringify(parsedSectionData)}; + `) + } + console.log(`Fetched ${count} course codes for ${termData.length} terms from Anteater API.`); console.log("Cache generated."); } diff --git a/apps/backend/src/lib/helpers.ts b/apps/backend/src/lib/helpers.ts new file mode 100644 index 000000000..976b4c75f --- /dev/null +++ b/apps/backend/src/lib/helpers.ts @@ -0,0 +1,20 @@ +export async function queryGraphQL(queryString: string): Promise { + const query = JSON.stringify({ + query: queryString, + }); + + const res = await fetch('https://anteaterapi.com/v2/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: query, + }); + + const json = await res.json(); + + if (!res.ok || json.data === null) return null; + + return json as Promise; +} \ No newline at end of file diff --git a/apps/backend/src/lib/term-section-codes.ts b/apps/backend/src/lib/term-section-codes.ts new file mode 100644 index 000000000..2e308edce --- /dev/null +++ b/apps/backend/src/lib/term-section-codes.ts @@ -0,0 +1,133 @@ +import { SectionSearchResult } from "@packages/antalmanac-types"; + +interface SectionData { + sectionCode: string; + sectionType: string; + sectionNum: string; +} + +interface CourseData { + courseTitle: string; + courseNumber: string; + sections: SectionData[]; +} + +interface DepartmentData { + deptCode: string; + courses: CourseData[]; +} + +export interface SectionCodesGraphQLResponse { + data: { + websoc: { + schools: { + departments: DepartmentData[] + }[] + } + }; +} + +export function parseSectionCodes(response: SectionCodesGraphQLResponse): Record { + const results: Record = {}; + + response.data.websoc.schools.forEach(school => { + school.departments.forEach(department => { + department.courses.forEach(course => { + course.sections.forEach(section => { + const sectionCode = section.sectionCode; + results[sectionCode] = { + type: 'SECTION', + department: department.deptCode, + courseNumber: course.courseNumber, + sectionCode: section.sectionCode, + sectionNum: section.sectionNum, + sectionType: section.sectionType, + }; + }); + }); + }); + }); + + return results; +} + +class Term { + shortName: `${string} ${string}`; + constructor( + shortName: `${string} ${string}`, + ) { + this.shortName = shortName; + } +} + +/** + * Quarterly Academic Calendar {@link https://www.reg.uci.edu/calendars/quarterly/2023-2024/quarterly23-24.html} + * Quick Reference Ten Year Calendar {@link https://www.reg.uci.edu/calendars/academic/tenyr-19-29.html} + * The `startDate`, if available, should correspond to the __instruction start date__ (not the quarter start date) + * The `finalsStartDate`, if available, should correspond to the __final exams__ first date (should be a Saturday) + * Months are 0-indexed + */ +export const termData = [ // Will be automatically fetched from Anteater API + new Term('2025 Winter'), + new Term('2024 Fall'), + new Term('2024 Summer2'), + new Term('2024 Summer10wk'), + new Term('2024 Summer1'), + new Term('2024 Spring'), + new Term('2024 Winter'), + new Term('2023 Fall'), + new Term('2023 Summer2'), + new Term('2023 Summer10wk'), + new Term('2023 Summer1'), + new Term('2023 Spring'), + new Term('2023 Winter'), + new Term('2022 Fall'), + new Term('2022 Summer2'), + new Term('2022 Summer10wk'), // nominal start date for SS1 and SS10wk + new Term('2022 Summer1'), // since Juneteenth is observed 6/20/22 + new Term('2022 Spring'), + new Term('2022 Winter'), + new Term('2021 Fall'), + new Term('2021 Summer2'), + new Term('2021 Summer10wk'), + new Term('2021 Summer1'), + new Term('2021 Spring'), + new Term('2021 Winter'), + new Term('2020 Fall'), + new Term('2020 Summer2'), + new Term('2020 Summer10wk'), + new Term('2020 Summer1'), + new Term('2020 Spring'), + new Term('2020 Winter'), + new Term('2019 Fall'), + new Term('2019 Summer2'), + new Term('2019 Summer10wk'), + new Term('2019 Summer1'), + new Term('2019 Spring'), + new Term('2019 Winter'), + new Term('2018 Fall'), + new Term('2018 Summer2'), + new Term('2018 Summer10wk'), + new Term('2018 Summer1'), + new Term('2018 Spring'), + new Term('2018 Winter'), + new Term('2017 Fall'), + new Term('2017 Summer2'), + new Term('2017 Summer10wk'), + new Term('2017 Summer1'), + new Term('2017 Spring'), + new Term('2017 Winter'), + new Term('2016 Fall'), + new Term('2016 Summer2'), + new Term('2016 Summer10wk'), + new Term('2016 Summer1'), + new Term('2016 Spring'), + new Term('2016 Winter'), + new Term('2015 Fall'), + new Term('2015 Summer2'), + new Term('2015 Summer10wk'), + new Term('2015 Summer1'), + new Term('2015 Spring'), + new Term('2015 Winter'), + new Term('2014 Fall'), +]; \ No newline at end of file diff --git a/apps/backend/src/routers/search.ts b/apps/backend/src/routers/search.ts index 7acca8486..7b674ad79 100644 --- a/apps/backend/src/routers/search.ts +++ b/apps/backend/src/routers/search.ts @@ -1,9 +1,11 @@ import { z } from 'zod'; -import type { GESearchResult, SearchResult } from '@packages/antalmanac-types'; +import type { GESearchResult, SearchResult, SectionSearchResult } from '@packages/antalmanac-types'; import uFuzzy from '@leeoniya/ufuzzy'; import * as fuzzysort from "fuzzysort"; import { procedure, router } from '../trpc'; -import {courses, departments} from "../generated/searchData"; +import * as searchData from "../generated/searchData"; + +type SearchDataExports = keyof typeof searchData; const geCategoryKeys = ['ge1a', 'ge1b', 'ge2', 'ge3', 'ge4', 'ge5a', 'ge5b', 'ge6', 'ge7', 'ge8'] as const; @@ -31,24 +33,50 @@ const toMutable = (arr: readonly T[]): T[] => arr as T[]; const searchRouter = router({ doSearch: procedure - .input(z.object({ query: z.string() })) + .input(z.object({ query: z.string(), term: z.string()})) .query(async ({ input }): Promise> => { const { query } = input; + + const [year, quarter] = input.term.split(" "); + const parsedTerm = `${quarter}${year}`; + const termData = searchData[parsedTerm as SearchDataExports] as Record; + + const num = Number(input.query); + const matchedSections: SectionSearchResult[] = []; + if (!isNaN(num) && num >= 0 && Number.isInteger(num)) { + const baseCourseCode = input.query; + if (input.query.length === 4) { + for (let i =0; i < 10; i++){ + const possibleCourseCode = `${baseCourseCode}${i}`; + if (termData[possibleCourseCode]) { + matchedSections.push(termData[possibleCourseCode]); + } + } + } else if (input.query.length === 5) { + if (termData[baseCourseCode]) { + matchedSections.push(termData[baseCourseCode]); + } + } + } + const u = new uFuzzy(); const matchedGEs = u.search(toMutable(geCategoryKeys), query)[0]?.map((i) => geCategoryKeys[i]) ?? []; if (matchedGEs.length) return Object.fromEntries(matchedGEs.map(toGESearchResult)); - const matchedDepts = fuzzysort.go(query, departments, { + + const matchedDepts = matchedSections.length === 10 ? [] : fuzzysort.go(query, searchData.departments, { keys: ['id', 'alias'], - limit: 10 + limit: 10 - matchedSections.length }) - const matchedCourses = matchedDepts.length === 10 ? [] : fuzzysort.go(query, courses, { + const matchedCourses = matchedSections.length + matchedDepts.length === 10 ? [] : fuzzysort.go(query, searchData.courses, { keys: ['id', 'name', 'alias', 'metadata.department', 'metadata.number'], - limit: 10 - matchedDepts.length + limit: 10 - matchedDepts.length - matchedSections.length }) - return Object.fromEntries( - [...matchedDepts.map(x => [x.obj.id, x.obj]), - ...matchedCourses.map(x => [x.obj.id, x.obj]),] - ); + + return Object.fromEntries([ + ...matchedSections.map(x => [x.sectionCode, x]), + ...matchedDepts.map(x => [x.obj.id, x.obj]), + ...matchedCourses.map(x => [x.obj.id, x.obj]), + ]); }), }); diff --git a/packages/types/src/search.ts b/packages/types/src/search.ts index a74dab384..329e39f17 100644 --- a/packages/types/src/search.ts +++ b/packages/types/src/search.ts @@ -19,4 +19,13 @@ export type CourseSearchResult = { }; }; -export type SearchResult = GESearchResult | DepartmentSearchResult | CourseSearchResult; +export type SectionSearchResult = { + type: 'SECTION'; + department: string; + courseNumber: string; + sectionCode: string; + sectionNum: string; + sectionType: string; +}; + +export type SearchResult = GESearchResult | DepartmentSearchResult | CourseSearchResult | SectionSearchResult;