feat: injected button - add all courses from MyUT AND passing URL to 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 <ethanlanting@gmail.com> Co-authored-by: doprz <52579214+doprz@users.noreply.github.com>
This commit is contained in:
@@ -18,11 +18,13 @@ const nameSuffix = isBeta ? ' (beta)' : mode === 'development' ? ' (dev)' : '';
|
|||||||
|
|
||||||
const HOST_PERMISSIONS: string[] = [
|
const HOST_PERMISSIONS: string[] = [
|
||||||
'*://*.utdirect.utexas.edu/apps/registrar/course_schedule/*',
|
'*://*.utdirect.utexas.edu/apps/registrar/course_schedule/*',
|
||||||
|
'*://*.utdirect.utexas.edu/registration/classlist/*',
|
||||||
'*://*.utexas.collegescheduler.com/*',
|
'*://*.utexas.collegescheduler.com/*',
|
||||||
'*://*.catalog.utexas.edu/ribbit/',
|
'*://*.catalog.utexas.edu/ribbit/',
|
||||||
'*://*.registrar.utexas.edu/schedules/*',
|
'*://*.registrar.utexas.edu/schedules/*',
|
||||||
'*://*.login.utexas.edu/login/*',
|
'*://*.login.utexas.edu/login/*',
|
||||||
'https://utexas.bluera.com/*',
|
'https://utexas.bluera.com/*',
|
||||||
|
'*://my.utexas.edu/student/student/*',
|
||||||
];
|
];
|
||||||
|
|
||||||
const manifest = defineManifest(async () => ({
|
const manifest = defineManifest(async () => ({
|
||||||
|
|||||||
@@ -31,6 +31,15 @@ const userScheduleHandler: MessageHandler<UserScheduleMessages> = {
|
|||||||
renameSchedule({ data, sendResponse }) {
|
renameSchedule({ data, sendResponse }) {
|
||||||
renameSchedule(data.scheduleId, data.newName).then(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;
|
export default userScheduleHandler;
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ export default async function addCourse(scheduleId: string, course: Course): Pro
|
|||||||
course.colors = getUnusedColor(activeSchedule, course);
|
course.colors = getUnusedColor(activeSchedule, course);
|
||||||
activeSchedule.courses.push(course);
|
activeSchedule.courses.push(course);
|
||||||
activeSchedule.updatedAt = Date.now();
|
activeSchedule.updatedAt = Date.now();
|
||||||
|
|
||||||
await UserScheduleStore.set('schedules', schedules);
|
await UserScheduleStore.set('schedules', schedules);
|
||||||
|
console.log(`Course added: ${course.courseName} (ID: ${course.uniqueId})`);
|
||||||
}
|
}
|
||||||
|
|||||||
64
src/pages/background/lib/addCourseByURL.ts
Normal file
64
src/pages/background/lib/addCourseByURL.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,27 @@
|
|||||||
import CourseCatalogMain from '@views/components/CourseCatalogMain';
|
import CourseCatalogMain from '@views/components/CourseCatalogMain';
|
||||||
|
import InjectedButton from '@views/components/injected/AddAllButton';
|
||||||
import getSiteSupport, { SiteSupport } from '@views/lib/getSiteSupport';
|
import getSiteSupport, { SiteSupport } from '@views/lib/getSiteSupport';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
const support = getSiteSupport(window.location.href);
|
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');
|
const container = document.createElement('div');
|
||||||
container.id = 'extension-root';
|
container.id = 'extension-root';
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
|
|
||||||
createRoot(container).render(
|
createRoot(container).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<CourseCatalogMain support={support} />
|
<Component />
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (support === SiteSupport.COURSE_CATALOG_DETAILS || support === SiteSupport.COURSE_CATALOG_LIST) {
|
||||||
|
renderComponent(() => <CourseCatalogMain support={support} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (support === SiteSupport.MY_UT) {
|
||||||
|
renderComponent(InjectedButton);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ export interface UserScheduleMessages {
|
|||||||
* @param data the schedule id and course to add
|
* @param data the schedule id and course to add
|
||||||
*/
|
*/
|
||||||
addCourse: (data: { scheduleId: string; course: Course }) => void;
|
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
|
* Remove a course from a schedule
|
||||||
* @param data the schedule id and course to remove
|
* @param data the schedule id and course to remove
|
||||||
|
|||||||
68
src/views/components/injected/AddAllButton.tsx
Normal file
68
src/views/components/injected/AddAllButton.tsx
Normal file
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import addCourse from '@pages/background/lib/addCourse';
|
import addCourse from '@pages/background/lib/addCourse';
|
||||||
|
import { addCourseByURL } from '@pages/background/lib/addCourseByURL';
|
||||||
import { deleteAllSchedules } from '@pages/background/lib/deleteSchedule';
|
import { deleteAllSchedules } from '@pages/background/lib/deleteSchedule';
|
||||||
import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
|
import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
|
||||||
|
// import { addCourseByUrl } from '@shared/util/courseUtils';
|
||||||
// import { getCourseColors } from '@shared/util/colors';
|
// import { getCourseColors } from '@shared/util/colors';
|
||||||
// import CalendarCourseCell from '@views/components/calendar/CalendarCourseCell';
|
// import CalendarCourseCell from '@views/components/calendar/CalendarCourseCell';
|
||||||
import { Button } from '@views/components/common/Button';
|
import { Button } from '@views/components/common/Button';
|
||||||
@@ -202,51 +204,13 @@ export default function Settings(): JSX.Element {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// todo: move into a util/shared place, rather than specifically in settings
|
// const handleAddCourseByLink = async () => {
|
||||||
const handleAddCourseByUrl = async () => {
|
// // todo: Use a proper modal instead of a prompt
|
||||||
// todo: Use a proper modal instead of a prompt
|
// const link: string | null = prompt('Enter course link');
|
||||||
// eslint-disable-next-line no-alert
|
// // Exit if the user cancels the prompt
|
||||||
const link: string | null = prompt('Enter course link');
|
// if (link === null) return;
|
||||||
|
// await addCourseByUrl(link, activeSchedule);
|
||||||
// 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 [devMode, toggleDevMode] = useDevMode(10);
|
const [devMode, toggleDevMode] = useDevMode(10);
|
||||||
|
|
||||||
@@ -445,7 +409,7 @@ export default function Settings(): JSX.Element {
|
|||||||
<h2 className='mb-4 text-xl text-ut-black font-semibold' onClick={toggleDevMode}>
|
<h2 className='mb-4 text-xl text-ut-black font-semibold' onClick={toggleDevMode}>
|
||||||
Developer Mode
|
Developer Mode
|
||||||
</h2>
|
</h2>
|
||||||
<Button variant='filled' color='ut-black' onClick={handleAddCourseByUrl}>
|
<Button variant='filled' color='ut-black' onClick={() => addCourseByURL(activeSchedule)}>
|
||||||
Add course by link
|
Add course by link
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant='filled' color='ut-burntorange' onClick={showMigrationDialog}>
|
<Button variant='filled' color='ut-burntorange' onClick={showMigrationDialog}>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export const SiteSupport = {
|
|||||||
EXTENSION_POPUP: 'EXTENSION_POPUP',
|
EXTENSION_POPUP: 'EXTENSION_POPUP',
|
||||||
MY_CALENDAR: 'MY_CALENDAR',
|
MY_CALENDAR: 'MY_CALENDAR',
|
||||||
REPORT_ISSUE: 'REPORT_ISSUE',
|
REPORT_ISSUE: 'REPORT_ISSUE',
|
||||||
|
MY_UT: 'MY_UT',
|
||||||
|
CLASSLIST: 'CLASSLIST',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,5 +48,11 @@ export default function getSiteSupport(url: string): SiteSupportType | null {
|
|||||||
if (url.includes('utdirect.utexas.edu') && (url.includes('waitlist') || url.includes('classlist'))) {
|
if (url.includes('utdirect.utexas.edu') && (url.includes('waitlist') || url.includes('classlist'))) {
|
||||||
return SiteSupport.WAITLIST;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user