Skip to content

Commit

Permalink
feat: injected button - add all courses from MyUT AND passing URL to …
Browse files Browse the repository at this point in the history
…handler (#291)

* feat: first button attempt

* feat: fetching each course code

* feat: adding courses function from there but idk where to get the active schedule from

* docs: todo

* feat: retrieved active schedule

* feat: button tactics

* feat: add support for my.utexas.edu

* feat: inject button into MyUT

* feat: refactor code to render components dynamically based on site

* feat: scrape course ids from MyUT and remove duplicates

* feat: site support links for classlist

* feat: add utility function to add course by URL

* feat: support additional case for course cal

* feat: duplicates

* chore: cleanup

* feat: temporary checkpoint

* feat: reroute to use new add course by url

* feat: linking to new function, cleaning up, adding messaging for course url add

* chore: unused import

* feat: relinking addCourse function to the button fingers crossed

* feat: we did it!

* chore: remove comment

* chore: cleanup cleanup

* feat: tried to handle the async stuff because of that small bug but nothing fixed. doesnt hurt tho

* feat: i have fixed it holy kevinnn

* chore: delete unused file and organization

* chore: removed unused log

* feat: better log for course add

* chore: refactor via data destructuring

* chore: pass component as prop via React.ComponentType

---------

Co-authored-by: Ethan Lanting <[email protected]>
Co-authored-by: doprz <[email protected]>
  • Loading branch information
3 people authored Nov 16, 2024
1 parent 9ad3239 commit c41467c
Show file tree
Hide file tree
Showing 9 changed files with 179 additions and 49 deletions.
2 changes: 2 additions & 0 deletions src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ const nameSuffix = isBeta ? ' (beta)' : mode === 'development' ? ' (dev)' : '';

const HOST_PERMISSIONS: string[] = [
'*://*.utdirect.utexas.edu/apps/registrar/course_schedule/*',
'*://*.utdirect.utexas.edu/registration/classlist/*',
'*://*.utexas.collegescheduler.com/*',
'*://*.catalog.utexas.edu/ribbit/',
'*://*.registrar.utexas.edu/schedules/*',
'*://*.login.utexas.edu/login/*',
'https://utexas.bluera.com/*',
'*://my.utexas.edu/student/student/*',
];

const manifest = defineManifest(async () => ({
Expand Down
9 changes: 9 additions & 0 deletions src/pages/background/handler/userScheduleHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ const userScheduleHandler: MessageHandler<UserScheduleMessages> = {
renameSchedule({ data, sendResponse }) {
renameSchedule(data.scheduleId, data.newName).then(sendResponse);
},
// proxy so we can add courses
addCourseByURL({ data: { url, method, body, response }, sendResponse }) {
fetch(url, {
method,
body,
})
.then(res => (response === 'json' ? res.json() : res.text()))
.then(sendResponse);
},
};

export default userScheduleHandler;
2 changes: 1 addition & 1 deletion src/pages/background/lib/addCourse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@ export default async function addCourse(scheduleId: string, course: Course): Pro
course.colors = getUnusedColor(activeSchedule, course);
activeSchedule.courses.push(course);
activeSchedule.updatedAt = Date.now();

await UserScheduleStore.set('schedules', schedules);
console.log(`Course added: ${course.courseName} (ID: ${course.uniqueId})`);
}
64 changes: 64 additions & 0 deletions src/pages/background/lib/addCourseByURL.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import addCourse from '@pages/background/lib/addCourse';
import { background } from '@shared/messages';
import type { UserSchedule } from '@shared/types/UserSchedule';
import { CourseCatalogScraper } from '@views/lib/CourseCatalogScraper';
import getCourseTableRows from '@views/lib/getCourseTableRows';
import { SiteSupport } from '@views/lib/getSiteSupport';

/**
* Adds a course to the active schedule by fetching course details from a provided URL.
* If no URL is provided, prompts the user to enter one.
* Sriram and Elie made this
*
* @param activeSchedule - The user's active schedule to which the course will be added.
* @param link - The URL from which to fetch the course details. If not provided, a prompt will ask for it.
*
* @returns A promise that resolves when the course has been added or the operation is cancelled.
*
* @throws an error if there is an issue with scraping the course details.
*/
export async function addCourseByURL(activeSchedule: UserSchedule, link?: string): Promise<void> {
// todo: Use a proper modal instead of a prompt
// eslint-disable-next-line no-param-reassign, no-alert
if (!link) link = prompt('Enter course link') || undefined;

// Exit if the user cancels the prompt
if (!link) return;

try {
let htmlText: string;
try {
htmlText = await background.addCourseByURL({
url: link,
method: 'GET',
response: 'text',
});
} catch (e) {
// eslint-disable-next-line no-alert
alert(`Failed to fetch url '${link}'`);
return;
}

const doc = new DOMParser().parseFromString(htmlText, 'text/html');

const scraper = new CourseCatalogScraper(SiteSupport.COURSE_CATALOG_DETAILS, doc, link);
const tableRows = getCourseTableRows(doc);
const scrapedCourses = scraper.scrape(tableRows, false);

if (scrapedCourses.length !== 1) return;

const description = scraper.getDescription(doc);
const row = scrapedCourses[0]!;
const course = row.course!;
course.description = description;

if (activeSchedule.courses.every(c => c.uniqueId !== course.uniqueId)) {
console.log('adding course');
await addCourse(activeSchedule.id, course);
} else {
console.log('course already exists');
}
} catch (error) {
console.error('Error scraping course:', error);
}
}
13 changes: 11 additions & 2 deletions src/pages/content/index.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
import CourseCatalogMain from '@views/components/CourseCatalogMain';
import InjectedButton from '@views/components/injected/AddAllButton';
import getSiteSupport, { SiteSupport } from '@views/lib/getSiteSupport';
import React from 'react';
import { createRoot } from 'react-dom/client';

const support = getSiteSupport(window.location.href);

if (support === SiteSupport.COURSE_CATALOG_DETAILS || support === SiteSupport.COURSE_CATALOG_LIST) {
const renderComponent = (Component: React.ComponentType) => {
const container = document.createElement('div');
container.id = 'extension-root';
document.body.appendChild(container);

createRoot(container).render(
<React.StrictMode>
<CourseCatalogMain support={support} />
<Component />
</React.StrictMode>
);
};

if (support === SiteSupport.COURSE_CATALOG_DETAILS || support === SiteSupport.COURSE_CATALOG_LIST) {
renderComponent(() => <CourseCatalogMain support={support} />);
}

if (support === SiteSupport.MY_UT) {
renderComponent(InjectedButton);
}
6 changes: 6 additions & 0 deletions src/shared/messages/UserScheduleMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ export interface UserScheduleMessages {
* @param data the schedule id and course to add
*/
addCourse: (data: { scheduleId: string; course: Course }) => void;
/**
* Adds a course by URL
* @param data
* @returns Response of the requested course URL
*/
addCourseByURL: (data: { url: string; method: string; body?: string; response: 'json' | 'text' }) => string;
/**
* Remove a course from a schedule
* @param data the schedule id and course to remove
Expand Down
68 changes: 68 additions & 0 deletions src/views/components/injected/AddAllButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { addCourseByURL } from '@pages/background/lib/addCourseByURL';
import { Button } from '@views/components/common/Button';
import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot';
import useSchedules from '@views/hooks/useSchedules';
import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom';

/**
* InjectedButton component renders a button that adds courses to UTRP from official MyUT calendar
* and adds the courses to the active schedule.
*
* @returns The rendered button component or null if the container is not found.
*/
export default function InjectedButton(): JSX.Element | null {
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const [activeSchedule, _] = useSchedules();

const extractCoursesFromCalendar = async () => {
const calendarElement = document.querySelector('#kgoui_Rcontent_I3_Rprimary_I1_Rcontent_I1_Rcontent_I0_Ritems');

if (!calendarElement) {
console.error('Calendar element not found');
return [];
}

const anchorTags = Array.from(calendarElement.querySelectorAll('a')).filter(
anchor => !anchor.href.includes('google.com')
);

// Make sure to remove duplicate anchorTags using set
const uniqueAnchorTags = Array.from(new Set(anchorTags.map(a => a.href)));

for (const a of uniqueAnchorTags) {
// eslint-disable-next-line no-await-in-loop
await addCourseByURL(activeSchedule, a);
}
};

useEffect(() => {
const targetElement = document.getElementById('kgoui_Rcontent_I3_Rsecondary');

if (
targetElement &&
targetElement.classList.contains('kgoui_container_responsive_asymmetric2_column_secondary')
) {
const buttonContainer = document.createElement('div');
targetElement.appendChild(buttonContainer);
setContainer(buttonContainer);

return () => {
buttonContainer.remove();
};
}
}, []);

if (!container) {
return null;
}

return ReactDOM.createPortal(
<ExtensionRoot>
<Button variant='filled' color='ut-burntorange' onClick={extractCoursesFromCalendar}>
Add Courses to UT Registration+
</Button>
</ExtensionRoot>,
container
);
}
56 changes: 10 additions & 46 deletions src/views/components/settings/Settings.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import addCourse from '@pages/background/lib/addCourse';
import { addCourseByURL } from '@pages/background/lib/addCourseByURL';
import { deleteAllSchedules } from '@pages/background/lib/deleteSchedule';
import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
// import { addCourseByUrl } from '@shared/util/courseUtils';
// import { getCourseColors } from '@shared/util/colors';
// import CalendarCourseCell from '@views/components/calendar/CalendarCourseCell';
import { Button } from '@views/components/common/Button';
Expand Down Expand Up @@ -202,51 +204,13 @@ export default function Settings(): JSX.Element {
});
};

// todo: move into a util/shared place, rather than specifically in settings
const handleAddCourseByUrl = async () => {
// todo: Use a proper modal instead of a prompt
// eslint-disable-next-line no-alert
const link: string | null = prompt('Enter course link');

// Exit if the user cancels the prompt
if (link === null) return;

try {
let response: Response;
try {
response = await fetch(link);
} catch (e) {
// eslint-disable-next-line no-alert
alert(`Failed to fetch url '${link}'`);
return;
}
const text = await response.text();
const doc = new DOMParser().parseFromString(text, 'text/html');

const scraper = new CourseCatalogScraper(SiteSupport.COURSE_CATALOG_DETAILS, doc, link);
const tableRows = getCourseTableRows(doc);
const courses = scraper.scrape(tableRows, false);

if (courses.length === 1) {
const description = scraper.getDescription(doc);
const row = courses[0]!;
const course = row.course!;
course.description = description;
// console.log(course);

if (activeSchedule.courses.every(c => c.uniqueId !== course.uniqueId)) {
console.log('adding course');
addCourse(activeSchedule.id, course);
} else {
console.log('course already exists');
}
} else {
console.log(courses);
}
} catch (error) {
console.error('Error scraping course:', error);
}
};
// const handleAddCourseByLink = async () => {
// // todo: Use a proper modal instead of a prompt
// const link: string | null = prompt('Enter course link');
// // Exit if the user cancels the prompt
// if (link === null) return;
// await addCourseByUrl(link, activeSchedule);
// };

const [devMode, toggleDevMode] = useDevMode(10);

Expand Down Expand Up @@ -445,7 +409,7 @@ export default function Settings(): JSX.Element {
<h2 className='mb-4 text-xl text-ut-black font-semibold' onClick={toggleDevMode}>
Developer Mode
</h2>
<Button variant='filled' color='ut-black' onClick={handleAddCourseByUrl}>
<Button variant='filled' color='ut-black' onClick={() => addCourseByURL(activeSchedule)}>
Add course by link
</Button>
<Button variant='filled' color='ut-burntorange' onClick={showMigrationDialog}>
Expand Down
8 changes: 8 additions & 0 deletions src/views/lib/getSiteSupport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export const SiteSupport = {
EXTENSION_POPUP: 'EXTENSION_POPUP',
MY_CALENDAR: 'MY_CALENDAR',
REPORT_ISSUE: 'REPORT_ISSUE',
MY_UT: 'MY_UT',
CLASSLIST: 'CLASSLIST',
} as const;

/**
Expand Down Expand Up @@ -46,5 +48,11 @@ export default function getSiteSupport(url: string): SiteSupportType | null {
if (url.includes('utdirect.utexas.edu') && (url.includes('waitlist') || url.includes('classlist'))) {
return SiteSupport.WAITLIST;
}
if (url.includes('my.utexas.edu/student/student/index') || url.includes('my.utexas.edu/student/')) {
return SiteSupport.MY_UT;
}
if (url.includes('registration/classlist.WBX')) {
return SiteSupport.CLASSLIST;
}
return null;
}

0 comments on commit c41467c

Please sign in to comment.