feat: UTRP v2 migration (#292)

* feat: wip add course by url

* chore: update imports

* feat: add useCourseFromUrl hook

* chore: extract logic into async function

* feat: add checkLoginStatus.ts

* feat: add useCourseMigration hook

* feat: working course migration

* fix: active schedule bug

* feat: refactor logic and add to onUpdate

* feat: update ui style

* feat: add changelog functionality to settings

* chore: update packages

* feat: migration + sentry stuffs

* feat: improve migration flow

* docs: add sentry jsdocs

* chore: fix lint and format

* chore: cleanup + fix race condition

---------

Co-authored-by: Samuel Gunter <sgunter@utexas.edu>
Co-authored-by: Razboy20 <razboy20@gmail.com>
This commit is contained in:
doprz
2024-10-14 21:30:37 -05:00
committed by GitHub
parent e774f316e3
commit d22237d561
23 changed files with 1980 additions and 1865 deletions

View File

@@ -26,11 +26,11 @@
"prepare": "husky" "prepare": "husky"
}, },
"dependencies": { "dependencies": {
"@sentry/react": "^8.33.1", "@headlessui/react": "^2.1.10",
"@headlessui/react": "^2.1.9",
"@hello-pangea/dnd": "^17.0.0", "@hello-pangea/dnd": "^17.0.0",
"@octokit/rest": "^21.0.2", "@octokit/rest": "^21.0.2",
"@unocss/vite": "^0.63.3", "@sentry/react": "^8.34.0",
"@unocss/vite": "^0.63.4",
"@vitejs/plugin-react": "^4.3.2", "@vitejs/plugin-react": "^4.3.2",
"chrome-extension-toolkit": "^0.0.54", "chrome-extension-toolkit": "^0.0.54",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -47,7 +47,7 @@
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.5.0",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"sass": "^1.79.4", "sass": "^1.79.5",
"simple-git": "^3.27.0", "simple-git": "^3.27.0",
"sql.js": "1.11.0" "sql.js": "1.11.0"
}, },
@@ -57,11 +57,12 @@
"@commitlint/config-conventional": "^19.5.0", "@commitlint/config-conventional": "^19.5.0",
"@commitlint/types": "^19.5.0", "@commitlint/types": "^19.5.0",
"@crxjs/vite-plugin": "2.0.0-beta.21", "@crxjs/vite-plugin": "2.0.0-beta.21",
"@iconify-json/bi": "^1.2.0", "@iconify-json/bi": "^1.2.1",
"@iconify-json/iconoir": "^1.2.1", "@iconify-json/iconoir": "^1.2.2",
"@iconify-json/material-symbols": "^1.2.2", "@iconify-json/material-symbols": "^1.2.4",
"@iconify-json/ri": "^1.2.0", "@iconify-json/ri": "^1.2.1",
"@semantic-release/exec": "^6.0.3", "@semantic-release/exec": "^6.0.3",
"@sentry/types": "^8.34.0",
"@storybook/addon-designs": "^8.0.3", "@storybook/addon-designs": "^8.0.3",
"@storybook/addon-essentials": "^8.3.5", "@storybook/addon-essentials": "^8.3.5",
"@storybook/addon-links": "^8.3.5", "@storybook/addon-links": "^8.3.5",
@@ -76,24 +77,24 @@
"@types/node": "^22.7.5", "@types/node": "^22.7.5",
"@types/prompts": "^2.4.9", "@types/prompts": "^2.4.9",
"@types/react": "^18.3.11", "@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.1",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@types/semantic-release": "^20.0.6", "@types/semantic-release": "^20.0.6",
"@types/semver": "^7.5.8", "@types/semver": "^7.5.8",
"@types/sql.js": "^1.4.9", "@types/sql.js": "^1.4.9",
"@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0", "@typescript-eslint/parser": "^7.18.0",
"@unocss/eslint-config": "^0.63.3", "@unocss/eslint-config": "^0.63.4",
"@unocss/postcss": "^0.63.3", "@unocss/postcss": "^0.63.4",
"@unocss/preset-uno": "^0.63.3", "@unocss/preset-uno": "^0.63.4",
"@unocss/preset-web-fonts": "^0.63.3", "@unocss/preset-web-fonts": "^0.63.4",
"@unocss/reset": "^0.63.3", "@unocss/reset": "^0.63.4",
"@unocss/transformer-directives": "^0.63.3", "@unocss/transformer-directives": "^0.63.4",
"@unocss/transformer-variant-group": "^0.63.3", "@unocss/transformer-variant-group": "^0.63.4",
"@vitejs/plugin-react-swc": "^3.7.1", "@vitejs/plugin-react-swc": "^3.7.1",
"@vitest/coverage-v8": "^2.1.2", "@vitest/coverage-v8": "^2.1.2",
"@vitest/ui": "^2.1.2", "@vitest/ui": "^2.1.2",
"chromatic": "^11.12.0", "chromatic": "^11.12.5",
"cssnano": "^7.0.6", "cssnano": "^7.0.6",
"cssnano-preset-advanced": "^7.0.6", "cssnano-preset-advanced": "^7.0.6",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
@@ -106,7 +107,7 @@
"eslint-import-resolver-typescript": "^3.6.3", "eslint-import-resolver-typescript": "^3.6.3",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.31.0",
"eslint-plugin-import-essentials": "^0.2.1", "eslint-plugin-import-essentials": "^0.2.1",
"eslint-plugin-jsdoc": "^50.3.1", "eslint-plugin-jsdoc": "^50.3.2",
"eslint-plugin-prettier": "^5.2.1", "eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-react": "^7.37.1", "eslint-plugin-react": "^7.37.1",
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-hooks": "^4.6.2",
@@ -120,8 +121,8 @@
"react-dev-utils": "^12.0.1", "react-dev-utils": "^12.0.1",
"semantic-release": "^24.1.2", "semantic-release": "^24.1.2",
"storybook": "^8.3.5", "storybook": "^8.3.5",
"typescript": "^5.6.2", "typescript": "^5.6.3",
"unocss": "^0.63.3", "unocss": "^0.63.4",
"unocss-preset-primitives": "0.0.2-beta.1", "unocss-preset-primitives": "0.0.2-beta.1",
"unplugin-icons": "^0.19.3", "unplugin-icons": "^0.19.3",
"vite": "^5.4.8", "vite": "^5.4.8",

