Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat/search by section code #1129

Merged
merged 4 commits into from
Jan 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
Loading