diff --git a/.gitignore b/.gitignore index e5dc2bc..2e2c8a5 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,10 @@ yarn-error.log* # local env files -.env* +.env.local +.env.development.local +.env.test.local +.env.production.local out/ build/ diff --git a/src/background/index.ts b/src/background/index.ts index 7f05410..d93d88a 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -1,15 +1,16 @@ import { Storage } from '@plasmohq/storage'; import { - type CourseHeader, + addGCalButtons, listenForTableChange, scrapeCourseData, } from '~content'; import { neededOrigins } from '~data/config'; +import { type SearchQuery } from '~utils/SearchQuery'; export interface ShowCourseTabPayload { - header: CourseHeader; - professors: string[]; + header: string | SearchQuery; + professors: SearchQuery[]; } // State vars @@ -23,11 +24,29 @@ const realBrowser = process.env.PLASMO_BROWSER === 'chrome' ? chrome : browser; /** Injects the content script if we hit a course page */ realBrowser.webNavigation.onHistoryStateUpdated.addListener((details) => { - if ( + const onOptions = /^.*:\/\/utdallas\.collegescheduler\.com\/terms\/.*\/courses\/.+$/.test( details.url, - ) - ) { + ); + const onCurrentSchedule = + /^.*:\/\/utdallas\.collegescheduler\.com\/terms\/.*\/currentschedule$/.test( + details.url, + ); + const onPotentialSchedule = + /^.*:\/\/utdallas\.collegescheduler\.com\/terms\/.*\/schedules/.test( + details.url, + ); + if (onOptions) { + //Listen for table change to rescrape data + realBrowser.tabs.sendMessage(details.tabId, 'disconnectObserver'); + realBrowser.scripting.executeScript({ + target: { + tabId: details.tabId, + }, + func: listenForTableChange, + }); + } + if (onOptions || onCurrentSchedule || onPotentialSchedule) { //Scrape data realBrowser.scripting.executeScript( { @@ -46,21 +65,25 @@ realBrowser.webNavigation.onHistoryStateUpdated.addListener((details) => { } }, ); - //Listen for table change to rescrape data - realBrowser.tabs.sendMessage(details.tabId, 'disconnectObserver'); - realBrowser.scripting.executeScript({ - target: { - tabId: details.tabId, - }, - func: listenForTableChange, - }); //Store tab info realBrowser.action.setBadgeText({ text: '!' }); realBrowser.action.setBadgeBackgroundColor({ color: 'green' }); courseTabId = details.tabId; storage.set('courseTabId', courseTabId); storage.set('courseTabUrl', details.url); - } else { + } + if (onCurrentSchedule) { + //Add GCal buttons + realBrowser.scripting.executeScript({ + target: { + tabId: details.tabId, + }, + // content script injection only works reliably on the prod packaged extension + // b/c of the plasmo dev server connections + func: addGCalButtons, + }); + } + if (!onOptions && !onCurrentSchedule && !onPotentialSchedule) { realBrowser.action.setBadgeText({ text: '' }); } }); diff --git a/src/components/CourseOverview.tsx b/src/components/CourseOverview.tsx index 73050d3..15ee147 100644 --- a/src/components/CourseOverview.tsx +++ b/src/components/CourseOverview.tsx @@ -6,36 +6,43 @@ import type { GenericFetchedData, GradesType } from '~pages'; import { type SearchQuery, searchQueryLabel } from '~utils/SearchQuery'; type CourseOverviewProps = { - course: SearchQuery; - grades: GenericFetchedData; + header: string | SearchQuery; + grades: undefined | GenericFetchedData; }; -const CourseOverview = ({ course, grades }: CourseOverviewProps) => { +const CourseOverview = ({ header, grades }: CourseOverviewProps) => { + const isCourse = typeof header !== 'string'; return (
-

{searchQueryLabel(course)}

- {(grades.state === 'loading' && ( - -

Overall grade: A+

-
- )) || - (grades.state === 'done' && ( -

- {'Overall grade: ' + grades.data.letter_grade} -

- ))} - +

Overall grade: A+