2886
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -31,6 +31,22 @@ chrome.runtime.onInstalled.addListener(details => {
} }
}); });
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
console.log(changeInfo);
if (changeInfo.url === 'https://utdirect.utexas.edu/apps/registrar/course_schedule/utrp_login/') {
console.log('UTDirect detected');
// close the tab, open popup
function openPopupAction() {
chrome.tabs.onActivated.removeListener(openPopupAction);
chrome.action.openPopup();
}
chrome.tabs.onActivated.addListener(openPopupAction);
await chrome.tabs.remove(tabId);
}
});
// initialize the message listener that will listen for messages from the content script // initialize the message listener that will listen for messages from the content script
const messageListener = new MessageListener<BACKGROUND_MESSAGES>({ const messageListener = new MessageListener<BACKGROUND_MESSAGES>({
...browserActionHandler, ...browserActionHandler,

View File

@@ -1,12 +1,14 @@
import { UserScheduleStore } from '@shared/storage/UserScheduleStore'; import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
import type { UserSchedule } from '@shared/types/UserSchedule';
import { generateRandomId } from '@shared/util/random'; import { generateRandomId } from '@shared/util/random';
import type { Serialized } from 'chrome-extension-toolkit';
/** /**
* Creates a new schedule with the given name * Creates a new schedule with the given name
* @param scheduleName the name of the schedule to create * @param scheduleName the name of the schedule to create
* @returns undefined if successful, otherwise an error message * @returns undefined if successful, otherwise an error message
*/ */
export default async function createSchedule(scheduleName: string): Promise<string | undefined> { export default async function createSchedule(scheduleName: string) {
const schedules = await UserScheduleStore.get('schedules'); const schedules = await UserScheduleStore.get('schedules');
// get the number of schedules that either have the same name or have the same name with a number appended (e.g. "New Schedule (1)") // get the number of schedules that either have the same name or have the same name with a number appended (e.g. "New Schedule (1)")
// this way we can prevent duplicate schedule names and increment the number if necessary // this way we can prevent duplicate schedule names and increment the number if necessary
@@ -22,14 +24,16 @@ export default async function createSchedule(scheduleName: string): Promise<stri
if (count > 0) { if (count > 0) {
name = `${scheduleName} (${count})`; name = `${scheduleName} (${count})`;
} }
schedules.push({
const newSchedule: Serialized<UserSchedule> = {
id: generateRandomId(), id: generateRandomId(),
name, name,
courses: [], courses: [],
hours: 0, hours: 0,
updatedAt: Date.now(), updatedAt: Date.now(),
}); };
schedules.push(newSchedule);
await UserScheduleStore.set('schedules', schedules); await UserScheduleStore.set('schedules', schedules);
return undefined; return newSchedule.id;
} }

View File

@@ -0,0 +1,78 @@
import { validateLoginStatus } from '@shared/util/checkLoginStatus';
import { getActiveSchedule } from '@views/hooks/useSchedules';
import { courseMigration } from '@views/lib/courseMigration';
import addCourse from './addCourse';
import createSchedule from './createSchedule';
import switchSchedule from './switchSchedule';
/**
* Retrieves the saved courses from the extension's chrome sync storage (old store) and returns an array of course links.
*
* @returns A promise that resolves to an array of course links.
*/
export async function getUTRPv1Courses(): Promise<string[]> {
const { savedCourses } = await chrome.storage.sync.get('savedCourses');
// Check if the savedCourses array is empty
if (!savedCourses || savedCourses.length === 0) {
return [];
}
// Extract the link property from each course object and return it as an array
return savedCourses.map((course: { link: string }) => course.link);
}
/**
* Handles the migration of UTRP v1 courses.
*
* This function checks if the user is logged into the UT course schedule page.
* If the user is not logged in, it logs a message and exits. If the user is
* logged in, it retrieves the UTRP v1 courses, creates a new schedule for the
* migration, switches to the new schedule, and migrates the courses to the
* active schedule.
*
* @returns A promise that resolves when the migration is complete.
*/
async function migrateUTRPv1Courses() {
const loggedInToUT = await validateLoginStatus(
'https://utdirect.utexas.edu/apps/registrar/course_schedule/utrp_login/'
);
if (!loggedInToUT) {
console.warn('Not logged in to UT Registrar.');
return false;
}
const oldCourses = await getUTRPv1Courses();
console.log(oldCourses);
const migratedCourses = await courseMigration(oldCourses);
if (migratedCourses.length > 0) {
console.log(oldCourses, migratedCourses);
const migrateSchedule = await createSchedule('Migrated Schedule');
await switchSchedule(migrateSchedule);
const activeSchedule = getActiveSchedule();
for (const course of migratedCourses) {
// Add the course if it doesn't already exist
if (activeSchedule.courses.every(c => c.uniqueId !== course.uniqueId)) {
// ignore eslint, as we *do* want to spend time on each iteration
// eslint-disable-next-line no-await-in-loop
await addCourse(activeSchedule.id, course);
}
}
// Remove the old courses from storage :>
await chrome.storage.sync.remove('savedCourses');
console.log('Successfully migrated UTRP v1 courses');
} else {
console.warn('No courses successfully found to migrate');
}
return true;
}
export default migrateUTRPv1Courses;

View File

@@ -2,6 +2,8 @@ import type TabInfoMessages from '@shared/messages/TabInfoMessages';
import Calendar from '@views/components/calendar/Calendar'; import Calendar from '@views/components/calendar/Calendar';
import DialogProvider from '@views/components/common/DialogProvider/DialogProvider'; import DialogProvider from '@views/components/common/DialogProvider/DialogProvider';
import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot'; import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot';
import { MigrationDialog } from '@views/components/common/MigrationDialog';
import SentryProvider from '@views/contexts/SentryContext';
import { MessageListener } from 'chrome-extension-toolkit'; import { MessageListener } from 'chrome-extension-toolkit';
import useKC_DABR_WASM from 'kc-dabr-wasm'; import useKC_DABR_WASM from 'kc-dabr-wasm';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
@@ -28,10 +30,13 @@ export default function CalendarMain() {
}, []); }, []);
return ( return (
<ExtensionRoot className='h-full w-full'> <SentryProvider fullInit>
<DialogProvider> <ExtensionRoot className='h-full w-full'>
<Calendar /> <DialogProvider>
</DialogProvider> <MigrationDialog />
</ExtensionRoot> <Calendar />
</DialogProvider>
</ExtensionRoot>
</SentryProvider>
); );
} }

View File

