diff --git a/client/package-lock.json b/client/package-lock.json index e7be883..b570b70 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1180,7 +1180,6 @@ "integrity": "sha512-GAAbkWrbRJvysL7+HOWs5v/+TmnRcEQPeED2sUcDFTHpPvRYADEtScL6x8hWuKp0DKauJVaVJLTjQVy9e7cMiw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -1220,7 +1219,6 @@ "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", @@ -1596,7 +1594,6 @@ "integrity": "sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1647,7 +1644,6 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -1865,7 +1861,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2216,7 +2211,6 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3332,7 +3326,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3360,7 +3353,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3494,7 +3486,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -3511,7 +3502,6 @@ "integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" @@ -3848,7 +3838,6 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.42.2.tgz", "integrity": "sha512-iSry5jsBHispVczyt9UrBX/1qu3HQ/UyKPAIjqlvlu3o/eUvc+kpyMyRS2O4HLLx4MvLurLGIUOyyP11pyD59g==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -3942,8 +3931,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz", "integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -4031,7 +4019,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4094,7 +4081,6 @@ "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index bd616e4..6a5dcba 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -1,25 +1,41 @@ -import type { UserSettings, isProcessed, ProcessedEvents } from "./types"; +import { EnvironmentManager } from "./environment"; +import type { isProcessed, ProcessedEvents, UniversityCalendarEvent, UniversityEventCategoryWithCount, UserSettings } from "./types"; export class API { - public static readonly baseUrl = 'https://heron-selected-literally.ngrok-free.app/api'; + private static async getBaseUrl(): Promise { + const baseUrl = await EnvironmentManager.getBaseUrl(); + return `${baseUrl}/api`; + } - public static async getJwtToken() { - const result = await chrome.storage.local.get('jwt_token'); - if (!result.jwt_token) { - throw new Error('No JWT token found'); - } - return result.jwt_token; + public static get baseUrl(): Promise { + return this.getBaseUrl(); + } + + public static async getJwtToken(): Promise { + const token = await EnvironmentManager.getJwtToken(); + return token; + } + + public static async checkFeatureFlag(flagName:string) { + const response = await fetch(`${this.baseUrl}/feature_flags/${flagName}`, { + method: 'GET' + }); + + const data = await response.json(); + return data.is_enabled; } public static async getTerms() { - const response = await fetch(`${this.baseUrl}/terms/current_and_next`, { + const baseUrl = await this.getBaseUrl(); + const response = await fetch(`${baseUrl}/terms/current_and_next`, { method: 'GET' }); return response.json(); } public static async getUserEmail() { - const response = await fetch(`${this.baseUrl}/user/email`, { + const baseUrl = await this.getBaseUrl(); + const response = await fetch(`${baseUrl}/user/email`, { method: 'GET', headers: { 'Authorization': `Bearer ${await this.getJwtToken()}` @@ -29,7 +45,8 @@ export class API { } public static async userSettings(settings?: UserSettings): Promise { - const url = `${this.baseUrl}/user/extension_config`; + const baseUrl = await this.getBaseUrl(); + const url = `${baseUrl}/user/extension_config`; const token = await this.getJwtToken(); const headers: HeadersInit = { 'Authorization': `Bearer ${token}` @@ -55,8 +72,9 @@ export class API { } public static async userIsProcessed(termUid: string): Promise { + const baseUrl = await this.getBaseUrl(); const token = await this.getJwtToken(); - const response = await fetch(`${this.baseUrl}/user/is_processed`, { + const response = await fetch(`${baseUrl}/user/is_processed`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, @@ -68,8 +86,9 @@ export class API { } public static async getProcessedEvents(termUid: string): Promise { + const baseUrl = await this.getBaseUrl(); const token = await this.getJwtToken(); - const response = await fetch(`${this.baseUrl}/user/processed_events`, { + const response = await fetch(`${baseUrl}/user/processed_events`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, @@ -81,8 +100,179 @@ export class API { } public static async getIcsUrl(): Promise<{ ics_url: string }> { + const baseUrl = await this.getBaseUrl(); + const token = await this.getJwtToken(); + const response = await fetch(`${baseUrl}/user/ics_url`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + return response.json(); + } + + public static async getUniversityEventCategories(): Promise<{ categories: UniversityEventCategoryWithCount[] }> { + const baseUrl = await this.getBaseUrl(); + const token = await this.getJwtToken(); + const response = await fetch(`${baseUrl}/university_calendar_events/categories`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + return response.json(); + } + + public static async getUniversityEvents(params?: { category?: string; categories?: string; start_date?: string; end_date?: string; term_id?: string; page?: number; per_page?: number }): Promise<{ events: UniversityCalendarEvent[]; meta: { current_page: number; total_pages: number; total_count: number; per_page: number } }> { + const baseUrl = await this.getBaseUrl(); + const token = await this.getJwtToken(); + const searchParams = new URLSearchParams(); + if (params?.category) searchParams.append('category', params.category); + if (params?.categories) searchParams.append('categories', params.categories); + if (params?.start_date) searchParams.append('start_date', params.start_date); + if (params?.end_date) searchParams.append('end_date', params.end_date); + if (params?.term_id) searchParams.append('term_id', params.term_id); + if (params?.page) searchParams.append('page', params.page.toString()); + if (params?.per_page) searchParams.append('per_page', params.per_page.toString()); + + const url = `${baseUrl}/university_calendar_events${searchParams.toString() ? '?' + searchParams.toString() : ''}`; + const response = await fetch(url, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + return response.json(); + } + + public static async getHolidays(params?: { term_id?: string; start_date?: string; end_date?: string }): Promise<{ holidays: UniversityCalendarEvent[] }> { + const baseUrl = await this.getBaseUrl(); + const token = await this.getJwtToken(); + const searchParams = new URLSearchParams(); + if (params?.term_id) searchParams.append('term_id', params.term_id); + if (params?.start_date) searchParams.append('start_date', params.start_date); + if (params?.end_date) searchParams.append('end_date', params.end_date); + + const url = `${baseUrl}/university_calendar_events/holidays${searchParams.toString() ? '?' + searchParams.toString() : ''}`; + const response = await fetch(url, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + return response.json(); + } + + // Course processing endpoints + public static async processCourses(courses: any[]): Promise<{ user_pub: string; ics_url: string }> { + const baseUrl = await this.getBaseUrl(); + const token = await this.getJwtToken(); + const response = await fetch(`${baseUrl}/process_courses`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(courses) + }); + return response.json(); + } + + public static async reprocessCourses(courses: any[]): Promise<{ + ics_url: string; + removed_enrollments: number; + removed_courses: Array<{ crn: number; title: string; course_number: number }>; + processed_courses: any[]; + }> { + const baseUrl = await this.getBaseUrl(); + const token = await this.getJwtToken(); + const response = await fetch(`${baseUrl}/courses/reprocess`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ courses }) + }); + return response.json(); + } + + // Event preferences endpoints + public static async getMeetingTimePreference(meetingTimeId: number | string): Promise { + const baseUrl = await this.getBaseUrl(); + const token = await this.getJwtToken(); + const response = await fetch(`${baseUrl}/meeting_times/${meetingTimeId}/preference`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + return response.json(); + } + + public static async updateMeetingTimePreference(meetingTimeId: number | string, preferences: any): Promise { + const baseUrl = await this.getBaseUrl(); + const token = await this.getJwtToken(); + const response = await fetch(`${baseUrl}/meeting_times/${meetingTimeId}/preference`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(preferences) + }); + return response.json(); + } + + public static async deleteMeetingTimePreference(meetingTimeId: number | string): Promise { + const baseUrl = await this.getBaseUrl(); + const token = await this.getJwtToken(); + const response = await fetch(`${baseUrl}/meeting_times/${meetingTimeId}/preference`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + return response.json(); + } + + // Global calendar preferences + public static async getGlobalCalendarPreference(): Promise { + const baseUrl = await this.getBaseUrl(); + const token = await this.getJwtToken(); + const response = await fetch(`${baseUrl}/calendar_preferences/global`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + return response.json(); + } + + public static async setGlobalCalendarPreference(preferences: { + reminder_settings?: any[]; + title_template?: string; + description_template?: string; + color_id?: string; + }): Promise { + const baseUrl = await this.getBaseUrl(); + const token = await this.getJwtToken(); + const response = await fetch(`${baseUrl}/calendar_preferences/global`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ calendar_preference: preferences }) + }); + return response.json(); + } + + // Connected Google accounts + public static async getConnectedAccounts(): Promise<{ oauth_credentials: Array<{id: number, email: string, provider: string}> }> { + const baseUrl = await this.getBaseUrl(); const token = await this.getJwtToken(); - const response = await fetch(`${this.baseUrl}/user/ics_url`, { + const response = await fetch(`${baseUrl}/user/oauth_credentials`, { method: 'GET', headers: { 'Authorization': `Bearer ${token}` @@ -91,4 +281,29 @@ export class API { return response.json(); } + public static async requestOAuthForEmail(email: string): Promise<{ oauth_url?: string, calendar_id?: string, error?: string }> { + const baseUrl = await this.getBaseUrl(); + const token = await this.getJwtToken(); + const response = await fetch(`${baseUrl}/user/gcal`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ email }) + }); + return response.json(); + } + + public static async disconnectAccount(credentialId: number): Promise { + const baseUrl = await this.getBaseUrl(); + const token = await this.getJwtToken(); + await fetch(`${baseUrl}/user/oauth_credentials/${credentialId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + } + } \ No newline at end of file diff --git a/client/src/lib/components/Settings.svelte b/client/src/lib/components/Settings.svelte index 1d6179f..3b7ed1c 100644 --- a/client/src/lib/components/Settings.svelte +++ b/client/src/lib/components/Settings.svelte @@ -1,67 +1,286 @@ @@ -69,6 +288,31 @@