+ + )) || + (grades.state === 'done' && ( +

+ {'Overall grade: ' + grades.data.letter_grade} +

+ ))} +
- {searchQueryLabel(convertToProfOnly(course))} + {searchQueryLabel( + showProfNameOnly ? convertToProfOnly(course) : course, + )} @@ -136,7 +148,8 @@ function Row({ course, grades, backupGrades, rmp, setPage }: RowProps) { - {((typeof grades === 'undefined' || grades.state === 'error') && + {(fallbackToProfOnly && + (typeof grades === 'undefined' || grades.state === 'error') && (((typeof backupGrades === 'undefined' || backupGrades.state === 'error') && <>) || (backupGrades.state === 'loading' && ( @@ -250,6 +263,8 @@ type SearchResultsTableProps = { grades: { [key: string]: GenericFetchedData }; rmp: { [key: string]: GenericFetchedData }; setPage: (arg0: SearchQuery) => void; + showProfNameOnly: boolean; + fallbackToProfOnly: boolean; }; const SearchResultsTable = ({ @@ -257,6 +272,8 @@ const SearchResultsTable = ({ grades, rmp, setPage, + showProfNameOnly, + fallbackToProfOnly, }: SearchResultsTableProps) => { //Table sorting category const [orderBy, setOrderBy] = useState<'none' | 'gpa' | 'rating'>('none'); @@ -392,6 +409,8 @@ const SearchResultsTable = ({ backupGrades={grades[searchQueryLabel(convertToProfOnly(result))]} rmp={rmp[searchQueryLabel(convertToProfOnly(result))]} setPage={setPage} + showProfNameOnly={showProfNameOnly} + fallbackToProfOnly={fallbackToProfOnly} /> ))} diff --git a/src/content.ts b/src/content.ts index ef59557..276f35b 100644 --- a/src/content.ts +++ b/src/content.ts @@ -1,13 +1,14 @@ import type { PlasmoCSConfig } from 'plasmo'; -export interface CourseHeader { - subjectPrefix: string; - courseNumber: string; -} +import { type SearchQuery } from '~utils/SearchQuery'; // Plasmo CS config export export const config: PlasmoCSConfig = { - matches: ['https://utdallas.collegescheduler.com/terms/*/courses/*'], + matches: [ + 'https://utdallas.collegescheduler.com/terms/*/courses/*', + 'https://utdallas.collegescheduler.com/terms/*/currentschedule', + 'https://utdallas.collegescheduler.com/terms/*/schedules', + ], world: 'MAIN', }; @@ -19,7 +20,7 @@ export const config: PlasmoCSConfig = { */ export async function scrapeCourseData() { const [header, professors] = await Promise.all([ - getCourseInfo(), + getHeader(), injectAndGetProfessorNames(), ]); return { header: header, professors: professors }; @@ -43,32 +44,56 @@ export async function scrapeCourseData() { }); } - /** Gets the course prefix and number from the course page */ - async function getCourseInfo(): Promise { - const course = await waitForElement('h1'); - const courseData = course.innerText.split(' '); - return { subjectPrefix: courseData[0], courseNumber: courseData[1] }; + /** Gets the header or course prefix and number from the page */ + async function getHeader(): Promise { + const header = (await waitForElement('h1')).innerText.trim(); + if (header.match(/^[a-zA-Z]{2,4} [0-9][0-9V]?[0-9]{0,2}$/)) { + // is course + const courseData = header.split(' '); + return { prefix: courseData[0], number: courseData[1] }; + } + // is text + return header; } /** Gets all professor names and then injects them into the section table */ - async function injectAndGetProfessorNames(): Promise { + async function injectAndGetProfessorNames(): Promise { + const professors: SearchQuery[] = []; const courseTable = await waitForElement('table'); - const professors: string[] = []; const courseRows = courseTable.querySelectorAll('tbody'); - // add Professor header to the table + // find place const tableHeaders = courseTable.querySelector('thead > tr'); - const newHeader = document.createElement('th'); - const line1 = document.createElement('div'); - line1.innerText = 'Instructor(s)'; - newHeader.append(line1); - // add Skedge reminder - const line2 = document.createElement('div'); - line2.style.fontWeight = 'normal'; - line2.style.paddingTop = '0.5rem'; - line2.innerText = 'From Skedge'; - newHeader.append(line2); - tableHeaders.insertBefore(newHeader, tableHeaders.children[7]); + let sectionPlace; + for ( + sectionPlace = 0; + sectionPlace < tableHeaders.children.length; + sectionPlace++ + ) { + if ( + (tableHeaders.children[sectionPlace] as HTMLElement).innerText === + 'Section' + ) { + break; + } + } + sectionPlace++; + + if (!courseTable.querySelector('[data-skedge="th"]')) { + // add Professor header to the table + const newHeader = document.createElement('th'); + newHeader.setAttribute('data-skedge', 'th'); + const line1 = document.createElement('div'); + line1.innerText = 'Instructor(s)'; + newHeader.append(line1); + // add Skedge reminder + const line2 = document.createElement('div'); + line2.style.fontWeight = 'normal'; + line2.style.paddingTop = '0.5rem'; + line2.innerText = 'From Skedge'; + newHeader.append(line2); + tableHeaders.insertBefore(newHeader, tableHeaders.children[sectionPlace]); + } courseRows.forEach((courseRow) => { // get professor name from course row @@ -78,30 +103,53 @@ export async function scrapeCourseData() { sectionDetailsButton.click(); const sectionDetails = courseRow.querySelector('tr:nth-child(2)'); const sectionDetailsList = sectionDetails.querySelectorAll('li'); - let professor = ''; + const searchQuery: SearchQuery = {}; + let professor; sectionDetailsList.forEach((li) => { const detailLabelText = li.querySelector('strong > span').innerText; + if (detailLabelText.includes('Subject')) { + searchQuery.prefix = li.innerText.split(':')[1].trim(); + } + if (detailLabelText.includes('Course')) { + searchQuery.number = li.innerText.split(':')[1].trim(); + } if (detailLabelText.includes('Instructor')) { professor = li.innerText.split(':')[1].trim(); } }); // append professor name to the table - const newTd = document.createElement('td'); - newTd.innerText = professor ?? 'No Instructor'; - // this is in case we have multiple instructions per section - const sectionProfessors = professor.split(','); - sectionProfessors.forEach((sectionProfessor) => { - professors.push(sectionProfessor.trim()); - }); const courseRowCells = courseRow.querySelector('tr'); - courseRowCells.insertBefore(newTd, courseRowCells.children[7]); - //Increase Disabled Reasons row colspan if necessary - const sectionDisabled = courseRow.querySelector( - 'tr:nth-child(3) > td', - ) as HTMLTableCellElement | null; - if (sectionDisabled !== null) { - sectionDisabled.colSpan = sectionDisabled.colSpan + 1; + let newTd = courseRowCells.querySelector( + '[data-skedge="td"]', + ) as HTMLElement; + if (!newTd) { + newTd = document.createElement('td'); + newTd.setAttribute('data-skedge', 'td'); + courseRowCells.insertBefore( + newTd, + courseRowCells.children[sectionPlace], + ); + //Increase Disabled Reasons row colspan if necessary + const sectionDisabled = courseRow.querySelector( + 'tr:nth-child(3) > td', + ) as HTMLTableCellElement | null; + if (sectionDisabled !== null) { + sectionDisabled.colSpan = sectionDisabled.colSpan + 1; + } + } + newTd.innerText = professor ?? 'No Instructor'; + if (typeof professor !== 'undefined') { + // this is in case we have multiple instructions per section + const sectionProfessors = professor.trim().split(','); + sectionProfessors.forEach((sectionProfessor) => { + const splitProf = sectionProfessor.trim().split(' '); + professors.push({ + ...searchQuery, + profFirst: splitProf[0], + profLast: splitProf[splitProf.length - 1], + }); + }); } // collapse section details sectionDetailsButton.click(); @@ -110,9 +158,10 @@ export async function scrapeCourseData() { } } -const realBrowser = process.env.PLASMO_BROWSER === 'chrome' ? chrome : browser; /** This listens for clicks on the buttons that switch between the enabled and disabled professor tabs and reports back to background.ts */ export function listenForTableChange() { + const realBrowser = + process.env.PLASMO_BROWSER === 'chrome' ? chrome : browser; const observer = new MutationObserver((mutationsList) => { for (const mutation of mutationsList) { if ( @@ -138,3 +187,119 @@ export function listenForTableChange() { } }); } + +export async function addGCalButtons() { + /** Gets the first element from the DOM specified by selector */ + function waitForElement(selector: string): Promise { + return new Promise((resolve) => { + if (document.querySelector(selector)) { + return resolve(document.querySelector(selector)); + } + const observer = new MutationObserver(() => { + if (document.querySelector(selector)) { + resolve(document.querySelector(selector)); + observer.disconnect(); + } + }); + observer.observe(document.body, { + childList: true, + subtree: true, + }); + }); + } + + const courseTable = await waitForElement('table'); + + // add Save to Google Calendar + const newHeader = document.createElement('th'); + const line1 = document.createElement('div'); + line1.innerText = 'Save to \nGoogle Calendar'; + newHeader.append(line1); + // add Skedge reminder + const line2 = document.createElement('div'); + line2.style.fontWeight = 'normal'; + line2.style.paddingTop = '0.5rem'; + line2.innerText = 'From Skedge'; + newHeader.append(line2); + const tableHeaders = courseTable.querySelector('thead > tr'); + tableHeaders.insertBefore(newHeader, tableHeaders.children[1]); + + const courseRows = courseTable.querySelectorAll('tbody'); + const newTds = []; + courseRows.forEach((courseRow) => { + const newTd = document.createElement('td'); + newTds.push(newTd); + const courseRowCells = courseRow.querySelector('tr'); + courseRowCells.insertBefore(newTd, courseRowCells.children[1]); + }); + + // automatically fetch current term from page URL + const termString = window.location + .toString() + .split('terms/')[1] + .split('/')[0]; + let courses = await fetch( + 'https://utdallas.collegescheduler.com/api/term-data/' + termString, + ); + courses = (await courses.json()).currentSections; + + if (typeof courses !== 'undefined') { + for (let i = 0; i <= newTds.length; i++) { + // append button to the table + const newTd = newTds[i]; + const courseData = courses[i]; + const links = []; // each metting + for (let j = 0; j < courseData.meetings.length; j++) { + const meeting = courseData.meetings[j]; + const formatTime = (date, time) => { + const datePart = date.split('T')[0].replaceAll('-', ''); + const timePart = String(time).padStart(4, '0'); + return `${datePart}T${timePart}00`; + }; + const formattedStartDate = formatTime( + meeting.startDate, + meeting.startTime, + ); + const formattedEndTime = formatTime(meeting.startDate, meeting.endTime); + const recurrenceEnd = + meeting.endDate.split('T')[0].replaceAll('-', '') + 'T235959'; + const meetingDays = meeting.days + .replaceAll('Th', 'X') + .split('') + .map( + (letter) => + ({ M: 'MO', T: 'TU', W: 'WE', X: 'TH', F: 'FR' })[letter], + ) + .join(','); + const recurrence = `RRULE:FREQ=WEEKLY;UNTIL=${recurrenceEnd};BYDAY=${meetingDays}`; + links.push( + `https://calendar.google.com/calendar/r/eventedit?text=${courseData.subject} ${courseData.course}&dates=${formattedStartDate}/${formattedEndTime}&ctz=America/Chicago&location=${meeting.building}&recur=${recurrence}`, + ); + } + // make a button to open multiple links at once when necessaary + let newLink; + if (links.length > 1) { + newLink = document.createElement('button'); + newLink.innerText = 'Add to Calendar (' + links.length + ')'; + newLink.onclick = function () { + for (const link of links) { + window.open(link); + } + }; + } else { + newLink = document.createElement('a'); + newLink.innerText = 'Add to Calendar'; + newLink.target = '_blank'; + newLink.href = links[0]; + } + newLink.style.background = '#E98300'; + newLink.style.color = '#000'; + newLink.style.border = 'none'; + newLink.style.borderRadius = '4px'; + newLink.style.padding = '6px 12px'; + newLink.style.margin = '10px auto'; + newLink.style.display = 'block'; + newTd.appendChild(newLink); + } + } +} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index d4d2764..a645af9 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -187,7 +187,7 @@ const Index = () => { } setPage(set); } - const [course, setCourse] = useState({}); + const [header, setHeader] = useState(''); const [results, setResults] = useState([]); @@ -198,27 +198,13 @@ const Index = () => { return; } setPage('list'); - const newCourse = { - prefix: payload.header.subjectPrefix, - number: payload.header.courseNumber, - }; - setCourse(newCourse); - fetchAndStoreGradesData(newCourse); - let newResults = []; - for (const professor of payload.professors) { - const splitProf = professor.split(' '); - const profFirst = splitProf[0]; - const profLast = splitProf[splitProf.length - 1]; - newResults.push({ - prefix: payload.header.subjectPrefix, - number: payload.header.courseNumber, - profFirst: profFirst, - profLast: profLast, - }); + setHeader(payload.header); + console.log(payload.header, typeof payload.header); + if (typeof payload.header !== 'string') { + fetchAndStoreGradesData(payload.header); } - newResults = removeDuplicates(newResults); - setResults(newResults); - getData(newResults); + setResults(removeDuplicates(payload.professors)); + getData(payload.professors); }); }, []); @@ -318,8 +304,12 @@ const Index = () => { >
{ grades={grades} rmp={rmp} setPage={setPageAndScroll} + showProfNameOnly={typeof header !== 'string'} + fallbackToProfOnly={typeof header !== 'string'} />
{page !== 'list' && (