@@ -1,6 +1,7 @@
import DialogProvider from '@views/components/common/DialogProvider/DialogProvider'; import DialogProvider from '@views/components/common/DialogProvider/DialogProvider';
import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot'; import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot';
import Settings from '@views/components/settings/Settings'; import Settings from '@views/components/settings/Settings';
import SentryProvider from '@views/contexts/SentryContext';
import useKC_DABR_WASM from 'kc-dabr-wasm'; import useKC_DABR_WASM from 'kc-dabr-wasm';
import React from 'react'; import React from 'react';
@@ -13,10 +14,12 @@ import React from 'react';
export default function SettingsPage() { export default function SettingsPage() {
useKC_DABR_WASM(); useKC_DABR_WASM();
return ( return (
<ExtensionRoot> <SentryProvider fullInit>
<DialogProvider> <ExtensionRoot>
<Settings /> <DialogProvider>
</DialogProvider> <Settings />
</ExtensionRoot> </DialogProvider>
</ExtensionRoot>
</SentryProvider>
); );
} }

View File

@@ -1,7 +1,20 @@
import 'uno.css'; import 'uno.css';
import DialogProvider from '@views/components/common/DialogProvider/DialogProvider';
import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot';
import { MigrationDialog } from '@views/components/common/MigrationDialog';
import PopupMain from '@views/components/PopupMain'; import PopupMain from '@views/components/PopupMain';
import SentryProvider from '@views/contexts/SentryContext';
import React from 'react'; import React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
createRoot(document.getElementById('root')!).render(<PopupMain />); createRoot(document.getElementById('root')!).render(
<SentryProvider fullInit>
<ExtensionRoot>
<DialogProvider>
<MigrationDialog />
<PopupMain />
</DialogProvider>
</ExtensionRoot>
</SentryProvider>
);

View File

@@ -1,5 +1,10 @@
import ReportIssueMain from '@views/components/ReportIssueMain'; import ReportIssueMain from '@views/components/ReportIssueMain';
import SentryProvider from '@views/contexts/SentryContext';
import React from 'react'; import React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
createRoot(document.getElementById('root')!).render(<ReportIssueMain />); createRoot(document.getElementById('root')!).render(
<SentryProvider fullInit>
<ReportIssueMain />
</SentryProvider>
);

View File

@@ -0,0 +1,27 @@
/**
* Checks the login status by making a request to the provided URL.
* If the response indicates that the user is not logged in (redirected to a login page or returns a 401/403 status),
* it opens a new tab with the login URL and returns `false`.
* If the user is logged in, it returns `true`.
*
* @param url - The URL to check the login status against.
* @returns A promise that resolves to `true` if the user is logged in, otherwise `false`.
*/
export async function validateLoginStatus(url: string) {
try {
const response = await fetch(url, { credentials: 'include' });
// Check if the response is redirecting to a login page or returning a 401/403
if (response.redirected || response.status === 401 || response.status === 403) {
// User is not logged in
chrome.tabs.create({ url });
return false;
}
// User is logged in
return true;
} catch (error) {
console.error('Error checking login status:', error);
return false;
}
}

View File

