Skip to content

Commit

Permalink
feat/search by section code (#1129)
Browse files Browse the repository at this point in the history
  • Loading branch information
jotalis authored Jan 22, 2025
1 parent 6f52e66 commit 4915764
Show file tree
Hide file tree
Showing 6 changed files with 248 additions and 15 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const emojiMap: Record<string, string> = {
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'];
Expand Down Expand Up @@ -79,6 +80,10 @@ class FuzzySearch extends PureComponent<FuzzySearchProps, FuzzySearchState> {
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;
}
Expand Down Expand Up @@ -106,6 +111,8 @@ class FuzzySearch extends PureComponent<FuzzySearchProps, FuzzySearchState> {
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 '';
}
Expand All @@ -121,7 +128,7 @@ class FuzzySearch extends PureComponent<FuzzySearchProps, FuzzySearchState> {
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({
Expand Down
40 changes: 38 additions & 2 deletions apps/backend/scripts/get-search-data.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<DepartmentSearchResult & { id: string }> = ${JSON.stringify(Array.from(deptMap.values()))};
export const courses: Array<CourseSearchResult & { id: string }> = ${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<SectionCodesGraphQLResponse>(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<string, SectionSearchResult> = ${JSON.stringify(parsedSectionData)};
`)
}
console.log(`Fetched ${count} course codes for ${termData.length} terms from Anteater API.`);
console.log("Cache generated.");
}

Expand Down
20 changes: 20 additions & 0 deletions apps/backend/src/lib/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export async function queryGraphQL<PromiseReturnType>(queryString: string): Promise<PromiseReturnType | null> {
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<PromiseReturnType>;
}
133 changes: 133 additions & 0 deletions apps/backend/src/lib/term-section-codes.ts
Original file line number Diff line number Diff line change
@@ -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<string, SectionSearchResult> {
const results: Record<string, SectionSearchResult> = {};

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'),
];
50 changes: 39 additions & 11 deletions apps/backend/src/routers/search.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -31,24 +33,50 @@ const toMutable = <T>(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<Record<string, SearchResult>> => {
const { query } = input;

const [year, quarter] = input.term.split(" ");
const parsedTerm = `${quarter}${year}`;
const termData = searchData[parsedTerm as SearchDataExports] as Record<string, SectionSearchResult>;

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]),
]);
}),
});

Expand Down
11 changes: 10 additions & 1 deletion packages/types/src/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

0 comments on commit 4915764

Please sign in to comment.