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:
@@ -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
|
||||
const messageListener = new MessageListener<BACKGROUND_MESSAGES>({
|
||||
...browserActionHandler,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
||||
import type { UserSchedule } from '@shared/types/UserSchedule';
|
||||
import { generateRandomId } from '@shared/util/random';
|
||||
import type { Serialized } from 'chrome-extension-toolkit';
|
||||
|
||||
/**
|
||||
* Creates a new schedule with the given name
|
||||
* @param scheduleName the name of the schedule to create
|
||||
* @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');
|
||||
// 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
|
||||
@@ -22,14 +24,16 @@ export default async function createSchedule(scheduleName: string): Promise<stri
|
||||
if (count > 0) {
|
||||
name = `${scheduleName} (${count})`;
|
||||
}
|
||||
schedules.push({
|
||||
|
||||
const newSchedule: Serialized<UserSchedule> = {
|
||||
id: generateRandomId(),
|
||||
name,
|
||||
courses: [],
|
||||
hours: 0,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
};
|
||||
schedules.push(newSchedule);
|
||||
|
||||
await UserScheduleStore.set('schedules', schedules);
|
||||
return undefined;
|
||||
return newSchedule.id;
|
||||
}
|
||||
|
||||
78
src/pages/background/lib/migrateUTRPv1Courses.ts
Normal file
78
src/pages/background/lib/migrateUTRPv1Courses.ts
Normal 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;
|
||||
@@ -2,6 +2,8 @@ import type TabInfoMessages from '@shared/messages/TabInfoMessages';
|
||||
import Calendar from '@views/components/calendar/Calendar';
|
||||
import DialogProvider from '@views/components/common/DialogProvider/DialogProvider';
|
||||
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 useKC_DABR_WASM from 'kc-dabr-wasm';
|
||||
import React, { useEffect } from 'react';
|
||||
@@ -28,10 +30,13 @@ export default function CalendarMain() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ExtensionRoot className='h-full w-full'>
|
||||
<DialogProvider>
|
||||
<Calendar />
|
||||
</DialogProvider>
|
||||
</ExtensionRoot>
|
||||
<SentryProvider fullInit>
|
||||
<ExtensionRoot className='h-full w-full'>
|
||||
<DialogProvider>
|
||||
<MigrationDialog />
|
||||
<Calendar />
|
||||
</DialogProvider>
|
||||
</ExtensionRoot>
|
||||
</SentryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import DialogProvider from '@views/components/common/DialogProvider/DialogProvider';
|
||||
import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot';
|
||||
import Settings from '@views/components/settings/Settings';
|
||||
import SentryProvider from '@views/contexts/SentryContext';
|
||||
import useKC_DABR_WASM from 'kc-dabr-wasm';
|
||||
import React from 'react';
|
||||
|
||||
@@ -13,10 +14,12 @@ import React from 'react';
|
||||
export default function SettingsPage() {
|
||||
useKC_DABR_WASM();
|
||||
return (
|
||||
<ExtensionRoot>
|
||||
<DialogProvider>
|
||||
<Settings />
|
||||
</DialogProvider>
|
||||
</ExtensionRoot>
|
||||
<SentryProvider fullInit>
|
||||
<ExtensionRoot>
|
||||
<DialogProvider>
|
||||
<Settings />
|
||||
</DialogProvider>
|
||||
</ExtensionRoot>
|
||||
</SentryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
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 SentryProvider from '@views/contexts/SentryContext';
|
||||
import React from 'react';
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import ReportIssueMain from '@views/components/ReportIssueMain';
|
||||
import SentryProvider from '@views/contexts/SentryContext';
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<ReportIssueMain />);
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<SentryProvider fullInit>
|
||||
<ReportIssueMain />
|
||||
</SentryProvider>
|
||||
);
|
||||
|
||||
27
src/shared/util/checkLoginStatus.ts
Normal file
27
src/shared/util/checkLoginStatus.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
|
||||
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
||||
import { openReportWindow } from '@shared/util/openReportWindow';
|
||||
import Divider from '@views/components/common/Divider';
|
||||
import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot';
|
||||
import List from '@views/components/common/List';
|
||||
import Text from '@views/components/common/Text/Text';
|
||||
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 CourseStatus from './common/CourseStatus';
|
||||
import DialogProvider from './common/DialogProvider/DialogProvider';
|
||||
import { SmallLogo } from './common/LogoIcon';
|
||||
import PopupCourseBlock from './common/PopupCourseBlock';
|
||||
import ScheduleDropdown from './common/ScheduleDropdown';
|
||||
@@ -84,130 +82,123 @@ export default function PopupMain(): JSX.Element {
|
||||
};
|
||||
|
||||
return (
|
||||
<ExtensionRoot>
|
||||
<DialogProvider>
|
||||
<div className='h-screen max-h-full flex flex-col bg-white'>
|
||||
<div className='p-5 py-3.5'>
|
||||
<div className='flex items-center justify-between bg-white'>
|
||||
<SmallLogo />
|
||||
<div className='flex items-center gap-2.5'>
|
||||
<button
|
||||
className='bg-ut-burntorange px-2 py-1.25 btn'
|
||||
onClick={handleCalendarOpenOnClick}
|
||||
>
|
||||
<CalendarIcon className='size-6 text-white' />
|
||||
</button>
|
||||
<button className='bg-transparent px-2 py-1.25 btn' onClick={handleOpenOptions}>
|
||||
<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 className='h-screen max-h-full flex flex-col bg-white'>
|
||||
<div className='p-5 py-3.5'>
|
||||
<div className='flex items-center justify-between bg-white'>
|
||||
<SmallLogo />
|
||||
<div className='flex items-center gap-2.5'>
|
||||
<button className='bg-ut-burntorange px-2 py-1.25 btn' onClick={handleCalendarOpenOnClick}>
|
||||
<CalendarIcon className='size-6 text-white' />
|
||||
</button>
|
||||
<button className='bg-transparent px-2 py-1.25 btn' onClick={handleOpenOptions}>
|
||||
<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>
|
||||
</DialogProvider>
|
||||
</ExtensionRoot>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
import 'uno.css';
|
||||
|
||||
import { BrowserClient, captureFeedback, defaultStackParser, getCurrentScope, makeFetchTransport } from '@sentry/react';
|
||||
import { captureFeedback } from '@sentry/react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Button } from './common/Button';
|
||||
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 [email, setEmail] = useState('');
|
||||
const [feedback, setFeedback] = useState('');
|
||||
|
||||
@@ -8,6 +8,7 @@ import ImportantLinks from '@views/components/calendar/ImportantLinks';
|
||||
import Divider from '@views/components/common/Divider';
|
||||
import CourseCatalogInjectedPopup from '@views/components/injected/CourseCatalogInjectedPopup/CourseCatalogInjectedPopup';
|
||||
import { CalendarContext } from '@views/contexts/CalendarContext';
|
||||
import useCourseFromUrl from '@views/hooks/useCourseFromUrl';
|
||||
import { useFlattenedCourseSchedule } from '@views/hooks/useFlattenedCourseSchedule';
|
||||
import { MessageListener } from 'chrome-extension-toolkit';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
@@ -21,21 +22,7 @@ import TeamLinks from './TeamLinks';
|
||||
export default function Calendar(): JSX.Element {
|
||||
const { courseCells, activeSchedule } = useFlattenedCourseSchedule();
|
||||
|
||||
const [course, setCourse] = useState<Course | null>((): 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;
|
||||
});
|
||||
const [course, setCourse] = useState<Course | null>(useCourseFromUrl());
|
||||
|
||||
const [showPopup, setShowPopup] = useState<boolean>(course !== null);
|
||||
const [showSidebar, setShowSidebar] = useState<boolean>(true);
|
||||
@@ -94,6 +81,8 @@ export default function Calendar(): JSX.Element {
|
||||
</div>
|
||||
|
||||
<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
|
||||
onClose={() => setShowPopup(false)}
|
||||
open={showPopup}
|
||||
|
||||
@@ -65,7 +65,7 @@ export function Button({
|
||||
>
|
||||
{Icon && <Icon className='h-6 w-6' />}
|
||||
{!isIconOnly && (
|
||||
<Text variant='h4' className='translate-y-0.08'>
|
||||
<Text variant='h4' className='inline-flex translate-y-0.08 items-center gap-2'>
|
||||
{children}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -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 type { ReactNode } 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.
|
||||
*/
|
||||
export function usePrompt(): (info: PromptInfo) => void {
|
||||
export function usePrompt(): (info: PromptInfo, options?: DialogOptions) => void {
|
||||
const showDialog = useDialog();
|
||||
|
||||
return (info: PromptInfo) => {
|
||||
showDialog({
|
||||
...info,
|
||||
title: (
|
||||
<Text variant='h2' as='h1' className='text-theme-black'>
|
||||
{info.title}
|
||||
</Text>
|
||||
),
|
||||
description: (
|
||||
<Text variant='p' as='p' className='text-ut-black'>
|
||||
{info.description}
|
||||
</Text>
|
||||
),
|
||||
className: 'max-w-[400px] flex flex-col gap-2.5 p-6.25',
|
||||
});
|
||||
return (info, options) => {
|
||||
showDialog(
|
||||
{
|
||||
...info,
|
||||
title: (
|
||||
<Text variant='h2' as='h1' className='text-theme-black'>
|
||||
{info.title}
|
||||
</Text>
|
||||
),
|
||||
description: (
|
||||
<Text variant='p' as='p' className='text-ut-black'>
|
||||
{info.description}
|
||||
</Text>
|
||||
),
|
||||
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 showDialog = useCallback<ShowDialogFn>(info => {
|
||||
const showDialog = useCallback<ShowDialogFn>((info, options) => {
|
||||
const id = nextId++;
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -89,11 +92,11 @@ export default function DialogProvider(props: { children: ReactNode }): JSX.Elem
|
||||
const dialogElement = (show: boolean) => (
|
||||
<Dialog
|
||||
key={id}
|
||||
onClose={handleClose}
|
||||
onClose={(options?.closeOnClickOutside ?? true) ? handleClose : () => {}}
|
||||
afterLeave={onLeave}
|
||||
title=<>{infoUnwrapped.title}</>
|
||||
description=<>{infoUnwrapped.description}</>
|
||||
appear
|
||||
appear={!(options?.immediate ?? false)}
|
||||
show={show}
|
||||
className={infoUnwrapped.className}
|
||||
>
|
||||
|
||||
134
src/views/components/common/MigrationDialog.tsx
Normal file
134
src/views/components/common/MigrationDialog.tsx
Normal 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 <></>;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import addCourse from '@pages/background/lib/addCourse';
|
||||
import { deleteAllSchedules } from '@pages/background/lib/deleteSchedule';
|
||||
import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
|
||||
// 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 SwitchButton from '@views/components/common/SwitchButton';
|
||||
import Text from '@views/components/common/Text/Text';
|
||||
import useChangelog from '@views/hooks/useChangelog';
|
||||
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 { SiteSupport } from '@views/lib/getSiteSupport';
|
||||
import { getUpdatedAtDateTimeString } from '@views/lib/getUpdatedAtDateTimeString';
|
||||
import clsx from 'clsx';
|
||||
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 DeleteForeverIcon from '~icons/material-symbols/delete-forever';
|
||||
|
||||
import { useMigrationDialog } from '../common/MigrationDialog';
|
||||
// import RefreshIcon from '~icons/material-symbols/refresh';
|
||||
import DevMode from './DevMode';
|
||||
import Preview from './Preview';
|
||||
@@ -79,6 +85,8 @@ export default function Settings(): JSX.Element {
|
||||
const [loadAllCourses, setLoadAllCourses] = useState<boolean>(false);
|
||||
const [enableDataRefreshing, setEnableDataRefreshing] = useState<boolean>(false);
|
||||
|
||||
const showMigrationDialog = useMigrationDialog();
|
||||
|
||||
// Toggle GitHub stats when the user presses the 'S' key
|
||||
const [showGitHubStats, setShowGitHubStats] = useState<boolean>(false);
|
||||
const [githubStats, setGitHubStats] = useState<Awaited<
|
||||
@@ -89,6 +97,7 @@ export default function Settings(): JSX.Element {
|
||||
// const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
|
||||
|
||||
const showDialog = usePrompt();
|
||||
const handleChangelogOnClick = useChangelog();
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
|
||||
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>
|
||||
</div>
|
||||
<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' />
|
||||
<Text variant='small' className='text-ut-gray font-normal'>
|
||||
v{manifest.version} - {process.env.NODE_ENV}
|
||||
</Text>
|
||||
</div>
|
||||
</Button>
|
||||
<img src={LDIconURL} alt='LD Icon' className='h-10 w-10 rounded-lg' />
|
||||
</div>
|
||||
</header>
|
||||
@@ -382,10 +426,16 @@ export default function Settings(): JSX.Element {
|
||||
|
||||
<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}>
|
||||
Developer Mode
|
||||
</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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,10 +16,22 @@ export interface DialogInfo {
|
||||
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.
|
||||
*/
|
||||
export type ShowDialogFn = (info: DialogInfo | CloseWrapper<DialogInfo>) => void;
|
||||
export type ShowDialogFn = (info: DialogInfo | CloseWrapper<DialogInfo>, options?: DialogOptions) => void;
|
||||
|
||||
/**
|
||||
* Context for the dialog provider.
|
||||
|
||||
94
src/views/contexts/SentryContext.tsx
Normal file
94
src/views/contexts/SentryContext.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/views/hooks/useCourseFromUrl.ts
Normal file
35
src/views/hooks/useCourseFromUrl.ts
Normal 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();
|
||||
}
|
||||
@@ -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.
|
||||
* @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);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -38,9 +38,13 @@ type DetailsSelectorType = (typeof DetailsSelector)[keyof typeof DetailsSelector
|
||||
*/
|
||||
export class CourseCatalogScraper {
|
||||
support: SiteSupportType;
|
||||
doc: Document;
|
||||
url: string;
|
||||
|
||||
constructor(support: SiteSupportType) {
|
||||
constructor(support: SiteSupportType, doc: Document = document, url: string = window.location.href) {
|
||||
this.support = support;
|
||||
this.doc = doc;
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,7 +95,7 @@ export class CourseCatalogScraper {
|
||||
uniqueId: this.getUniqueId(row),
|
||||
instructionMode: this.getInstructionMode(row),
|
||||
instructors: this.getInstructors(row) as Instructor[],
|
||||
description: this.getDescription(document),
|
||||
description: this.getDescription(this.doc),
|
||||
semester: this.getSemester(),
|
||||
scrapedAt: Date.now(),
|
||||
colors: getCourseColors('emerald', 500),
|
||||
@@ -165,7 +169,7 @@ export class CourseCatalogScraper {
|
||||
*/
|
||||
getURL(row: HTMLTableRowElement): string {
|
||||
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
|
||||
* @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
|
||||
*/
|
||||
getDescription(document: Document): string[] {
|
||||
const lines = document.querySelectorAll(DetailsSelector.COURSE_DESCRIPTION);
|
||||
getDescription(doc: Document): string[] {
|
||||
const lines = doc.querySelectorAll(DetailsSelector.COURSE_DESCRIPTION);
|
||||
return Array.from(lines)
|
||||
.map(line => line.textContent || '')
|
||||
.map(line => line.replace(/\s\s+/g, ' ').trim())
|
||||
@@ -233,7 +237,7 @@ export class CourseCatalogScraper {
|
||||
}
|
||||
|
||||
getSemester(): Semester {
|
||||
const { pathname } = new URL(window.location.href);
|
||||
const { pathname } = new URL(this.url);
|
||||
|
||||
const code = pathname.split('/')[4];
|
||||
if (!code) {
|
||||
@@ -276,7 +280,7 @@ export class CourseCatalogScraper {
|
||||
*/
|
||||
getFullName(row?: HTMLTableRowElement): string {
|
||||
if (!row) {
|
||||
return document.querySelector(DetailsSelector.COURSE_NAME)?.textContent || '';
|
||||
return this.doc.querySelector(DetailsSelector.COURSE_NAME)?.textContent || '';
|
||||
}
|
||||
const div = row.querySelector(TableDataSelector.COURSE_HEADER);
|
||||
return div?.textContent || '';
|
||||
|
||||
51
src/views/lib/courseMigration.ts
Normal file
51
src/views/lib/courseMigration.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user