@@ -5,7 +5,6 @@ import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
import { UserScheduleStore } from '@shared/storage/UserScheduleStore'; import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
import { openReportWindow } from '@shared/util/openReportWindow'; import { openReportWindow } from '@shared/util/openReportWindow';
import Divider from '@views/components/common/Divider'; import Divider from '@views/components/common/Divider';
import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot';
import List from '@views/components/common/List'; import List from '@views/components/common/List';
import Text from '@views/components/common/Text/Text'; import Text from '@views/components/common/Text/Text';
import useSchedules, { getActiveSchedule, replaceSchedule, switchSchedule } from '@views/hooks/useSchedules'; import useSchedules, { getActiveSchedule, replaceSchedule, switchSchedule } from '@views/hooks/useSchedules';
@@ -22,7 +21,6 @@ import SettingsIcon from '~icons/material-symbols/settings';
import { Button } from './common/Button'; import { Button } from './common/Button';
import CourseStatus from './common/CourseStatus'; import CourseStatus from './common/CourseStatus';
import DialogProvider from './common/DialogProvider/DialogProvider';
import { SmallLogo } from './common/LogoIcon'; import { SmallLogo } from './common/LogoIcon';
import PopupCourseBlock from './common/PopupCourseBlock'; import PopupCourseBlock from './common/PopupCourseBlock';
import ScheduleDropdown from './common/ScheduleDropdown'; import ScheduleDropdown from './common/ScheduleDropdown';
@@ -84,130 +82,123 @@ export default function PopupMain(): JSX.Element {
}; };
return ( return (
<ExtensionRoot> <div className='h-screen max-h-full flex flex-col bg-white'>
<DialogProvider> <div className='p-5 py-3.5'>
<div className='h-screen max-h-full flex flex-col bg-white'> <div className='flex items-center justify-between bg-white'>
<div className='p-5 py-3.5'> <SmallLogo />
<div className='flex items-center justify-between bg-white'> <div className='flex items-center gap-2.5'>
<SmallLogo /> <button className='bg-ut-burntorange px-2 py-1.25 btn' onClick={handleCalendarOpenOnClick}>
<div className='flex items-center gap-2.5'> <CalendarIcon className='size-6 text-white' />
<button </button>
className='bg-ut-burntorange px-2 py-1.25 btn' <button className='bg-transparent px-2 py-1.25 btn' onClick={handleOpenOptions}>
onClick={handleCalendarOpenOnClick} <SettingsIcon className='size-6 color-ut-black' />
> </button>
<CalendarIcon className='size-6 text-white' /> <button className='bg-transparent px-2 py-1.25 btn' onClick={openReportWindow}>
</button> <Feedback className='size-6 color-ut-black' />
<button className='bg-transparent px-2 py-1.25 btn' onClick={handleOpenOptions}> </button>
<SettingsIcon className='size-6 color-ut-black' />
</button>
<button className='bg-transparent px-2 py-1.25 btn' onClick={openReportWindow}>
<Feedback className='size-6 color-ut-black' />
</button>
</div>
</div>
</div>
<Divider orientation='horizontal' size='100%' />
<div className='px-5 pb-2.5 pt-3.75'>
<ScheduleDropdown>
<List
draggables={schedules}
itemKey={schedule => schedule.id}
onReordered={reordered => {
const activeSchedule = getActiveSchedule();
const activeIndex = reordered.findIndex(s => s.id === activeSchedule.id);
// don't care about the promise
UserScheduleStore.set('schedules', reordered);
UserScheduleStore.set('activeIndex', activeIndex);
}}
gap={10}
>
{(schedule, handleProps) => (
<ScheduleListItem
schedule={schedule}
onClick={() => {
switchSchedule(schedule.id);
}}
dragHandleProps={handleProps}
/>
)}
</List>
<div className='bottom-0 right-0 mt-2.5 w-full flex justify-end'>
<Button
variant='filled'
color='ut-burntorange'
className='h-fit p-0 btn'
onClick={() => createSchedule('New Schedule')}
>
<AddSchedule className='h-6 w-6' />
</Button>
</div>
</ScheduleDropdown>
</div>
{activeSchedule?.courses?.length === 0 && (
<div className='max-w-64 flex flex-col items-center self-center gap-1.25 px-2 py-2 pt-24'>
<Text variant='p' className='text-center text-ut-gray !font-normal'>
{funny}
</Text>
<Text variant='small' className='text-center text-black'>
(No courses added)
</Text>
</div>
)}
<div className='flex-1 self-stretch overflow-y-auto px-5'>
{activeSchedule?.courses?.length > 0 && (
<List
draggables={activeSchedule.courses}
onReordered={reordered => {
activeSchedule.courses = reordered;
replaceSchedule(getActiveSchedule(), activeSchedule);
}}
itemKey={e => e.uniqueId}
gap={10}
>
{(course, handleProps) => (
<PopupCourseBlock
key={course.uniqueId}
course={course}
colors={course.colors}
dragHandleProps={handleProps}
/>
)}
</List>
)}
</div>
<div className='w-full flex flex-col items-center gap-1.25 p-5 pt-3.75'>
<div className='flex gap-2.5'>
{enableCourseStatusChips && (
<>
<CourseStatus status='WAITLISTED' size='mini' />
<CourseStatus status='CLOSED' size='mini' />
<CourseStatus status='CANCELLED' size='mini' />
</>
)}
</div>
{enableDataRefreshing && (
<div className='inline-flex items-center self-center gap-1'>
<Text variant='mini' className='text-ut-gray !font-normal'>
DATA LAST UPDATED: {getUpdatedAtDateTimeString(activeSchedule.updatedAt)}
</Text>
<button
className='h-4 w-4 bg-transparent p-0 btn'
onClick={() => {
setIsRefreshing(true);
}}
>
<RefreshIcon
className={clsx('h-4 w-4 text-ut-black animate-duration-800', {
'animate-spin': isRefreshing,
})}
/>
</button>
</div>
)}
</div> </div>
</div> </div>
</DialogProvider> </div>
</ExtensionRoot> <Divider orientation='horizontal' size='100%' />
<div className='px-5 pb-2.5 pt-3.75'>
<ScheduleDropdown>
<List
draggables={schedules}
itemKey={schedule => schedule.id}
onReordered={reordered => {
const activeSchedule = getActiveSchedule();
const activeIndex = reordered.findIndex(s => s.id === activeSchedule.id);
// don't care about the promise
UserScheduleStore.set('schedules', reordered);
UserScheduleStore.set('activeIndex', activeIndex);
}}
gap={10}
>
{(schedule, handleProps) => (
<ScheduleListItem
schedule={schedule}
onClick={() => {
switchSchedule(schedule.id);
}}
dragHandleProps={handleProps}
/>
)}
</List>
<div className='bottom-0 right-0 mt-2.5 w-full flex justify-end'>
<Button
variant='filled'
color='ut-burntorange'
className='h-fit p-0 btn'
onClick={() => createSchedule('New Schedule')}
>
<AddSchedule className='h-6 w-6' />
</Button>
</div>
</ScheduleDropdown>
</div>
{activeSchedule?.courses?.length === 0 && (
<div className='max-w-64 flex flex-col items-center self-center gap-1.25 px-2 py-2 pt-24'>
<Text variant='p' className='text-center text-ut-gray !font-normal'>
{funny}
</Text>
<Text variant='small' className='text-center text-black'>
(No courses added)
</Text>
</div>
)}
<div className='flex-1 self-stretch overflow-y-auto px-5'>
{activeSchedule?.courses?.length > 0 && (
<List
draggables={activeSchedule.courses}
onReordered={reordered => {
activeSchedule.courses = reordered;
replaceSchedule(getActiveSchedule(), activeSchedule);
}}
itemKey={e => e.uniqueId}
gap={10}
>
{(course, handleProps) => (
<PopupCourseBlock
key={course.uniqueId}
course={course}
colors={course.colors}
dragHandleProps={handleProps}
/>
)}
</List>
)}
</div>
<div className='w-full flex flex-col items-center gap-1.25 p-5 pt-3.75'>
<div className='flex gap-2.5'>
{enableCourseStatusChips && (
<>
<CourseStatus status='WAITLISTED' size='mini' />
<CourseStatus status='CLOSED' size='mini' />
<CourseStatus status='CANCELLED' size='mini' />
</>
)}
</div>
{enableDataRefreshing && (
<div className='inline-flex items-center self-center gap-1'>
<Text variant='mini' className='text-ut-gray !font-normal'>
DATA LAST UPDATED: {getUpdatedAtDateTimeString(activeSchedule.updatedAt)}
</Text>
<button
className='h-4 w-4 bg-transparent p-0 btn'
onClick={() => {
setIsRefreshing(true);
}}
>
<RefreshIcon
className={clsx('h-4 w-4 text-ut-black animate-duration-800', {
'animate-spin': isRefreshing,
})}
/>
</button>
</div>
)}
</div>
</div>
); );
} }

View File