Currently signed in as: {email}

+
+
+

Environment

+

+ {#each Object.values(ENVIRONMENTS) as env} + {#if authenticatedEnvironments.includes(env.name)} + ✓ {env.displayName} + {:else} + ○ {env.displayName} + {/if} + {#if env.name !== 'prod'}  {/if} + {/each} +

+
+
+ +
+

Default Lecture Color

@@ -133,6 +377,96 @@
+
+
+

Disable All Notifications

+

Turn off all calendar event reminders

+
+
+ +
+
+ + +
+
+

Connected Google Accounts

+

Add multiple Google accounts to sync your calendar

+
+ + {#if connectedAccounts.length > 0} +
+ {#each connectedAccounts as account} +
+
+ + + + {account.email} +
+ {#if connectedAccounts.length > 1} + + {/if} +
+ {/each} +
+ {:else} +

No Google accounts connected

+ {/if} + +
+ e.key === 'Enter' && addGoogleAccount()} + /> + +
+
+ + +
+
+
+

Sync University Events

+

Add campus events to your calendar (holidays are always synced)

+
+
+ +
+
+ + {#if syncUniversityEventsValue && availableCategories.length > 0} +
+

Select event types to sync:

+ {#each availableCategories.filter(c => c.id !== 'holiday') as category} + + {/each} +
+ {/if} +
+

diff --git a/client/src/lib/environment.ts b/client/src/lib/environment.ts new file mode 100644 index 0000000..56f9d1f --- /dev/null +++ b/client/src/lib/environment.ts @@ -0,0 +1,102 @@ +export type Environment = 'dev' | 'staging' | 'prod'; + +export interface EnvironmentConfig { + name: Environment; + displayName: string; + baseUrl: string; +} + +export const ENVIRONMENTS: Record = { + dev: { + name: 'dev', + displayName: 'Development', + baseUrl: 'https://heron-selected-literally.ngrok-free.app' + }, + staging: { + name: 'staging', + displayName: 'Staging', + baseUrl: 'https://staging-calendar.witcc.dev' + }, + prod: { + name: 'prod', + displayName: 'Production', + baseUrl: 'https://server-calendar.witcc.dev' + } +}; + +interface StoredEnvironmentData { + current_environment: Environment; + jwt_tokens: Partial>; +} + +export class EnvironmentManager { + private static readonly STORAGE_KEY = 'environment_data'; + + public static async getEnvironmentData(): Promise { + const result = await chrome.storage.local.get(this.STORAGE_KEY); + return result[this.STORAGE_KEY] || { + current_environment: 'prod', + jwt_tokens: {} + }; + } + + public static async getCurrentEnvironment(): Promise { + const data = await this.getEnvironmentData(); + return data.current_environment; + } + + public static async getCurrentEnvironmentConfig(): Promise { + const env = await this.getCurrentEnvironment(); + return ENVIRONMENTS[env]; + } + + public static async getBaseUrl(): Promise { + const config = await this.getCurrentEnvironmentConfig(); + return config.baseUrl; + } + + public static async getJwtToken(environment?: Environment): Promise { + const data = await this.getEnvironmentData(); + const env = environment || data.current_environment; + return data.jwt_tokens[env]; + } + + public static async setJwtToken(token: string, environment?: Environment): Promise { + const data = await this.getEnvironmentData(); + const env = environment || data.current_environment; + data.jwt_tokens[env] = token; + await chrome.storage.local.set({ [this.STORAGE_KEY]: data }); + } + + public static async switchEnvironment(environment: Environment): Promise { + const data = await this.getEnvironmentData(); + data.current_environment = environment; + await chrome.storage.local.set({ [this.STORAGE_KEY]: data }); + + return !!data.jwt_tokens[environment]; + } + + public static async clearJwtToken(environment?: Environment): Promise { + const data = await this.getEnvironmentData(); + const env = environment || data.current_environment; + delete data.jwt_tokens[env]; + await chrome.storage.local.set({ [this.STORAGE_KEY]: data }); + } + + public static async clearAllData(): Promise { + await chrome.storage.local.remove(this.STORAGE_KEY); + } + + public static async getAuthenticatedEnvironments(): Promise { + const data = await this.getEnvironmentData(); + return Object.keys(data.jwt_tokens) as Environment[]; + } + + public static async migrateOldJwtToken(): Promise { + const result = await chrome.storage.local.get('jwt_token'); + if (result.jwt_token) { + await this.setJwtToken(result.jwt_token, 'dev'); + await chrome.storage.local.remove('jwt_token'); + } + } +} diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index 5172e37..e0cbd32 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -1,6 +1,7 @@ interface Building { name: string; abbreviation: string; + pub_id?: string; } interface Course { @@ -13,13 +14,23 @@ interface Course { meeting_times: MeetingTime[]; } +const FEATUE_FLAGS = [ + "v1", + "v2" +] + +interface FeatureFlagEnabled { + feature_name: string; + is_enabled: boolean; +} + interface Location { building: Building; room: string; } interface MeetingTime { - id: number; + id: number | string; // Can be internal ID or public_id begin_time: string; end_time: string; start_date: string; @@ -34,6 +45,15 @@ interface MeetingTime { sunday: boolean; color?: string; title_overrides?: Partial>; + calendar_config?: CalendarConfig; +} + +interface CalendarConfig { + title: string; + description?: string; + color_id?: string; + reminder_settings?: ReminderSettings[]; + visibility?: string; } interface isProcessed { @@ -45,6 +65,7 @@ interface Professor { last_name: string; email: string; rmp_id?: string; + pub_id?: string; } interface ResponseData { @@ -60,6 +81,7 @@ interface Term { uid: number; season: string; year: number; + pub_id?: string; } interface UserSettings { @@ -67,16 +89,55 @@ interface UserSettings { default_color_lecture: string; default_color_lab: string; advanced_editing: boolean; + sync_university_events: boolean; + university_event_categories: string[]; + available_university_event_categories?: UniversityEventCategory[]; +} + +interface UniversityEventCategory { + id: string; + name: string; + description: string; +} + +interface UniversityEventCategoryWithCount { + id: string; + name: string; + count: number; +} + +interface UniversityCalendarEvent { + id: string; + summary: string; + description?: string; + location?: string; + start_time: string; + end_time: string; + all_day: boolean; + category: string; + organization?: string; + academic_term?: string; + term_id?: string; + excludes_classes: boolean; + formatted_date: string; + created_at: string; + updated_at: string; } interface CurrentTerm { name: string; id: number; + pub_id?: string; + start_date?: string; + end_date?: string; } interface NextTerm { name: string; id: number; + pub_id?: string; + start_date?: string; + end_date?: string; } interface TermResponse { @@ -100,24 +161,24 @@ interface EventPreferences { } interface TemplateVariables { - title: string; - course_code: string; - subject: string; - course_number: string; - section_number: string; - crn: string; - room: string; - building: string; - location: string; + title: string; + course_code: string; + subject: string; + course_number: string; + section_number: string; + crn: string; + room: string; + building: string; + location: string; faculty: string; - faculty_email: string; + faculty_email: string; all_faculty: string; - start_time: string; - end_time: string; - day: string; - day_abbr: string; - term: string; - schedule_type: string; + start_time: string; + end_time: string; + day: string; + day_abbr: string; + term: string; + schedule_type: string; } interface ResolvedData { @@ -158,27 +219,9 @@ interface NotificationSetting { } export { - type Building, - type Course, - type Location, - type MeetingTime, - type Professor, - type ResponseData, - type Term, - type UserSettings, - type CurrentTerm, - type NextTerm, - type TermResponse, - type isProcessed, - type ProcessedEvents, - type DayItem, - type EventPreferences, - type ReminderSettings, - type GetPreferencesResponse, - type Preview, - type TemplateVariables, - type ResolvedData, - type NotificationType, - type NotificationSetting, - type NotificationMethod + FEATUE_FLAGS, + type Building, + type CalendarConfig, type Course, type CurrentTerm, type DayItem, + type EventPreferences, type GetPreferencesResponse, type isProcessed, type Location, + type MeetingTime, type NextTerm, type NotificationMethod, type NotificationSetting, type NotificationType, type Preview, type ProcessedEvents, type Professor, type ReminderSettings, type ResolvedData, type ResponseData, type TemplateVariables, type Term, type TermResponse, type UniversityCalendarEvent, type UniversityEventCategory, type UniversityEventCategoryWithCount, type UserSettings }; diff --git a/client/src/routes/calendar/+page.svelte b/client/src/routes/calendar/+page.svelte index 47257fe..2b21010 100644 --- a/client/src/routes/calendar/+page.svelte +++ b/client/src/routes/calendar/+page.svelte @@ -27,14 +27,13 @@ let lectureColor = $derived($storedUserSettings?.default_color_lecture ?? "#039be5"); let labColor = $derived($storedUserSettings?.default_color_lab ?? "#f6bf26"); let advancedEditing = $derived($storedUserSettings?.advanced_editing ?? false); - let otherCalUser = $state(false); let currentEventPrefs = $state(undefined); let templates: TemplateVariables | undefined = $derived(currentEventPrefs?.templates); let resolved: ResolvedData | undefined = $derived(currentEventPrefs?.resolved); let editMode = $state(false); let titleTemplates = [ - "{{title}}", + "{% if schedule_type == 'Laboratory' %}{{title | remove: '- Lab'}} - {{schedule_type_short}}{% else %}{{title}}{% endif %}", "{% if schedule_type == 'Laboratory' %}{{course_code}}{% else %}{{title}} - {{schedule_type_short}}{% endif %}" ] let descriptionTemplates = [ @@ -80,7 +79,7 @@ } const result: string[] = []; let lastIndex = 0; - const regex = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g; + const regex = /\{\{\s*([a-zA-Z0-9_]+)(?:\s*\|\s*remove:\s*(["'])(.*?)\2)?\s*\}\}/g; let m: RegExpExecArray | null; while ((m = regex.exec(s)) !== null) { if (m.index > lastIndex) { @@ -88,6 +87,9 @@ } const key = m[1] as keyof TemplateVariables; let value = templates?.[key] ?? ''; + if (m[3]) { + value = value.replaceAll(m[3], ''); + } result.push(value); lastIndex = regex.lastIndex; } @@ -301,20 +303,8 @@ }); const registrationData = results[0]?.result ?? []; - const newData = await fetch(`${API.baseUrl}/process_courses`, { - method: 'POST', - body: JSON.stringify(registrationData), - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${jwt_token}` - } - }); - - if (!newData.ok) { - throw new Error(`Failed to process courses: ${newData.status} ${newData.statusText}`); - } + const response = await API.processCourses(registrationData); - const response = await newData.json(); if (typeof response === 'string') { return { ics_url: response }; } @@ -364,36 +354,25 @@ } } - async function getEventPerfs(eventId: number) { - const res = await fetch(`${API.baseUrl}/meeting_times/${eventId}/preference`, { - method: 'GET', - headers: { - 'Authorization': `Bearer ${jwt_token}` - } - }); - const data = await res.json(); + async function getEventPerfs(eventId: number | string) { + const data = await API.getMeetingTimePreference(eventId); currentEventPrefs = data; } async function refreshAllEventPrefsForCurrentTerm() { - if (!selected || !jwt_token || !processedData) return; + if (!selected || !processedData) return; const ids = Array.from(new Set(processedData.flatMap(c => c.meeting_times.map(mt => mt.id)))); const responses = await Promise.all(ids.map(async (id) => { try { - const res = await fetch(`${API.baseUrl}/meeting_times/${id}/preference`, { - method: 'GET', - headers: { 'Authorization': `Bearer ${jwt_token}` } - }); - if (!res.ok) return undefined; - const data: GetPreferencesResponse = await res.json(); + const data: GetPreferencesResponse = await API.getMeetingTimePreference(id); return { id, data }; } catch { return undefined; } })); - const map = new Map(); + const map = new Map(); for (const r of responses) { - if (r?.id && r.data) map.set(r.id, r.data); + if (r?.id !== undefined && r.data) map.set(r.id, r.data); } if (map.size === 0) return; const dayKeys = ['monday','tuesday','wednesday','thursday','friday','saturday','sunday'] as const; @@ -459,11 +438,117 @@ } } - function checkIsOtherCalendar() { - const stored = browser ? localStorage.getItem('isOtherCalendar') === 'true' : false; - return stored; + async function refreshSchedule(termId: string | undefined) { + if (!termId || refreshing || loading) return; + try { + refreshing = true; + lastRefreshResult = null; + + // Scrape current courses from LeopardWeb + let tabToUse: any; + let shouldCloseTab = false; + const targetUrl = 'https://selfservice.wit.edu/StudentRegistrationSsb/ssb/registrationHistory/registrationHistory'; + + const [currentTab] = await chrome.tabs.query({ + active: true, + currentWindow: true + }); + + const isOnTargetPage = currentTab.url === targetUrl; + tabToUse = currentTab; + if (!isOnTargetPage) { + tabToUse = await chrome.tabs.create({ url: targetUrl }); + shouldCloseTab = true; + + await new Promise((resolve) => { + const listener = (tabId: number, changeInfo: any) => { + if (tabId === tabToUse.id && changeInfo.status === 'complete') { + chrome.tabs.onUpdated.removeListener(listener); + resolve(); + } + }; + chrome.tabs.onUpdated.addListener(listener); + }); + + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + if (!tabToUse?.id) { + snackbar('Failed to open LeopardWeb tab', undefined, true); + return; + } + + const results = await chrome.scripting.executeScript({ + target: { tabId: tabToUse.id }, + world: 'MAIN', + func: async (term: string) => { + try { + const r0 = await fetch(`https://selfservice.wit.edu/StudentRegistrationSsb/ssb/registrationHistory/reset?term=${term}`, { + credentials: 'include' + }); + await r0.json(); + const r1 = await fetch('https://selfservice.wit.edu/StudentRegistrationSsb/ssb/classRegistration/getRegistrationEvents?termFilter=', { + credentials: 'include' + }); + return await r1.json(); + } catch (e) { + return ({ error: (e as Error).message }); + } + }, + args: [termId] + }); + + if (shouldCloseTab && tabToUse?.id) { + await chrome.tabs.remove(tabToUse.id); + } + + const registrationData = results[0]?.result ?? []; + + // Call the reprocess endpoint + const response = await API.reprocessCourses(registrationData); + + if (response.ics_url) { + storedIcsUrl.set(response.ics_url); + } + + // Update the store with fresh data + const events = await API.getProcessedEvents(termId); + storedProcessedData.update((list) => { + const tid = String(termId); + const i = list.findIndex((x) => String(x.termId) === tid); + const next = [...list]; + const ics = response.ics_url || $storedIcsUrl || ''; + const responseData: ResponseData = { ics_url: ics, classes: events.classes }; + if (i >= 0) next[i] = { termId: tid, responseData }; + else next.push({ termId: tid, responseData }); + return next; + }); + + // Show results + lastRefreshResult = { + removed: response.removed_enrollments, + removedCourses: response.removed_courses + }; + + if (response.removed_enrollments > 0) { + const courseNames = response.removed_courses.map(c => c.title).join(', '); + snackbar(`Schedule refreshed. Removed ${response.removed_enrollments} class${response.removed_enrollments > 1 ? 'es' : ''}: ${courseNames}`, undefined, true); + } else { + snackbar('Schedule refreshed. No changes detected.', undefined, true); + } + + // Refresh event preferences after reprocessing + await refreshAllEventPrefsForCurrentTerm(); + + } catch (e) { + console.error('Failed to refresh schedule:', e); + snackbar('Failed to refresh schedule: ' + e, undefined, true); + } finally { + refreshing = false; + } } + async function saveEventPerfs() { const event_preference: Partial<{ title_template: string; @@ -527,30 +612,8 @@ } const payload = { event_preference }; - const put = await fetch(`${API.baseUrl}/meeting_times/${activeMeeting?.id}/preference`, { - method: 'PUT', - body: JSON.stringify(payload), - headers: { - 'Authorization': `Bearer ${jwt_token}`, - 'Content-Type': 'application/json' - } - }); - if (!put.ok) { - activeCourse = undefined; - activeMeeting = undefined; - activeDay = undefined; - currentEventPrefs = undefined; - editTitle = ""; - editDescription = ""; - editLocation = ""; - editTitleManual = ""; - editDescriptionManual = ""; - editLocationManual = ""; - courseColor = "#d50000"; - notifications = []; - editMode = false; - snackbar('Failed to save event preferences: ' + put.statusText, undefined, true); - } else { + try { + await API.updateMeetingTimePreference(activeMeeting?.id!, payload); snackbar('Event preferences saved successfully!', undefined, true); let updatedTitle: string | undefined = undefined; if (titleManualChanged) { @@ -623,10 +686,38 @@ courseColor = "#d50000"; notifications = []; editMode = false; + } catch (e) { + activeCourse = undefined; + activeMeeting = undefined; + activeDay = undefined; + currentEventPrefs = undefined; + editTitle = ""; + editDescription = ""; + editLocation = ""; + editTitleManual = ""; + editDescriptionManual = ""; + editLocationManual = ""; + courseColor = "#d50000"; + notifications = []; + editMode = false; + snackbar('Failed to save event preferences: ' + e, undefined, true); } } - let tab = $state("a"); + // Check if returning to settings after environment switch (before render) + let shouldReturnToSettings = browser && sessionStorage.getItem('returnToSettings') === 'true'; + let shouldClearData = browser && sessionStorage.getItem('clearCalendarData') === 'true'; + let tab = $state(shouldReturnToSettings ? "settings" : "a"); + + // Clear data immediately if switching environments (before render) + if (shouldClearData && browser) { + sessionStorage.removeItem('returnToSettings'); + sessionStorage.removeItem('clearCalendarData'); + // Clear stores immediately to prevent old data from showing + localStorage.removeItem('processedData'); + localStorage.removeItem('userSettings'); + localStorage.removeItem('icsUrl'); + } let notifications = $state([]); let courseColor = $state("#d50000"); @@ -636,13 +727,62 @@ let editTitleManual = $state(""); let editDescriptionManual = $state(""); let editLocationManual = $state(""); + let refreshing = $state(false); + let lastRefreshResult = $state<{ removed: number; removedCourses: Array<{ crn: number; title: string; course_number: number }> } | null>(null); + + async function listenforEnvironmentChanges() { + chrome.storage.onChanged.addListener((changes: any) => { + Object.entries(changes).forEach(async ([key]) => { + if (key === 'environment_data') { + checkBetaAccess(); + jwt_token = await API.getJwtToken(); + if (!jwt_token) { + // No JWT token for current environment, redirect to welcome page + goto('/'); + return; + } + + // IMPORTANT: Clear data FIRST before fetching anything for environment switches + if (shouldClearData) { + // Clear stored data to force refetch for new environment + storedProcessedData.set([]); + storedUserSettings.set(undefined); + storedIcsUrl.set(undefined); + attemptedTerms = new Set(); + refreshedTerms = new Set(); + } + + // Now fetch fresh data for the current environment + terms = await API.getTerms(); + storedUserSettings.set(await API.userSettings()); + } + }); + }); + } onMount(async () => { checkBetaAccess(); jwt_token = await API.getJwtToken(); + if (!jwt_token) { + // No JWT token for current environment, redirect to welcome page + goto('/'); + return; + } + + // IMPORTANT: Clear data FIRST before fetching anything for environment switches + if (shouldClearData) { + // Clear stored data to force refetch for new environment + storedProcessedData.set([]); + storedUserSettings.set(undefined); + storedIcsUrl.set(undefined); + attemptedTerms = new Set(); + refreshedTerms = new Set(); + } + + // Now fetch fresh data for the current environment terms = await API.getTerms(); storedUserSettings.set(await API.userSettings()); - otherCalUser = checkIsOtherCalendar(); + listenforEnvironmentChanges(); }); $effect(() => { @@ -695,7 +835,7 @@

- {#if !processedData} + {#if !processedData && tab === "a"}

Get Your Calendar

@@ -721,25 +861,23 @@ {/if}
- {:else} + {:else if processedData || tab !== "a"}

Your Calendar

- {#if otherCalUser} -

- Copy the link below and add it to your calendar app. -

-
- -
- {/if} +

+ Subscribe in any calendar app with the link below. +

+
+ +

{#if tab == "a"} - - { const tid = terms?.current_term.id?.toString(); if (tid && !$storedProcessedData.some((d) => String(d.termId) === tid) && !attemptedTerms.has(tid) && !loading) { const next = new Set(attemptedTerms); next.add(tid); attemptedTerms = next; await ensureProcessedForTerm(tid); } }} /> - - { const tid = terms?.next_term.id?.toString(); if (tid && !$storedProcessedData.some((d) => String(d.termId) === tid) && !attemptedTerms.has(tid) && !loading) { const next = new Set(attemptedTerms); next.add(tid); attemptedTerms = next; await ensureProcessedForTerm(tid); } }} /> - - +
+ + { const tid = terms?.current_term.id?.toString(); if (tid && !$storedProcessedData.some((d) => String(d.termId) === tid) && !attemptedTerms.has(tid) && !loading) { const next = new Set(attemptedTerms); next.add(tid); attemptedTerms = next; await ensureProcessedForTerm(tid); } }} /> + + { const tid = terms?.next_term.id?.toString(); if (tid && !$storedProcessedData.some((d) => String(d.termId) === tid) && !attemptedTerms.has(tid) && !loading) { const next = new Set(attemptedTerms); next.add(tid); attemptedTerms = next; await ensureProcessedForTerm(tid); } }} /> + + + {#if processedData} + + {/if} +
{/if} {/if} - {#if tab === "a"} - {#if processedData} - {@const latestHour = getLatestEndHour(processedData)} - {@const numHours = latestHour - 8 + 1} + {#if tab === "a" && processedData} + {@const latestHour = getLatestEndHour(processedData)} + {@const numHours = latestHour - 8 + 1}
@@ -817,8 +968,7 @@
- {/if} - {:else if tab === "settings"} + {:else if tab === "settings"} {:else if tab === "help"} diff --git a/client/src/routes/feature-access-denied/+page.svelte b/client/src/routes/feature-access-denied/+page.svelte new file mode 100644 index 0000000..4f836b7 --- /dev/null +++ b/client/src/routes/feature-access-denied/+page.svelte @@ -0,0 +1,58 @@ + + +
+
+ +
+ + + +
+ + +

Feature Access Denied

+ + +

+ You don't have access to this feature! +

+

+ Please contact support if you believe you should have access. +

+ + +
+

Contact Support (Jasper)

+ mayonej@wit.edu +
+ + +
+ +
+
+
diff --git a/client/src/routes/gcalendar/+page.svelte b/client/src/routes/gcalendar/+page.svelte index dfdbdc7..fffbef3 100644 --- a/client/src/routes/gcalendar/+page.svelte +++ b/client/src/routes/gcalendar/+page.svelte @@ -50,9 +50,10 @@ } async function submitEmail() { - const emailToUse = emailToSignInWith || emailToSubmit; - - const response = await fetch(`${API.baseUrl}/user/gcal`, { + const emailToUse = emailToSignInWith || emailToSubmit; + const baseUrl = await API.baseUrl; + + const response = await fetch(`${baseUrl}/user/gcal`, { method: 'POST', body: JSON.stringify({email: emailToUse}), headers: { @@ -89,6 +90,11 @@ onMount(async () => { checkBetaAccess(); jwt_token = await API.getJwtToken(); + if (!jwt_token) { + // No JWT token for current environment, redirect to welcome page + goto('/'); + return; + } checkGcalStatus(); tryForEmail(); setupListener(); diff --git a/client/src/routes/loading/+page.svelte b/client/src/routes/loading/+page.svelte index 9d0d83f..f522755 100644 --- a/client/src/routes/loading/+page.svelte +++ b/client/src/routes/loading/+page.svelte @@ -1,90 +1,123 @@