├── .gitignore ├── client ├── public │ ├── robots.txt │ ├── icon2.png │ ├── manifest.json │ ├── index.html │ └── icon.svg ├── .firebaserc ├── src │ ├── components │ │ ├── visual │ │ │ ├── CellView.css │ │ │ ├── SessionView.css │ │ │ ├── Schedule.css │ │ │ ├── CellView.js │ │ │ ├── SessionView.js │ │ │ └── Schedule.js │ │ └── smart │ │ │ ├── MySchedule.css │ │ │ ├── FloatingButton.css │ │ │ ├── FloatingButton.js │ │ │ ├── Home.css │ │ │ ├── App.js │ │ │ ├── App(menu).css │ │ │ ├── MySchedule.js │ │ │ └── Home.js │ ├── index.js │ ├── sw-builder.js │ ├── js │ │ ├── analytics.js │ │ ├── storedSchedule.js │ │ ├── theme.js │ │ ├── consts.js │ │ ├── sw-manager.js │ │ └── utils.js │ ├── index.css │ └── normalize.css ├── firebase.json ├── .gitignore ├── README.md └── package.json ├── server ├── functions │ ├── tsconfig.dev.json │ ├── .prettierrc.json │ ├── src │ │ ├── env.ts │ │ ├── firestore.ts │ │ ├── utils.ts │ │ ├── types.ts │ │ ├── index.ts │ │ ├── requests.ts │ │ ├── consts.ts │ │ ├── providers │ │ │ ├── studentDataReport.ts │ │ │ ├── courseSchedule.ts │ │ │ └── groupsSchedule.ts │ │ ├── getStudentSchedule.ts │ │ └── prepareCourses.ts │ ├── .gitignore │ ├── tsconfig.json │ ├── .eslintrc.js │ └── package.json ├── .firebaserc ├── firestore.indexes.json ├── firestore.rules ├── firebase.json ├── .gitignore └── README.md ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | TODO.md 2 | tests* 3 | dev 4 | data -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /server/functions/tsconfig.dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [".eslintrc.js"] 3 | } 4 | -------------------------------------------------------------------------------- /client/.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "gucschedule" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /server/.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "gucschedule" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /server/firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [], 3 | "fieldOverrides": [] 4 | } 5 | -------------------------------------------------------------------------------- /client/public/icon2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pandemic1617/GUC_Schedule_Viewer/HEAD/client/public/icon2.png -------------------------------------------------------------------------------- /server/functions/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 4, 4 | "semi": true, 5 | "printWidth": 120 6 | } 7 | -------------------------------------------------------------------------------- /client/src/components/visual/CellView.css: -------------------------------------------------------------------------------- 1 | #cell { 2 | display: flex; 3 | justify-content: center; 4 | width: 100%; 5 | min-height: 5vmin; 6 | } 7 | -------------------------------------------------------------------------------- /server/functions/src/env.ts: -------------------------------------------------------------------------------- 1 | import { defineString } from "firebase-functions/params"; 2 | 3 | export const USERNAME = defineString("USERNAME"); 4 | export const PASSWORD = defineString("PASSWORD"); 5 | -------------------------------------------------------------------------------- /server/firestore.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | service cloud.firestore { 3 | match /databases/{database}/documents { 4 | match /{document=**} { 5 | allow read, write: if false; 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /server/functions/.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled JavaScript files 2 | lib/**/*.js 3 | lib/**/*.js.map 4 | 5 | # TypeScript v1 declaration files 6 | typings/ 7 | 8 | # Node.js dependency directory 9 | node_modules/ 10 | -------------------------------------------------------------------------------- /client/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "build", 4 | "ignore": [ 5 | "firebase.json", 6 | "**/.*", 7 | "**/node_modules/**" 8 | ], 9 | "rewrites": [ 10 | { 11 | "source": "**", 12 | "destination": "/index.html" 13 | } 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /server/functions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "noImplicitReturns": true, 5 | "noUnusedLocals": false, 6 | "outDir": "lib", 7 | "sourceMap": true, 8 | "strict": true, 9 | "target": "es2017" 10 | }, 11 | "compileOnSave": true, 12 | "include": ["src"] 13 | } 14 | -------------------------------------------------------------------------------- /client/src/components/smart/MySchedule.css: -------------------------------------------------------------------------------- 1 | .MySchedule { 2 | text-align: center; 3 | } 4 | 5 | #title { 6 | font-size: min(36px, 6.5vw); 7 | font-weight: 200; 8 | padding: 30px; 9 | color: var(--color3); 10 | } 11 | 12 | #noSchedule { 13 | /* font-size: min(36px, 6.5vw); */ 14 | /* font-weight: 200; */ 15 | padding: 20px max(30px, 10vw); 16 | font-size: large; 17 | color: var(--text-color); 18 | } 19 | -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import "./index.css"; 4 | import "./normalize.css"; 5 | import App from "./components/smart/App"; 6 | 7 | import "./js/analytics"; 8 | import { installer as swInstaller } from "./js/sw-manager"; 9 | 10 | swInstaller(); 11 | 12 | ReactDOM.render( 13 | 14 | 15 | , 16 | document.getElementById("root") 17 | ); 18 | -------------------------------------------------------------------------------- /client/src/sw-builder.js: -------------------------------------------------------------------------------- 1 | const { generateSW } = require("workbox-build"); 2 | 3 | const swDest = "build/sw.js"; 4 | const globDirectory = "build"; 5 | const globPatterns = ["**"]; 6 | const navigateFallback = "/index.html"; 7 | generateSW({ 8 | swDest, 9 | globDirectory, 10 | globPatterns, 11 | navigateFallback, 12 | }).then(({ count, size }) => { 13 | console.log(`Generated ${swDest}, which will precache ${count} files, totaling ${size} bytes.`); 14 | }); 15 | -------------------------------------------------------------------------------- /client/src/components/smart/FloatingButton.css: -------------------------------------------------------------------------------- 1 | .FloatingButtonContainer { 2 | margin: 0; 3 | } 4 | 5 | .FloatingButton { 6 | background-color: var(--color1); 7 | border: 1px solid var(--color3); 8 | color: var(--color3); 9 | padding: 3px 6px 3px 6px; 10 | font-size: 13.3px; 11 | border-radius: 15px; 12 | /* margin: 20px; */ 13 | /* font-weight: 500; */ 14 | position: absolute; 15 | top: 6px; 16 | right: max(12px, calc(39vw - 190px)); 17 | } 18 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | firebase-debug.log* 25 | firebase-debug.*.log* 26 | 27 | .firebase/ 28 | secret.js 29 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # Client 2 | 3 | The client is designed to be deployed to any static hosting service. It is implemented in [ReactJS](https://reactjs.org/). The client requests the schedule from the server when "Load schedule " is clicked and then displays the data. 4 | 5 | ## Setup 6 | 7 | 1. after opening a terminal in the client folder, run `npm i` to install the required dependencies 8 | 2. create your own `secret.js` file with your firebase config 9 | 3. run `npm run start` 10 | 4. your browser should automatically be opened to the locally hosted client website :) 11 | -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "GUC Sched", 3 | "name": "GUC Schedule Viewer", 4 | "description": "Displays a GUC student's schedule", 5 | "icons": [ 6 | { 7 | "src": "/icon.svg", 8 | "sizes": "any", 9 | "type": "image/svg", 10 | "purpose": "any" 11 | }, 12 | { 13 | "src": "/icon2.png", 14 | "sizes": "1536x1536", 15 | "type": "image/png" 16 | } 17 | ], 18 | "start_url": "/my_schedule", 19 | "display": "standalone", 20 | "theme_color": "#3e8881", 21 | "background_color": "#001a23" 22 | } -------------------------------------------------------------------------------- /client/src/js/analytics.js: -------------------------------------------------------------------------------- 1 | import { initializeApp } from "firebase/app"; 2 | import { logEvent as rawLogEvent, initializeAnalytics, setUserProperties } from "firebase/analytics"; 3 | import { firebaseConfig } from "./secret"; 4 | import { appVersion } from "./consts"; 5 | 6 | const app = initializeApp(firebaseConfig); 7 | const analytics = initializeAnalytics(app, { config: { connection: "online", appVersion } }); 8 | setUserProperties(analytics, { appVersion }); 9 | const logEvent = (eventName, eventParams) => rawLogEvent(analytics, eventName, { ...eventParams }); 10 | 11 | export { logEvent }; 12 | -------------------------------------------------------------------------------- /client/src/components/smart/FloatingButton.js: -------------------------------------------------------------------------------- 1 | import "./FloatingButton.css"; 2 | import React from "react"; 3 | 4 | class FloatingButton extends React.Component { 5 | constructor(props) { 6 | super(props); 7 | if (this.props.onInit) this.props.onInit(); 8 | } 9 | 10 | render() { 11 | return ( 12 |
13 | 16 |
17 | ); 18 | } 19 | } 20 | 21 | export default FloatingButton; 22 | -------------------------------------------------------------------------------- /client/src/js/storedSchedule.js: -------------------------------------------------------------------------------- 1 | const saveSchedule = (schedule) => { 2 | const stringSchedule = JSON.stringify(schedule); 3 | localStorage.setItem("stored_schedule_0", stringSchedule); 4 | }; 5 | 6 | const retrieveSchedule = () => { 7 | const stringSchedule = localStorage.getItem("stored_schedule_0"); 8 | console.log(stringSchedule); 9 | const ret = stringSchedule ? JSON.parse(stringSchedule) : []; 10 | 11 | if (Array.isArray(ret)) { 12 | return { 13 | schedCategory: "", 14 | slots: ret, 15 | }; 16 | } 17 | 18 | return ret; 19 | }; 20 | 21 | export { saveSchedule, retrieveSchedule }; 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021, Noureldin Shaker 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /client/src/components/visual/SessionView.css: -------------------------------------------------------------------------------- 1 | #location { 2 | font-size: smaller; 3 | } 4 | #course { 5 | font-size: larger; 6 | -webkit-text-stroke: 0.2px; 7 | } 8 | #staff { 9 | font-size: small; 10 | font-weight: 700; 11 | } 12 | 13 | #group { 14 | font-size: medium; 15 | margin: 0; 16 | } 17 | 18 | .sessioncontainer { 19 | margin: 1vmin; 20 | } 21 | 22 | .SessionPopupKey{ 23 | display: inline-block; 24 | font-style: italic; 25 | font-size: smaller; 26 | } 27 | 28 | .SessionPopupValue{ 29 | display: inline-block; 30 | } 31 | 32 | /* 33 | .Lecture { 34 | color: #6f5060; 35 | } 36 | 37 | .Tutorial { 38 | color: #a78682; 39 | } 40 | 41 | .Practical { 42 | color: #a09ebb; 43 | } */ 44 | -------------------------------------------------------------------------------- /server/functions/src/firestore.ts: -------------------------------------------------------------------------------- 1 | import { firestore } from "firebase-admin"; 2 | 3 | export const courseFirestore = (courseCode: string) => 4 | firestore() 5 | .collection("schedules") 6 | .doc("student_schedules") 7 | .collection("course_" + courseCode) 8 | .doc("info"); 9 | 10 | export const getCoursesInfoFirestore = () => firestore().collection("schedules").doc("get_courses_info"); 11 | 12 | export const groupFirestore = (groupName: string) => 13 | firestore() 14 | .collection("schedules") 15 | .doc("student_schedules") 16 | .collection("group_" + groupName) 17 | .doc("info"); 18 | 19 | export const getGroupsInfoFirestore = () => firestore().collection("schedules").doc("get_group_schedules_info"); 20 | -------------------------------------------------------------------------------- /client/src/js/theme.js: -------------------------------------------------------------------------------- 1 | import { themes } from "./consts"; 2 | 3 | let currentTheme; 4 | 5 | const initTheme = () => { 6 | let currentTheme = localStorage.getItem("theme_choice"); 7 | if (currentTheme == null) currentTheme = 0; 8 | setTheme(currentTheme, false); 9 | }; 10 | 11 | // sets the current theme and saves it in 12 | const setTheme = (newTheme, save = true) => { 13 | if (currentTheme) document.documentElement.classList.remove(themes[currentTheme]); 14 | if (save) localStorage.setItem("theme_choice", newTheme); 15 | document.documentElement.classList.add(themes[newTheme]); 16 | currentTheme = newTheme; 17 | }; 18 | 19 | // switches the current theme to the next one in themes 20 | const switchTheme = () => { 21 | setTheme((currentTheme + 1) % themes.length); 22 | }; 23 | 24 | export { initTheme, switchTheme }; 25 | -------------------------------------------------------------------------------- /server/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "firestore": { 3 | "rules": "firestore.rules", 4 | "indexes": "firestore.indexes.json" 5 | }, 6 | "hosting": { 7 | "public": "public", 8 | "ignore": [ 9 | "firebase.json", 10 | "**/.*", 11 | "**/node_modules/**" 12 | ], 13 | "rewrites": [ 14 | { 15 | "source": "**", 16 | "destination": "/index.html" 17 | } 18 | ] 19 | }, 20 | "functions": [ 21 | { 22 | "source": "functions", 23 | "codebase": "default", 24 | "ignore": [ 25 | "node_modules", 26 | ".git", 27 | "firebase-debug.log", 28 | "firebase-debug.*.log" 29 | ], 30 | "predeploy": [ 31 | "npm --prefix \"$RESOURCE_DIR\" run lint", 32 | "npm --prefix \"$RESOURCE_DIR\" run build" 33 | ] 34 | } 35 | ] 36 | } -------------------------------------------------------------------------------- /client/src/components/visual/Schedule.css: -------------------------------------------------------------------------------- 1 | #sched { 2 | margin: auto; 3 | padding: 5vmin; 4 | } 5 | 6 | table, 7 | td, 8 | th { 9 | border: 1px solid var(--color3); 10 | color: var(--text-color); 11 | padding: 5px; 12 | min-width: 15vmin; 13 | } 14 | 15 | table { 16 | border-collapse: collapse; 17 | max-width: 100vw; 18 | } 19 | 20 | .SchedView { 21 | margin: 5vmin; 22 | max-width: 100vw; 23 | overflow-x: auto; 24 | padding-bottom: 10px; 25 | } 26 | 27 | .SchedView::-webkit-scrollbar { 28 | height: 1.2vh; 29 | } 30 | 31 | .SchedView::-webkit-scrollbar-track { 32 | border: 1px solid var(--color3); 33 | border-radius: 10px; 34 | } 35 | 36 | .SchedView::-webkit-scrollbar-thumb { 37 | background-color: var(--color3); 38 | border-radius: 10px; 39 | border: 1px solid var(--color2); 40 | } 41 | -------------------------------------------------------------------------------- /server/functions/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | es6: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | "eslint:recommended", 9 | "plugin:import/errors", 10 | "plugin:import/warnings", 11 | "plugin:import/typescript", 12 | "google", 13 | "plugin:@typescript-eslint/recommended", 14 | ], 15 | parser: "@typescript-eslint/parser", 16 | parserOptions: { 17 | project: ["tsconfig.json", "tsconfig.dev.json"], 18 | sourceType: "module", 19 | }, 20 | ignorePatterns: [ 21 | "/lib/**/*", // Ignore built files. 22 | ], 23 | plugins: ["@typescript-eslint", "import"], 24 | rules: { 25 | quotes: ["error", "double"], 26 | "import/no-unresolved": 0, 27 | indent: ["error", 2], 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | .theme-dark { 2 | --background: #001a23; 3 | --color1: #001015; 4 | --color2: #7ace9d; 5 | --color3: #3e8881; 6 | --text-color: #bbb5bd; 7 | } 8 | 9 | .theme-light { 10 | --background: #f0f0f0; 11 | --color1: #d1d1d1; 12 | --color2: #30a381; 13 | --color3: #3e8881; 14 | --text-color: #01180e; 15 | } 16 | 17 | body { 18 | margin: 0; 19 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 20 | sans-serif; 21 | -webkit-font-smoothing: antialiased; 22 | -moz-osx-font-smoothing: grayscale; 23 | background-color: var(--background); 24 | width: 100%; 25 | height: 100%; 26 | } 27 | 28 | code { 29 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; 30 | } 31 | -------------------------------------------------------------------------------- /client/src/components/visual/CellView.js: -------------------------------------------------------------------------------- 1 | // import logo from './logo.svg'; 2 | import "./CellView.css"; 3 | import React from "react"; 4 | import SessionView from "./SessionView"; 5 | 6 | class CellView extends React.Component { 7 | constructor(props) { 8 | super(props); 9 | this.state = { ini: props.ini }; 10 | } 11 | 12 | render() { 13 | return ( 14 |
15 |
16 | {this.state.ini.map((e, i) => { 17 | return ( 18 |
19 | 20 |
21 | ); 22 | })} 23 |
24 |
25 | ); 26 | } 27 | } 28 | 29 | export default CellView; 30 | -------------------------------------------------------------------------------- /server/functions/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { timingSafeEqual } from "crypto"; 2 | 3 | export const currentTime = () => new Date().getTime(); // returns the current time in millis; 4 | 5 | export const doRetries: (func: () => Promise, maxRetries: number) => Promise = async (func, maxRetries) => { 6 | // func is executed atmost 1 + maxRetries times 7 | let retries = 0; 8 | while (true) { 9 | try { 10 | return await func(); 11 | } catch (err) { 12 | if (retries++ >= maxRetries) throw err; 13 | } 14 | } 15 | }; 16 | 17 | export const uniqueItems = (arr: Array): Array => { 18 | return Array.from(new Set(arr)); 19 | }; 20 | 21 | export const safeCompare = (a: string, b: string) => { 22 | const A = Buffer.from(a); 23 | const B = Buffer.from(b); 24 | if (A.length !== B.length) return false; 25 | return timingSafeEqual(A, B); 26 | }; 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GUC Schedule Viewer 2 | 3 | A website that displays the schedule of a student at the GUC 4 | 5 | You can check out the live version [here](https://gucschedule.web.app) 6 | 7 | ## About 8 | 9 | This project is divided into two parts: Server, Client 10 | 11 | ### [Server](server/README.md) 12 | 13 | The server exposes an api that given the student's id returns the schedule. 14 | 15 | The server is implemented in JS and TS and is designed to be deployed to [Google Cloud Platform](https://cloud.google.com/). It uses two main services: Google Cloud Functions and Google Cloud Firestore. 16 | 17 | ### [Client](client/README.md) 18 | 19 | The client is implemented in [ReactJS](https://reactjs.org/) and is currently hosted on Google Firebase Hosting. Although, it can be hosted as a static page on any hosting service. 20 | 21 | ## License 22 | 23 | This project is licensed under the [ISC License](https://opensource.org/licenses/ISC) -------------------------------------------------------------------------------- /server/functions/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface RequestValidation { 2 | view_state: string; 3 | event_validation: string; 4 | } 5 | 6 | export namespace Parsed { 7 | export interface Schedule { 8 | [group: string]: Array; 9 | } 10 | 11 | export interface Sessions { 12 | x: number; 13 | y: number; 14 | location?: string; 15 | staff?: Staff; 16 | } 17 | 18 | export interface Staff { 19 | id?: string; 20 | name?: string; 21 | email?: string; 22 | } 23 | } 24 | 25 | export namespace Stored { 26 | export interface Course { 27 | loaded: boolean; 28 | sched?: Parsed.Schedule; 29 | lastUpdateTime: number; // unix millis 30 | code: string; 31 | id: string; 32 | course_name: string; 33 | } 34 | 35 | export interface Group { 36 | loaded: boolean; 37 | sched?: Parsed.Schedule; 38 | lastUpdateTime: number; // unix millis 39 | group_name: string; 40 | id: string[]; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /client/src/components/smart/Home.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | #id { 6 | color: var(--text-color); 7 | border: 1.8px solid var(--color2); 8 | border-radius: 3px; 9 | min-width: 40vw; 10 | text-align: center; 11 | min-height: 5vh; 12 | margin-top: 20px; 13 | box-sizing: border-box; 14 | font-size: larger; 15 | background-color: var(--color1); 16 | padding: 20px 20px; 17 | outline: none; 18 | font-weight: 500; 19 | } 20 | 21 | #id::placeholder { 22 | color: var(--color2); 23 | opacity: 0.5; 24 | } 25 | 26 | #get[ready] { 27 | color: var(--color1); 28 | background-color: var(--color2); 29 | } 30 | 31 | #get { 32 | background-color: var(--color3); 33 | border: none; 34 | color: var(--color1); 35 | padding: 15px 32px; 36 | font-size: 16px; 37 | border-radius: 15px; 38 | margin: 20px; 39 | font-weight: 500; 40 | } 41 | 42 | #get:disabled { 43 | background-color: var(--color1); 44 | color: var(--background); 45 | } 46 | 47 | #name { 48 | font-size: min(48px, 8.7vw); 49 | font-weight: 200; 50 | padding: 30px; 51 | color: var(--color3); 52 | } 53 | -------------------------------------------------------------------------------- /client/src/js/consts.js: -------------------------------------------------------------------------------- 1 | const currentDisclaimerVersion = 3; 2 | 3 | const ApiUrl = "https://europe-west1-gucschedule.cloudfunctions.net/get_student_schedule"; 4 | 5 | const disclaimerText = 6 | 'This service comes with absolutely no warranties or guarantees. You are solely responsible for the use of this service and should only use it on people who have given you permission. This service merely uses information available to any GUC student through the admin system. This is a website made by a GUC student and is in no way endorsed by the GUC. This website uses google analytics. Source Code'; 7 | 8 | const themes = ["theme-dark", "theme-light"]; 9 | 10 | const days = ["Saturday", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]; 11 | 12 | const ordinals = ["1st", "2nd", "3rd", "4th", "5th"]; 13 | 14 | const slotTimes = { 15 | eng: ["8:15 - 9:45", "10:00 - 11:30", "11:45 - 13:15", "13:45 - 15:15", "15:45 - 17:15"], 16 | law: ["8:15 - 9:45", "10:00 - 11:30", "12:00 - 13:30", "13:45 - 15:15", "15:45 - 17:15"], 17 | }; 18 | 19 | const idRegex = /^\d{1,2}-\d{4,5}$/; 20 | 21 | const appVersion = "0.1.2"; 22 | 23 | export { currentDisclaimerVersion, ApiUrl, disclaimerText, themes, days, idRegex, appVersion, ordinals, slotTimes }; 24 | -------------------------------------------------------------------------------- /server/functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functions", 3 | "scripts": { 4 | "lint": "eslint --ext .js,.ts .", 5 | "build": "tsc", 6 | "build:watch": "tsc --watch", 7 | "serve": "npm run build && firebase emulators:start", 8 | "shell": "npm run build && firebase functions:shell", 9 | "start": "npm run shell", 10 | "deploy": "firebase deploy --only functions", 11 | "format": "prettier --write .", 12 | "logs": "firebase functions:log" 13 | }, 14 | "engines": { 15 | "node": "18" 16 | }, 17 | "main": "lib/index.js", 18 | "dependencies": { 19 | "dataloader": "^2.2.2", 20 | "firebase-admin": "^11.8.0", 21 | "firebase-functions": "^4.3.1", 22 | "jsdom": "^24.0.0", 23 | "lodash": "^4.17.21", 24 | "request-ntlm-promise": "^1.2.3" 25 | }, 26 | "devDependencies": { 27 | "@types/jsdom": "^21.1.6", 28 | "@types/request-promise": "^4.1.51", 29 | "@typescript-eslint/eslint-plugin": "^5.12.0", 30 | "@typescript-eslint/parser": "^5.12.0", 31 | "eslint": "^8.9.0", 32 | "eslint-config-google": "^0.14.0", 33 | "eslint-plugin-import": "^2.25.4", 34 | "firebase-functions-test": "^3.1.0", 35 | "prettier": "3.2.5", 36 | "typescript": "^4.9.0" 37 | }, 38 | "private": true 39 | } 40 | -------------------------------------------------------------------------------- /client/src/js/sw-manager.js: -------------------------------------------------------------------------------- 1 | const installer = () => { 2 | if ("serviceWorker" in navigator) { 3 | console.log("serviceWorker found in navigator"); 4 | window.addEventListener("load", () => { 5 | navigator.serviceWorker.register("/sw.js"); 6 | }); 7 | } 8 | }; 9 | 10 | const { captureInstall, promptInstall, listenToInstall } = (() => { 11 | let deferredPrompt, 12 | isInstallAvailable = false, 13 | installListener; 14 | 15 | const listenToInstall = (func) => { 16 | installListener = func; 17 | return isInstallAvailable; 18 | }; 19 | const handleBeforeInstall = (e) => { 20 | e.preventDefault(); 21 | deferredPrompt = e; 22 | isInstallAvailable = true; 23 | if (installListener) installListener(); 24 | console.log("captured"); 25 | }; 26 | 27 | const captureInstall = () => { 28 | window.addEventListener("beforeinstallprompt", handleBeforeInstall); 29 | console.log("capture began"); 30 | }; 31 | 32 | const promptInstall = (e) => { 33 | if (!isInstallAvailable) throw new Error("prompt not available"); 34 | isInstallAvailable = false; 35 | deferredPrompt.prompt(); 36 | return deferredPrompt.userChoice; 37 | }; 38 | return { captureInstall, promptInstall, listenToInstall }; 39 | })(); 40 | 41 | export { installer, captureInstall, promptInstall, listenToInstall }; 42 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.1", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.11.4", 7 | "@testing-library/react": "^11.1.0", 8 | "@testing-library/user-event": "^12.1.10", 9 | "axios": "^0.21.4", 10 | "firebase": "^9.2.0", 11 | "lodash": "^4.17.21", 12 | "react": "^17.0.2", 13 | "react-burger-menu": "^3.0.6", 14 | "react-dom": "^17.0.2", 15 | "react-router-dom": "^5.3.0", 16 | "sweetalert2": "^11.1.2", 17 | "sweetalert2-react-content": "^4.1.1", 18 | "web-vitals": "^1.0.1" 19 | }, 20 | "scripts": { 21 | "start": "react-scripts start", 22 | "test": "react-scripts test", 23 | "eject": "react-scripts eject", 24 | "deploy": "firebase deploy --only hosting", 25 | "build-react": "react-scripts build", 26 | "build-sw": "node ./src/sw-builder.js", 27 | "build": "npm run build-react && npm run build-sw" 28 | }, 29 | "eslintConfig": { 30 | "extends": [ 31 | "react-app", 32 | "react-app/jest" 33 | ] 34 | }, 35 | "browserslist": { 36 | "production": [ 37 | ">0.2%", 38 | "not dead", 39 | "not op_mini all" 40 | ], 41 | "development": [ 42 | "last 1 chrome version", 43 | "last 1 firefox version", 44 | "last 1 safari version" 45 | ] 46 | }, 47 | "devDependencies": { 48 | "react-scripts": "^4.0.3", 49 | "workbox-build": "^6.6.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | firebase-debug.log* 8 | firebase-debug.*.log* 9 | 10 | # Firebase cache 11 | .firebase/ 12 | 13 | # Firebase config 14 | 15 | # Uncomment this if you'd like others to create their own Firebase project. 16 | # For a team working on the same Firebase project(s), it is recommended to leave 17 | # it commented so all members can deploy to the same project(s) in .firebaserc. 18 | # .firebaserc 19 | 20 | # Runtime data 21 | pids 22 | *.pid 23 | *.seed 24 | *.pid.lock 25 | 26 | # Directory for instrumented libs generated by jscoverage/JSCover 27 | lib-cov 28 | 29 | # Coverage directory used by tools like istanbul 30 | coverage 31 | 32 | # nyc test coverage 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | bower_components 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (http://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | node_modules/ 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Optional REPL history 57 | .node_repl_history 58 | 59 | # Output of 'npm pack' 60 | *.tgz 61 | 62 | # Yarn Integrity file 63 | .yarn-integrity 64 | 65 | # dotenv environment variables file 66 | .env* 67 | -------------------------------------------------------------------------------- /server/functions/src/index.ts: -------------------------------------------------------------------------------- 1 | import { initializeApp } from "firebase-admin"; 2 | import * as logger from "firebase-functions/logger"; 3 | import { onRequest } from "firebase-functions/v2/https"; 4 | import { getStudentSchedule } from "./getStudentSchedule"; 5 | import { prepareCourses } from "./prepareCourses"; 6 | import { PASSWORD } from "./env"; 7 | import { safeCompare } from "./utils"; 8 | import { runtimeOptsGetStudentSchedule, runtimeOptsPrepareCourses } from "./consts"; 9 | 10 | export const get_student_schedule = onRequest(runtimeOptsGetStudentSchedule, (request, response) => { 11 | const id = request.query.id as string; 12 | if (typeof id !== "string") { 13 | response.status(400).send("invalid id"); 14 | return; 15 | } 16 | getStudentSchedule(id) 17 | .then((result) => { 18 | response.send(result); 19 | }) 20 | .catch((err) => { 21 | logger.error(err); 22 | response.status(500).send("internal error"); 23 | }); 24 | }); 25 | 26 | export const prepare_courses = onRequest(runtimeOptsPrepareCourses, (request, response) => { 27 | const key = request.query.key; 28 | if (typeof key !== "string") { 29 | response.status(400).send("invalid key"); 30 | return; 31 | } 32 | if (!safeCompare(key, PASSWORD.value())) { 33 | response.status(500).send({ error: "whatcha doin" }); 34 | return; 35 | } 36 | 37 | const loaded = request.query.loaded === "true"; 38 | 39 | prepareCourses({ loaded }) 40 | .then(() => { 41 | response.send("done"); 42 | }) 43 | .catch((err) => { 44 | logger.error(err); 45 | response.status(500).send("internal error"); 46 | }); 47 | }); 48 | 49 | setTimeout(() => initializeApp()); 50 | -------------------------------------------------------------------------------- /client/src/components/smart/App.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { BrowserRouter as Router, Switch, Route, NavLink } from "react-router-dom"; 3 | import { slide as Menu } from "react-burger-menu"; 4 | import "./App(menu).css"; 5 | import { initTheme, switchTheme } from "../../js/theme"; 6 | import { captureInstall } from "../../js/sw-manager"; 7 | import MySchedule from "./MySchedule"; 8 | import Home from "./Home"; 9 | 10 | captureInstall(); 11 | initTheme(); 12 | 13 | const menuStyles = { 14 | bmItemList: { 15 | height: "auto", 16 | }, 17 | }; 18 | 19 | const App = () => { 20 | return ( 21 | 22 |
23 | 24 | 25 | 28 | 29 | 30 | 33 | 34 |
35 | Change Theme 36 |
37 |
38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 |
48 |
49 | ); 50 | }; 51 | 52 | export default App; 53 | -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # Server 2 | 3 | The server is designed to be deployed to [Google Cloud Platform](https://cloud.google.com/) and to run on [Google Cloud Functions](https://cloud.google.com/functions). It also uses [Google Cloud Firestore](https://cloud.google.com/firestore) to cache course schedules. 4 | The server is implemented in JavaScript except for the utils and constants which are in TypeScript. The server has a get method called "get_student_data" which when called with the student's id returns the student schedule for the provided id. 5 | 6 | ## Setup 7 | 8 | 1. after opening a terminal in the server folder, run `npm i` to install the required dependencies 9 | 2. run `firebase functions:config:set credentials.username="USERNAME" credentials.password="PASSWORD"` and replace `USERNAME` and `PASSWORD` with you GUC username and password 10 | 3. run `npm run setup-env` to save the environment variables in `.runtimeconfig.json`. This is required for the local development server to have access to the credentials 11 | 4. create the `secret.js` file that contains the `prepare_courses` key 12 | 5. run `npm run serve` to run the development server on localhost 13 | 6. call `prepare_courses` with your key 14 | 7. now you have an api exposes on `http://localhost:5001/gucschedule/europe-west1/get_student_schedule` (by default) :) 15 | 16 | 17 | ### What happens when get_student_data is called 18 | 19 | 1. The student's enrolled courses are fetched from the student data report. 20 | 2. For each course the student is enrolled in, the server gets the course schedule from the cache, if it is available. If the schedule is not in the cache, then the server fetches the schedule, saves the schedule in cache and returns the data. 21 | 3. The course schedules are filtered for the tutorials that the student attends and then the student schedule is returned. 22 | -------------------------------------------------------------------------------- /server/functions/src/requests.ts: -------------------------------------------------------------------------------- 1 | import * as ntlm from "request-ntlm-promise"; 2 | import { maxGetRetries, maxPostRetries } from "./consts"; 3 | import { doRetries } from "./utils"; 4 | import qs = require("qs"); 5 | import { USERNAME, PASSWORD } from "./env"; 6 | 7 | const defaultHeaders = { 8 | "User-Agent": 9 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36", 10 | Cookie: "ASP.NET_SessionId=000000000000000000000000", 11 | Origin: "https://student.guc.edu.eg", 12 | Host: "student.guc.edu.eg", 13 | Referer: "https://student.guc.edu.eg/External/Student/CourseGroup/StudentDataReport.aspx", 14 | }; 15 | 16 | export const doPostRequest = async ({ url, formData }: { url: string; formData: any }) => { 17 | return doRetries(async () => { 18 | const resp = await ntlm.post({ 19 | username: USERNAME.value(), 20 | password: PASSWORD.value(), 21 | url, 22 | headers: { 23 | ...defaultHeaders, 24 | "Content-Type": "application/x-www-form-urlencoded", 25 | }, 26 | body: qs.stringify(formData), 27 | }); 28 | 29 | if (resp == undefined) throw "doPostRequest, result is undefined"; 30 | 31 | return resp; 32 | }, maxPostRetries); 33 | }; 34 | 35 | export const doGetRequest = async ({ url, qs }: { url: string; qs?: any }) => { 36 | return doRetries(async () => { 37 | const resp = await ntlm.get({ 38 | username: USERNAME.value(), 39 | password: PASSWORD.value(), 40 | url, 41 | qs, 42 | headers: defaultHeaders, 43 | }); 44 | 45 | if (resp == undefined) throw "doGetRequest, result is undefined"; 46 | 47 | return resp; 48 | }, maxGetRetries); 49 | }; 50 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | GUC Schedule Viewer 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /client/src/components/visual/SessionView.js: -------------------------------------------------------------------------------- 1 | import "./SessionView.css"; 2 | import React from "react"; 3 | import { showAlert, escapeHTML, camelToSpace } from "../../js/utils.js"; 4 | 5 | class SessionView extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | this.state = { data: props.data }; 9 | } 10 | 11 | handleClick = () => { 12 | const rawData = this.state.data; 13 | const parsedData = [ 14 | ["Course Code", rawData.courseCode], 15 | ["Course Name", rawData.courseName], 16 | ["Type", rawData.type], 17 | ["Session Group", rawData.tutorialGroup], 18 | ["Location", rawData.location], 19 | ["Staff", rawData.staff.map((v) => (typeof v === "string" ? v : v.email ? `${v.name}(predicted email: ${v.email})` : v.name)).join(", ")], 20 | ] 21 | .map(([key, value]) => `
${escapeHTML(key)}:
${escapeHTML(value)}
`) // escapeHTML on key not currently needed but should be kept to handle future changes 22 | .join("\n"); 23 | 24 | showAlert(rawData.courseCode, parsedData, { showConfirmButton: false, dontEscape: true }); 25 | }; 26 | 27 | render() { 28 | return ( 29 |
30 |
31 |
{this.state.data.location}
32 |
{this.state.data.courseCode}
33 |
{this.state.data.tutorialGroup}
34 |
{this.state.data.staff.map((v) => (typeof v === "string" ? v : v.name)).join(", ")}
35 |
36 |
37 | ); 38 | } 39 | } 40 | export default SessionView; 41 | -------------------------------------------------------------------------------- /client/src/components/smart/App(menu).css: -------------------------------------------------------------------------------- 1 | /* Position and sizing of burger button */ 2 | .bm-burger-button { 3 | position: fixed; 4 | width: 6vmin; 5 | height: 5vmin; 6 | left: 2.5vmin; 7 | top: 2.5vmin; 8 | } 9 | 10 | /* Color/shape of burger icon bars */ 11 | .bm-burger-bars { 12 | background: var(--color3); 13 | } 14 | 15 | /* Color/shape of burger icon bars on hover*/ 16 | .bm-burger-bars-hover { 17 | background: var(--color1); 18 | } 19 | 20 | /* Position and sizing of clickable cross button */ 21 | .bm-cross-button { 22 | height: 24px; 23 | width: 24px; 24 | } 25 | 26 | /* Color/shape of close button cross */ 27 | .bm-cross { 28 | background: var(--color2); 29 | } 30 | 31 | /* 32 | Sidebar wrapper styles 33 | Note: Beware of modifying this element as it can break the animations - you should not need to touch it in most cases 34 | */ 35 | .bm-menu-wrap { 36 | position: fixed; 37 | height: 100%; 38 | } 39 | 40 | /* General sidebar styles */ 41 | .bm-menu { 42 | background: var(--background); 43 | /* padding: 2.5em 1.5em 0; */ 44 | font-size: 1.15em; 45 | } 46 | 47 | /* Morph shape necessary with bubble or elastic */ 48 | /* .bm-morph-shape { 49 | fill: #373a47; 50 | } */ 51 | 52 | /* Wrapper for item list */ 53 | .bm-item-list { 54 | color: var(--color2); 55 | padding: 0.8em; 56 | } 57 | 58 | /* Individual item */ 59 | .bm-item { 60 | display: inline-block; 61 | text-decoration: none; 62 | color: var(--color3); 63 | font-size: x-large; 64 | padding: 15px 0px 15px 0px; 65 | width: 100%; 66 | text-align: center; 67 | border-top: var(--color1) 2px solid; 68 | } 69 | 70 | .bm-item:first-child { 71 | border-top: none; 72 | } 73 | 74 | /* Styling of overlay */ 75 | .bm-overlay { 76 | background: rgba(0, 0, 0, 0.3); 77 | } 78 | 79 | .link-active { 80 | color: var(--color2); 81 | } 82 | 83 | #changeTheme-button { 84 | margin-top: auto; 85 | font-size: medium; 86 | } 87 | -------------------------------------------------------------------------------- /client/src/components/smart/MySchedule.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./MySchedule.css"; 3 | import Schedule from "../visual/Schedule"; 4 | import { retrieveSchedule } from "../../js/storedSchedule"; 5 | import { listenToInstall, promptInstall } from "../../js/sw-manager"; 6 | import FloatingButton from "./FloatingButton"; 7 | import { logEvent } from "../../js/analytics"; 8 | import { showAlert } from "../../js/utils"; 9 | 10 | class MySchedule extends React.Component { 11 | constructor(props) { 12 | super(props); 13 | const installAvailable = listenToInstall(() => this.setState({ installAvailable: true })); 14 | this.state = { 15 | installAvailable, 16 | }; 17 | logEvent("page_view", { page: "my_schedule" }); 18 | } 19 | 20 | handleInstallClick = async () => { 21 | this.setState({ installAvailable: false }); 22 | let result = await promptInstall(); 23 | console.log(result); 24 | if (result !== "accepted" && result.outcome !== "accepted") return; 25 | showAlert("GUC Schedule has been added to your homescreen", "Now you can access your saved schedule from your homescreen, even when offline :)"); 26 | logEvent("A2HS"); 27 | }; 28 | 29 | render = () => { 30 | const sched = retrieveSchedule(); 31 | return ( 32 |
33 |
My Schedule
34 | {sched.slots.length > 0 ? ( 35 | 36 | ) : ( 37 |
38 | No Schedule is saved
To save a schedule navigate to Home, load the Schedule and click "Save My Schedule" on the top right 39 |
40 | )} 41 | {this.state.installAvailable ? : ""} 42 |
43 | ); 44 | }; 45 | } 46 | 47 | export default MySchedule; 48 | -------------------------------------------------------------------------------- /client/src/components/visual/Schedule.js: -------------------------------------------------------------------------------- 1 | import "./Schedule.css"; 2 | import React from "react"; 3 | import CellView from "./CellView"; 4 | import { days, ordinals, slotTimes } from "../../js/consts.js"; 5 | import { parseScheudle } from "../../js/utils.js"; 6 | 7 | class Schedule extends React.Component { 8 | constructor(props) { 9 | console.debug("Schedule constructor called"); 10 | super(props); 11 | } 12 | 13 | shouldComponentUpdate(nextProps) { 14 | return ( 15 | (nextProps.schedule.slots.length || this.props.schedule.slots.length) && // if both arrays are empty don't update 16 | nextProps.schedule !== this.props.schedule // if both arrays have the same refrence don't update 17 | ); 18 | } 19 | 20 | render() { 21 | const { schedCategory, slots } = this.props.schedule; // '' or 'eng', 'law' 22 | const chosenSlotTimes = slotTimes[schedCategory] ?? []; 23 | return ( 24 |
25 | 26 | 27 | 28 | 29 | {new Array(5).fill(0).map((e, i) => { 30 | return ( 31 | 35 | ); 36 | })} 37 | 38 | {parseScheudle(slots).map((e, i) => { 39 | return ( 40 | 41 | 42 | {e.map((v, j) => { 43 | return ( 44 | 47 | ); 48 | })} 49 | 50 | ); 51 | })} 52 | 53 |
Day/Session 32 |
{ordinals[i]}
33 |
{chosenSlotTimes[i]}
34 |
{days[i]} 45 | 46 |
54 |
55 | ); 56 | } 57 | } 58 | 59 | export default Schedule; 60 | -------------------------------------------------------------------------------- /server/functions/src/consts.ts: -------------------------------------------------------------------------------- 1 | export const maxPostRetries = 2; 2 | export const maxGetRetries = 2; 3 | 4 | export const courseDataMaxAge = 1000 * 60 * 60 * 6; // in millis 5 | export const groupScheduleMaxAge = 1000 * 60 * 60 * 6; // in millis 6 | 7 | export const generalGroupScheduleUrl = "https://student.guc.edu.eg/Web/Student/Schedule/GeneralGroupSchedule.aspx"; 8 | export const academicScheduleUrl = 9 | "https://student.guc.edu.eg/External/LSI/EDUMS/CSMS/SearchAcademicScheduled_001.aspx"; 10 | export const studentDataReportUrl = "https://student.guc.edu.eg/External/Student/CourseGroup/StudentDataReport.aspx"; 11 | 12 | export const runtimeOptsGetStudentSchedule = { 13 | timeoutSeconds: 120, 14 | memory: "256MiB", 15 | maxInstances: 30, 16 | region: "europe-west1", 17 | } as const; 18 | export const runtimeOptsPrepareCourses = { 19 | timeoutSeconds: 540, 20 | memory: "1GiB", 21 | maxInstances: 1, 22 | region: "europe-west1", 23 | } as const; 24 | 25 | export const ENG = "eng"; 26 | export const LAW = "law"; 27 | 28 | export const courseCategories: { 29 | [courseCode: string]: string; 30 | } = { 31 | ABSK: LAW, 32 | ARCH: ENG, 33 | BINF: LAW, 34 | BIOT: LAW, 35 | BIOTp: LAW, 36 | BIOTt: LAW, 37 | CHEMt: LAW, 38 | CICO: LAW, 39 | CIG: ENG, 40 | CILA: LAW, 41 | CIS: ENG, 42 | CIT: ENG, 43 | CIW: ENG, 44 | CLPH: LAW, 45 | CLPHp: LAW, 46 | CLPHt: LAW, 47 | CMLA: LAW, 48 | COLA: LAW, 49 | COMM: ENG, 50 | CRLA: LAW, 51 | CSEN: ENG, 52 | CSIS: ENG, 53 | CTRL: ENG, 54 | DMET: ENG, 55 | ECON: ENG, 56 | EDPT: ENG, 57 | ELCT: ENG, 58 | ELECp: ENG, 59 | ELECt: ENG, 60 | ENGD: ENG, 61 | ENME: ENG, 62 | FINC: ENG, 63 | GD: ENG, 64 | GMAT: ENG, 65 | HROB: LAW, 66 | IBUS: ENG, 67 | INNO: ENG, 68 | INSY: ENG, 69 | ISSH: LAW, 70 | LAW: LAW, 71 | LAWS: LAW, 72 | MATS: ENG, 73 | MCTR: ENG, 74 | MGMT: ENG, 75 | MRKT: ENG, 76 | NETW: ENG, 77 | OPER: ENG, 78 | PD: LAW, 79 | PEPF: LAW, 80 | PHBC: LAW, 81 | PHBCp: LAW, 82 | PHBCt: LAW, 83 | PHBL: LAW, 84 | PHBLp: LAW, 85 | PHBLt: LAW, 86 | PHBT: LAW, 87 | PHBTp: LAW, 88 | PHBTt: LAW, 89 | PHCMp: LAW, 90 | PHCMt: LAW, 91 | PHMU: LAW, 92 | PHMUp: LAW, 93 | PHMUt: LAW, 94 | PHTC: LAW, 95 | PHTCp: LAW, 96 | PHTCt: LAW, 97 | PHTX: LAW, 98 | PHYS: ENG, 99 | PHYSp: ENG, 100 | PHYSt: ENG, 101 | PRIN: LAW, 102 | PUIN: LAW, 103 | STRA: ENG, 104 | UP: ENG, 105 | }; 106 | -------------------------------------------------------------------------------- /server/functions/src/providers/studentDataReport.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "firebase-functions/v2"; 2 | import { JSDOM } from "jsdom"; 3 | import { studentDataReportUrl } from "../consts"; 4 | import { doGetRequest } from "../requests"; 5 | import DataLoader = require("dataloader"); 6 | 7 | // parses the raw HTML to return the courses that a student takes 8 | const parseGetStudentDataReport = (data: string) => { 9 | const doc = new JSDOM(data).window.document; 10 | 11 | if (doc.querySelector("#L_Info1")?.textContent?.trim()) { 12 | throw "invalid id provided"; 13 | } 14 | const children = doc.querySelector("#DG_ChangeGroupOffers")?.firstElementChild?.children; 15 | if (!children) throw new Error("no children found"); 16 | const ret = Array.from(children) 17 | .slice(1) 18 | .map((v) => { 19 | const courseInfo = v.children[0].textContent; 20 | const groupInfo = v.children[1].textContent; 21 | 22 | if (!courseInfo || !groupInfo) throw new Error("no courseInfo or groupInfo found"); 23 | 24 | const [courseGroupName, courseCombinedName] = courseInfo.split(" - "); 25 | const matchResult = courseCombinedName.match(/^\s*([A-Za-z]+)(\s*[\d]+)/); 26 | if (!matchResult) throw new Error("no match found"); 27 | const [courseCode, courseMatchAlpha, courseMatchNum] = matchResult; 28 | const spIndex = courseCode.length; 29 | const courseLongName = courseCombinedName.slice(spIndex + 1); 30 | const tutorialGroup = groupInfo.slice(groupInfo.lastIndexOf(" ") + 1); 31 | const type = tutorialGroup[0]; 32 | const attendanceGroup = 33 | groupInfo.slice(0, groupInfo.lastIndexOf(" ")) + tutorialGroup.slice(1).replace(/^0+/, ""); 34 | 35 | let typeName = ""; 36 | if (type == "T") { 37 | typeName = "Tutorial"; 38 | } else if (type == "L") { 39 | typeName = "Lecture"; 40 | } else if (type == "P") { 41 | typeName = "Practical"; 42 | } else { 43 | typeName = "Unknown"; 44 | logger.error("unknown type", { courseInfo, groupInfo }); 45 | } 46 | 47 | const courseCodeSp = courseMatchAlpha + " " + courseMatchNum; 48 | const expectedGroup = courseCodeSp + " - " + attendanceGroup + " (" + typeName + ")"; 49 | 50 | return { 51 | courseCode, 52 | type, 53 | attendanceGroup, 54 | tutorialGroup, 55 | expectedGroup, 56 | typeName, 57 | courseGroupName, 58 | }; 59 | }); 60 | return ret; 61 | }; 62 | 63 | // returns the courses a student takes by requesting it from guc.edu.eg 64 | export const getStudentDataReport = async (id: string) => { 65 | if (!/^\d{1,2}-\d{4,5}$/.test(id)) throw "invalid id"; 66 | 67 | const studentDataReport = await doGetRequest({ 68 | url: studentDataReportUrl, 69 | qs: { 70 | StudentAppNo: id, 71 | }, 72 | }).then(parseGetStudentDataReport); 73 | 74 | logger.debug(`got studentDataReport for ${id} ${JSON.stringify({ studentDataReport })}`); 75 | 76 | return studentDataReport; 77 | }; 78 | -------------------------------------------------------------------------------- /client/src/components/smart/Home.js: -------------------------------------------------------------------------------- 1 | import "./Home.css"; 2 | import React from "react"; 3 | 4 | import Schedule from "../visual/Schedule"; 5 | 6 | import { idRegex } from "../../js/consts"; 7 | import { showAlert, checkDisclaimer, downloadSchedule } from "../../js/utils.js"; 8 | import { logEvent } from "../../js/analytics"; 9 | import { saveSchedule } from "../../js/storedSchedule"; 10 | import FloatingButton from "./FloatingButton"; 11 | 12 | const sched = { 13 | schedCategory: "", 14 | slots: [], 15 | }; 16 | 17 | class Home extends React.Component { 18 | constructor(props) { 19 | super(props); 20 | this.state = { 21 | sched, 22 | lastGucId: "", 23 | }; 24 | this.getButton = React.createRef(); 25 | this.idInput = React.createRef(); 26 | 27 | checkDisclaimer(); 28 | logEvent("page_view", { page: "home" }); 29 | } 30 | 31 | // called when the load schedule button is called 32 | onGetClick = async () => { 33 | let id = this.updateID(); 34 | let a = this.getButton.current; 35 | if (a.disabled === true) return; 36 | this.setState({ sched }); 37 | a.disabled = true; 38 | this.getSchedule(id).then((e) => { 39 | a.disabled = false; 40 | }); 41 | }; 42 | 43 | // makes web request to get the schedule and then sets the state 44 | getSchedule = async (id) => { 45 | try { 46 | const { scheduleData, warning } = await downloadSchedule(id); 47 | if (showAlert.title) showAlert(warning.title, warning.description); 48 | this.setState({ sched: scheduleData, lastGucId: id }); 49 | } catch (error) { 50 | showAlert("Error", error.message); 51 | } 52 | }; 53 | 54 | // called each time the update field changes to update the state and load scheudle button color 55 | updateID = () => { 56 | let value = this.idInput.current.value; 57 | this.setState({ id: value }); 58 | let button = this.getButton.current; 59 | if (idRegex.test(value)) button.setAttribute("ready", ""); 60 | else button.removeAttribute("ready"); 61 | return value; 62 | }; 63 | 64 | // triggers getScheudle when the enter key is pressed 65 | keyUpListener = (e) => { 66 | if (e.keyCode !== 13) return; 67 | this.onGetClick(); 68 | }; 69 | 70 | onSaveSchedule = () => { 71 | const message = this.state.lastGucId.length > 0 ? `Are you sure you want to save ${this.state.lastGucId}'s schedule?` : "Are you sure you want to clear the saved schedule?"; 72 | showAlert("Save Schedule", message, { showCancelButton: true, confirmButtonStyledText: "Yes", cancelButtonStyledText: "No" }).then((result) => { 73 | if (!result.isConfirmed) return; 74 | saveSchedule(this.state.sched); 75 | logEvent("saved_schedule"); 76 | window.location.href = "/my_schedule"; 77 | }); 78 | }; 79 | 80 | render() { 81 | return ( 82 |
83 |
GUC Schedule Viewer
84 | 85 |