@@ -1,21 +1,11 @@
import 'uno.css'; import 'uno.css';
import { BrowserClient, captureFeedback, defaultStackParser, getCurrentScope, makeFetchTransport } from '@sentry/react'; import { captureFeedback } from '@sentry/react';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Button } from './common/Button'; import { Button } from './common/Button';
import Text from './common/Text/Text'; import Text from './common/Text/Text';
const client = new BrowserClient({
dsn: 'https://ed1a50d8626ff6be35b98d7b1ec86d9d@o4508033820852224.ingest.us.sentry.io/4508033822490624',
integrations: [],
transport: makeFetchTransport,
stackParser: defaultStackParser,
});
getCurrentScope().setClient(client);
client.init();
const ReportIssueMain: React.FC = () => { const ReportIssueMain: React.FC = () => {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [feedback, setFeedback] = useState(''); const [feedback, setFeedback] = useState('');

View File

@@ -8,6 +8,7 @@ import ImportantLinks from '@views/components/calendar/ImportantLinks';
import Divider from '@views/components/common/Divider'; import Divider from '@views/components/common/Divider';
import CourseCatalogInjectedPopup from '@views/components/injected/CourseCatalogInjectedPopup/CourseCatalogInjectedPopup'; import CourseCatalogInjectedPopup from '@views/components/injected/CourseCatalogInjectedPopup/CourseCatalogInjectedPopup';
import { CalendarContext } from '@views/contexts/CalendarContext'; import { CalendarContext } from '@views/contexts/CalendarContext';
import useCourseFromUrl from '@views/hooks/useCourseFromUrl';
import { useFlattenedCourseSchedule } from '@views/hooks/useFlattenedCourseSchedule'; import { useFlattenedCourseSchedule } from '@views/hooks/useFlattenedCourseSchedule';
import { MessageListener } from 'chrome-extension-toolkit'; import { MessageListener } from 'chrome-extension-toolkit';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
@@ -21,21 +22,7 @@ import TeamLinks from './TeamLinks';
export default function Calendar(): JSX.Element { export default function Calendar(): JSX.Element {
const { courseCells, activeSchedule } = useFlattenedCourseSchedule(); const { courseCells, activeSchedule } = useFlattenedCourseSchedule();
const [course, setCourse] = useState<Course | null>((): Course | null => { const [course, setCourse] = useState<Course | null>(useCourseFromUrl());
const urlParams = new URLSearchParams(window.location.search);
const uniqueIdRaw = urlParams.get('uniqueId');
if (uniqueIdRaw === null) return null;
const uniqueId = Number(uniqueIdRaw);
const course = activeSchedule.courses.find(course => course.uniqueId === uniqueId);
if (course === undefined) return null;
urlParams.delete('uniqueId');
const newUrl = `${window.location.pathname}?${urlParams}`.replace(/\?$/, '');
window.history.replaceState({}, '', newUrl);
return course;
});
const [showPopup, setShowPopup] = useState<boolean>(course !== null); const [showPopup, setShowPopup] = useState<boolean>(course !== null);
const [showSidebar, setShowSidebar] = useState<boolean>(true); const [showSidebar, setShowSidebar] = useState<boolean>(true);
@@ -94,6 +81,8 @@ export default function Calendar(): JSX.Element {
</div> </div>
<CourseCatalogInjectedPopup <CourseCatalogInjectedPopup
// Ideally let's not use ! here, but it's fine since we know course is always defined when showPopup is true
// Let's try to refactor this
course={course!} // always defined when showPopup is true course={course!} // always defined when showPopup is true
onClose={() => setShowPopup(false)} onClose={() => setShowPopup(false)}
open={showPopup} open={showPopup}

View File

@@ -65,7 +65,7 @@ export function Button({
> >
{Icon && <Icon className='h-6 w-6' />} {Icon && <Icon className='h-6 w-6' />}
{!isIconOnly && ( {!isIconOnly && (
<Text variant='h4' className='translate-y-0.08'> <Text variant='h4' className='inline-flex translate-y-0.08 items-center gap-2'>
{children} {children}
</Text> </Text>
)} )}

View File

@@ -1,4 +1,4 @@
import type { CloseWrapper, DialogInfo, ShowDialogFn } from '@views/contexts/DialogContext'; import type { CloseWrapper, DialogInfo, DialogOptions, ShowDialogFn } from '@views/contexts/DialogContext';
import { DialogContext, useDialog } from '@views/contexts/DialogContext'; import { DialogContext, useDialog } from '@views/contexts/DialogContext';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import React, { useCallback, useRef, useState } from 'react'; import React, { useCallback, useRef, useState } from 'react';
@@ -29,24 +29,27 @@ function unwrapCloseWrapper<T>(obj: T | CloseWrapper<T>, close: () => void): T {
/** /**
* Hook to show prompt with default stylings. * Hook to show prompt with default stylings.
*/ */
export function usePrompt(): (info: PromptInfo) => void { export function usePrompt(): (info: PromptInfo, options?: DialogOptions) => void {
const showDialog = useDialog(); const showDialog = useDialog();
return (info: PromptInfo) => { return (info, options) => {
showDialog({ showDialog(
...info, {
title: ( ...info,
<Text variant='h2' as='h1' className='text-theme-black'> title: (
{info.title} <Text variant='h2' as='h1' className='text-theme-black'>
</Text> {info.title}
), </Text>
description: ( ),
<Text variant='p' as='p' className='text-ut-black'> description: (
{info.description} <Text variant='p' as='p' className='text-ut-black'>
</Text> {info.description}
), </Text>
className: 'max-w-[400px] flex flex-col gap-2.5 p-6.25', ),
}); className: 'max-w-[400px] flex flex-col gap-2.5 p-6.25',
},
options
);
}; };
} }
@@ -64,7 +67,7 @@ export default function DialogProvider(props: { children: ReactNode }): JSX.Elem
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const showDialog = useCallback<ShowDialogFn>(info => { const showDialog = useCallback<ShowDialogFn>((info, options) => {
const id = nextId++; const id = nextId++;
const handleClose = () => { const handleClose = () => {
@@ -89,11 +92,11 @@ export default function DialogProvider(props: { children: ReactNode }): JSX.Elem
const dialogElement = (show: boolean) => ( const dialogElement = (show: boolean) => (
<Dialog <Dialog
key={id} key={id}
onClose={handleClose} onClose={(options?.closeOnClickOutside ?? true) ? handleClose : () => {}}
afterLeave={onLeave} afterLeave={onLeave}
title=<>{infoUnwrapped.title}</> title=<>{infoUnwrapped.title}</>
description=<>{infoUnwrapped.description}</> description=<>{infoUnwrapped.description}</>
appear appear={!(options?.immediate ?? false)}
show={show} show={show}
className={infoUnwrapped.className} className={infoUnwrapped.className}
> >

View File

@@ -0,0 +1,134 @@
import migrateUTRPv1Courses, { getUTRPv1Courses } from '@background/lib/migrateUTRPv1Courses';
import Text from '@views/components/common/Text/Text';
import { useSentryScope } from '@views/contexts/SentryContext';
import React, { useEffect, useState } from 'react';
import { Button } from './Button';
import { usePrompt } from './DialogProvider/DialogProvider';
import Spinner from './Spinner';
function MigrationButtons({ close }: { close: () => void }): JSX.Element {
const [processState, setProcessState] = useState(0);
const [error, setError] = useState<string | undefined>(undefined);
const [sentryScope] = useSentryScope();
useEffect(() => {
const handleMigration = async () => {
if (processState === 1) {
try {
await chrome.storage.session.set({ pendingMigration: true });
const successful = await migrateUTRPv1Courses();
if (successful) {
await chrome.storage.local.set({ finishedMigration: true });
await chrome.storage.session.remove('pendingMigration');
}
} catch (error) {
console.error(error);
const sentryId = sentryScope.captureException(error);
setError(sentryId);
await chrome.storage.session.remove('pendingMigration');
return;
}
setProcessState(2);
close();
} else {
const { pendingMigration } = await chrome.storage.session.get('pendingMigration');
if (pendingMigration) setProcessState(1);
}
};
handleMigration();
}, [processState]);
return (
<>
{error && (
<Text variant='p' className='text-ut-red'>
An error occurred while migrating your courses. Please try again later in settings. (
{error.substring(0, 8)})
</Text>
)}
<Button
variant='single'
color='ut-black'
onClick={() => {
close();
if (!error) {
chrome.storage.local.set({ finishedMigration: true });
}
}}
>
Cancel
</Button>
<Button
variant='filled'
color='ut-green'
disabled={processState > 0}
onClick={() => {
setProcessState(1);
}}
>
{processState === 1 ? (
<>
Migrating... <Spinner className='ml-2.75 inline-block h-4! w-4! text-current!' />
</>
) : (
'Migrate courses'
)}
</Button>
</>
);
}
export function useMigrationDialog() {
const showDialog = usePrompt();
return async () => {
if ((await getUTRPv1Courses()).length > 0) {
showDialog(
{
title: 'This extension has updated!',
description:
"You may have already began planning your Spring '25 schedule. Click the button below to transfer your saved schedules into a new schedule. (You may be required to login to the UT Registrar)",
buttons: close => <MigrationButtons close={close} />,
},
{
closeOnClickOutside: false,
}
);
} else {
showDialog({
title: 'Already migrated!',
description:
'There are no courses to migrate. If you have any issues, please submit a feedback report by clicking the flag at the top right of the extension popup.',
buttons: close => (
<Button variant='filled' color='ut-burntorange' onClick={close}>
I Understand
</Button>
),
});
}
};
}
export function MigrationDialog(): JSX.Element {
const showMigrationDialog = useMigrationDialog();
useEffect(() => {
const checkMigration = async () => {
// check if migration was already attempted
if ((await chrome.storage.local.get('finishedMigration')).finishedMigration) return;
if ((await getUTRPv1Courses()).length > 0) showMigrationDialog();
};
checkMigration();
}, []);
// (not actually a useless fragment)
// eslint-disable-next-line react/jsx-no-useless-fragment
return <></>;
}

View File

@@ -1,3 +1,4 @@
import addCourse from '@pages/background/lib/addCourse';
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 { getCourseColors } from '@shared/util/colors'; // import { getCourseColors } from '@shared/util/colors';
@@ -9,8 +10,12 @@ import { SmallLogo } from '@views/components/common/LogoIcon';
// import PopupCourseBlock from '@views/components/common/PopupCourseBlock'; // import PopupCourseBlock from '@views/components/common/PopupCourseBlock';
import SwitchButton from '@views/components/common/SwitchButton'; import SwitchButton from '@views/components/common/SwitchButton';
import Text from '@views/components/common/Text/Text'; import Text from '@views/components/common/Text/Text';
import useChangelog from '@views/hooks/useChangelog';
import useSchedules from '@views/hooks/useSchedules'; import useSchedules from '@views/hooks/useSchedules';
import { CourseCatalogScraper } from '@views/lib/CourseCatalogScraper';
import getCourseTableRows from '@views/lib/getCourseTableRows';
import { GitHubStatsService, LONGHORN_DEVELOPERS_ADMINS } from '@views/lib/getGitHubStats'; import { GitHubStatsService, LONGHORN_DEVELOPERS_ADMINS } from '@views/lib/getGitHubStats';
import { SiteSupport } from '@views/lib/getSiteSupport';
import { getUpdatedAtDateTimeString } from '@views/lib/getUpdatedAtDateTimeString'; import { getUpdatedAtDateTimeString } from '@views/lib/getUpdatedAtDateTimeString';
import clsx from 'clsx'; import clsx from 'clsx';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
@@ -19,6 +24,7 @@ import IconoirGitFork from '~icons/iconoir/git-fork';
// import { ExampleCourse } from 'src/stories/components/ConflictsWithWarning.stories'; // import { ExampleCourse } from 'src/stories/components/ConflictsWithWarning.stories';
import DeleteForeverIcon from '~icons/material-symbols/delete-forever'; import DeleteForeverIcon from '~icons/material-symbols/delete-forever';
import { useMigrationDialog } from '../common/MigrationDialog';
// import RefreshIcon from '~icons/material-symbols/refresh'; // import RefreshIcon from '~icons/material-symbols/refresh';
import DevMode from './DevMode'; import DevMode from './DevMode';
import Preview from './Preview'; import Preview from './Preview';
@@ -79,6 +85,8 @@ export default function Settings(): JSX.Element {
const [loadAllCourses, setLoadAllCourses] = useState<boolean>(false); const [loadAllCourses, setLoadAllCourses] = useState<boolean>(false);
const [enableDataRefreshing, setEnableDataRefreshing] = useState<boolean>(false); const [enableDataRefreshing, setEnableDataRefreshing] = useState<boolean>(false);
const showMigrationDialog = useMigrationDialog();
// Toggle GitHub stats when the user presses the 'S' key // Toggle GitHub stats when the user presses the 'S' key
const [showGitHubStats, setShowGitHubStats] = useState<boolean>(false); const [showGitHubStats, setShowGitHubStats] = useState<boolean>(false);
const [githubStats, setGitHubStats] = useState<Awaited< const [githubStats, setGitHubStats] = useState<Awaited<
@@ -89,6 +97,7 @@ export default function Settings(): JSX.Element {
// const [isRefreshing, setIsRefreshing] = useState<boolean>(false); // const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const showDialog = usePrompt(); const showDialog = usePrompt();
const handleChangelogOnClick = useChangelog();
useEffect(() => { useEffect(() => {
const fetchGitHubStats = async () => { const fetchGitHubStats = async () => {
@@ -189,6 +198,41 @@ 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;
const response = await fetch(link);
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);
}
};
const [devMode, toggleDevMode] = useDevMode(10); const [devMode, toggleDevMode] = useDevMode(10);
if (devMode) { if (devMode) {
@@ -204,12 +248,12 @@ export default function Settings(): JSX.Element {
<h1 className='pl-4 text-xl text-ut-burntorange font-bold'>UTRP SETTINGS & CREDITS PAGE</h1> <h1 className='pl-4 text-xl text-ut-burntorange font-bold'>UTRP SETTINGS & CREDITS PAGE</h1>
</div> </div>
<div className='flex space-x-4'> <div className='flex space-x-4'>
<div className='flex items-center'> <Button variant='single' color='theme-black' onClick={handleChangelogOnClick}>
<IconoirGitFork className='h-6 w-6 text-ut-gray' /> <IconoirGitFork className='h-6 w-6 text-ut-gray' />
<Text variant='small' className='text-ut-gray font-normal'> <Text variant='small' className='text-ut-gray font-normal'>
v{manifest.version} - {process.env.NODE_ENV} v{manifest.version} - {process.env.NODE_ENV}
</Text> </Text>
</div> </Button>
<img src={LDIconURL} alt='LD Icon' className='h-10 w-10 rounded-lg' /> <img src={LDIconURL} alt='LD Icon' className='h-10 w-10 rounded-lg' />
</div> </div>
</header> </header>
@@ -382,10 +426,16 @@ export default function Settings(): JSX.Element {
<Divider size='auto' orientation='horizontal' /> <Divider size='auto' orientation='horizontal' />
<section className='my-8'> <section className='my-8 space-y-4'>
<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}>
Add course by link
</Button>
<Button variant='filled' color='ut-burntorange' onClick={showMigrationDialog}>
Show Migration Dialog
</Button>
</section> </section>
</div> </div>

View File

@@ -16,10 +16,22 @@ export interface DialogInfo {
onClose?: () => void; onClose?: () => void;
} }
export interface DialogOptions {
/**
* Whether to show the dialog immediately.
*/
immediate?: boolean;
/**
* Whether to allow the user to close the dialog by clicking outside of it. (Defaults to true)
*/
closeOnClickOutside?: boolean;
}
/** /**
* Function to show a dialog. * Function to show a dialog.
*/ */
export type ShowDialogFn = (info: DialogInfo | CloseWrapper<DialogInfo>) => void; export type ShowDialogFn = (info: DialogInfo | CloseWrapper<DialogInfo>, options?: DialogOptions) => void;
/** /**
* Context for the dialog provider. * Context for the dialog provider.

View File

@@ -0,0 +1,94 @@
import {
BrowserClient,
defaultStackParser,
ErrorBoundary,
getCurrentScope,
getDefaultIntegrations,
init,
makeFetchTransport,
Scope,
} from '@sentry/react';
import type { Client, ClientOptions } from '@sentry/types';
import React, { createContext, useContext, useMemo } from 'react';
/**
* Context for the sentry provider.
*/
export const SentryContext = createContext<[scope: Scope, client: Client]>(undefined!);
/**
* @returns The dialog context for showing dialogs.
*/
export const useSentryScope = () => useContext(SentryContext);
/**
* SentryProvider component initializes and provides Sentry error tracking context to its children.
* It ensures that Sentry is not initialized more than once and configures the Sentry client and scope.
*
* @param props - The properties object.
* @param props.children - The child components that will have access to the Sentry context.
* @param props.transactionName - Optional name for the Sentry transaction.
* @param props.fullInit - Flag to determine if full initialization of Sentry should be performed.
*
* @returns The Sentry context provider wrapping the children components.
*/
export default function SentryProvider({
children,
transactionName,
fullInit,
}: {
children: React.ReactNode;
transactionName?: string;
fullInit?: boolean;
}): JSX.Element {
// prevent accidentally initializing sentry twice
const parent = useSentryScope();
const providerValue = useMemo((): [scope: Scope, client: Client] => {
if (parent) {
const [parentScope, parentClient] = parent;
const scope = parentScope.clone();
if (transactionName) scope.setTransactionName(transactionName);
return [scope, parentClient];
}
// filter integrations that use the global variable
const integrations = getDefaultIntegrations({}).filter(
defaultIntegration =>
!['BrowserApiErrors', 'Breadcrumbs', 'GlobalHandlers'].includes(defaultIntegration.name)
);
const options: ClientOptions = {
dsn: 'https://ed1a50d8626ff6be35b98d7b1ec86d9d@o4508033820852224.ingest.us.sentry.io/4508033822490624',
integrations,
transport: makeFetchTransport,
stackParser: defaultStackParser,
// debug: true,
release: import.meta.env.VITE_PACKAGE_VERSION,
};
let client: Client;
let scope: Scope;
if (fullInit) {
client = init(options)!;
scope = getCurrentScope();
} else {
client = new BrowserClient(options);
scope = new Scope();
scope.setClient(client);
client.init();
}
return [scope, client];
}, []);
return (
<ErrorBoundary>
<SentryContext.Provider value={providerValue}>{children}</SentryContext.Provider>
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,35 @@
import type { Course } from '@shared/types/Course';
import { useFlattenedCourseSchedule } from './useFlattenedCourseSchedule';
/**
* Custom hook that retrieves a course from the URL parameters.
*
* This hook extracts the `uniqueId` parameter from the URL, finds the corresponding
* course in the active schedule, and returns it. If the `uniqueId` is not found or
* does not correspond to any course, it returns `null`. After retrieving the course,
* it removes the `uniqueId` parameter from the URL.
*
* @returns The course corresponding to the `uniqueId` in the URL, or `null` if not found.
*/
export default function useCourseFromUrl(): Course | null {
const { activeSchedule } = useFlattenedCourseSchedule();
const getCourseFromUrl = (): Course | null => {
const urlParams = new URLSearchParams(window.location.search);
const uniqueIdRaw = urlParams.get('uniqueId');
if (uniqueIdRaw === null) return null;
const uniqueId = Number(uniqueIdRaw);
const course = activeSchedule.courses.find(course => course.uniqueId === uniqueId);
if (course === undefined) return null;
urlParams.delete('uniqueId');
const newUrl = `${window.location.pathname}?${urlParams}`.replace(/\?$/, '');
window.history.replaceState({}, '', newUrl);
return course;
};
return getCourseFromUrl();
}

View File

@@ -93,7 +93,7 @@ export async function replaceSchedule(oldSchedule: UserSchedule, newSchedule: Us
} }
/** /**
* Switches the active schedule to the one with the specified name. * Switches the active schedule to the one with the specified id.
* @param id - The id of the schedule to switch to. * @param id - The id of the schedule to switch to.
* @returns A promise that resolves when the active schedule has been switched. * @returns A promise that resolves when the active schedule has been switched.
*/ */
@@ -103,3 +103,15 @@ export async function switchSchedule(id: string): Promise<void> {
const activeIndex = schedules.findIndex(s => s.id === id); const activeIndex = schedules.findIndex(s => s.id === id);
await UserScheduleStore.set('activeIndex', activeIndex); await UserScheduleStore.set('activeIndex', activeIndex);
} }
/**
* Switches the active schedule to the one with the specified name.
* @param name - The name of the schedule to switch to.
* @returns A promise that resolves when the active schedule has been switched.
*/
export async function switchScheduleByName(name: string): Promise<void> {
console.log('Switching schedule...');
const schedules = await UserScheduleStore.get('schedules');
const activeIndex = schedules.findIndex(s => s.name === name);
await UserScheduleStore.set('activeIndex', activeIndex);
}

View File

@@ -38,9 +38,13 @@ type DetailsSelectorType = (typeof DetailsSelector)[keyof typeof DetailsSelector
*/ */
export class CourseCatalogScraper { export class CourseCatalogScraper {
support: SiteSupportType; support: SiteSupportType;
doc: Document;
url: string;
constructor(support: SiteSupportType) { constructor(support: SiteSupportType, doc: Document = document, url: string = window.location.href) {
this.support = support; this.support = support;
this.doc = doc;
this.url = url;
} }
/** /**
@@ -91,7 +95,7 @@ export class CourseCatalogScraper {
uniqueId: this.getUniqueId(row), uniqueId: this.getUniqueId(row),
instructionMode: this.getInstructionMode(row), instructionMode: this.getInstructionMode(row),
instructors: this.getInstructors(row) as Instructor[], instructors: this.getInstructors(row) as Instructor[],
description: this.getDescription(document), description: this.getDescription(this.doc),
semester: this.getSemester(), semester: this.getSemester(),
scrapedAt: Date.now(), scrapedAt: Date.now(),
colors: getCourseColors('emerald', 500), colors: getCourseColors('emerald', 500),
@@ -165,7 +169,7 @@ export class CourseCatalogScraper {
*/ */
getURL(row: HTMLTableRowElement): string { getURL(row: HTMLTableRowElement): string {
const div = row.querySelector<HTMLAnchorElement>(`${TableDataSelector.UNIQUE_ID} a`); const div = row.querySelector<HTMLAnchorElement>(`${TableDataSelector.UNIQUE_ID} a`);
return div?.href || window.location.href; return div?.href || this.url;
} }
/** /**
@@ -221,11 +225,11 @@ export class CourseCatalogScraper {
/** /**
* Scrapes the description of the course from the course details page and separates it into an array of cleaned up lines * Scrapes the description of the course from the course details page and separates it into an array of cleaned up lines
* @param document the document of the course details page to scrape * @param doc the document of the course details page to scrape
* @returns an array of lines of the course description * @returns an array of lines of the course description
*/ */
getDescription(document: Document): string[] { getDescription(doc: Document): string[] {
const lines = document.querySelectorAll(DetailsSelector.COURSE_DESCRIPTION); const lines = doc.querySelectorAll(DetailsSelector.COURSE_DESCRIPTION);
return Array.from(lines) return Array.from(lines)
.map(line => line.textContent || '') .map(line => line.textContent || '')
.map(line => line.replace(/\s\s+/g, ' ').trim()) .map(line => line.replace(/\s\s+/g, ' ').trim())
@@ -233,7 +237,7 @@ export class CourseCatalogScraper {
} }
getSemester(): Semester { getSemester(): Semester {
const { pathname } = new URL(window.location.href); const { pathname } = new URL(this.url);
const code = pathname.split('/')[4]; const code = pathname.split('/')[4];
if (!code) { if (!code) {
@@ -276,7 +280,7 @@ export class CourseCatalogScraper {
*/ */
getFullName(row?: HTMLTableRowElement): string { getFullName(row?: HTMLTableRowElement): string {
if (!row) { if (!row) {
return document.querySelector(DetailsSelector.COURSE_NAME)?.textContent || ''; return this.doc.querySelector(DetailsSelector.COURSE_NAME)?.textContent || '';
} }
const div = row.querySelector(TableDataSelector.COURSE_HEADER); const div = row.querySelector(TableDataSelector.COURSE_HEADER);
return div?.textContent || ''; return div?.textContent || '';

View File

@@ -0,0 +1,51 @@
import { CourseCatalogScraper } from '@views/lib/CourseCatalogScraper';
import getCourseTableRows from '@views/lib/getCourseTableRows';
import { SiteSupport } from '@views/lib/getSiteSupport';
/**
* Migrates courses from UTRP v1 to a new schedule.
*
* @param activeSchedule - The active schedule to migrate the courses to.
* @param links - An array of UTRP v1 course URLs.
* @returns A promise that resolves when the migration is complete.
*
* This hook performs the following steps:
* 1. Fetches the course details from the links.
* 2. Scrapes the course information from the fetched HTML.
* 3. Checks if the course was found and adds it to the active schedule if it doesn't already exist.
*
* Notes:
* - Chrome warns in the console that in the future, cookies will not work when we do a network request like how we are doing it now, so might need to open a new tab instead.
*/
export const courseMigration = async (links: string[]) => {
const migratedCourses = [];
// Loop over the links
for (const link of links) {
// eslint-disable-next-line no-await-in-loop
const response = await fetch(link);
// eslint-disable-next-line no-await-in-loop
const text = await response.text();
const doc = new DOMParser().parseFromString(text, 'text/html');
// Scrape the course
const scraper = new CourseCatalogScraper(SiteSupport.COURSE_CATALOG_DETAILS, doc, link);
const tableRows = getCourseTableRows(doc);
const courses = scraper.scrape(tableRows, false);
// Check if the course was found
if (courses.length === 1) {
const description = scraper.getDescription(doc);
const row = courses[0]!;
const course = row.course!;
course.description = description;
// Add the course to the migrated courses
migratedCourses.push(course);
} else {
console.warn('Invalid course link:', link);
}
}
return migratedCourses;
};