├── .editorconfig ├── .env ├── .github ├── dependabot.yaml └── workflows │ ├── README.md │ ├── autodeploy.yml │ └── ci.yml ├── .gitignore ├── .prettierrc ├── .python-version ├── LICENSE.txt ├── README.md ├── deploy ├── README.md ├── cron_scripts │ ├── cronic │ ├── crontab │ └── update_latest.sh └── web_scripts │ └── notify_build.py ├── eslint.config.js ├── package-lock.json ├── package.json ├── public ├── .htaccess ├── f22.json ├── f23.json ├── f24.json ├── hydrant.png ├── i25.json ├── latestTerm.json ├── s23.json ├── s24.json └── s25.json ├── pylintrc ├── react-router.config.ts ├── requirements.txt ├── scrapers ├── README.md ├── __init__.py ├── __main__.py ├── catalog.py ├── cim.py ├── fireroad.py ├── math_dept.py ├── overrides.toml.d │ ├── 11.toml │ ├── 12.toml │ ├── 15.toml │ ├── 21a.toml │ ├── 21g.toml │ ├── 21l.toml │ ├── 21m.toml │ ├── 4.toml │ ├── 6.toml │ ├── cms.toml │ └── override-schema.json ├── package.py ├── text_mining.py └── utils.py ├── src ├── assets │ ├── Lab.gif │ ├── PartLab.gif │ ├── calendar-button.png │ ├── cih.gif │ ├── cihw.gif │ ├── fall.gif │ ├── fuzzAndAnt.png │ ├── fuzzball.png │ ├── grad.gif │ ├── hassA.gif │ ├── hassE.gif │ ├── hassH.gif │ ├── hassS.gif │ ├── hydraAnt.png │ ├── iap.gif │ ├── logo-dark.svg │ ├── logo.svg │ ├── nonext.gif │ ├── repeat.gif │ ├── rest.gif │ ├── simple-fuzzball.png │ ├── spring.gif │ ├── summer.gif │ └── under.gif ├── components │ ├── ActivityButtons.tsx │ ├── ActivityDescription.tsx │ ├── Calendar.scss │ ├── Calendar.tsx │ ├── ClassTable.scss │ ├── ClassTable.tsx │ ├── FeedbackBanner.tsx │ ├── Footers.tsx │ ├── Header.tsx │ ├── MatrixLink.tsx │ ├── PreregLink.tsx │ ├── SIPBLogo.tsx │ ├── ScheduleOption.tsx │ ├── ScheduleSwitcher.tsx │ ├── SelectedActivities.tsx │ ├── TermSwitcher.tsx │ └── ui │ │ ├── button.tsx │ │ ├── checkbox.tsx │ │ ├── close-button.tsx │ │ ├── color-mode.tsx │ │ ├── color-picker.tsx │ │ ├── dialog.tsx │ │ ├── field.tsx │ │ ├── input-group.tsx │ │ ├── link-button.tsx │ │ ├── menu.tsx │ │ ├── provider.tsx │ │ ├── radio.tsx │ │ ├── select.tsx │ │ └── tooltip.tsx ├── entry.client.tsx ├── lib │ ├── activity.ts │ ├── calendarSlots.ts │ ├── class.ts │ ├── colors.ts │ ├── dates.ts │ ├── gapi.ts │ ├── hydrant.ts │ ├── rawClass.ts │ ├── schema.ts │ ├── state.ts │ ├── store.ts │ └── utils.tsx ├── root.tsx ├── routes.ts ├── routes │ ├── Index.tsx │ ├── Overrides.tsx │ └── export.ts └── vite-env.d.ts ├── tsconfig.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | 10 | [*.{ts,tsx,js,json}] 11 | indent_size = 2 12 | max_line_length = 80 13 | 14 | [*.py] 15 | indent_size = 4 16 | max_line_length = 88 17 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | VITE_GOOGLE_CLIENT_ID=706375540729-sqnnig7o0d0uqmvav0h8nh8aft6l55u3.apps.googleusercontent.com 2 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: "npm" 7 | directory: "/" 8 | schedule: 9 | interval: "monthly" 10 | - package-ecosystem: "pip" 11 | directory: "/" 12 | schedule: 13 | interval: "monthly" 14 | - package-ecosystem: "github-actions" 15 | directory: "/" 16 | schedule: 17 | interval: "monthly" 18 | -------------------------------------------------------------------------------- /.github/workflows/README.md: -------------------------------------------------------------------------------- 1 | # Deploy scripts 2 | Scripts in this directory are processed by Github's CI service. 3 | 4 | ## autodeploy.yml 5 | This script automatically deploys a build of Hydrant to Scripts when it receives the `push` Git hook. 6 | 7 | ## ci.yml 8 | This script runs Black (for backend formatting), Prettier (for frontend formatting), and ESLint (for frontend linting) for any pull request or push to the `main` branch. 9 | -------------------------------------------------------------------------------- /.github/workflows/autodeploy.yml: -------------------------------------------------------------------------------- 1 | name: Autodeploy branch 2 | 3 | on: 4 | push: 5 | branches: 6 | - deploy 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Install node 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: 20 18 | - name: Install node dependencies 19 | run: npm install 20 | - name: Checks types 21 | run: npm run typecheck 22 | - name: Run build 23 | run: npm run build 24 | - name: Archive build artifacts 25 | uses: actions/upload-artifact@v4 26 | with: 27 | name: built-site 28 | path: | 29 | build/client 30 | !build/client/latest.json 31 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | env: 10 | node-version: 20.x 11 | python-version: 3.7 12 | 13 | jobs: 14 | black: 15 | name: Black 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: psf/black@stable 20 | pylint: 21 | name: Pylint 22 | runs-on: ubuntu-22.04 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Set up Python 3.7 26 | uses: actions/setup-python@v5 27 | with: 28 | python-version: ${{ env.python-version }} 29 | cache: "pip" 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip 33 | pip install -r requirements.txt 34 | pip install pylint 35 | - name: Analysing the code with pylint 36 | run: | 37 | pylint $(git ls-files '*.py') 38 | prettier: 39 | name: Prettier 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@v4 43 | - name: Install node 44 | uses: actions/setup-node@v4 45 | with: 46 | node-version: ${{ env.node-version }} 47 | cache: "npm" 48 | - name: Install node dependencies 49 | run: npm install 50 | - name: Run Prettier 51 | run: npm run ci-prettier 52 | lint: 53 | name: Lint + Typecheck 54 | runs-on: ubuntu-latest 55 | steps: 56 | - uses: actions/checkout@v4 57 | - name: Install node 58 | uses: actions/setup-node@v4 59 | with: 60 | node-version: ${{ env.node-version }} 61 | cache: "npm" 62 | - name: Install node dependencies 63 | run: npm install 64 | - name: Check types 65 | run: npm run typecheck 66 | - name: Run ESLint 67 | run: npm run ci-eslint 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | 27 | # artifacts 28 | scrapers/catalog.json 29 | scrapers/fireroad-sem.json 30 | scrapers/fireroad-presem.json 31 | scrapers/cim.json 32 | public/latest.json 33 | public/m25.json 34 | 35 | # python 36 | __pycache__ 37 | 38 | # deploy script 39 | deploy.sh 40 | 41 | #virtual env 42 | hydrant-venv 43 | 44 | # router 45 | .react-router 46 | build 47 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.7.7 -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 MIT Student Information Processing Board 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hydrant 2 | 3 | ## Setup 4 | 5 | Install: 6 | 7 | - Python 3, at least Python 3.7. 8 | - Node.js, at least Node.js 20. 9 | - One way to manage Node versions is using [nvm](https://github.com/nvm-sh/nvm). 10 | - [Black](https://black.readthedocs.io/en/stable/index.html), if you plan on contributing changes to the Python backend. 11 | 12 | In the root directory, run: 13 | 14 | - `pip install -r requirements.txt` to install dependencies. 15 | - `npm install` to install dependencies. 16 | 17 | ## Updating 18 | 19 | ### Local development 20 | 21 | There's the frontend, which is the website and the interface. Then there's the backend, or the schedules, which are the files that have information about class schedules. 22 | 23 | To spin up the site, we need two steps: 24 | 25 | (1) We need to update the backend to get the data. Run `python3 -m scrapers`. 26 | 27 | (2) We then can update the frontend, via running `npm run dev`. This will start a developer server. Open a browser tab to [`http://localhost:5173/`](http://localhost:5173/), which will update live as you edit code. 28 | 29 | If this is the **first time** you're spinning up the website, the two steps have to be taken in order: step (1), and then step (2). If not followed, you'll see a blank frontend. 30 | 31 | After the first time, the step order doesn't matter to bring up the site; in fact, backend step (1) can even be skipped -- since you'd already have locally cached data. Though backend commands are still necessary if you'd like to keep the data updated. 32 | 33 | Before making commits, run `black .` (for the backend) and `npm run format` (for the frontend) to ensure that your changes comply with the project's code style. (This will also get checked by CI when you open a pull request.) Both [Black](https://black.readthedocs.io/en/stable/integrations/editors.html) and [Prettier](https://prettier.io/docs/en/editors) have editor integrations as well. 34 | 35 | ### Changing semesters 36 | 37 | Let's say you're updating from e.g. Spring 2023 to Fall 2023. 38 | 39 | First, archive the old semester. Make sure you have updated schedule files. Then run `mv public/latest.json public/s23.json`. 40 | 41 | Then, update the new semester. Open `public/latestTerm.json`, change `urlName` to `m23` (for the "pre-semester" summer 2023) and `f23` (for the semester fall 2023), and update the dates per [Registrar](https://registrar.mit.edu/calendar). 42 | 43 | Next, update the `.gitignore` to ignore `public/m23.json` rather than `public/i23.json`. 44 | 45 | Finally, run the normal update process and commit the results to the repo. 46 | 47 | ### Updating the server 48 | 49 | The server's frontend updates based on the `deploy` branch on GitHub, so any changes pushed there will become live. 50 | 51 | The server's backend is updated through a cron script that runs `update.py` every hour. 52 | 53 | See `deploy/README.md` for more info. 54 | 55 | ## Development notes 56 | 57 | ### Architecture 58 | 59 | _I want to change..._ 60 | 61 | - _...the data available to Hydrant._ 62 | - The entry point is `scrapers/update.py`. 63 | - This goes through the client loader in `src/routes/Index.tsx`, which looks for the data. 64 | - The exit point is through the constructor of `State` in `src/lib/state.ts`. 65 | - _...the way Hydrant behaves._ 66 | - The entry point is `src/lib/state.ts`. 67 | - The exit point is through `src/routes/Index.tsx`, which constructs `hydrant` and adds it to a reusable context. 68 | - _...the way Hydrant looks._ 69 | - The entry point is `src/routes/Index.tsx`. 70 | - We use [Chakra UI](https://chakra-ui.com/) as our component library. Avoid writing CSS. 71 | - _...routes available in Hydrant._ 72 | - Routes are stored in `src/routes.ts` and can be modified there. 73 | - Run `npm run typecheck` to make sure route types are still ok once you're done 74 | 75 | ### Technologies 76 | 77 | Try to introduce as few technologies as possible to keep this mostly future-proof. If you introduce something, make sure it'll last a few years. Usually one of these is a sign it'll last: 78 | 79 | - some MIT class teaches how to use it 80 | - e.g. web.lab teaches React, 6.102 teaches Typescript 81 | - it's tiny and used in only a small part of the app 82 | - e.g. msgpack-lite is only used for URL encoding, nanoid is only used to make IDs 83 | - it's a big, popular, well-documented project that's been around for several years 84 | - e.g. FullCalendar has been around since 2010, Chakra UI has a large community 85 | -------------------------------------------------------------------------------- /deploy/README.md: -------------------------------------------------------------------------------- 1 | # Hydrant - deploy steps 2 | 3 | This file, and other contents of this folder, should be mirrored in `/afs/sipb/project/hydrant`. This folder is called our *locker*. 4 | 5 | If you're in the `sipb-hydrant` list, you can login to Athena and do `ssh hydrant@scripts`. You'll sign in to the Scripts account, whose home folder `~` is `/afs/sipb/project/hydrant`. 6 | 7 | Our repo has a copy at `~/hydrant` with symlinks to other places. In particular: 8 | 9 | - `~/README.md` links to `~/hydrant/deploy/README.md`. 10 | - `~/cron_scripts` links to `~/hydrant/deploy/cron_scripts`. 11 | - `~/web_scripts/notify_build.py` links to `~/hydrant/deploy/web_scripts/notify_build.py`. 12 | - Nothing else in `~/web_scripts` should be in the repo. 13 | - Nothing in `~/ci_secrets` should be in the repo. 14 | - But read the `~/ci_secrets/README` file! 15 | 16 | ## Frontend 17 | 18 | Everything on `~/web_scripts` is served to the internet under (https://hydrant.scripts.mit.edu/). The service that does this is called [Scripts](https://scripts.mit.edu/). The link (https://hydrant.mit.edu) points to the subfolder `~/web_scripts/hydrant`, which is where the deployed files are. 19 | 20 | The server's frontend updates based on the `deploy` branch on GitHub, so any changes pushed there will become live. In particular: 21 | 22 | 1. GitHub actions CI triggers (https://github.com/sipb/hydrant/actions). This builds the frontend on the GitHub servers, saving the built directory in an 'artifact'. You can find the artifact by hand at https://github.com/sipb/hydrant/actions/runs/RUN_ID_HERE. 23 | 24 | 2. Once the build is done, CI fires a GitHub webhook (https://github.com/sipb/hydrant/settings/hooks). This one is pointed at (https://hydrant.scripts.mit.edu/report_build.py); hosted at `~/web_scripts/report_build.py` in the locker. 25 | 26 | The webhook does something called a [POST request](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST). You can think of it as running the `~/web_scripts/report_build.py` script and giving it input, except we're giving the input through the internet. The input is taken in through `stdin`, and is encoded with JSON. 27 | 28 | You can look at the output of the webhook in the Github hooks settings: (https://github.com/sipb/hydrant/settings/hooks). 29 | 30 | 3. The script grabs the URL to the 'artifact'* and downloads it. The relevant API docs are on GitHub at (https://docs.github.com/en/rest/actions/artifacts). 31 | 32 | 4. The script unpacks the files to the production directory `~/web_scripts/hydrant`. 33 | 34 | Note that the GitHub token in `ci_secrets` must be [regenerated](https://github.com/settings/personal-access-tokens/) if it's ever invalidated. 35 | 36 | ## Backend 37 | 38 | The entire backend is the `latest.json` file in `~/web_scripts/hydrant/latest.json`. 39 | 40 | We have a [cron job](https://en.wikipedia.org/wiki/Cron) that runs every hour that updates this file. Cron is configured with a crontab file, in `~/cron_scripts/crontab`. There's a line (the one starting with `0 * * * *`) that calls the `~/cron_scripts/update_latest.sh` every hour. 41 | 42 | The `~/cron_scripts/update_latest.sh` pulls the latest version of the `deploy` branch on GitHub to the folder `~/hydrant`. Inside that folder, it then runs the `scrapers/update.py` script. Then it copies the `latest.json` from that folder to `~/web_scripts/hydrant`, where it is served to the internet. 43 | -------------------------------------------------------------------------------- /deploy/cron_scripts/cronic: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Cronic v3 - cron job report wrapper 4 | # Copyright 2007-2016 Chuck Houpt. No rights reserved, whatsoever. 5 | # Public Domain CC0: http://creativecommons.org/publicdomain/zero/1.0/ 6 | 7 | set -eu 8 | 9 | TMP=$(mktemp -d) 10 | OUT=$TMP/cronic.out 11 | ERR=$TMP/cronic.err 12 | TRACE=$TMP/cronic.trace 13 | 14 | set +e 15 | "$@" >$OUT 2>$TRACE 16 | RESULT=$? 17 | set -e 18 | 19 | PATTERN="^${PS4:0:1}\\+${PS4:1}" 20 | if grep -aq "$PATTERN" $TRACE 21 | then 22 | ! grep -av "$PATTERN" $TRACE > $ERR 23 | else 24 | ERR=$TRACE 25 | fi 26 | 27 | if [ $RESULT -ne 0 -o -s "$ERR" ] 28 | then 29 | echo "Cronic detected failure or error output for the command:" 30 | echo "$@" 31 | echo 32 | echo "RESULT CODE: $RESULT" 33 | echo 34 | echo "ERROR OUTPUT:" 35 | cat "$ERR" 36 | echo 37 | echo "STANDARD OUTPUT:" 38 | cat "$OUT" 39 | if [ $TRACE != $ERR ] 40 | then 41 | echo 42 | echo "TRACE-ERROR OUTPUT:" 43 | cat "$TRACE" 44 | fi 45 | fi 46 | 47 | rm -rf "$TMP" 48 | -------------------------------------------------------------------------------- /deploy/cron_scripts/crontab: -------------------------------------------------------------------------------- 1 | # If you edit this file, make sure to: 2 | # - SSH into our Scripts account, 3 | # - then run "cronload crontab" in the cron_scripts directory. 4 | 5 | # This line sets a reasonable default path 6 | PATH=/mit/hydrant/cron_scripts:/usr/kerberos/sbin:/usr/kerberos/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/X11R6/bin 7 | 8 | # This line mails the STDOUT and STDERR of every cron script to a person 9 | # (can be useful for debugging) 10 | # You can always redirect the output of individual commands to /dev/null 11 | MAILTO="sipb-hydrant@mit.edu" 12 | # If you do not want to receive any mail from cron, use the line below instead 13 | #MAILTO="" 14 | 15 | # Update data pulled from the most recent APIs; then plop it down in production 16 | # This runs every hour, on the 0th minute. 17 | 0 * * * * cronic update_latest.sh 18 | 19 | # See http://en.wikipedia.org/wiki/Cron (or google for crontab) for more info 20 | -------------------------------------------------------------------------------- /deploy/cron_scripts/update_latest.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | # This script runs the machinery in the given dir to scrape latest.json 6 | # using the Hydrant scrapers, then updates it into the given directory. 7 | 8 | # Hydrant base directory, i.e. the one that has the copy of the repo, with 9 | # all the scripts in it. In the locker, this is ~/hydrant. 10 | REPO_DIR="/afs/sipb.mit.edu/project/hydrant/hydrant" 11 | 12 | # The output directory, i.e. the one that has the folder being served to the 13 | # internet. In the locker, this is ~/web_scripts/hydrant. 14 | OUT_DIR="/afs/sipb.mit.edu/project/hydrant/web_scripts/hydrant" 15 | 16 | cd "$REPO_DIR" 17 | 18 | # -q means quietly; don't report anything in stdout or stderr. 19 | # make sure we're in the right branch: 20 | git checkout -f deploy -q 21 | git pull -q 22 | 23 | # The scripts machine we use has Python 3.7, so use that. 24 | # This updates $OUT_FILE. 25 | python3.7 -m scrapers 26 | OUT_FILE="$REPO_DIR/public/*.json" 27 | 28 | # Copy $OUT_FILE to the output directory, so it can be served to the internet. 29 | cp $OUT_FILE "$OUT_DIR" 30 | -------------------------------------------------------------------------------- /deploy/web_scripts/notify_build.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Accept a web-hook from GitHub telling us about a new built version of Hydrant.""" 4 | 5 | import json 6 | import traceback 7 | from hmac import digest 8 | from os import environ, path 9 | from sys import stdin, stdout 10 | from zipfile import ZipFile 11 | 12 | import requests 13 | 14 | LOCKER_DIR = "/afs/sipb.mit.edu/project/hydrant" 15 | 16 | OUTPUT_DIR = path.join(LOCKER_DIR, "web_scripts/hydrant") 17 | ERROR_LOG = path.join(LOCKER_DIR, "error_log") 18 | 19 | CI_SECRETS_DIR = path.join(LOCKER_DIR, "ci_secrets") 20 | HASH_SECRET = path.join(CI_SECRETS_DIR, "hash_secret") 21 | GITHUB_TOKEN = path.join(CI_SECRETS_DIR, "github_token") 22 | 23 | 24 | # pylint: disable=too-many-locals 25 | def main(): 26 | """ 27 | Fetch the artifact from the GitHub API and extract it into the output directory. 28 | """ 29 | # Secret, used for HMAC input validation (so we know GitHub is being real) 30 | with open(HASH_SECRET, encoding="utf-8") as file_hash: 31 | secret = file_hash.read().strip().encode("utf-8") 32 | # API token for GitHub API requests (to get a path to the file). 33 | with open(GITHUB_TOKEN, encoding="utf-8") as file_token: 34 | token = file_token.read().strip() 35 | 36 | # Slurp content and validate with HMAC 37 | body = stdin.read() 38 | hexdigest = "sha256=" + digest(secret, body.encode("utf-8"), "sha256").hex() 39 | if hexdigest != environ.get("HTTP_X_HUB_SIGNATURE_256", ""): 40 | raise ValueError("bad digest") 41 | 42 | # Extract the Run ID for the build 43 | payload = json.loads(body) 44 | if payload.get("action") != "completed": 45 | raise ValueError("not completed") 46 | job_id = payload.get("workflow_job", {}).get("run_id") 47 | if not job_id: 48 | raise ValueError("no job id") 49 | 50 | # Fetch a list of artifacts from the GitHub API 51 | response = requests.get( 52 | f"https://api.github.com/repos/sipb/hydrant/actions/runs/{job_id}/artifacts", 53 | timeout=3, 54 | ) 55 | if not response.ok: 56 | raise ValueError("bad artifact fetch response: " + str(response.status_code)) 57 | artifact_info = response.json() 58 | 59 | # For each known artifact: 60 | success = False 61 | artifacts = artifact_info.get("artifacts", []) 62 | for artifact in artifacts: 63 | # check that its name is correct, 64 | if artifact.get("name") != "built-site": 65 | continue 66 | url = artifact.get("archive_download_url") 67 | if not url: 68 | continue 69 | # then fetch it. 70 | response = requests.get( 71 | url, headers={"Authorization": ("Bearer " + token)}, timeout=3 72 | ) 73 | fname = path.join(LOCKER_DIR, "build_artifact.zip") 74 | with open(fname, "wb") as file_buffer: 75 | for chunk in response.iter_content(chunk_size=4096): 76 | file_buffer.write(chunk) 77 | # Extract into the output directory. 78 | with ZipFile(fname, "r") as zfh: 79 | zfh.extractall(OUTPUT_DIR) 80 | success = True 81 | break 82 | 83 | if success: 84 | return "Fetched artifact successfully" 85 | 86 | artifact_names = ", ".join(a.get("name") for a in artifacts) 87 | return f"Could not find artifact among {len(artifacts)}: {artifact_names}" 88 | 89 | 90 | if __name__ == "__main__": 91 | # Respond to the request, it's only polite. 92 | print("Content-Type: text/plain\r\n\r") 93 | try: 94 | print(main()) 95 | # pylint: disable=broad-except 96 | except Exception as e: 97 | print(traceback.format_exc(), file=stdout) 98 | with open(ERROR_LOG, "w", encoding="utf-8") as fe: 99 | print(traceback.format_exc(), file=fe) 100 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import tseslint from "typescript-eslint"; 4 | import eslintConfigPrettier from "eslint-config-prettier"; 5 | 6 | import reactHooks from "eslint-plugin-react-hooks"; 7 | import reactRefresh from "eslint-plugin-react-refresh"; 8 | 9 | import { includeIgnoreFile } from "@eslint/compat"; 10 | import { fileURLToPath } from "node:url"; 11 | 12 | const gitignorePath = fileURLToPath(new URL(".gitignore", import.meta.url)); 13 | 14 | export default tseslint.config(includeIgnoreFile(gitignorePath), { 15 | extends: [ 16 | js.configs.recommended, 17 | ...tseslint.configs.strictTypeChecked, 18 | ...tseslint.configs.stylisticTypeChecked, 19 | reactHooks.configs["recommended-latest"], 20 | reactRefresh.configs.vite, 21 | eslintConfigPrettier, 22 | ], 23 | files: ["**/*.{ts,tsx}"], 24 | languageOptions: { 25 | ecmaVersion: 2022, 26 | globals: globals.browser, 27 | parserOptions: { 28 | projectService: true, 29 | tsconfigRootDir: import.meta.dirname, 30 | }, 31 | }, 32 | rules: { 33 | "@typescript-eslint/no-unused-vars": [ 34 | "error", 35 | { 36 | args: "all", 37 | argsIgnorePattern: "^_", 38 | caughtErrors: "all", 39 | caughtErrorsIgnorePattern: "^_", 40 | destructuredArrayIgnorePattern: "^_", 41 | varsIgnorePattern: "^_", 42 | ignoreRestSiblings: true, 43 | }, 44 | ], 45 | "@typescript-eslint/consistent-type-exports": "warn", 46 | "@typescript-eslint/consistent-type-imports": "warn", 47 | "@typescript-eslint/switch-exhaustiveness-check": "error", 48 | }, 49 | }); 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hydrant", 3 | "homepage": ".", 4 | "version": "0.1.0", 5 | "private": true, 6 | "type": "module", 7 | "dependencies": { 8 | "@chakra-ui/react": "^3.20.0", 9 | "@emotion/react": "^11.14.0", 10 | "@fontsource-variable/inter": "^5.2.5", 11 | "@fullcalendar/core": "^6.1.17", 12 | "@fullcalendar/interaction": "^6.1.17", 13 | "@fullcalendar/react": "^6.1.17", 14 | "@fullcalendar/timegrid": "^6.1.17", 15 | "@react-router/node": "^7.6.0", 16 | "@rjsf/chakra-ui": "^6.0.0-beta.10", 17 | "@rjsf/core": "^6.0.0-beta.10", 18 | "@rjsf/utils": "^6.0.0-beta.10", 19 | "@rjsf/validator-ajv8": "^6.0.0-beta.10", 20 | "ag-grid-react": "^33.3.2", 21 | "html-entities": "^2.6.0", 22 | "ical-generator": "^9.0.0", 23 | "isbot": "^5.1.28", 24 | "lucide-react": "^0.511.0", 25 | "msgpackr": "^1.11.4", 26 | "nanoid": "^5.1.5", 27 | "next-themes": "^0.4.6", 28 | "react": "^19.1.0", 29 | "react-dom": "^19.1.0", 30 | "react-icons": "^5.5.0", 31 | "react-router": "^7.6.0", 32 | "react-use": "^17.6.0", 33 | "rrule": "^2.8.1", 34 | "smol-toml": "^1.3.4", 35 | "timezones-ical-library": "^1.10.0" 36 | }, 37 | "scripts": { 38 | "dev": "react-router dev", 39 | "typecheck": "react-router typegen && tsc", 40 | "build": "react-router build", 41 | "preview": "vite preview", 42 | "format": "prettier --write '**/*.{ts,tsx}'", 43 | "ci-prettier": "prettier --check '**/*.{ts,tsx,js}'", 44 | "ci-eslint": "eslint ." 45 | }, 46 | "engines": { 47 | "node": ">=20.0" 48 | }, 49 | "devDependencies": { 50 | "@chakra-ui/cli": "^3.20.0", 51 | "@eslint/compat": "^1.2.9", 52 | "@eslint/js": "^9.27.0", 53 | "@react-router/dev": "^7.6.2", 54 | "@types/node": "^22.15.30", 55 | "@types/react": "^19.1.6", 56 | "@types/react-dom": "19.1.5", 57 | "eslint": "^9.28.0", 58 | "eslint-config-prettier": "^10.1.5", 59 | "eslint-plugin-react-hooks": "^5.2.0", 60 | "eslint-plugin-react-refresh": "^0.4.20", 61 | "globals": "^16.2.0", 62 | "prettier": "^3.5.3", 63 | "sass-embedded": "^1.89.1", 64 | "typescript": "^5.8.3", 65 | "typescript-eslint": "^8.33.1", 66 | "vite": "^6.3.5", 67 | "vite-plugin-checker": "^0.9.3", 68 | "vite-plugin-node-polyfills": "^0.23.0", 69 | "vite-tsconfig-paths": "^5.1.4" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | 2 | 3 | RewriteEngine On 4 | RewriteBase / 5 | RewriteRule ^index\.html$ - [L] 6 | RewriteCond %{REQUEST_FILENAME} !-f 7 | RewriteCond %{REQUEST_FILENAME} !-d 8 | RewriteCond %{REQUEST_FILENAME} !-l 9 | RewriteRule . /index.html [L] 10 | 11 | 12 | -------------------------------------------------------------------------------- /public/hydrant.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/public/hydrant.png -------------------------------------------------------------------------------- /public/latestTerm.json: -------------------------------------------------------------------------------- 1 | { 2 | "preSemester": { 3 | "urlName": "m25", 4 | "startDate": "2025-06-09", 5 | "endDate": "2025-08-15", 6 | "holidayDates": [ 7 | "2025-06-19", 8 | "2025-07-04" 9 | ] 10 | }, 11 | "semester": { 12 | "urlName": "f25", 13 | "startDate": "2025-09-03", 14 | "h1EndDate": "2025-10-17", 15 | "h2StartDate": "2025-10-20", 16 | "endDate": "2025-12-10", 17 | "mondayScheduleDate": null, 18 | "holidayDates": [ 19 | "2025-09-19", 20 | "2025-10-13", 21 | "2025-11-10", 22 | "2025-11-11", 23 | "2025-11-27", 24 | "2025-11-28" 25 | ] 26 | } 27 | } -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [format] 2 | max-line-length = 88 3 | -------------------------------------------------------------------------------- /react-router.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@react-router/dev/config"; 2 | 3 | export default { 4 | appDirectory: "src", 5 | ssr: false, 6 | } satisfies Config; 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | beautifulsoup4==4.11.1 2 | lxml==4.9.3 3 | requests==2.31.0 4 | tomli>=2.0.1; python_version < "3.11" 5 | nltk>=3.6.5 # skip yanked version; see https://pypi.org/project/nltk/#history -------------------------------------------------------------------------------- /scrapers/README.md: -------------------------------------------------------------------------------- 1 | ## About ## 2 | 3 | This folder contains several files. The files tracked by git are: 4 | 5 | * `__init__.py` 6 | * `__main__.py` 7 | * `catalog.py` 8 | * `cim.py` 9 | * `fireroad.py` 10 | * `math_dept.py` 11 | * `package.py` 12 | * `update.py` 13 | * `utils.py` 14 | * `README.md` - this very file! 15 | * `.pylintrc` 16 | * `overrides.json` - to override scraped data; currently empty 17 | 18 | The files intentionally left out of git are: 19 | 20 | * `catalog.json` 21 | * `cim.json` 22 | * `fireroad.json` 23 | * `fireroad-presem.json` 24 | * `__pycache__` 25 | * `.DS_Store` 26 | 27 | ## Usage ## 28 | 29 | Run `python3 -m scrapers` from the root directory to execute the code. In production, there is a cron job that runs this every hour. 30 | 31 | This program gets its data from MIT classes from two sources: 32 | 33 | * the official catalog: http://student.mit.edu/catalog/index.cgi 34 | * the Fireroad API: https://fireroad.mit.edu/courses/all?full=true 35 | 36 | It is mainly intended to serve as a data source for the frontend, which is the real deal. This is just the backend. 37 | 38 | ## How it works ## 39 | 40 | `__main__.py` calls four other programs, in this order: `fireroad.py`, `catalog.py`, `cim.py`, `package.py`. Each of these four files has a `run()` function, which is its main entry point to the codebase. Broadly speaking: 41 | 42 | * `fireroad.py` creates `fireroad.json` and `fireroad-presem.json` 43 | * `catalog.py` creates `catalog.json` 44 | * `cim.py` creates `cim.json` 45 | * `package.py` combines these to create `../public/latest.json` and another JSON file under `../public/` that corresponds to IAP or summer. (This is the final product that our frontend ingests.) 46 | 47 | `math_dept.py` is an irregularly run file that helps create override data for courses in the MIT math department (since those are formatted slightly differently). `utils.py` contains a few utility functions and variables, which in turn are used by `fireroad.py` and `package.py`. The file `__init__.py` is empty but we include it anyways for completeness. 48 | 49 | ## Contributing ## 50 | 51 | This folder is actually a subfolder of a larger git repository. If you want to contribute to this repository, submit a pull request to https://github.com/sipb/hydrant and we'll merge it if it looks good. 52 | 53 | Depending on how you work, you might find `pylint` and/or running individual programs one at a time and then playing around with the Python shell to be helpful. 54 | -------------------------------------------------------------------------------- /scrapers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/scrapers/__init__.py -------------------------------------------------------------------------------- /scrapers/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is the entry point. Run `python3 -m scrapers` to test this code. 3 | 4 | In production, there's a cron job that runs this script every hour. 5 | 6 | Functions: 7 | * run() 8 | """ 9 | 10 | from .fireroad import run as fireroad_run 11 | from .catalog import run as catalog_run 12 | from .cim import run as cim_run 13 | from .package import run as package_run 14 | 15 | 16 | def run(): 17 | """ 18 | This function is the entry point. There are no arguments. 19 | """ 20 | print("=== Update fireroad data (pre-semester) ===") 21 | fireroad_run(False) 22 | print("=== Update fireroad data (semester) ===") 23 | fireroad_run(True) 24 | print("=== Update catalog data ===") 25 | catalog_run() 26 | print("=== Update CI-M data ===") 27 | cim_run() 28 | print("=== Packaging ===") 29 | package_run() 30 | 31 | 32 | if __name__ == "__main__": 33 | run() 34 | -------------------------------------------------------------------------------- /scrapers/cim.py: -------------------------------------------------------------------------------- 1 | """ 2 | To include CI-M metadata, we scrape the Registrar's website which includes a 3 | list of all CI-M subjects for each course. 4 | 5 | run() scrapes this data and writes it to cim.json, in the format: 6 | 7 | .. code-block:: json 8 | { 9 | "6.1800": { 10 | "cim": [ 11 | "6-1, 6-2, 6-3, 6-4, 6-5, 6-P", 12 | "10C", 13 | "18-C", 14 | ] 15 | } 16 | } 17 | """ 18 | 19 | from __future__ import annotations 20 | 21 | import json 22 | import os.path 23 | from collections import OrderedDict 24 | from collections.abc import Iterable 25 | 26 | 27 | import requests 28 | from bs4 import BeautifulSoup, Tag 29 | 30 | # pylint: disable=line-too-long 31 | CIM_URL = "https://registrar.mit.edu/registration-academics/academic-requirements/communication-requirement/ci-m-subjects/subject" 32 | 33 | 34 | def get_sections() -> Iterable[Tag]: 35 | """ 36 | Scrapes accordion sections from Registrar page that contains lists of CI-M 37 | 38 | Returns: 39 | Iterable[bs4.element.Tag]: The accordion sections that contain lists of CI-M 40 | subjects 41 | """ 42 | cim_req = requests.get( 43 | CIM_URL, 44 | timeout=5, 45 | ) 46 | soup = BeautifulSoup(cim_req.text, "html.parser") 47 | 48 | return ( 49 | item 50 | for item in soup.select("[data-accordion-item]") 51 | if item.select(".ci-m__section") 52 | ) 53 | 54 | 55 | def get_courses(section: Tag) -> OrderedDict[str, set[str]]: 56 | """ 57 | Extracts the courses contained in a section and their corresponding CI-M 58 | subjects. 59 | 60 | Args: 61 | section (bs4.element.Tag): from get_sections() 62 | 63 | Returns: 64 | OrderedDict[str, set[str]]: A mapping from each course (major) contained 65 | within the given section to the set of subject numbers (classes) that may 66 | satisfy the CI-M requirement for that course number. 67 | """ 68 | courses: OrderedDict[str, set[str]] = OrderedDict() 69 | for subsec in section.select(".ci-m__section"): 70 | title = subsec.select_one(".ci-m__section-title").text.strip().replace("*", "") # type: ignore 71 | 72 | # If no title, add to the previous subsection 73 | if title: 74 | subjects: set[str] = set() 75 | else: 76 | title, subjects = courses.popitem() 77 | 78 | subjects |= { 79 | subj.text.strip() for subj in subsec.select(".ci-m__subject-number") 80 | } 81 | courses[title] = subjects 82 | return courses 83 | 84 | 85 | def run() -> None: 86 | """ 87 | The main entry point. 88 | """ 89 | sections = get_sections() 90 | 91 | # This maps each course number to a set of CI-M subjects for that course 92 | courses: OrderedDict[str, set[str]] = OrderedDict() 93 | for section in sections: 94 | new_courses = get_courses(section) 95 | assert new_courses.keys().isdisjoint(courses.keys()) 96 | courses.update(new_courses) 97 | 98 | # This maps each subject to a list of courses for which it is a CI-M 99 | subjects: dict[str, dict[str, list[str]]] = {} 100 | for course in courses: 101 | for subj in courses[course]: 102 | for number in subj.replace("J", "").split("/"): 103 | subjects.setdefault(number, {"cim": []})["cim"].append(course) 104 | 105 | fname = os.path.join(os.path.dirname(__file__), "cim.json") 106 | with open(fname, "w", encoding="utf-8") as cim_file: 107 | json.dump(subjects, cim_file) 108 | 109 | 110 | if __name__ == "__main__": 111 | run() 112 | -------------------------------------------------------------------------------- /scrapers/math_dept.py: -------------------------------------------------------------------------------- 1 | """ 2 | Temporary workaround to the math classes being wrong (2023). 3 | Was used to generate the math overrides in package.py; currently unnecessary. 4 | 5 | Functions: 6 | parse_when(when) 7 | test_parse_when() 8 | parse_many_timeslots(days, times) 9 | make_raw_sections(days, times, room): 10 | make_section_override(timeslots, room) 11 | get_rows() 12 | parse_subject(subject) 13 | parse_row(row) 14 | run() 15 | """ 16 | 17 | from __future__ import annotations 18 | 19 | from pprint import pprint 20 | from collections.abc import Iterable, Sequence 21 | from typing import Union 22 | from bs4 import BeautifulSoup, Tag 23 | import requests 24 | from .fireroad import parse_timeslot, parse_section 25 | 26 | 27 | def parse_when(when: str) -> tuple[str, str]: 28 | """ 29 | Parses when the class happens. 30 | 31 | Args: 32 | when (str): A string describing when the class happens. 33 | 34 | Returns: 35 | tuple[str, str]: A parsed version of this string. 36 | """ 37 | # special casing is good enough (otherwise this could be a for loop) 38 | if when[1].isdigit(): 39 | day_times = when[:1], when[1:] 40 | elif when[2].isdigit(): 41 | day_times = when[:2], when[2:] 42 | elif when[3].isdigit(): 43 | day_times = when[:3], when[3:] 44 | else: 45 | assert False 46 | days, times = day_times 47 | # fireroad.py wants dots instead of colons 48 | times = times.replace(":", ".") 49 | return days, times 50 | 51 | 52 | def test_parse_when() -> None: 53 | """ 54 | Test cases for parse_when 55 | """ 56 | assert parse_when("F10:30-12") == ("F", "10.30-12") 57 | assert parse_when("MW1") == ("MW", "1") 58 | assert parse_when("MWF11") == ("MWF", "11") 59 | 60 | 61 | def parse_many_timeslots(days: str, times: str) -> Iterable[tuple[int, int]]: 62 | """ 63 | Parses many timeslots 64 | 65 | Args: 66 | day (str): A list of days 67 | times (str): The timeslot 68 | 69 | Returns: 70 | Iterable[tuple[int, int]]: All of the parsed timeslots, as a list 71 | """ 72 | # parse timeslot wants only one letter 73 | return (parse_timeslot(day, times, False) for day in days) 74 | 75 | 76 | def make_raw_sections(days: str, times: str, room: str) -> str: 77 | """ 78 | Formats a raw section 79 | 80 | Args: 81 | room (str): The room 82 | days (str): The days 83 | times (str): The times 84 | 85 | Returns: 86 | str: The room, days, and times, presented as a single string 87 | """ 88 | return f"{room}/{days}/0/{times}" 89 | 90 | 91 | def make_section_override( 92 | timeslots: Sequence[Sequence[int]], room: str 93 | ) -> tuple[tuple[Sequence[Sequence[int]], str]]: 94 | """ 95 | Makes a section override 96 | 97 | Args: 98 | timeslots (Sequence[Sequence[int]]): The timeslots of the section 99 | room (str): The room 100 | 101 | Returns: 102 | tuple[tuple[Sequence[Sequence[int]], str]]: The section override 103 | """ 104 | return ((timeslots, room),) 105 | # lol this is wrong 106 | # return [[section, room] for section in timeslots] 107 | 108 | 109 | def get_rows(): 110 | """ 111 | Scrapes rows from https://math.mit.edu/academics/classes.html 112 | 113 | Returns: 114 | bs4.element.ResultSet: The rows of the table listing classes 115 | """ 116 | response = requests.get("https://math.mit.edu/academics/classes.html", timeout=1) 117 | soup = BeautifulSoup(response.text, features="lxml") 118 | course_list: Tag = soup.find("ul", {"class": "course-list"}) # type: ignore 119 | rows = course_list.findAll("li", recursive=False) 120 | return rows 121 | 122 | 123 | def parse_subject(subject: str) -> list[str]: 124 | """ 125 | Parses the subject 126 | 127 | Args: 128 | subject (str): The subject name to parse 129 | 130 | Returns: 131 | list[str]: A clean list of subjects corresponding to that subject. 132 | """ 133 | # remove "J" from joint subjects 134 | subject = subject.replace("J", "") 135 | 136 | # special case specific to math, if a slash it means that there 137 | # is an additional graduate subject ending in 1 138 | if " / " in subject: 139 | subject = subject.split(" / ")[0] 140 | subjects = [subject, f"{subject}1"] 141 | else: 142 | subjects = [subject] 143 | assert ["/" not in subject for subject in subjects] 144 | 145 | return subjects 146 | 147 | 148 | def parse_row( 149 | row: Tag, 150 | ) -> dict[str, dict[str, Union[str, tuple[tuple[Sequence[Sequence[int]], str]]]]]: 151 | """ 152 | Parses the provided row 153 | 154 | Args: 155 | row (bs4.element.Tag): The row that needs to be parsed. 156 | 157 | Returns: 158 | dict[str, dict[str, Union[str, tuple[tuple[Sequence[Sequence[int]], str]]]]]: 159 | The parsed row 160 | """ 161 | result: dict[ 162 | str, dict[str, Union[str, tuple[tuple[Sequence[Sequence[int]], str]]]] 163 | ] = {} 164 | 165 | subject: str = row.find("div", {"class": "subject"}).text # type: ignore 166 | subjects = parse_subject(subject) 167 | 168 | where_when: Tag = row.find("div", {"class": "where-when"}) # type: ignore 169 | when, where = where_when.findAll("div", recursive=False) 170 | where = where.text 171 | when = when.text 172 | if ";" in when: 173 | # Don't want to handle special case - calculus, already right 174 | return {} 175 | days, times = parse_when(when) 176 | timeslots = parse_many_timeslots(days, times) 177 | for subject in subjects: 178 | lecture_raw_sections = make_raw_sections(days, times, where) 179 | lecture_sections = make_section_override(list(timeslots), where) 180 | result[subject] = { 181 | "lectureRawSections": lecture_raw_sections, 182 | "lectureSections": lecture_sections, 183 | } 184 | # Make sure the raw thing that I do not comprehend is actually correct 185 | assert parse_section(lecture_raw_sections) == lecture_sections[0] 186 | return result 187 | 188 | 189 | def run() -> ( 190 | dict[str, dict[str, Union[str, tuple[tuple[Sequence[Sequence[int]], str]]]]] 191 | ): 192 | """ 193 | The main entry point 194 | 195 | Returns: 196 | dict[str, dict[str, Union[str, tuple[tuple[Sequence[Sequence[int]], str]]]]]: 197 | All the schedules 198 | """ 199 | rows = get_rows() 200 | overrides: dict[ 201 | str, dict[str, Union[str, tuple[tuple[Sequence[Sequence[int]], str]]]] 202 | ] = {} 203 | 204 | for row in rows: 205 | parsed_row = parse_row(row) 206 | overrides.update(parsed_row) 207 | 208 | return overrides 209 | 210 | 211 | if __name__ == "__main__": 212 | test_parse_when() 213 | pprint(run()) 214 | -------------------------------------------------------------------------------- /scrapers/overrides.toml.d/12.toml: -------------------------------------------------------------------------------- 1 | #:schema ./override-schema.json 2 | # Course 12 special subjects (fall 2025) 3 | 4 | ['12.S593'] 5 | name = "Special Seminar in EAPS-Exploring Solar System Surfaces (No Spacesuit Required)" 6 | description = "Investigates the diverse surfaces of planetary bodies throughout our Solar System through the lens of remote sensing and robotic exploration. Examines how mission data reveals the formation, evolution, and current state of rocky planets, icy moons, and small bodies. Compares and contrasts geological processes across different planetary environments, from Mercury's extreme temperature variations to the subsurface oceans of Europa and Enceladus. Topics may include impact cratering; volcanism; tectonics; weathering and erosion processes; sedimentary deposits; glacial and periglacial features; and the search for habitable environments. Analyzes datasets of planetary surfaces from past and current missions (Cassini, New Horizons, OSIRIS-REx, Mars rovers, and more) alongside recent scientific literature. Emphasizes critical reading, student-led discussions, and comparative planetology approaches, with a component of hypothesis-driven mission concepts driven by current scientific advances." 7 | inCharge = "G. Stucky de Quay" 8 | level = "G" 9 | isVariableUnits = true 10 | -------------------------------------------------------------------------------- /scrapers/overrides.toml.d/15.toml: -------------------------------------------------------------------------------- 1 | #:schema ./override-schema.json 2 | # Course 15 special subjects (fall 2025) 3 | 4 | ["15.S03"] 5 | name = "SSIM: AI and Money" 6 | level = "G" 7 | lectureUnits = 3 8 | labUnits = 0 9 | preparationUnits = 6 10 | description = "'AI & Money' examines the evolving impact of artificial inteligence on money & finance. The course helps equip students with critical reasoning skills to understand AI driven innovation in finance.\n\nWe will explore how machine learning, generative AI, and advanced analytics are rdefining customer interactions, investing, payments, risk management, trading, underwriting, and compliance. The course also will touch on real-world applications including cyber risk, fraud detection, and AI supply chain decisions.\n\nThe course will review how AI developments could impact markets, economics, and monetary policy. Students will gain an understanding of these emerging technologies, along with their regulatory frameworks around the globe." 11 | inCharge = "Gary Gensler" 12 | -------------------------------------------------------------------------------- /scrapers/overrides.toml.d/21a.toml: -------------------------------------------------------------------------------- 1 | #:schema ./override-schema.json 2 | # Course 21A special subjects (fall 2025) 3 | 4 | ['21A.S01'] 5 | name = "Special Topic: Reimagining Indigeneity — Pathways of Identity, Cultural Expression, and Continuity in a Changing World" 6 | hassS = true 7 | lectureRawSections = ['4-163/W/0/11-2'] 8 | lectureSections = [[[[66, 6]], '4-163']] 9 | inCharge = "J. Knox-Hayes, L. Jonas" 10 | -------------------------------------------------------------------------------- /scrapers/overrides.toml.d/21g.toml: -------------------------------------------------------------------------------- 1 | #:schema ./override-schema.json 2 | # Course 21G special subjects (fall 2025) 3 | 4 | ['21G.312'] 5 | name = "Basic Themes in French Literature and Culture: Science, Mysteries, and the Francophone World" 6 | description = "Discover the unknown through the stories of the French-speaking world. This course is designed for intermediate and advanced French learners who are curious about science, innovation, and culture. Through exciting topics like Marie Curie, artificial intelligence, climate change, and science fiction, you will improve your French while exploring the mysteries and big questions of our time — from the past to the future, and from Europe to Africa to the Caribbean." 7 | -------------------------------------------------------------------------------- /scrapers/overrides.toml.d/21l.toml: -------------------------------------------------------------------------------- 1 | #:schema ./override-schema.json 2 | # Course 21L special subjects (fall 2025) 3 | 4 | ['21L.003'] 5 | name = "Jane Austen: Reading Fiction" 6 | 7 | ['21L.050'] 8 | name = "The Art of Seeing Things: Reading Nonfiction" 9 | 10 | ['21L.345'] 11 | name = "Around the World in Short Film: On the Screen" 12 | 13 | ['21L.433'] 14 | name = "Kubrick: Film Styles and Genres" 15 | 16 | ['21L.450'] 17 | name = "Ecofeminism: Global Environmental Literature" 18 | 19 | ['21L.475'] 20 | name = "Writers Responding to a Rapidly Modernizing World: Enlightenment and Modernity" 21 | 22 | ['21L.S89'] 23 | name = "French Modernity: Special Subject in Literature" 24 | 25 | ['21L.703'] 26 | name = "Murder & Mayhem Remade: Studies in Drama" 27 | 28 | ['21L.704'] 29 | name = "The Poetry of Witness: Studies in Poetry" 30 | 31 | ['21L.706'] 32 | name = "On Love: Studies in Film" 33 | 34 | ['21L.707'] 35 | name = "The Art of War and Peace: Problems in Cultural Interpretation" 36 | -------------------------------------------------------------------------------- /scrapers/overrides.toml.d/21m.toml: -------------------------------------------------------------------------------- 1 | ["21M.139"] 2 | name = "Introduction to Arranging" 3 | oldNumber = "Moments in Music: Composition B" 4 | prereqs = " ''Permission of instructor''" 5 | level = "U" 6 | lectureUnits = 2 7 | labUnits = 0 8 | preparationUnits = 4 9 | description = "Do you love listening to different covers of your favorite artists and songs? Are you intrigued by how a simple melody can be heard in a variety of colors and styles by different ensembles and instruments? The craft of arranging previously composed music, whether one’s own or another’s, is a way to express oneself musically in a variety of timbres, sounds, and textures. We will explore arranging as a bi-directional process: reducing a large score to a piano reduction and taking something as basic as a lead sheet melody with chords and expanding it to a larger vocal or instrumental piece. As a final project students will arrange a short piece of their choice for an a cappella or small instrumental ensemble.\n" 10 | hassA = true 11 | inCharge = "Garo Saraydarian" 12 | url = "https://mta.mit.edu/music/class-schedule" 13 | 14 | ["21M.299"] 15 | name = " Pirate Songs & Whaling Chanteys" 16 | oldNumber = "Studies in Global Musics" 17 | prereqs = "21M.030 or permission of Instructor" 18 | level = "U" 19 | lectureUnits = 3 20 | labUnits = 0 21 | preparationUnits = 9 22 | description = "In this class, we will explore the role of music in U.S. maritime culture—both factual and fictional. From African American dockworkers’ songs in 19th-century Southern ports, to chanteys on American whaling vessels, to 20th-century folk revivals, to the singing pirates of film and video games, we will examine the musics that have animated the U.S.’s cultural fascination with the sea. Students will analyze primary source material including song collections, recordings, compositions, and viral social media videos. The class will involve frequent singing as an exploration of the repertoire and the cultural phenomenon of participatory chantey sings. No prior musical experience is needed (we will teach you to sing!).\n\n" 23 | hassA = true 24 | inCharge = "J. Maurer" 25 | url = "https://mta.mit.edu/music/class-schedule" -------------------------------------------------------------------------------- /scrapers/overrides.toml.d/4.toml: -------------------------------------------------------------------------------- 1 | #:schema ./override-schema.json 2 | # Course 6 special subjects (fall 2025) 3 | 4 | ["4.S14"] 5 | name = "Architecture of Longevity: Designs for the Third Age" 6 | description = "If Maria Montessori designed the tools and environment to meet the cognitive and physical stages of children, how might we similarly design our environment to meet the needs of the \"Young Old\"?\n\nThis workshop involves collecting, analyzing and drawing examples of designs for older adults from around the world across three ‘scales’: the body, the room, the street. Students in this course will help build an architectural index useful to help navigate the unprecedented \"Silver Tsunami\" that the United States and other industrialized countries have never before encountered, students will develop a variety of new designs on that can aid in alleviating the double housing and care crises that financially cripples 90% of older adults. Unless redressed, these financial burdens will in turn, fall on the shoulders of younger generations. How can we use architecture to reframe this opportunity and redesign our environments to fully embrace the cognitive, perceptual, and physical changes of humans across all ages — and thrive at each stage?" 7 | inCharge = "R. Segal" 8 | level = "G" 9 | lectureUnits = 2 10 | labUnits = 2 11 | preparationUnits = 5 12 | 13 | ["4.S22"] 14 | name = "System Change" 15 | description = "How do you go from a moment of obligation to starting or accelerating a movement?\n\nThis course explores the difference between innovation, social innovation, and systems change for social impact. Students interested in navigating complex environmental and social problems will explore frameworks and case studies from real systems change innovators to develop a more comprehensive view of complex problems and the systems they are part of —systems that often keep those problems in place.\n\nIn the course, you will apply experiential tools and methods to interrogate your own call to action, strengths, and gaps to address complex problems or needs. You will gain an understanding of the importance of understanding problems from the impact target’s perspective and explore innovative ways to create a scalable movement that ultimately can change a system. The final deliverable from the course is writing a case study on system change based on detailed actor mapping and interviews where you share your deeper understanding of a system you care about." 16 | inCharge = "Y. Jimenez" 17 | level = "G" 18 | lectureUnits = 2 19 | labUnits = 0 20 | preparationUnits = 7 21 | 22 | ["4.S24"] 23 | name = "Creative Careers" 24 | description = "How can you build your own creative practice in today’s international landscape—one that is sustainable, leverages innovation, and contributes meaningfully to the future of the cultural and creative sectors?\n\nThis half-semester course offers you, as a student in the arts, cultural, and creative fields, fundamental tools and strategies for designing your career as an independent professional or studio founder.\n\nYou will:\n\nA) Develop an understanding of the international framework of institutions, relationships, and policies that support professionals aiming to create impact through their creative practice—and learn how this knowledge can help you shape offerings that stand out and create a competitive advantage.\nB) Learn concepts and mechanisms commonly found in the economics of art and culture, and explore how critical issues can be transformed into strategic opportunities.\nC) Examine the diverse types of value generated by cultural production, discover how to combine them into distinctive offerings, and effectively communicate and market your work. You’ll also study business models within the creative industries and develop the adaptability to navigate evolving markets.\nD) Acquire practical skills in branding, legal business structures, and intellectual property—enabling you to protect and leverage your creative output while building a sustainable professional practice." 25 | inCharge = "G. Picchi" 26 | level = "G" 27 | lectureUnits = 3 28 | labUnits = 0 29 | preparationUnits = 3 30 | 31 | ["4.S28"] 32 | name = " X-Machine: AI and Design Innovation" 33 | description = "In an AI-enhanced future, humans will become better at everything. The machine targets real-world artificial intelligence challenges designed to help address issues related to climate change, and urbanization in cities. X Machine is a mini accelerator workshop course designed to unite computer science and design/architecture together to design and create innovative and impactful technological solutions to problems in the built and human environment. This half-semester course promotes the development of strategic thinking and technical exploration in the realm of AI, focusing on problem framing and early-stage ideation.\n\nThe course will allow students to develop a foundational knowledge of AI within an interdisciplinary context. Students will learn how to design and create a prototype, learn how to maximize their engagement with their users/customers, and learn how to determine the value proposition that will make an AI-empowered startup successful. By the end of this class, students will be able to develop a conceptual business plan for an AI-based technology solution and apply to other programs at MIT such as DesignX, Sandbox, Delta V, The Engine, and more. " 34 | inCharge = "N. Bayomi, N. Chang" 35 | lectureUnits = 2 36 | labUnits = 0 37 | preparationUnits = 6 38 | level = "G" 39 | 40 | ["4.S43"] 41 | name = "Applied Category Theory for Engineering Design" 42 | description = "Considers the multiple trade-offs at various abstraction levels and scales when designing complex, multi-component systems. Covers topics from foundational principles to advanced applications, emphasizing the role of compositional thinking in engineering. Introduces category theory as a mathematical framework for abstraction and composition, enabling a unified and modular approach to modeling, analyzing, and designing interconnected systems. Showcases successful applications in areas such as dynamical systems and automated system design optimization, with a focus on autonomous robotics and mobility. Offers students the opportunity to work on their own application through a dedicated project in the second half of the term. \n\nStudents taking graduate version complete additional assignments." 43 | lectureUnits = 3 44 | labUnits = 1 45 | preparationUnits = 8 46 | level = "G" 47 | inCharge = "G. Zardini" 48 | same = "1.144" 49 | 50 | ["4.S65"] 51 | name = "Decolonial Ecologies" 52 | description = "This seminar examines the relationship between political ecology, ecological crises, and the process of (de)colonization. Students will critically analyze historical understandings of decolonization and contemporary proposals for decolonial ecologies. Following Stefanie K. Dunning’s invocation “May our egos die so that the world may live,” this seminar asks, how can we continually transform our praxis on a personal and structural level to create the possibility and space for decolonial ecologies? And whose imaginations are presently shaping our collective futures? Open for cross-registration. And open to undergraduates with instructor’s permission." 53 | isVariableUnits = false 54 | level = "G" 55 | lectureUnits = 3 56 | labUnits = 0 57 | preparationUnits = 9 58 | inCharge = "H. Gupta" 59 | -------------------------------------------------------------------------------- /scrapers/overrides.toml.d/6.toml: -------------------------------------------------------------------------------- 1 | #:schema ./override-schema.json 2 | # Course 6 special subjects (fall 2025) 3 | 4 | ['6.S056'] 5 | name = "Hack Yourself: Data-driven Wellbeing and Learning" 6 | inCharge = "A. Bell" 7 | 8 | ['6.S061'] 9 | name = "Humane User Experience Design" 10 | inCharge = "A. Satyanarayan" 11 | 12 | ['6.S077'] 13 | name = "Life Science and Semiconductor" 14 | meets = "6.S897" 15 | inCharge = "A. Bahai, T. Heldt" 16 | 17 | ['6.S897'] 18 | name = "Life Science and Semiconductor" 19 | meets = "6.S077" 20 | inCharge = "A. Bahai, T. Heldt" 21 | 22 | ['6.S890'] 23 | name = "Topics in Mulitagent Learning" 24 | inCharge = "G. Farina" 25 | 26 | ['6.S892'] 27 | name = "Advanced Topics in Power Electronics" 28 | inCharge = "D. Perreault" 29 | 30 | ['6.S894'] 31 | name = "Accelerated Computing" 32 | inCharge = "J. Ragan-Kelley" 33 | 34 | ['6.S896'] 35 | name = "Algorithmic Statistics" 36 | inCharge = "S. Hopkins" 37 | 38 | ['6.S965'] 39 | name = "Digital Systems Laboratory II" 40 | inCharge = "J. Steinmeyer" 41 | -------------------------------------------------------------------------------- /scrapers/overrides.toml.d/cms.toml: -------------------------------------------------------------------------------- 1 | #:schema ./override-schema.json 2 | # CMS special subjects (fall 2025) 3 | 4 | ["CMS.S61"] 5 | name = "Special Subject: STEAM Learning Architecture: A Framework for Educational Innovation" 6 | meets = "CMS.S97" 7 | hassS = true 8 | level = "U" 9 | lectureUnits = 2 10 | labUnits = 1 11 | preparationUnits = 9 12 | inCharge = "Claudia Urrea" 13 | description = "This course explores the creation of new learning environments and experiences through the lens of the STEAM Learning Architecture, a framework developed by pk-12 @ Open Learning to guide approaches to teaching and learning for educational innovation. The framework prioritizes student-centered, hands-on learning, rooted in the Constructionist theory that the most powerful learning occurs through discovery, exploration, and creation. Guest speakers and site visits will provide context for final projects that produce innovative learning experiences for k-12 audiences. Class meets Tuesdays 10-12, Recitation is Thursdays 10-11." 14 | 15 | ["CMS.S97"] 16 | name = "Special Subject: STEAM Learning Architecture: A Framework for Educational Innovation" 17 | meets = "CMS.S61" 18 | level = "G" 19 | lectureUnits = 2 20 | labUnits = 1 21 | preparationUnits = 9 22 | inCharge = "Claudia Urrea" 23 | description = "This course explores the creation of new learning environments and experiences through the lens of the STEAM Learning Architecture, a framework developed by pk-12 @ Open Learning to guide approaches to teaching and learning for educational innovation. The framework prioritizes student-centered, hands-on learning, rooted in the Constructionist theory that the most powerful learning occurs through discovery, exploration, and creation. Guest speakers and site visits will provide context for final projects that produce innovative learning experiences for k-12 audiences. Class meets Tuesdays 10-12, Recitation is Thursdays 10-11." 24 | -------------------------------------------------------------------------------- /scrapers/package.py: -------------------------------------------------------------------------------- 1 | """ 2 | We combine the data from the Fireroad API and the data we scrape from the 3 | catalog, into the format specified by src/lib/rawClass.ts. 4 | 5 | Functions: 6 | load_json_data(jsonfile): Loads data from the provided JSON file 7 | merge_data(datasets, keys_to_keep): Combines the datasets. 8 | run(): The main entry point. 9 | 10 | Dependencies: 11 | datetime 12 | json 13 | utils (within this folder) 14 | """ 15 | 16 | from __future__ import annotations 17 | 18 | import datetime 19 | import json 20 | import os 21 | import os.path 22 | import sys 23 | from typing import Any, Union 24 | from collections.abc import Iterable 25 | 26 | from .utils import get_term_info 27 | 28 | if sys.version_info >= (3, 11): 29 | import tomllib 30 | else: 31 | import tomli as tomllib 32 | 33 | 34 | package_dir = os.path.dirname(__file__) 35 | 36 | 37 | def load_json_data(json_path: str) -> Any: 38 | """ 39 | Loads data from the provided file 40 | 41 | Args: 42 | * json_path (str): The file to load from 43 | 44 | Returns: 45 | * any: The data contained within the file 46 | """ 47 | json_path = os.path.join(package_dir, json_path) 48 | with open(json_path, mode="r", encoding="utf-8") as json_file: 49 | return json.load(json_file) 50 | 51 | 52 | def load_toml_data(toml_dir: str) -> dict[str, Any]: 53 | """ 54 | Loads data from the provided directory that consists exclusively of TOML files 55 | 56 | Args: 57 | * tomldir (str): The directory to load from 58 | 59 | Returns: 60 | * dict: The data contained within the directory 61 | """ 62 | toml_dir = os.path.join(package_dir, toml_dir) 63 | out: dict[str, Any] = {} 64 | for fname in os.listdir(toml_dir): 65 | if fname.endswith(".toml"): 66 | with open(os.path.join(toml_dir, fname), "rb") as toml_file: 67 | out.update(tomllib.load(toml_file)) 68 | return out 69 | 70 | 71 | def merge_data( 72 | datasets: Iterable[dict[Any, dict[str, Any]]], keys_to_keep: Iterable[str] 73 | ) -> dict[Any, dict[str, Any]]: 74 | """ 75 | Combines the provided datasets, retaining only keys from keys_to_keep. 76 | 77 | .. note:: 78 | Later datasets will override earlier ones 79 | 80 | Args: 81 | * datasets (iterable[dict[any, dict]]): 82 | * keys_to_keep (iterable): The keys to retain in the output 83 | 84 | Returns: 85 | * dict[any, dict]: The combined data 86 | """ 87 | result: dict[str, dict[str, Any]] = {k: {} for k in keys_to_keep} 88 | for key in keys_to_keep: 89 | for dataset in datasets: 90 | if key in dataset: 91 | result[key].update(dataset[key]) 92 | return result 93 | 94 | 95 | # pylint: disable=too-many-locals 96 | def run() -> None: 97 | """ 98 | The main entry point. 99 | Takes data from fireroad.json and catalog.json; outputs latest.json. 100 | There are no arguments and no return value. 101 | """ 102 | fireroad_presem = load_json_data("fireroad-presem.json") 103 | fireroad_sem = load_json_data("fireroad-sem.json") 104 | catalog = load_json_data("catalog.json") 105 | cim = load_json_data("cim.json") 106 | overrides = load_toml_data("overrides.toml.d") 107 | 108 | # The key needs to be in BOTH fireroad and catalog to make it: 109 | # If it's not in Fireroad, it's not offered in this semester (fall, etc.). 110 | # If it's not in catalog, it's not offered this year. 111 | courses_presem = merge_data( 112 | datasets=[fireroad_presem, catalog, cim, overrides], 113 | keys_to_keep=set(fireroad_presem) & set(catalog), 114 | ) 115 | courses_sem = merge_data( 116 | datasets=[fireroad_sem, catalog, cim, overrides], 117 | keys_to_keep=set(fireroad_sem) & set(catalog), 118 | ) 119 | 120 | term_info_presem = get_term_info(False) 121 | url_name_presem = term_info_presem["urlName"] 122 | term_info_sem = get_term_info(True) 123 | url_name_sem = term_info_sem["urlName"] 124 | now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") 125 | 126 | obj_presem: dict[str, Union[dict[str, Any], str, dict[Any, dict[str, Any]]]] = { 127 | "termInfo": term_info_presem, 128 | "lastUpdated": now, 129 | "classes": courses_presem, 130 | } 131 | obj_sem: dict[str, Union[dict[str, Any], str, dict[Any, dict[str, Any]]]] = { 132 | "termInfo": term_info_sem, 133 | "lastUpdated": now, 134 | "classes": courses_sem, 135 | } 136 | 137 | with open( 138 | os.path.join(package_dir, f"../public/{url_name_presem}.json"), 139 | mode="w", 140 | encoding="utf-8", 141 | ) as presem_file: 142 | json.dump(obj_presem, presem_file, separators=(",", ":")) 143 | 144 | with open( 145 | os.path.join(package_dir, "../public/latest.json"), mode="w", encoding="utf-8" 146 | ) as latest_file: 147 | json.dump(obj_sem, latest_file, separators=(",", ":")) 148 | 149 | print(f"{url_name_presem}: got {len(courses_presem)} courses") 150 | print(f"{url_name_sem}: got {len(courses_sem)} courses") 151 | 152 | 153 | if __name__ == "__main__": 154 | run() 155 | -------------------------------------------------------------------------------- /scrapers/text_mining.py: -------------------------------------------------------------------------------- 1 | """ 2 | Mines hydrant data 3 | 4 | Functions: 5 | has_keyword(sometext) 6 | find_key_sentences(sometext) 7 | get_description_list(dataset) 8 | get_my_data() 9 | find_matching_records(descriptions) 10 | run() 11 | 12 | Constants: 13 | KEYWORDS 14 | FOLDER 15 | FILEPATHS 16 | """ 17 | 18 | from __future__ import annotations 19 | 20 | import json 21 | from typing import Iterable 22 | from collections.abc import Mapping 23 | from nltk.tokenize import word_tokenize, sent_tokenize # type: ignore 24 | 25 | KEYWORDS = ["limited", "restricted", "enrollment", "preference", "priority"] 26 | FOLDER = "../public/" 27 | FILEPATHS = ["f22.json", "f23.json", "f24.json", "i25.json", "s23.json", "s24.json"] 28 | 29 | 30 | def has_keyword(sometext: str) -> bool: 31 | """ 32 | Checks if the given text contains any of the keywords. 33 | 34 | Args: 35 | sometext (str): The text to search for keywords 36 | 37 | Returns: 38 | bool: True if sometext contains a keyword, False otherwise 39 | """ 40 | words = word_tokenize(sometext) # word_tokenize better than the in operator 41 | lowered_words = [w.lower() for w in words] # make it case insensitive 42 | for keyword in KEYWORDS: 43 | if keyword in lowered_words: 44 | return True 45 | return False 46 | 47 | 48 | def find_key_sentences(sometext: str) -> list[str]: 49 | """ 50 | Returns a list of all sentences that contain a keyword 51 | 52 | Args: 53 | sometext (str): The text to search for keywords 54 | 55 | Returns: 56 | list[str]: A list of sentences that contain a keyword 57 | """ 58 | my_sentences = sent_tokenize(sometext) # sent_tokenize is much better than .split() 59 | return [sentence for sentence in my_sentences if has_keyword(sentence)] 60 | 61 | 62 | def get_description_list( 63 | dataset: Mapping[str, Mapping[str, Mapping[str, str]]], 64 | ) -> list[str]: 65 | """ 66 | Obtains a list of descriptions from the dataset 67 | 68 | Args: 69 | dataset (Mapping[str, Mapping[str, Mapping[str, str]]]): 70 | The dataset containing class information 71 | 72 | Returns: 73 | list[str]: A list of descriptions from the dataset 74 | """ 75 | classlist = dataset["classes"].values() 76 | return [record["description"] for record in classlist] 77 | 78 | 79 | def get_my_data() -> list[str]: 80 | """ 81 | obtains the data 82 | 83 | Returns: 84 | list[str]: A list of descriptions from all the JSON files 85 | """ 86 | descriptions: list[str] = [] 87 | for filepath in FILEPATHS: 88 | full_path = FOLDER + filepath 89 | with open(full_path, "r", encoding="utf-8") as file: 90 | rawdata = json.load(file) 91 | descriptions.extend(get_description_list(rawdata)) 92 | return descriptions 93 | 94 | 95 | def find_matching_records(descriptions: Iterable[str]) -> list[str]: 96 | """ 97 | find sentences from record descriptions that contain a keyword 98 | 99 | Args: 100 | descriptions (Iterable[str]): A list of descriptions to search for keywords 101 | 102 | Returns: 103 | list[str]: A sorted list of unique sentences that contain a keyword 104 | """ 105 | result: list[str] = [] 106 | for description in descriptions: 107 | result.extend(find_key_sentences(description)) 108 | return list(sorted(set(result))) 109 | 110 | 111 | def run() -> None: 112 | """ 113 | The main function! 114 | """ 115 | mydata = get_my_data() 116 | mymatches = find_matching_records(mydata) 117 | for match in mymatches: 118 | print(match) 119 | 120 | 121 | if __name__ == "__main__": 122 | run() 123 | -------------------------------------------------------------------------------- /scrapers/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utility data and functions for the scrapers folder. 3 | 4 | Data: 5 | GIR_REWRITE: dict[str, str] 6 | TIMESLOTS: int 7 | DAYS: dict[str, int] 8 | TIMES: dict[str, int] 9 | EVE_TIMES: dict[str, int] 10 | Term: enum.EnumType 11 | 12 | Functions: 13 | find_timeslot(day, slot, pm) 14 | zip_strict(*iterables) 15 | grouper(iterable, n) 16 | get_term_info() 17 | """ 18 | 19 | from itertools import zip_longest 20 | import json 21 | import os.path 22 | from enum import Enum 23 | from typing import Any, Dict, Generator, Iterable, Tuple 24 | 25 | GIR_REWRITE = { 26 | "GIR:CAL1": "Calculus I (GIR)", 27 | "GIR:CAL2": "Calculus II (GIR)", 28 | "GIR:PHY1": "Physics I (GIR)", 29 | "GIR:PHY2": "Physics II (GIR)", 30 | "GIR:CHEM": "Chemistry (GIR)", 31 | "GIR:BIOL": "Biology (GIR)", 32 | } 33 | 34 | TIMESLOTS = 30 35 | 36 | DAYS = { 37 | "M": 0, 38 | "T": TIMESLOTS, 39 | "W": TIMESLOTS * 2, 40 | "R": TIMESLOTS * 3, 41 | "F": TIMESLOTS * 4, 42 | } 43 | 44 | TIMES = { 45 | "8": 0, 46 | "8.30": 1, 47 | "9": 2, 48 | "9.30": 3, 49 | "10": 4, 50 | "10.30": 5, 51 | "11": 6, 52 | "11.30": 7, 53 | "12": 8, 54 | "12.30": 9, 55 | "1": 10, 56 | "1.30": 11, 57 | "2": 12, 58 | "2.30": 13, 59 | "3": 14, 60 | "3.30": 15, 61 | "4": 16, 62 | "4.30": 17, 63 | "5": 18, 64 | "5.30": 19, 65 | } 66 | 67 | EVE_TIMES = { 68 | "12": 8, 69 | "12.30": 9, 70 | "1": 10, 71 | "1.30": 11, 72 | "2": 12, 73 | "2.30": 13, 74 | "3": 14, 75 | "3.30": 15, 76 | "4": 16, 77 | "4.30": 17, 78 | "5": 18, 79 | "5.30": 19, 80 | "6": 20, 81 | "6.30": 21, 82 | "7": 22, 83 | "7.30": 23, 84 | "8": 24, 85 | "8.30": 25, 86 | "9": 26, 87 | "9.30": 27, 88 | "10": 28, 89 | "10.30": 29, 90 | } 91 | 92 | MONTHS = { 93 | "jan": 1, 94 | "feb": 2, 95 | "mar": 3, 96 | "apr": 4, 97 | "may": 5, 98 | "jun": 6, 99 | "jul": 7, 100 | "aug": 8, 101 | "sep": 9, 102 | "oct": 10, 103 | "nov": 11, 104 | "dec": 12, 105 | } 106 | 107 | 108 | class Term(Enum): 109 | """Terms for the academic year.""" 110 | 111 | FA = "fall" 112 | JA = "IAP" 113 | SP = "spring" 114 | SU = "summer" 115 | 116 | 117 | def find_timeslot(day: str, slot: str, is_slot_pm: bool) -> int: 118 | """ 119 | Finds the numeric code for a timeslot. 120 | Example: find_timeslot("W", "11.30", False) -> 67 121 | 122 | Args: 123 | day (str): The day of the timeslot 124 | slot (str): The time of the timeslot 125 | is_slot_pm (bool): Whether the timeslot is in the evening 126 | 127 | Raises: 128 | ValueError: If no matching timeslot could be found. 129 | 130 | Returns: 131 | int: A numeric code for the timeslot 132 | """ 133 | time_dict = EVE_TIMES if is_slot_pm else TIMES 134 | if day not in DAYS or slot not in time_dict: # error handling! 135 | raise ValueError(f"Invalid timeslot {day}, {slot}, {is_slot_pm}") 136 | return DAYS[day] + time_dict[slot] 137 | 138 | 139 | def zip_strict(*iterables: Iterable[Any]) -> Generator[Tuple[Any, ...], Any, None]: 140 | """ 141 | Helper function for grouper. 142 | Groups values of the iterator on the same iteration together. 143 | 144 | Raises: 145 | ValueError: If iterables have different lengths. 146 | 147 | Yields: 148 | Tuple[Any, ...]: A generator, which you can iterate over. 149 | """ 150 | sentinel = object() 151 | for group in zip_longest(*iterables, fillvalue=sentinel): 152 | if any(sentinel is t for t in group): 153 | raise ValueError("Iterables have different lengths") 154 | yield group 155 | 156 | 157 | def grouper( 158 | iterable: Iterable[Any], group_size: int 159 | ) -> Generator[Tuple[Any, ...], Any, None]: 160 | """ 161 | Groups items of the iterable in equally spaced blocks of group_size items. 162 | If the iterable's length ISN'T a multiple of group_size, you'll get a 163 | ValueError on the last iteration. 164 | 165 | >>> list(grouper("ABCDEFGHI", 3)) 166 | [('A', 'B', 'C'), ('D', 'E', 'F'), ('G', 'H', 'I')] 167 | 168 | From https://docs.python.org/3/library/itertools.html#itertools-recipes. 169 | 170 | Args: 171 | iterable (Iterable[Any]): an iterator 172 | group_size (int): The size of the groups 173 | 174 | Returns: 175 | Generator[Tuple[Any, ...], Any, None]: 176 | The result of the grouping, which you can iterate over. 177 | """ 178 | args = [iter(iterable)] * group_size 179 | return zip_strict(*args) 180 | 181 | 182 | def get_term_info(is_semester_term: bool) -> Dict[str, Any]: 183 | """ 184 | Gets the latest term info from "../public/latestTerm.json" as a dictionary. 185 | If is_semester_term = True, looks at semester term (fall/spring). 186 | If is_semester_term = False, looks at pre-semester term (summer/IAP) 187 | 188 | Args: 189 | is_semester_term (bool): whether to look at the semester 190 | or the pre-semester term. 191 | 192 | Returns: 193 | Dict[str, Any]: the term info for the selected term from latestTerm.json. 194 | """ 195 | fname = os.path.join(os.path.dirname(__file__), "../public/latestTerm.json") 196 | with open(fname, encoding="utf-8") as latest_term_file: 197 | term_info = json.load(latest_term_file) 198 | if is_semester_term: 199 | return term_info["semester"] 200 | 201 | return term_info["preSemester"] 202 | 203 | 204 | def url_name_to_term(url_name: str) -> Term: 205 | """ 206 | Extract the term (without academic year) from a urlName. 207 | 208 | >>> url_name_to_term("f24") 209 | Term.FA 210 | 211 | Args: 212 | url_name (string): a urlName representing a term, as found in latestTerm.json. 213 | 214 | Raises: 215 | ValueError: If the url_name does not start with a valid term character. 216 | 217 | Returns: 218 | Term: the enum value corresponding to the current term (without academic year). 219 | """ 220 | if url_name[0] == "f": 221 | return Term.FA 222 | if url_name[0] == "i": 223 | return Term.JA 224 | if url_name[0] == "s": 225 | return Term.SP 226 | if url_name[0] == "m": 227 | return Term.SU 228 | 229 | raise ValueError(f"Invalid term {url_name[0]}") 230 | -------------------------------------------------------------------------------- /src/assets/Lab.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/Lab.gif -------------------------------------------------------------------------------- /src/assets/PartLab.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/PartLab.gif -------------------------------------------------------------------------------- /src/assets/calendar-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/calendar-button.png -------------------------------------------------------------------------------- /src/assets/cih.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/cih.gif -------------------------------------------------------------------------------- /src/assets/cihw.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/cihw.gif -------------------------------------------------------------------------------- /src/assets/fall.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/fall.gif -------------------------------------------------------------------------------- /src/assets/fuzzAndAnt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/fuzzAndAnt.png -------------------------------------------------------------------------------- /src/assets/fuzzball.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/fuzzball.png -------------------------------------------------------------------------------- /src/assets/grad.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/grad.gif -------------------------------------------------------------------------------- /src/assets/hassA.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/hassA.gif -------------------------------------------------------------------------------- /src/assets/hassE.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/hassE.gif -------------------------------------------------------------------------------- /src/assets/hassH.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/hassH.gif -------------------------------------------------------------------------------- /src/assets/hassS.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/hassS.gif -------------------------------------------------------------------------------- /src/assets/hydraAnt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/hydraAnt.png -------------------------------------------------------------------------------- /src/assets/iap.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/iap.gif -------------------------------------------------------------------------------- /src/assets/logo-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 14 | 17 | 21 | 25 | 29 | 33 | 37 | 41 | 45 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 14 | 17 | 21 | 25 | 29 | 33 | 37 | 41 | 45 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/assets/nonext.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/nonext.gif -------------------------------------------------------------------------------- /src/assets/repeat.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/repeat.gif -------------------------------------------------------------------------------- /src/assets/rest.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/rest.gif -------------------------------------------------------------------------------- /src/assets/simple-fuzzball.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/simple-fuzzball.png -------------------------------------------------------------------------------- /src/assets/spring.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/spring.gif -------------------------------------------------------------------------------- /src/assets/summer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/summer.gif -------------------------------------------------------------------------------- /src/assets/under.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/under.gif -------------------------------------------------------------------------------- /src/components/ActivityDescription.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { decode } from "html-entities"; 3 | 4 | import { Flex, Heading, Image, Link, Text, Button } from "@chakra-ui/react"; 5 | import { useColorMode } from "./ui/color-mode"; 6 | import { Tooltip } from "./ui/tooltip"; 7 | 8 | import type { NonClass } from "../lib/activity"; 9 | import type { Flags } from "../lib/class"; 10 | import { Class, DARK_IMAGES, getFlagImg } from "../lib/class"; 11 | import { linkClasses } from "../lib/utils"; 12 | import { HydrantContext } from "../lib/hydrant"; 13 | 14 | import { ClassButtons, NonClassButtons } from "./ActivityButtons"; 15 | import { LuExternalLink } from "react-icons/lu"; 16 | 17 | /** A small image indicating a flag, like Spring or CI-H. */ 18 | function TypeSpan(props: { flag?: keyof Flags; title: string }) { 19 | const { flag, title } = props; 20 | const { colorMode } = useColorMode(); 21 | const filter = 22 | colorMode === "dark" && DARK_IMAGES.includes(flag ?? "") ? "invert()" : ""; 23 | 24 | return flag ? ( 25 | 26 | {title} 33 | 34 | ) : ( 35 | <>{title} 36 | ); 37 | } 38 | 39 | /** Header for class description; contains flags and related classes. */ 40 | function ClassTypes(props: { cls: Class }) { 41 | const { cls } = props; 42 | const { state } = useContext(HydrantContext); 43 | const { flags, totalUnits, units } = cls; 44 | 45 | /** 46 | * Wrap a group of flags in TypeSpans. 47 | * 48 | * @param arr - Arrays with [flag name, alt text]. 49 | */ 50 | const makeFlags = (arr: [keyof Flags, string][]) => 51 | arr 52 | .filter(([flag, _]) => flags[flag]) 53 | .map(([flag, title]) => ( 54 | 55 | )); 56 | 57 | const currentYear = parseInt(state.term.fullRealYear); 58 | const nextAcademicYearStart = 59 | state.term.semester === "f" ? currentYear + 1 : currentYear; 60 | const nextAcademicYearEnd = nextAcademicYearStart + 1; 61 | 62 | const types1 = makeFlags([ 63 | [ 64 | "nonext", 65 | `Not offered ${nextAcademicYearStart.toString()}-${nextAcademicYearEnd.toString()}`, 66 | ], 67 | ["under", "Undergrad"], 68 | ["grad", "Graduate"], 69 | ]); 70 | 71 | const seasons = makeFlags([ 72 | ["fall", "Fall"], 73 | ["iap", "IAP"], 74 | ["spring", "Spring"], 75 | ["summer", "Summer"], 76 | ]) 77 | .map((tag) => [tag, ", "]) 78 | .flat() 79 | .slice(0, -1); 80 | 81 | const types2 = makeFlags([ 82 | ["repeat", "Can be repeated for credit"], 83 | ["rest", "REST"], 84 | ["Lab", "Institute Lab"], 85 | ["PartLab", "Partial Institute Lab"], 86 | ["hassH", "HASS-H"], 87 | ["hassA", "HASS-A"], 88 | ["hassS", "HASS-S"], 89 | ["hassE", "HASS-E"], 90 | ["cih", "CI-H"], 91 | ["cihw", "CI-HW"], 92 | ]); 93 | 94 | const halfType = 95 | flags.half === 1 ? ( 96 | 97 | ) : flags.half === 2 ? ( 98 | 99 | ) : ( 100 | "" 101 | ); 102 | 103 | const unitsDescription = cls.isVariableUnits 104 | ? "Units arranged" 105 | : `${totalUnits.toString()} units: ${units.join("-")}`; 106 | 107 | return ( 108 | 109 | 110 | {types1} 111 | 112 | ({seasons}) 113 | 114 | {types2} 115 | {halfType} 116 | 117 | {unitsDescription} 118 | {flags.final ? Has final : null} 119 | 120 | ); 121 | } 122 | 123 | /** List of related classes, appears after flags and before description. */ 124 | function ClassRelated(props: { cls: Class }) { 125 | const { cls } = props; 126 | const { state } = useContext(HydrantContext); 127 | const { prereq, same, meets } = cls.related; 128 | 129 | return ( 130 | <> 131 | Prereq: {linkClasses(state, prereq)} 132 | {same !== "" && Same class as: {linkClasses(state, same)}} 133 | {meets !== "" && Meets with: {linkClasses(state, meets)} } 134 | 135 | ); 136 | } 137 | 138 | /** List of programs for which this class is a CI-M. */ 139 | function ClassCIM(props: { cls: Class }) { 140 | const { cls } = props; 141 | const { cim } = cls; 142 | 143 | const url = 144 | "https://registrar.mit.edu/registration-academics/academic-requirements/communication-requirement/ci-m-subjects/subject"; 145 | 146 | if (cim.length > 0) { 147 | return ( 148 | 149 | CI-M for: {cim.join("; ")} ( 150 | 151 | more info 152 | 153 | ) 154 | 155 | ); 156 | } else { 157 | return null; 158 | } 159 | } 160 | 161 | /** Class evaluation info. */ 162 | function ClassEval(props: { cls: Class }) { 163 | const { cls } = props; 164 | const { rating, hours, people } = cls.evals; 165 | 166 | return ( 167 | 168 | Rating: {rating} 169 | Hours: {hours} 170 | Avg # of students: {people} 171 | 172 | ); 173 | } 174 | 175 | /** Class description, person in-charge, and any URLs afterward. */ 176 | function ClassBody(props: { cls: Class }) { 177 | const { cls } = props; 178 | const { state } = useContext(HydrantContext); 179 | const { description, inCharge, extraUrls } = cls.description; 180 | 181 | return ( 182 | 183 | 184 | {linkClasses(state, decode(description))} 185 | 186 | {inCharge !== "" && In-charge: {inCharge}.} 187 | {extraUrls.length > 0 && ( 188 | 189 | {extraUrls.map(({ label, url }) => ( 190 | 198 | {label} 199 | 200 | ))} 201 | 202 | )} 203 | 204 | ); 205 | } 206 | 207 | /** Full class description, from title to URLs at the end. */ 208 | function ClassDescription(props: { cls: Class }) { 209 | const { cls } = props; 210 | 211 | return ( 212 | 213 | 214 | {cls.number}: {cls.name} 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | ); 226 | } 227 | 228 | /** Full non-class activity description, from title to timeslots. */ 229 | function NonClassDescription(props: { activity: NonClass }) { 230 | const { activity } = props; 231 | const { state } = useContext(HydrantContext); 232 | 233 | return ( 234 | 235 | 236 | 237 | {activity.timeslots.map((t) => ( 238 | 239 | 247 | {t.toString()} 248 | 249 | ))} 250 | 251 | 252 | ); 253 | } 254 | 255 | /** Activity description, whether class or non-class. */ 256 | export function ActivityDescription() { 257 | const { hydrantState } = useContext(HydrantContext); 258 | const { viewedActivity: activity } = hydrantState; 259 | if (!activity) { 260 | return null; 261 | } 262 | 263 | return activity instanceof Class ? ( 264 | 265 | ) : ( 266 | 267 | ); 268 | } 269 | -------------------------------------------------------------------------------- /src/components/Calendar.scss: -------------------------------------------------------------------------------- 1 | .fc .fc-scrollgrid, 2 | .fc .fc-scrollgrid-section > td, 3 | .fc .fc-timegrid-axis { 4 | border-color: transparent; 5 | 6 | .dark & { 7 | border-color: transparent; 8 | } 9 | } 10 | 11 | .fc .fc-timegrid-slot { 12 | border-color: var(--chakra-colors-border-emphasized); 13 | border-width: 1.5px; 14 | border-left-color: transparent; 15 | } 16 | 17 | .fc-theme-standard td, 18 | .fc-theme-standard th { 19 | border-color: var(--chakra-colors-border-emphasized); 20 | border-width: 1.5px; 21 | } 22 | 23 | .fc .fc-scrollgrid-section > th:nth-child(1), 24 | .fc .fc-col-header-cell:last-child { 25 | border-right-color: transparent; 26 | } 27 | 28 | .fc .fc-scrollgrid-section-sticky > * { 29 | background: transparent; 30 | } 31 | 32 | .fc .fc-col-header-cell .fc-scrollgrid-sync-inner { 33 | background: var(--chakra-colors-bg); 34 | } 35 | 36 | .fc .fc-timegrid-event-harness-inset .fc-timegrid-event { 37 | box-shadow: var(--chakra-colors-bg) 0px 0px 0px 1.5px; 38 | } 39 | 40 | .fc .fc-scroller-harness { 41 | overflow: visible; 42 | } 43 | 44 | .fc .fc-timegrid-slot-label { 45 | border: none; 46 | } 47 | 48 | .fc .fc-timegrid-slot-label-frame { 49 | position: relative; 50 | } 51 | 52 | .fc .fc-timegrid-slot-label-cushion { 53 | font-size: 0.75rem; 54 | line-height: 1.3; 55 | opacity: 0.7; 56 | position: absolute; 57 | top: -1.25rem; 58 | right: 0.25rem; 59 | z-index: 2; 60 | } 61 | 62 | .fc .fc-timegrid-event { 63 | border-radius: 0; 64 | border-width: 0; 65 | padding-left: 0.25rem; 66 | } 67 | 68 | .fc .fc-col-header-cell-cushion { 69 | font-size: 0.8rem; 70 | letter-spacing: 0.05rem; 71 | text-transform: uppercase; 72 | } 73 | -------------------------------------------------------------------------------- /src/components/Calendar.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | 3 | import { Box, Text } from "@chakra-ui/react"; 4 | import { Tooltip } from "./ui/tooltip"; 5 | 6 | import FullCalendar from "@fullcalendar/react"; 7 | import type { EventContentArg } from "@fullcalendar/core"; 8 | import timeGridPlugin from "@fullcalendar/timegrid"; 9 | import interactionPlugin from "@fullcalendar/interaction"; 10 | 11 | import type { Activity } from "../lib/activity"; 12 | import { NonClass, Timeslot } from "../lib/activity"; 13 | import { Slot } from "../lib/dates"; 14 | import { Class } from "../lib/class"; 15 | import { HydrantContext } from "../lib/hydrant"; 16 | 17 | import "./Calendar.scss"; 18 | 19 | /** 20 | * Calendar showing all the activities, including the buttons on top that 21 | * change the schedule option selected. 22 | */ 23 | export function Calendar() { 24 | const { state, hydrantState } = useContext(HydrantContext); 25 | const { selectedActivities, viewedActivity } = hydrantState; 26 | 27 | const renderEvent = ({ event }: EventContentArg) => { 28 | const TitleText = () => ( 29 | 36 | {event.title} 37 | 38 | ); 39 | 40 | return ( 41 | 48 | {event.extendedProps.activity instanceof Class ? ( 49 | 55 | ) : ( 56 | 57 | )} 58 | {event.extendedProps.room} 59 | 60 | ); 61 | }; 62 | 63 | return ( 64 | act.events) 72 | .flatMap((event) => event.eventInputs)} 73 | eventContent={renderEvent} 74 | eventClick={(e) => { 75 | // extendedProps: non-standard props of {@link Event.eventInputs} 76 | state.setViewedActivity(e.event.extendedProps.activity as Activity); 77 | }} 78 | headerToolbar={false} 79 | height="auto" 80 | // a date that is, conveniently enough, a monday 81 | initialDate="2001-01-01" 82 | slotDuration="00:30:00" 83 | slotLabelFormat={({ date }) => { 84 | const { hour } = date; 85 | return hour === 12 86 | ? "noon" 87 | : hour < 12 88 | ? `${hour.toString()} AM` 89 | : `${(hour - 12).toString()} PM`; 90 | }} 91 | slotMinTime="08:00:00" 92 | slotMaxTime="22:00:00" 93 | weekends={false} 94 | selectable={viewedActivity instanceof NonClass} 95 | select={(e) => { 96 | if (viewedActivity instanceof NonClass) { 97 | state.addTimeslot( 98 | viewedActivity, 99 | Timeslot.fromStartEnd( 100 | Slot.fromStartDate(e.start), 101 | Slot.fromStartDate(e.end), 102 | ), 103 | ); 104 | } 105 | }} 106 | /> 107 | ); 108 | } 109 | -------------------------------------------------------------------------------- /src/components/ClassTable.scss: -------------------------------------------------------------------------------- 1 | .ag-root { 2 | width: 100%; 3 | } 4 | 5 | .ag-row { 6 | cursor: pointer; 7 | } 8 | 9 | .ag-row:hover .ag-cell:first-child { 10 | text-decoration: underline; 11 | } 12 | 13 | .ag-cell-muted-text { 14 | color: var(--chakra-colors-fg-muted); 15 | } 16 | 17 | .ag-cell-success-text { 18 | color: var(--chakra-colors-fg-success); 19 | } 20 | 21 | .ag-cell-warning-text { 22 | color: var(--chakra-colors-fg-warning); 23 | } 24 | 25 | .ag-cell-error-text { 26 | color: var(--chakra-colors-fg-error); 27 | } 28 | 29 | .ag-cell-normal-text { 30 | color: var(--chakra-colors-fg); 31 | } 32 | -------------------------------------------------------------------------------- /src/components/FeedbackBanner.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | 3 | import { 4 | Center, 5 | Flex, 6 | Text, 7 | Box, 8 | Float, 9 | Presence, 10 | CloseButton, 11 | } from "@chakra-ui/react"; 12 | import { LinkButton } from "./ui/link-button"; 13 | 14 | import { HydrantContext } from "../lib/hydrant"; 15 | 16 | export const FeedbackBanner = () => { 17 | const { state } = useContext(HydrantContext); 18 | 19 | return ( 20 | 28 | 29 |
39 | 40 | 41 | Do you have feedback on Hydrant? We'd love to hear it! 42 | 43 | 55 | Contact us 56 | 57 | { 63 | state.showFeedback = false; 64 | }} 65 | /> 66 | 67 |
68 | 69 | { 74 | state.showFeedback = false; 75 | }} 76 | /> 77 | 78 |
79 |
80 | ); 81 | }; 82 | -------------------------------------------------------------------------------- /src/components/Footers.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useState } from "react"; 2 | 3 | import { 4 | DialogRoot, 5 | DialogBody, 6 | DialogContent, 7 | DialogFooter, 8 | DialogHeader, 9 | DialogTitle, 10 | DialogActionTrigger, 11 | } from "./ui/dialog"; 12 | import { Flex, Link, Text, Button, Image } from "@chakra-ui/react"; 13 | 14 | import fuzzAndAnt from "../assets/fuzzAndAnt.png"; 15 | import { HydrantContext } from "../lib/hydrant"; 16 | 17 | function AboutDialog() { 18 | const [visible, setVisible] = useState(false); 19 | 20 | return ( 21 | <> 22 | { 24 | setVisible(true); 25 | }} 26 | colorPalette="blue" 27 | > 28 | About 29 | 30 | { 33 | setVisible(false); 34 | }} 35 | > 36 | 37 | 38 | Hydrant 39 | 40 | 41 | 42 | 43 | Hydrant is a student-run class planner for MIT students, 44 | maintained by SIPB, the{" "} 45 | 46 | Student Information Processing Board 47 | 48 | . 49 | 50 | 51 | We welcome contributions! View the source code or file issues on{" "} 52 | 56 | GitHub 57 | 58 | , or come to a SIPB meeting and ask how to help. 59 | 60 | 61 | We'd like to thank CJ Quines '23 for creating Hydrant and Edward 62 | Fan '19 for creating{" "} 63 | 64 | Firehose 65 | 66 | , the basis for Hydrant. We'd also like to thank the{" "} 67 | 68 | FireRoad 69 | {" "} 70 | team for collaborating with us. 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | ); 83 | } 84 | 85 | function PrivacyPolicyDialog() { 86 | const [visible, setVisible] = useState(false); 87 | 88 | return ( 89 | <> 90 | { 92 | setVisible(true); 93 | }} 94 | colorPalette="blue" 95 | > 96 | Privacy Policy 97 | 98 | { 101 | setVisible(false); 102 | }} 103 | > 104 | 105 | 106 | Privacy Policy 107 | 108 | 109 | 110 | 111 | Hydrant does not store any of your data outside of your browser. 112 | Data is only transmitted upstream when you export to Google 113 | Calendar. When you export to Google Calendar, Hydrant sends 114 | calendar information to Google to place into your calendar. 115 | 116 | 117 | No data is transmitted otherwise. That means that our servers do 118 | not store your class or calendar information. If you never 119 | export to Google Calendar we never send your data anywhere else. 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | ); 132 | } 133 | 134 | /** The footer on the bottom of the calendar. */ 135 | export function LeftFooter() { 136 | const { state } = useContext(HydrantContext); 137 | 138 | return ( 139 | 140 | Hydra ant and fuzzball stare at a calendar 147 | 148 | Last updated: {state.lastUpdated}. 149 | 150 | 151 | 152 | Contact 153 | 154 | 155 | 156 | Accessibility 157 | 158 | 159 | 160 | 161 | ); 162 | } 163 | -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useContext } from "react"; 2 | import { useSearchParams } from "react-router"; 3 | 4 | import { 5 | Card, 6 | IconButton, 7 | Flex, 8 | Image, 9 | Text, 10 | Button, 11 | createListCollection, 12 | } from "@chakra-ui/react"; 13 | import { 14 | DialogRoot, 15 | DialogBody, 16 | DialogContent, 17 | DialogFooter, 18 | DialogHeader, 19 | DialogTitle, 20 | DialogTrigger, 21 | DialogActionTrigger, 22 | } from "./ui/dialog"; 23 | import { useColorModeValue } from "./ui/color-mode"; 24 | import { 25 | SelectContent, 26 | SelectItem, 27 | SelectLabel, 28 | SelectRoot, 29 | SelectTrigger, 30 | SelectValueText, 31 | } from "./ui/select"; 32 | import { LuSettings, LuX } from "react-icons/lu"; 33 | 34 | import { COLOR_SCHEME_PRESETS } from "../lib/colors"; 35 | import type { Preferences } from "../lib/schema"; 36 | import { DEFAULT_PREFERENCES } from "../lib/schema"; 37 | import { HydrantContext } from "../lib/hydrant"; 38 | 39 | import logo from "../assets/logo.svg"; 40 | import logoDark from "../assets/logo-dark.svg"; 41 | import hydraAnt from "../assets/hydraAnt.png"; 42 | import { SIPBLogo } from "./SIPBLogo"; 43 | 44 | export function PreferencesDialog() { 45 | const { state, hydrantState } = useContext(HydrantContext); 46 | const { preferences: originalPreferences } = hydrantState; 47 | 48 | const [visible, setVisible] = useState(false); 49 | const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES); 50 | const initialPreferencesRef = useRef(DEFAULT_PREFERENCES); 51 | const initialPreferences = initialPreferencesRef.current; 52 | 53 | const onOpen = () => { 54 | initialPreferencesRef.current = originalPreferences; 55 | setPreferences(originalPreferences); 56 | setVisible(true); 57 | }; 58 | 59 | const previewPreferences = (newPreferences: Preferences) => { 60 | setPreferences(newPreferences); 61 | state.setPreferences(newPreferences, false); 62 | }; 63 | 64 | const onCancel = () => { 65 | setPreferences(initialPreferences); 66 | state.setPreferences(initialPreferences); 67 | setVisible(false); 68 | }; 69 | 70 | const onConfirm = () => { 71 | state.setPreferences(preferences); 72 | setVisible(false); 73 | }; 74 | 75 | const collection = createListCollection({ 76 | items: [ 77 | { label: "System Default", value: "" }, 78 | ...COLOR_SCHEME_PRESETS.map(({ name }) => ({ 79 | label: name, 80 | value: name, 81 | })), 82 | ], 83 | }); 84 | 85 | return ( 86 | <> 87 | { 90 | if (e.open) { 91 | onOpen(); 92 | } else { 93 | onCancel(); 94 | } 95 | }} 96 | > 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | Preferences 105 | 106 | 107 | 108 | { 112 | if (e.value[0] === "") { 113 | previewPreferences({ 114 | ...preferences, 115 | colorScheme: null, 116 | }); 117 | return; 118 | } 119 | 120 | const colorScheme = COLOR_SCHEME_PRESETS.find( 121 | ({ name }) => name === e.value[0], 122 | ); 123 | if (!colorScheme) return; 124 | previewPreferences({ ...preferences, colorScheme }); 125 | }} 126 | > 127 | Color scheme: 128 | 129 | 130 | 131 | 132 | {collection.items.map(({ label, value }) => ( 133 | 134 | {label} 135 | 136 | ))} 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | ); 151 | } 152 | 153 | /** Header above the left column, with logo and semester selection. */ 154 | export function Header() { 155 | const { state } = useContext(HydrantContext); 156 | const logoSrc = useColorModeValue(logo, logoDark); 157 | const [searchParams, setSearchParams] = useSearchParams(); 158 | 159 | const urlNameOrig = searchParams.get("ti"); 160 | const urlName = searchParams.get("t") ?? state.latestUrlName; 161 | 162 | const [show, setShow] = useState(urlNameOrig !== null); 163 | 164 | const onClose = () => { 165 | setSearchParams((searchParams) => { 166 | searchParams.delete("ti"); 167 | return searchParams; 168 | }); 169 | setShow(false); 170 | }; 171 | 172 | return ( 173 | 174 | Hydrant ant logo 182 | 183 | Hydrant logo 190 | 191 | 192 | 193 | 194 | {show && ( 195 | 196 | 197 | 198 | 199 | Term {urlNameOrig} not found; loaded term {urlName} instead. 200 | 201 | 207 | 208 | 209 | 210 | 211 | 212 | )} 213 | 214 | ); 215 | } 216 | -------------------------------------------------------------------------------- /src/components/MatrixLink.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | 3 | import { Class } from "../lib/class"; 4 | import { HydrantContext } from "../lib/hydrant"; 5 | 6 | import { LuMessageSquare } from "react-icons/lu"; 7 | import { Tooltip } from "./ui/tooltip"; 8 | import { LinkButton } from "./ui/link-button"; 9 | 10 | /** A link to SIPB Matrix's class group chat importer UI */ 11 | export function MatrixLink() { 12 | const { 13 | state: { selectedActivities }, 14 | } = useContext(HydrantContext); 15 | 16 | // reference: https://github.com/gabrc52/class_group_chats/tree/main/src/routes/import 17 | const matrixLink = `https://matrix.mit.edu/classes/import?via=Hydrant${selectedActivities 18 | .filter((activity) => activity instanceof Class) 19 | .map((cls) => `&class=${cls.number}`) 20 | .join("")}`; 21 | 22 | return ( 23 | 24 | 33 | 34 | Join Matrix group chats 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/components/PreregLink.tsx: -------------------------------------------------------------------------------- 1 | import { Class } from "../lib/class"; 2 | import { LuClipboardList } from "react-icons/lu"; 3 | 4 | import { LinkButton } from "./ui/link-button"; 5 | import { Tooltip } from "./ui/tooltip"; 6 | import { useContext } from "react"; 7 | import { HydrantContext } from "../lib/hydrant"; 8 | 9 | /** A link to SIPB Matrix's class group chat importer UI */ 10 | export function PreregLink() { 11 | const { 12 | state: { selectedActivities }, 13 | } = useContext(HydrantContext); 14 | 15 | // reference: https://github.com/gabrc52/class_group_chats/tree/main/src/routes/import 16 | const preregLink = `https://student.mit.edu/cgi-bin/sfprwtrm.sh?${selectedActivities 17 | .filter((activity) => activity instanceof Class) 18 | .map((cls) => cls.number) 19 | .join(",")}`; 20 | 21 | return ( 22 | 23 | 32 | 33 | Pre-register classes 34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/components/SIPBLogo.tsx: -------------------------------------------------------------------------------- 1 | import { Image, Link } from "@chakra-ui/react"; 2 | import sipbLogo from "../assets/simple-fuzzball.png"; 3 | 4 | export function SIPBLogo() { 5 | return ( 6 | 14 | by SIPB 15 | SIPB Logo 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/ScheduleOption.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton, Flex, Text } from "@chakra-ui/react"; 2 | 3 | import { CloseButton } from "./ui/close-button"; 4 | import { useContext, useState } from "react"; 5 | 6 | import { LuArrowLeft, LuArrowRight } from "react-icons/lu"; 7 | import { HydrantContext } from "../lib/hydrant"; 8 | 9 | export function ScheduleOption() { 10 | const [tooManyOptions, setTooManyOptions] = useState(true); 11 | const { state, hydrantState } = useContext(HydrantContext); 12 | const { selectedOption, totalOptions } = hydrantState; 13 | 14 | return ( 15 | 16 | 17 | { 19 | state.selectOption( 20 | (selectedOption - 1 + totalOptions) % totalOptions, 21 | ); 22 | }} 23 | size="xs" 24 | variant="ghost" 25 | aria-label="Previous schedule" 26 | > 27 | 28 | 29 | {selectedOption + 1} of {totalOptions} 30 | { 32 | state.selectOption(selectedOption + 1); 33 | }} 34 | size="xs" 35 | variant="ghost" 36 | aria-label="Next schedule" 37 | > 38 | 39 | 40 | 41 | {tooManyOptions && totalOptions > 15 && ( 42 | 43 | 44 | Too many options? Use the "Edit sections" button above the class 45 | description. 46 | 47 | { 50 | setTooManyOptions(false); 51 | }} 52 | /> 53 | 54 | )} 55 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/components/ScheduleSwitcher.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Flex, 4 | IconButton, 5 | Input, 6 | Link, 7 | createListCollection, 8 | Button, 9 | } from "@chakra-ui/react"; 10 | import type { ComponentPropsWithoutRef, ReactNode } from "react"; 11 | import { useContext, useEffect, useState } from "react"; 12 | 13 | import { 14 | DialogRoot, 15 | DialogBody, 16 | DialogContent, 17 | DialogFooter, 18 | DialogHeader, 19 | DialogTitle, 20 | DialogTrigger, 21 | DialogActionTrigger, 22 | } from "./ui/dialog"; 23 | import { MenuContent, MenuItem, MenuRoot, MenuTrigger } from "./ui/menu"; 24 | import { 25 | SelectContent, 26 | SelectItem, 27 | SelectRoot, 28 | SelectTrigger, 29 | SelectValueText, 30 | SelectLabel, 31 | } from "./ui/select"; 32 | 33 | import type { Save } from "../lib/schema"; 34 | import { HydrantContext } from "../lib/hydrant"; 35 | 36 | import { 37 | LuCopy, 38 | LuEllipsis, 39 | LuFilePlus2, 40 | LuPencilLine, 41 | LuPin, 42 | LuPinOff, 43 | LuSave, 44 | LuShare2, 45 | LuTrash2, 46 | } from "react-icons/lu"; 47 | 48 | import useCopyToClipboard from "react-use/lib/useCopyToClipboard.js"; 49 | 50 | function SmallButton(props: ComponentPropsWithoutRef<"button">) { 51 | const { children, ...otherProps } = props; 52 | return ( 53 | 56 | ); 57 | } 58 | 59 | function SelectWithWarn(props: { saveId: string; saves: Save[] }) { 60 | const { saveId, saves } = props; 61 | const { state } = useContext(HydrantContext); 62 | const [confirmSave, setConfirmSave] = useState(""); 63 | const confirmName = saves.find((save) => save.id === confirmSave)?.name; 64 | const defaultScheduleId = state.defaultSchedule; 65 | 66 | const formatScheduleName = (id: string, name: string) => { 67 | return id === defaultScheduleId ? `${name} (default)` : name; 68 | }; 69 | 70 | const scheduleCollection = createListCollection({ 71 | items: [ 72 | { label: "Not saved", value: "" }, 73 | ...saves.map(({ id, name }) => ({ 74 | label: formatScheduleName(id, name), 75 | value: id, 76 | })), 77 | ], 78 | }); 79 | 80 | return ( 81 | <> 82 | { 90 | if (!saveId) { 91 | setConfirmSave(e.value[0]); 92 | } else { 93 | state.loadSave(e.value[0]); 94 | } 95 | }} 96 | > 97 | 98 | 99 | 100 | 101 | 102 | {scheduleCollection.items.map(({ label, value }) => 103 | value != "" ? ( 104 | 105 | {label} 106 | 107 | ) : null, 108 | )} 109 | 110 | 111 | { 114 | if (!e.open) { 115 | setConfirmSave(""); 116 | } 117 | }} 118 | > 119 | 120 | 121 | Are you sure? 122 | 123 | 124 | The current schedule is loaded from a URL and is not saved. Are you 125 | sure you want to load schedule {confirmName} without saving your 126 | current schedule? 127 | 128 | 129 | 130 | 131 | 132 | 140 | 141 | 142 | 143 | 144 | ); 145 | } 146 | 147 | function DeleteDialog(props: { 148 | saveId: string; 149 | name: string; 150 | children: ReactNode; 151 | }) { 152 | const { saveId, name, children } = props; 153 | const { state } = useContext(HydrantContext); 154 | const [show, setShow] = useState(false); 155 | 156 | return ( 157 | { 160 | setShow(e.open); 161 | }} 162 | role="alertdialog" 163 | > 164 | {children} 165 | 166 | 167 | Are you sure? 168 | 169 | Are you sure you want to delete {name}? 170 | 171 | 172 | 173 | 174 | 184 | 185 | 186 | 187 | ); 188 | } 189 | 190 | function ExportDialog(props: { children: ReactNode }) { 191 | const { children } = props; 192 | const { state } = useContext(HydrantContext); 193 | const [show, setShow] = useState(false); 194 | const link = state.urlify(); 195 | const [clipboardState, copyToClipboard] = useCopyToClipboard(); 196 | 197 | return ( 198 | { 201 | setShow(e.open); 202 | }} 203 | > 204 | {children} 205 | 206 | 207 | Share schedule 208 | 209 | 210 | Share the following link: 211 |
212 | 213 | {link} 214 | 215 |
216 | 217 | 218 | 219 | 220 | 227 | 228 |
229 |
230 | ); 231 | } 232 | 233 | export function ScheduleSwitcher() { 234 | const { state, hydrantState } = useContext(HydrantContext); 235 | const { saves, saveId } = hydrantState; 236 | 237 | const currentName = saves.find((save) => save.id === saveId)?.name ?? ""; 238 | const [isRenaming, setIsRenaming] = useState(false); 239 | const [name, setName] = useState(currentName); 240 | const defaultScheduleId = state.defaultSchedule; 241 | 242 | useEffect(() => { 243 | setName(saves.find((save) => save.id === saveId)?.name ?? ""); 244 | }, [saves, saveId]); 245 | 246 | const [renderHeading, renderButtons] = (() => { 247 | if (isRenaming) { 248 | const renderHeading = () => ( 249 | { 252 | setName(e.target.value); 253 | }} 254 | autoFocus 255 | onKeyUp={(e) => { 256 | if (e.key === "Enter") { 257 | onConfirm(); 258 | } else if (e.key === "Escape") { 259 | onCancel(); 260 | } 261 | }} 262 | placeholder="New Schedule" 263 | size="sm" 264 | width="fit-content" 265 | /> 266 | ); 267 | const onConfirm = () => { 268 | state.renameSave(saveId, name); 269 | setIsRenaming(false); 270 | }; 271 | const onCancel = () => { 272 | setName(currentName); 273 | setIsRenaming(false); 274 | }; 275 | const renderButtons = () => ( 276 | <> 277 | Confirm 278 | Cancel 279 | 280 | ); 281 | return [renderHeading, renderButtons]; 282 | } 283 | 284 | const renderHeading = () => ( 285 | 286 | ); 287 | const onRename = () => { 288 | setIsRenaming(true); 289 | }; 290 | const onSave = () => { 291 | state.addSave(Boolean(saveId)); 292 | }; 293 | const onCopy = () => { 294 | state.addSave(false, `${currentName} copy`); 295 | }; 296 | const renderButtons = () => ( 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | {saveId && ( 305 | 306 | 307 | Rename… 308 | 309 | )} 310 | 311 | 312 | Copy 313 | 314 | {saveId && ( 315 | save.id === saveId)?.name ?? ""} 318 | > 319 | 324 | 325 | Delete… 326 | 327 | 328 | )} 329 | 330 | {saveId ? ( 331 | <> 332 | 333 | New 334 | 335 | ) : ( 336 | <> 337 | 338 | Save 339 | 340 | )} 341 | 342 | {saveId && ( 343 | { 346 | state.defaultSchedule = 347 | defaultScheduleId === saveId ? null : saveId; 348 | }} 349 | > 350 | {defaultScheduleId === saveId ? ( 351 | <> 352 | 353 | Unset as default 354 | 355 | ) : ( 356 | <> 357 | 358 | Set as default 359 | 360 | )} 361 | 362 | )} 363 | 364 | 365 | 366 | Share 367 | 368 | 369 | 370 | 371 | ); 372 | return [renderHeading, renderButtons]; 373 | })(); 374 | 375 | return ( 376 | 377 | {renderHeading()} 378 | {renderButtons()} 379 | 380 | ); 381 | } 382 | -------------------------------------------------------------------------------- /src/components/SelectedActivities.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Text, Button, ButtonGroup } from "@chakra-ui/react"; 2 | import { useContext, type ComponentPropsWithoutRef } from "react"; 3 | 4 | import type { Activity } from "../lib/activity"; 5 | import { textColor } from "../lib/colors"; 6 | import { Class } from "../lib/class"; 7 | import { HydrantContext } from "../lib/hydrant"; 8 | 9 | import { LuPlus } from "react-icons/lu"; 10 | 11 | export function ColorButton( 12 | props: ComponentPropsWithoutRef<"button"> & { color: string }, 13 | ) { 14 | const { children, color, style, ...otherProps } = props; 15 | return ( 16 | 27 | ); 28 | } 29 | 30 | /** A button representing a single, selected activity. */ 31 | function ActivityButton(props: { activity: Activity }) { 32 | const { activity } = props; 33 | const { state } = useContext(HydrantContext); 34 | const color = activity.backgroundColor; 35 | return ( 36 | { 39 | state.setViewedActivity(activity); 40 | }} 41 | onDoubleClick={() => { 42 | state.removeActivity(activity); 43 | }} 44 | > 45 | {activity.buttonName} 46 | 47 | ); 48 | } 49 | 50 | /** List of selected activities; one button for each activity. */ 51 | export function SelectedActivities() { 52 | const { state, hydrantState } = useContext(HydrantContext); 53 | const { selectedActivities, units, hours, warnings } = hydrantState; 54 | 55 | return ( 56 | 57 | 58 | {units} units 59 | {hours.toFixed(1)} hours 60 | 61 | 62 | {selectedActivities.map((activity) => ( 63 | 67 | ))} 68 | 76 | 77 | {warnings.map((warning) => ( 78 | 79 | {warning} 80 | 81 | ))} 82 | 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /src/components/TermSwitcher.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | 3 | import { 4 | SelectContent, 5 | SelectItem, 6 | SelectLabel, 7 | SelectRoot, 8 | SelectTrigger, 9 | SelectValueText, 10 | } from "./ui/select"; 11 | import { createListCollection } from "@chakra-ui/react"; 12 | 13 | import { Term, toFullUrl, getUrlNames } from "../lib/dates"; 14 | import { HydrantContext } from "../lib/hydrant"; 15 | 16 | export function TermSwitcher() { 17 | const { state } = useContext(HydrantContext); 18 | const toUrl = (urlName: string) => toFullUrl(urlName, state.latestUrlName); 19 | const defaultValue = toUrl(state.term.urlName); 20 | 21 | const urlOptions = createListCollection({ 22 | items: getUrlNames(state.latestUrlName).map((urlName) => { 23 | const { niceName } = new Term({ urlName }); 24 | return { 25 | label: niceName, 26 | value: toUrl(urlName), 27 | }; 28 | }), 29 | }); 30 | 31 | return ( 32 | { 36 | window.location.href = e.value[0]; 37 | }} 38 | size="sm" 39 | w="8rem" 40 | > 41 | 42 | 43 | 44 | 45 | 46 | {urlOptions.items.map(({ label, value }) => { 47 | return ( 48 | 49 | {label} 50 | 51 | ); 52 | })} 53 | 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import type { Tooltip as ChakraTooltip } from "@chakra-ui/react"; 2 | import { Button, type ButtonProps } from "@chakra-ui/react"; 3 | import { Tooltip } from "./tooltip"; 4 | import type { RefObject } from "react"; 5 | 6 | export interface LabelledButtonProps extends ButtonProps { 7 | showArrow?: boolean; 8 | portalled?: boolean; 9 | portalRef?: RefObject; 10 | titleProps?: ChakraTooltip.ContentProps; 11 | disabled?: boolean; 12 | } 13 | 14 | export const LabelledButton = (props: LabelledButtonProps) => { 15 | const { showArrow, title, titleProps, portalled, disabled, ...rest } = props; 16 | if (!title) return 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | ); 136 | } 137 | 138 | // eslint-disable-next-line react-refresh/only-export-components 139 | export const meta: Route.MetaFunction = () => [ 140 | { title: "Hydrant" }, 141 | { 142 | name: "description", 143 | content: "Hydrant is a class planner for MIT students.", 144 | }, 145 | ]; 146 | 147 | /** The main application. */ 148 | export default function App({ loaderData }: Route.ComponentProps) { 149 | const { globalState } = loaderData; 150 | const hydrantData = useHydrant({ globalState }); 151 | 152 | return ( 153 | 154 | 155 | 156 | ); 157 | } 158 | -------------------------------------------------------------------------------- /src/routes/export.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "react-router"; 2 | 3 | import { fetchNoCache, type SemesterData } from "../lib/hydrant"; 4 | import { getClosestUrlName, Term, type LatestTermInfo } from "../lib/dates"; 5 | import { State } from "../lib/state"; 6 | import { Class } from "../lib/class"; 7 | 8 | import type { Route } from "./+types/export"; 9 | 10 | /** 11 | * "Integration callbacks" allow other SIPB projects to integrate with Hydrant by redirecting to 12 | * https://hydrant.mit.edu/#/export with a `callback` as a query parameter. 13 | * 14 | * Currently, the only application that uses this is the Matrix class group chat picker, 15 | * but in the future, a prompt "[Application name] would like to access your Hydrant class list" 16 | * could be implemented. 17 | */ 18 | const ALLOWED_INTEGRATION_CALLBACKS = [ 19 | "https://matrix.mit.edu/classes/hydrantCallback", 20 | "https://uplink.mit.edu/classes/hydrantCallback", 21 | ]; 22 | 23 | export async function clientLoader({ request }: Route.ClientLoaderArgs) { 24 | const searchParams = new URL(request.url).searchParams; 25 | const currentTerm = searchParams.get("t"); 26 | const callback = searchParams.get("callback"); 27 | 28 | const latestTerm = await fetchNoCache("/latestTerm.json"); 29 | const { urlName } = getClosestUrlName( 30 | currentTerm, 31 | latestTerm.semester.urlName, 32 | ); 33 | 34 | const term = urlName === latestTerm.semester.urlName ? "latest" : urlName; 35 | 36 | const { classes, lastUpdated, termInfo } = await fetchNoCache( 37 | `/${term}.json`, 38 | ); 39 | const classesMap = new Map(Object.entries(classes)); 40 | const hydrantObj = new State( 41 | classesMap, 42 | new Term(termInfo), 43 | lastUpdated, 44 | latestTerm.semester.urlName, 45 | ); 46 | 47 | if (!callback || !ALLOWED_INTEGRATION_CALLBACKS.includes(callback)) { 48 | console.warn("callback", callback, "not in allowed callbacks list!"); 49 | window.alert(`${callback ?? ""} is not allowed to read your class list!`); 50 | 51 | return redirect("/"); 52 | } 53 | 54 | const encodedClasses = hydrantObj.selectedActivities 55 | .filter((activity) => activity instanceof Class) 56 | .map((cls) => `&class=${cls.number}`) 57 | .join(""); 58 | const filledCallback = `${callback}?hydrant=true${encodedClasses}`; 59 | return redirect(filledCallback); 60 | } 61 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_GOOGLE_CLIENT_ID: string; 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "**/*", 4 | "**/.server/**/*", 5 | "**/.client/**/*", 6 | ".react-router/types/**/*" 7 | ], 8 | "compilerOptions": { 9 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 10 | "types": ["node", "vite/client"], 11 | "target": "ES2022", 12 | "module": "ES2022", 13 | "moduleResolution": "bundler", 14 | "jsx": "react-jsx", 15 | "rootDirs": [".", "./.react-router/types"], 16 | "baseUrl": ".", 17 | "paths": { 18 | "~/*": ["./src/*"] 19 | }, 20 | "esModuleInterop": true, 21 | "forceConsistentCasingInFileNames": true, 22 | "isolatedModules": true, 23 | "noEmit": true, 24 | "resolveJsonModule": true, 25 | "skipLibCheck": true, 26 | "strict": true 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { reactRouter } from "@react-router/dev/vite"; 2 | import { defineConfig } from "vite"; 3 | import tsconfigPaths from "vite-tsconfig-paths"; 4 | import checker from "vite-plugin-checker"; 5 | import { nodePolyfills } from "vite-plugin-node-polyfills"; 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | plugins: [ 10 | reactRouter(), 11 | tsconfigPaths(), 12 | nodePolyfills({ include: ["buffer"] }), 13 | checker({ 14 | typescript: true, 15 | eslint: { lintCommand: "eslint **/*.{ts,tsx}", useFlatConfig: true }, 16 | }), 17 | ], 18 | }); 19 | --------------------------------------------------------------------------------