86 | 89 | 90 | 91 |
92 | ); 93 | } 94 | } 95 | 96 | export default Home; 97 | -------------------------------------------------------------------------------- /client/src/js/utils.js: -------------------------------------------------------------------------------- 1 | import Swal from "sweetalert2"; 2 | import axios from "axios"; 3 | import { logEvent } from "./analytics.js"; 4 | import withReactContent from "sweetalert2-react-content"; 5 | import { ApiUrl } from "./consts.js"; 6 | import _ from "lodash"; 7 | import { disclaimerText, currentDisclaimerVersion, idRegex, appVersion } from "./consts"; 8 | 9 | let escapeHTML = (text) => { 10 | let a = document.createElement("div"); 11 | a.appendChild(document.createTextNode(text)); 12 | return a.innerHTML; 13 | }; 14 | 15 | // parses the schedule input as given from the api into an array ready to be rendered 16 | let parseScheudle = (inSched) => { 17 | let out = new Array(7).fill(0).map((e) => { 18 | return new Array(5).fill(0).map((e) => []); 19 | }); 20 | 21 | for (let course of inSched) { 22 | for (let session of course.sessions) { 23 | let ret = {}; 24 | 25 | ret.courseCode = course.course_code; 26 | ret.courseName = course.course_name || ""; 27 | ret.type = course.type; 28 | ret.tutorialGroup = course.tut_group; 29 | ret.location = session.location; 30 | ret.staff = session.staff; 31 | 32 | out[session.x][session.y].push(ret); // maybe y >=5 ie. something after the 5th session 33 | } 34 | } 35 | 36 | let cnt = Array(7).fill(0); 37 | for (let i = 0; i < out.length; i++) for (let blah of out[i]) cnt[i] += blah.length; 38 | 39 | if (cnt[6] === 0) out.pop(); 40 | console.debug("parseSchedule", out); 41 | return out; 42 | }; 43 | 44 | const MySwal = withReactContent(Swal); 45 | 46 | const showAlert = (type, info = "", obj = {}) => { 47 | console.debug("displayed alert"); 48 | 49 | const confirmButtonStyledText = obj.confirmButtonStyledText || "OK"; 50 | const cancelButtonStyledText = obj.cancelButtonStyledText || "Cancel"; 51 | return MySwal.fire({ 52 | title: '' + escapeHTML(type) + "", 53 | html: '' + (obj.dontEscape ? info : escapeHTML(info)) + "", 54 | background: "var(--background)", 55 | confirmButtonColor: "var(--color3)", 56 | confirmButtonText: `${confirmButtonStyledText}`, 57 | cancelButtonColor: "var(--color3)", 58 | cancelButtonText: `${cancelButtonStyledText}`, 59 | ..._.omit(obj, "dontEscape", "confirmButtonStyledText", "cancelButtonStyledText"), 60 | }); 61 | }; 62 | 63 | // checks if the disclaimer hasn't been accpeted it prompts the user to accept it 64 | const checkDisclaimer = () => { 65 | if (localStorage.getItem("disclaimer_seen") >= currentDisclaimerVersion) return; 66 | 67 | showAlert("Disclaimer", disclaimerText, { backdrop: true, allowOutsideClick: () => false, dontEscape: true }).then((e) => { 68 | if (e.isConfirmed) localStorage.setItem("disclaimer_seen", currentDisclaimerVersion); 69 | }); 70 | }; 71 | 72 | const downloadSchedule = async (id) => { 73 | console.debug("getSchedule is called with", id); 74 | if (!idRegex.test(id)) { 75 | logEvent("Load Scheudle", { type: "Error", result: "invalid id provided (internal)" }); 76 | throw new Error("invalid id provided"); 77 | } 78 | 79 | const a = await axios.get(ApiUrl, { params: { id } }).catch((e) => { 80 | console.error("exception in getScheudle", e.toString()); 81 | logEvent("Load Scheudle", { type: "Error while making request", result: e.toString(), appVersion }); 82 | throw new Error(e.toString()); 83 | }); 84 | 85 | if (a.data.status !== "ok") { 86 | logEvent("Load Scheudle", { type: a.data.status, result: a.data.error }); 87 | throw new Error(a.data.error); 88 | } 89 | 90 | let warning = { title: "", description: "" }; 91 | if (a.data.error) { 92 | logEvent("Load Scheudle", { type: "Warning", result: a.data.error }); 93 | warning = { title: "Warning", description: a.data.error }; 94 | } else { 95 | logEvent("Load Scheudle", { type: "ok", result: "successful request" }); 96 | } 97 | 98 | return { scheduleData: a.data.data, warning }; 99 | }; 100 | 101 | export { escapeHTML, parseScheudle, showAlert, checkDisclaimer, downloadSchedule }; 102 | -------------------------------------------------------------------------------- /server/functions/src/getStudentSchedule.ts: -------------------------------------------------------------------------------- 1 | import { courseCategories, ENG, LAW } from "./consts"; 2 | import { courseScheduleDataLoader } from "./providers/courseSchedule"; 3 | import { groupsScheduleDataLoader } from "./providers/groupsSchedule"; 4 | import { getStudentDataReport } from "./providers/studentDataReport"; 5 | import { Parsed } from "./types"; 6 | import functions = require("firebase-functions"); 7 | import _ = require("lodash"); 8 | 9 | const predictedEmails: { [a: string]: string } = {}; 10 | 11 | const combineSessions = (input: Parsed.Sessions[]) => { 12 | const collection = _.groupBy(input, (v) => JSON.stringify([v.x, v.y, v.location])); 13 | 14 | const ret = Object.entries(collection).map(([key, value]) => { 15 | return { 16 | x: value[0].x, 17 | y: value[0].y, 18 | location: value[0].location, 19 | staff: value 20 | .flatMap((v) => (v.staff ? [v.staff] : [])) 21 | .flatMap((staff) => [ 22 | { 23 | name: staff.name, 24 | email: staff.name && predictedEmails[staff.name], 25 | }, 26 | ]), 27 | }; 28 | }); 29 | 30 | return ret; 31 | }; 32 | 33 | const courseNamesToSchedCategory = (courseNames: string[]) => { 34 | const categories = courseNames 35 | .map((v) => v.split(" ")[0]) 36 | .filter((v) => v) 37 | .map((v) => courseCategories[v]) 38 | .filter((v) => v); 39 | const engCount = categories.filter((v) => v == ENG).length; 40 | const lawCount = categories.filter((v) => v == LAW).length; 41 | 42 | if (engCount && lawCount) 43 | functions.logger.warn("courseNamesToSlotCategories detected both eng and law", { 44 | courseNames, 45 | categories, 46 | engCount, 47 | lawCount, 48 | }); 49 | 50 | if (engCount == 0 && lawCount == 0) return ""; 51 | 52 | if (engCount > lawCount) return ENG; 53 | 54 | return LAW; 55 | }; 56 | 57 | // get_student_schedule called with get request or an option preflight request and returns the student schedule for the provided id 58 | export const getStudentSchedule = async (id: string) => { 59 | const studentData = await getStudentDataReport(id); 60 | 61 | const err: string[] = []; 62 | const result: { 63 | course_code: string; 64 | tut_group: string; 65 | type: string; 66 | sessions: { x: any; y: any; location: any; staff: any }[]; 67 | course_name: any; 68 | }[] = []; 69 | 70 | await Promise.all( 71 | studentData.map(async (courseSession) => { 72 | const courseInfo = await courseScheduleDataLoader.load(courseSession.courseCode); 73 | const groupInfo = await groupsScheduleDataLoader.load(courseSession.courseGroupName); 74 | const availableScheds = []; 75 | if (courseInfo.status === "fulfilled") availableScheds.push(courseInfo.value); 76 | if (groupInfo.status === "fulfilled") availableScheds.push(groupInfo.value); 77 | const data = combineSessions( 78 | availableScheds 79 | .map((info) => info.sched![courseSession.expectedGroup]) 80 | .flat() 81 | .filter((v) => v) 82 | ); 83 | 84 | const courseName = 85 | courseInfo.status === "fulfilled" ? courseInfo.value.course_name : courseSession.courseCode; 86 | 87 | if (data.length) 88 | result.push({ 89 | course_code: courseSession.courseCode, 90 | tut_group: courseSession.tutorialGroup, 91 | type: courseSession.typeName, 92 | sessions: data, 93 | course_name: courseName, 94 | }); 95 | else 96 | err.push( 97 | courseSession.courseCode + 98 | `;${courseSession.courseGroupName}` + 99 | `: group ${courseSession.expectedGroup} not found` 100 | ); 101 | }) 102 | ); 103 | 104 | const schedCategory = courseNamesToSchedCategory(result.map((v) => v.course_name)); 105 | 106 | const ret = { 107 | status: "ok", 108 | error: err.join("\n"), 109 | data: { slots: result, schedCategory }, 110 | }; 111 | return ret; 112 | }; 113 | -------------------------------------------------------------------------------- /server/functions/src/providers/courseSchedule.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "firebase-functions/v2"; 2 | import { JSDOM } from "jsdom"; 3 | import * as _ from "lodash"; 4 | import { academicScheduleUrl, courseDataMaxAge } from "../consts"; 5 | import { courseFirestore, getCoursesInfoFirestore } from "../firestore"; 6 | import { doPostRequest } from "../requests"; 7 | import { Parsed, RequestValidation, Stored } from "../types"; 8 | import { currentTime } from "../utils"; 9 | import DataLoader = require("dataloader"); 10 | 11 | const courseScheduleRequestDetails = _.memoize(async () => 12 | getCoursesInfoFirestore() 13 | .get() 14 | .then((doc) => doc.data()?.request_details as RequestValidation) 15 | ); 16 | 17 | // extract the raw HTML to return the course schedule 18 | const extractCourseSchedule = (data: string) => { 19 | const doc = new JSDOM(data).window.document; 20 | const children = doc.querySelector("#schedule")?.firstElementChild?.children; 21 | if (!children) throw new Error("no children found"); 22 | const ret = Array.from(children) 23 | .slice(1) 24 | .map((row) => { 25 | const day = row.children[0].textContent?.trim(); 26 | const ret = Array.from(row.children) 27 | .slice(1) 28 | .map((cell) => { 29 | return Array.from(cell.children).map((s) => { 30 | const session = s.firstElementChild; 31 | if (!session) throw new Error("no session found"); 32 | return { 33 | group: session.children[1].textContent, 34 | location: session.children[3].textContent, 35 | staff: session.children[5].textContent, 36 | }; 37 | }); 38 | }); 39 | return { ret, day }; 40 | }); 41 | return ret; 42 | }; 43 | 44 | const parseCourseSchedules = (schedules: ReturnType) => { 45 | const ret: Parsed.Schedule = {}; 46 | for (let i = 0; i < schedules.length; i += 1) { 47 | for (let j = 0; j < schedules[i].ret.length; j += 1) { 48 | const cell = schedules[i].ret[j]; 49 | for (const session of cell) { 50 | const group = session.group; 51 | if (!group) continue; 52 | 53 | if (!ret[group]) ret[group] = []; 54 | 55 | ret[group].push({ 56 | x: i, 57 | y: j, 58 | location: session.location ?? "", 59 | staff: { name: session.staff ?? undefined }, 60 | }); 61 | } 62 | } 63 | } 64 | return ret; 65 | }; 66 | 67 | // gets the course schedule from the guc website and saves it to the store 68 | const downloadCourseSchedule = async (course: Stored.Course) => { 69 | const { view_state: viewState, event_validation: eventValidation } = await courseScheduleRequestDetails(); 70 | 71 | const formData = { 72 | __VIEWSTATE: viewState, 73 | __EVENTVALIDATION: eventValidation, 74 | "course[]": course.id, 75 | }; 76 | const rawCourseSchedules = await doPostRequest({ 77 | url: academicScheduleUrl, 78 | formData, 79 | }); 80 | const a = extractCourseSchedule(rawCourseSchedules); 81 | console.log(`starting extraction2 of course_schedule with ${a.length}`); 82 | const courseSchedules = parseCourseSchedules(a); 83 | 84 | logger.info(`downloaded course_schedule ${JSON.stringify({ course, courseSchedules })}`); 85 | return courseSchedules; 86 | }; 87 | 88 | // returns the course schedule from either the store or calling downloadCourseSchedule 89 | const getCourseSchedule = async ({ courseCode }: { courseCode: string }) => { 90 | if (!courseCode) throw "invalid course_code"; 91 | 92 | const docRef = courseFirestore(courseCode); 93 | 94 | const doc = await docRef.get(); 95 | const course = doc.data() as Stored.Course | undefined; 96 | if (!doc.exists || !course) throw "course does not exist"; 97 | 98 | const updateTime = doc.updateTime?.toMillis() ?? 0; 99 | 100 | const time = currentTime(); 101 | const isExpired = updateTime + courseDataMaxAge < time; 102 | const isLoaded = course.loaded; 103 | if (!isExpired && isLoaded) return course; 104 | 105 | const newCourseSchedule = await downloadCourseSchedule(course); 106 | 107 | const newDocData = { 108 | loaded: true, 109 | sched: newCourseSchedule, 110 | lastUpdateTime: time, 111 | }; 112 | 113 | await docRef.set(newDocData, { merge: true }); 114 | 115 | return { ...course, ...newDocData }; 116 | }; 117 | 118 | export const courseScheduleDataLoader = new DataLoader( 119 | async (courseCodes: readonly string[]) => { 120 | const courseSchedules = await Promise.allSettled( 121 | courseCodes.map((courseCode) => getCourseSchedule({ courseCode })) 122 | ); 123 | return courseSchedules; 124 | }, 125 | { cache: false } 126 | ); 127 | -------------------------------------------------------------------------------- /server/functions/src/providers/groupsSchedule.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "firebase-functions/v2"; 2 | import { JSDOM } from "jsdom"; 3 | import * as _ from "lodash"; 4 | import { generalGroupScheduleUrl, groupScheduleMaxAge } from "../consts"; 5 | import { getGroupsInfoFirestore, groupFirestore } from "../firestore"; 6 | import { doPostRequest } from "../requests"; 7 | import { Parsed, RequestValidation, Stored } from "../types"; 8 | import { currentTime } from "../utils"; 9 | import DataLoader = require("dataloader"); 10 | 11 | const groupScheduleRequestDetails = _.memoize(async () => 12 | getGroupsInfoFirestore() 13 | .get() 14 | .then((doc) => doc.data()?.request_details as RequestValidation) 15 | ); 16 | 17 | // extract the group schedule from the raw html data 18 | const extractGroupSchedules = (data: string) => { 19 | const doc = new JSDOM(data).window.document; 20 | const children = doc.querySelector("#scdTbl")?.firstElementChild?.children; 21 | if (!children) throw new Error("no children found"); 22 | const ret = Array.from(children) 23 | .slice(1) 24 | .map((row) => { 25 | if (row.children.length == 2) return []; 26 | return Array.from(row.children) 27 | .slice(1) 28 | .map((slot) => { 29 | if (!slot.firstElementChild?.firstElementChild) return []; 30 | return Array.from(slot.firstElementChild.firstElementChild.children).map((session) => { 31 | const s = session.children; 32 | const matchResult = s[2].textContent?.match(/^(\w*\s*\w*)\n\s*(\w*)\n\s*$/); 33 | if (!matchResult) throw new Error("no match found"); 34 | const [course, courseCode, type] = matchResult; 35 | const group = s[0].textContent; 36 | if (!group) throw new Error("no group found"); 37 | const idx = group.lastIndexOf(" "); 38 | const sessionGroup = group.slice(idx + 1); 39 | let typeName = ""; 40 | if (sessionGroup[0] == "T") { 41 | typeName = "Tutorial"; 42 | } else if (sessionGroup[0] == "L") { 43 | typeName = "Lecture"; 44 | } else if (sessionGroup[0] == "P") { 45 | typeName = "Practical"; 46 | } else { 47 | typeName = "Unknown"; 48 | logger.error("unknown type", { 49 | courseCode, 50 | sessionGroup, 51 | course, 52 | }); 53 | } 54 | const expectedGroup = 55 | courseCode + 56 | " - " + 57 | group.slice(0, idx) + 58 | sessionGroup.slice(1).replace(/^0+/, "") + 59 | " (" + 60 | typeName + 61 | ")"; 62 | return { 63 | courseCode, 64 | type, 65 | location: s[1].textContent, 66 | group, 67 | expectedGroup, 68 | }; 69 | }); 70 | }); 71 | }); 72 | return ret; 73 | }; 74 | 75 | const parseGroupSchedules = (schedules: ReturnType[]) => { 76 | const ret: Parsed.Schedule = {}; 77 | for (const schedule of schedules) 78 | for (let i = 0; i < schedule.length; i += 1) 79 | for (let j = 0; j < schedule[i].length; j += 1) 80 | if (schedule[i][j]) 81 | for (const session of schedule[i][j]) { 82 | if (!ret[session.expectedGroup]) ret[session.expectedGroup] = []; 83 | ret[session.expectedGroup].push({ 84 | x: i, 85 | y: j, 86 | location: session.location ?? undefined, 87 | }); 88 | } 89 | 90 | return ret; 91 | }; 92 | 93 | // gets the group schedule from the guc website and saves it to the store 94 | const downloadGroupSchedule = async (groupScheduleData: Stored.Group) => { 95 | const { view_state: viewState, event_validation: eventValidation } = await groupScheduleRequestDetails(); 96 | 97 | const rawGroupsSchedules = await Promise.all( 98 | groupScheduleData.id.map(async (id) => { 99 | const formData = { 100 | __VIEWSTATE: viewState, 101 | __EVENTVALIDATION: eventValidation, 102 | scdTpLst: id, 103 | __EVENTTARGET: "scdTpLst", 104 | }; 105 | // const rawGroupsSchedules = await doPostRequest(generalGroupScheduleUrl, formData); 106 | const rawGroupsSchedule = await doPostRequest({ 107 | url: generalGroupScheduleUrl, 108 | formData, 109 | }); 110 | return rawGroupsSchedule; 111 | }) 112 | ); 113 | const schedules = rawGroupsSchedules.map((rawGroupsSchedule) => extractGroupSchedules(rawGroupsSchedule)); 114 | 115 | const groupsSchedules = parseGroupSchedules(schedules); 116 | 117 | logger.debug(`downloaded group_schedule ${JSON.stringify({ groupScheduleData, groupsSchedules })}`); 118 | return groupsSchedules; 119 | }; 120 | 121 | // returns the group schedule from either the store or calling downloadGroupSchedule 122 | const getGroupSchedule = async ({ groupName }: { groupName: string }) => { 123 | if (!groupName) throw "invalid group"; 124 | 125 | const docRef = groupFirestore(groupName); 126 | 127 | const doc = await docRef.get(); 128 | const groupScheduleData = doc.data() as Stored.Group | undefined; 129 | if (!doc.exists || !groupScheduleData) throw "group does not exist"; 130 | 131 | const updateTime = doc.updateTime?.toMillis() ?? 0; 132 | 133 | const time = currentTime(); 134 | 135 | const isExpired = updateTime + groupScheduleMaxAge < time; 136 | const isLoaded = groupScheduleData.loaded; 137 | if (!isExpired && isLoaded) return groupScheduleData; 138 | 139 | const newGroupSchedule = await downloadGroupSchedule(groupScheduleData); 140 | 141 | const newDocData = { 142 | loaded: true, 143 | sched: newGroupSchedule, 144 | lastUpdateTime: time, 145 | }; 146 | 147 | await docRef.set(newDocData, { merge: true }); 148 | 149 | return { ...groupScheduleData, ...newDocData }; 150 | }; 151 | 152 | export const groupsScheduleDataLoader = new DataLoader( 153 | async (groupNames: readonly string[]) => { 154 | const groupSchedules = await Promise.allSettled(groupNames.map((groupName) => getGroupSchedule({ groupName }))); 155 | return groupSchedules; 156 | }, 157 | { cache: false } 158 | ); 159 | -------------------------------------------------------------------------------- /client/src/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /* Document 4 | ========================================================================== */ 5 | 6 | /** 7 | * 1. Correct the line height in all browsers. 8 | * 2. Prevent adjustments of font size after orientation changes in iOS. 9 | */ 10 | 11 | html { 12 | line-height: 1.3; /* 1 */ 13 | -webkit-text-size-adjust: 100%; /* 2 */ 14 | } 15 | 16 | /* Sections 17 | ========================================================================== */ 18 | 19 | /** 20 | * Remove the margin in all browsers. 21 | */ 22 | 23 | body { 24 | margin: 0; 25 | } 26 | 27 | /** 28 | * Render the `main` element consistently in IE. 29 | */ 30 | 31 | main { 32 | display: block; 33 | } 34 | 35 | /** 36 | * Correct the font size and margin on `h1` elements within `section` and 37 | * `article` contexts in Chrome, Firefox, and Safari. 38 | */ 39 | 40 | h1 { 41 | font-size: 2em; 42 | margin: 0.67em 0; 43 | } 44 | 45 | /* Grouping content 46 | ========================================================================== */ 47 | 48 | /** 49 | * 1. Add the correct box sizing in Firefox. 50 | * 2. Show the overflow in Edge and IE. 51 | */ 52 | 53 | hr { 54 | box-sizing: content-box; /* 1 */ 55 | height: 0; /* 1 */ 56 | overflow: visible; /* 2 */ 57 | } 58 | 59 | /** 60 | * 1. Correct the inheritance and scaling of font size in all browsers. 61 | * 2. Correct the odd `em` font sizing in all browsers. 62 | */ 63 | 64 | pre { 65 | font-family: monospace, monospace; /* 1 */ 66 | font-size: 1em; /* 2 */ 67 | } 68 | 69 | /* Text-level semantics 70 | ========================================================================== */ 71 | 72 | /** 73 | * Remove the gray background on active links in IE 10. 74 | */ 75 | 76 | a { 77 | background-color: transparent; 78 | } 79 | 80 | /** 81 | * 1. Remove the bottom border in Chrome 57- 82 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 83 | */ 84 | 85 | abbr[title] { 86 | border-bottom: none; /* 1 */ 87 | text-decoration: underline; /* 2 */ 88 | text-decoration: underline dotted; /* 2 */ 89 | } 90 | 91 | /** 92 | * Add the correct font weight in Chrome, Edge, and Safari. 93 | */ 94 | 95 | b, 96 | strong { 97 | font-weight: bolder; 98 | } 99 | 100 | /** 101 | * 1. Correct the inheritance and scaling of font size in all browsers. 102 | * 2. Correct the odd `em` font sizing in all browsers. 103 | */ 104 | 105 | code, 106 | kbd, 107 | samp { 108 | font-family: monospace, monospace; /* 1 */ 109 | font-size: 1em; /* 2 */ 110 | } 111 | 112 | /** 113 | * Add the correct font size in all browsers. 114 | */ 115 | 116 | small { 117 | font-size: 80%; 118 | } 119 | 120 | /** 121 | * Prevent `sub` and `sup` elements from affecting the line height in 122 | * all browsers. 123 | */ 124 | 125 | sub, 126 | sup { 127 | font-size: 75%; 128 | line-height: 0; 129 | position: relative; 130 | vertical-align: baseline; 131 | } 132 | 133 | sub { 134 | bottom: -0.25em; 135 | } 136 | 137 | sup { 138 | top: -0.5em; 139 | } 140 | 141 | /* Embedded content 142 | ========================================================================== */ 143 | 144 | /** 145 | * Remove the border on images inside links in IE 10. 146 | */ 147 | 148 | img { 149 | border-style: none; 150 | } 151 | 152 | /* Forms 153 | ========================================================================== */ 154 | 155 | /** 156 | * 1. Change the font styles in all browsers. 157 | * 2. Remove the margin in Firefox and Safari. 158 | */ 159 | 160 | button, 161 | input, 162 | optgroup, 163 | select, 164 | textarea { 165 | font-family: inherit; /* 1 */ 166 | font-size: 100%; /* 1 */ 167 | line-height: 1.3; /* 1 */ 168 | margin: 0; /* 2 */ 169 | } 170 | 171 | /** 172 | * Show the overflow in IE. 173 | * 1. Show the overflow in Edge. 174 | */ 175 | 176 | button, 177 | input { /* 1 */ 178 | overflow: visible; 179 | } 180 | 181 | /** 182 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 183 | * 1. Remove the inheritance of text transform in Firefox. 184 | */ 185 | 186 | button, 187 | select { /* 1 */ 188 | text-transform: none; 189 | } 190 | 191 | /** 192 | * Correct the inability to style clickable types in iOS and Safari. 193 | */ 194 | 195 | button, 196 | [type="button"], 197 | [type="reset"], 198 | [type="submit"] { 199 | -webkit-appearance: button; 200 | } 201 | 202 | /** 203 | * Remove the inner border and padding in Firefox. 204 | */ 205 | 206 | button::-moz-focus-inner, 207 | [type="button"]::-moz-focus-inner, 208 | [type="reset"]::-moz-focus-inner, 209 | [type="submit"]::-moz-focus-inner { 210 | border-style: none; 211 | padding: 0; 212 | } 213 | 214 | /** 215 | * Restore the focus styles unset by the previous rule. 216 | */ 217 | 218 | button:-moz-focusring, 219 | [type="button"]:-moz-focusring, 220 | [type="reset"]:-moz-focusring, 221 | [type="submit"]:-moz-focusring { 222 | outline: 1px dotted ButtonText; 223 | } 224 | 225 | /** 226 | * Correct the padding in Firefox. 227 | */ 228 | 229 | fieldset { 230 | padding: 0.35em 0.75em 0.625em; 231 | } 232 | 233 | /** 234 | * 1. Correct the text wrapping in Edge and IE. 235 | * 2. Correct the color inheritance from `fieldset` elements in IE. 236 | * 3. Remove the padding so developers are not caught out when they zero out 237 | * `fieldset` elements in all browsers. 238 | */ 239 | 240 | legend { 241 | box-sizing: border-box; /* 1 */ 242 | color: inherit; /* 2 */ 243 | display: table; /* 1 */ 244 | max-width: 100%; /* 1 */ 245 | padding: 0; /* 3 */ 246 | white-space: normal; /* 1 */ 247 | } 248 | 249 | /** 250 | * Add the correct vertical alignment in Chrome, Firefox, and Opera. 251 | */ 252 | 253 | progress { 254 | vertical-align: baseline; 255 | } 256 | 257 | /** 258 | * Remove the default vertical scrollbar in IE 10+. 259 | */ 260 | 261 | textarea { 262 | overflow: auto; 263 | } 264 | 265 | /** 266 | * 1. Add the correct box sizing in IE 10. 267 | * 2. Remove the padding in IE 10. 268 | */ 269 | 270 | [type="checkbox"], 271 | [type="radio"] { 272 | box-sizing: border-box; /* 1 */ 273 | padding: 0; /* 2 */ 274 | } 275 | 276 | /** 277 | * Correct the cursor style of increment and decrement buttons in Chrome. 278 | */ 279 | 280 | [type="number"]::-webkit-inner-spin-button, 281 | [type="number"]::-webkit-outer-spin-button { 282 | height: auto; 283 | } 284 | 285 | /** 286 | * 1. Correct the odd appearance in Chrome and Safari. 287 | * 2. Correct the outline style in Safari. 288 | */ 289 | 290 | [type="search"] { 291 | -webkit-appearance: textfield; /* 1 */ 292 | outline-offset: -2px; /* 2 */ 293 | } 294 | 295 | /** 296 | * Remove the inner padding in Chrome and Safari on macOS. 297 | */ 298 | 299 | [type="search"]::-webkit-search-decoration { 300 | -webkit-appearance: none; 301 | } 302 | 303 | /** 304 | * 1. Correct the inability to style clickable types in iOS and Safari. 305 | * 2. Change font properties to `inherit` in Safari. 306 | */ 307 | 308 | ::-webkit-file-upload-button { 309 | -webkit-appearance: button; /* 1 */ 310 | font: inherit; /* 2 */ 311 | } 312 | 313 | /* Interactive 314 | ========================================================================== */ 315 | 316 | /* 317 | * Add the correct display in Edge, IE 10+, and Firefox. 318 | */ 319 | 320 | details { 321 | display: block; 322 | } 323 | 324 | /* 325 | * Add the correct display in all browsers. 326 | */ 327 | 328 | summary { 329 | display: list-item; 330 | } 331 | 332 | /* Misc 333 | ========================================================================== */ 334 | 335 | /** 336 | * Add the correct display in IE 10+. 337 | */ 338 | 339 | template { 340 | display: none; 341 | } 342 | 343 | /** 344 | * Add the correct display in IE 10. 345 | */ 346 | 347 | [hidden] { 348 | display: none; 349 | } -------------------------------------------------------------------------------- /server/functions/src/prepareCourses.ts: -------------------------------------------------------------------------------- 1 | const functions = require("firebase-functions"); 2 | 3 | // The Firebase Admin SDK to access Firestore. 4 | const admin = require("firebase-admin"); 5 | 6 | import { JSDOM } from "jsdom"; 7 | import { timingSafeEqual } from "crypto"; 8 | const { runtimeOptsPerpareCourses } = require("./consts"); 9 | 10 | import { academicScheduleUrl, generalGroupScheduleUrl } from "./consts"; 11 | import { doGetRequest } from "./requests"; 12 | import { getCoursesInfoFirestore, getGroupsInfoFirestore, courseFirestore, groupFirestore } from "./firestore"; 13 | 14 | // parses the raw HTML to return the list of all courses and some constants 15 | const parseCourses = (data: string) => { 16 | const doc = new JSDOM(data).window.document; 17 | 18 | const event_validation = (doc.querySelector("#__EVENTVALIDATION") as HTMLInputElement)?.value; // a constant needed for future requests 19 | const view_state = (doc.querySelector("#__VIEWSTATE") as HTMLInputElement)?.value; // a constant needed for future requests 20 | 21 | const matchResult = data.match(/