├── .circleci └── config.yml ├── .git-blame-ignore-revs ├── .github ├── PULL_REQUEST_TEMPLATE.md └── dependabot.yml ├── .gitignore ├── .hlint.yaml ├── .husky ├── .gitignore └── pre-commit ├── .prettierignore ├── .prettierrc ├── .stylelintrc.json ├── .stylish-haskell.yaml ├── .yarn └── releases │ └── yarn-3.3.1.cjs ├── .yarnrc.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── PRIVACY.md ├── README.md ├── Setup.hs ├── app ├── Config.hs ├── Controllers │ ├── Course.hs │ ├── Generate.hs │ ├── Graph.hs │ └── Timetable.hs ├── Css │ └── Constants.hs ├── Database │ ├── CourseInsertion.hs │ ├── CourseQueries.hs │ ├── CourseVideoSeed.hs │ ├── DataType.hs │ ├── Database.hs │ ├── README.md │ ├── Requirement.hs │ └── Tables.hs ├── DynamicGraphs │ ├── CourseFinder.hs │ ├── GraphGenerator.hs │ ├── GraphNodeUtils.hs │ ├── GraphOptions.hs │ └── WriteRunDot.hs ├── Export │ ├── GetImages.hs │ ├── ImageConversion.hs │ ├── LatexGenerator.hs │ ├── PdfGenerator.hs │ ├── README.md │ └── TimetableImageCreator.hs ├── Main.hs ├── MasterTemplate.hs ├── Models │ └── Course.hs ├── Response.hs ├── Response │ ├── About.hs │ ├── Draw.hs │ ├── Image.hs │ ├── Loading.hs │ ├── NotFound.hs │ └── README.md ├── Routes.hs ├── Scripts.hs ├── Server.hs ├── Svg │ ├── Builder.hs │ ├── Database.hs │ ├── Generator.hs │ ├── Parser.hs │ └── README.md ├── Util │ ├── Blaze.hs │ ├── Documentation.hs │ ├── Happstack.hs │ └── README.md └── WebParsing │ ├── ArtSciParser.hs │ ├── Ligature.hs │ ├── ParsecCombinators.hs │ ├── PostParser.hs │ ├── ReqParser.hs │ └── UtsgJsonParser.hs ├── babel.config.json ├── backend-test ├── Controllers │ ├── ControllerTests.hs │ ├── CourseControllerTests.hs │ └── GraphControllerTests.hs ├── Database │ ├── CourseQueriesTests.hs │ └── DatabaseTests.hs ├── Main.hs ├── RequirementTests │ ├── ModifierTests.hs │ ├── PostParserTests.hs │ ├── PreProcessingTests.hs │ ├── ReqParserTests.hs │ └── RequirementTests.hs ├── SvgTests │ ├── IntersectionTests.hs │ └── SvgTests.hs └── TestHelpers.hs ├── config.yaml ├── courseography.cabal ├── cypress.json ├── cypress └── integration │ └── graph_spec │ ├── bool_spec.js │ ├── edge_spec.js │ ├── graph_spec.js │ └── node_spec.js ├── db └── building.csv ├── eslint.config.cjs ├── graphs ├── Estonian.graphml ├── Finnish.graphml ├── German.graphml ├── Italian.graphml ├── Linguistics.graphml ├── Rotman.graphml ├── abs2015.graphml ├── abs2015.svg ├── bch2015.graphml ├── bch2015.svg ├── csb2015.graphml ├── csb2015.svg ├── csc2014.graphml ├── csc2014.svg ├── csc2015.graphml ├── csc2015.svg ├── csc2016.graphml ├── csc2016.svg ├── csc2017.graphml ├── csc2017.svg ├── csc2019.graphml ├── csc2019.svg ├── csc2020.graphml ├── csc2020.svg ├── csc2021.svg ├── csc2023.graphml ├── csc2023.svg ├── csc2024.graphml ├── csc2024.svg ├── eas2015.graphml ├── eas2015.svg ├── eco2015.graphml ├── eco2015.svg ├── eng2015.graphml ├── eng2015.svg ├── est2015.svg ├── fin2015.svg ├── fre2015.graphml ├── fre2015.svg ├── ger2015.svg ├── ggr2015.graphml ├── ggr2015.svg ├── his2015.graphml ├── his2015.svg ├── hps2015.graphml ├── hps2015.svg ├── ita2015.svg ├── lin2015.graphml ├── lin2015.svg ├── math_specialist2022.svg ├── matt_specialist2022.graphml ├── prt2015.graphml ├── prt2015.svg ├── rotman2015.svg ├── sla2015.graphml ├── sla2015.svg ├── spa2015.graphml ├── spa2015.svg ├── sta2015.graphml ├── sta2015.svg ├── sta2017.graphml ├── sta2017.svg ├── sta2022.graphml └── sta2022.svg ├── jest.config.js ├── js ├── components │ ├── about │ │ └── about.js │ ├── common │ │ ├── Disclaimer.js │ │ ├── __tests__ │ │ │ ├── ConvertToLink.test.js │ │ │ ├── CourseModalButtons.test.js │ │ │ ├── GetTable.test.js │ │ │ └── TimetableLoading.test.js │ │ ├── export.js.jsx │ │ ├── react_modal.js.jsx │ │ └── utils.js │ ├── draw │ │ ├── draw.js │ │ └── main.js │ ├── generate │ │ ├── GenerateForm.js │ │ ├── __mocks__ │ │ │ └── sample_responses.json │ │ ├── __tests__ │ │ │ └── generate.test.js │ │ └── generate.jsx │ ├── graph │ │ ├── Bool.js │ │ ├── Button.js │ │ ├── Container.js │ │ ├── Edge.js │ │ ├── FocusBar.js │ │ ├── FocusTab.js │ │ ├── Graph.js │ │ ├── GraphDropdown.js │ │ ├── InfoBox.js │ │ ├── Node.js │ │ ├── Sidebar.js │ │ ├── __mocks__ │ │ │ ├── aaa100-course-info.js │ │ │ ├── bbb100-course-info.js │ │ │ ├── ccc100-course-info.js │ │ │ ├── defaultTestData.js │ │ │ ├── focusData.js │ │ │ ├── statisticsTestData.js │ │ │ ├── styleMock.js │ │ │ └── testContainerData.js │ │ ├── __tests__ │ │ │ ├── Bool.test.js │ │ │ ├── Button.test.js │ │ │ ├── Edge.test.js │ │ │ ├── FocusBar.test.js │ │ │ ├── FocusTab.test.js │ │ │ ├── Graph.test.js │ │ │ ├── GraphDropdown.test.js │ │ │ ├── InfoBox.test.js │ │ │ ├── Node.test.js │ │ │ ├── Sidebar.test.js │ │ │ ├── TestContainer.js │ │ │ ├── TestFocusBar.js │ │ │ ├── TestGraph.js │ │ │ ├── TestSidebar.js │ │ │ ├── __snapshots__ │ │ │ │ ├── Bool.test.js.snap │ │ │ │ ├── Button.test.js.snap │ │ │ │ ├── Edge.test.js.snap │ │ │ │ ├── FocusBar.test.js.snap │ │ │ │ ├── Graph.test.js.snap │ │ │ │ ├── GraphDropdown.test.js.snap │ │ │ │ ├── InfoBox.test.js.snap │ │ │ │ ├── Node.test.js.snap │ │ │ │ └── Sidebar.test.js.snap │ │ │ ├── cleanup-after-each.js │ │ │ ├── findRelationship.test.js │ │ │ └── populateHybridRelatives.test.js │ │ └── main.js │ └── grid │ │ ├── calendar.js.jsx │ │ ├── course_panel.js.jsx │ │ └── grid.js.jsx ├── setupTests.js ├── setupTestsAfterEnv.js └── util │ ├── parse.test.js │ └── util.js ├── package.json ├── public ├── js │ ├── draw │ │ ├── draw.js │ │ ├── path.js │ │ ├── setup.js │ │ └── variables.js │ └── vendor │ │ └── jscolor.min.js ├── res │ ├── backgrounds │ │ └── draw-background.png │ ├── graphs │ │ └── gen │ │ │ ├── 1.svg │ │ │ ├── 10.svg │ │ │ ├── 11.svg │ │ │ ├── 12.svg │ │ │ ├── 13.svg │ │ │ ├── 14.svg │ │ │ ├── 15.svg │ │ │ ├── 16.svg │ │ │ ├── 17.svg │ │ │ ├── 18.svg │ │ │ ├── 19.svg │ │ │ ├── 2.svg │ │ │ ├── 20.svg │ │ │ ├── 21.svg │ │ │ ├── 22.svg │ │ │ ├── 23.svg │ │ │ ├── 24.svg │ │ │ ├── 25.svg │ │ │ ├── 26.svg │ │ │ ├── 3.svg │ │ │ ├── 4.svg │ │ │ ├── 5.svg │ │ │ ├── 6.svg │ │ │ ├── 7.svg │ │ │ ├── 8.svg │ │ │ └── 9.svg │ ├── ico │ │ ├── about.png │ │ ├── blue-marker.png │ │ ├── check.png │ │ ├── delete.png │ │ ├── doubledown-darkpurple.png │ │ ├── doubledown-lightpurple.png │ │ ├── doubleup-darkpurple.png │ │ ├── doubleup-lightpurple.png │ │ ├── export.png │ │ ├── favicon.png │ │ ├── red-marker.png │ │ ├── reset-view.png │ │ ├── search.png │ │ ├── shadow.png │ │ ├── sidebar.png │ │ └── tempSidebar.png │ └── img │ │ ├── C-logo-small.png │ │ ├── C-logo.png │ │ ├── compass-small.png │ │ ├── compass.png │ │ ├── dcs.png │ │ ├── dcs.svg │ │ └── logo.png └── style │ └── bootstrap.min.3.1.1.css ├── scripts └── BuildingGeocoder.py ├── stack.yaml ├── stack.yaml.lock ├── style ├── app.js └── app.scss ├── test.config.yaml ├── tests ├── common_tests.js └── tests.html ├── vendor └── sendfile-0.7.9 │ ├── LICENSE │ ├── Setup.hs │ ├── sendfile.cabal │ └── src │ └── Network │ └── Socket │ ├── SendFile.hs │ └── SendFile │ ├── Darwin.hsc │ ├── FreeBSD.hs │ ├── Handle.hs │ ├── Internal.hs │ ├── Iter.hs │ ├── Linux.hsc │ ├── Portable.hs │ ├── Util.hs │ └── Win32.hsc ├── webpack.common.js ├── webpack.config.js ├── webpack.dev.js ├── webpack.prod.js └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | coveralls: coveralls/coveralls@2.2.5 5 | 6 | workflows: 7 | tests: 8 | jobs: 9 | - build: 10 | context: coveralls-context 11 | 12 | jobs: 13 | build: 14 | docker: 15 | - image: fpco/stack-build:lts-23 16 | steps: 17 | - checkout 18 | - run: git remote add upstream https://github.com/Courseography/courseography.git 19 | - run: git fetch upstream 20 | 21 | - restore_cache: 22 | name: Restore Cached Dependencies (Haskell) 23 | keys: 24 | - v1.4-courseography-haskell-{{ checksum "courseography.cabal" }}-{{ checksum "stack.yaml.lock" }} 25 | 26 | - run: 27 | name: Install Package Dependencies 28 | command: | 29 | sudo apt-get update 30 | sudo apt-get install -y texlive-latex-base 31 | sudo wget https://imagemagick.org/archive/binaries/magick 32 | sudo cp magick /usr/local/bin 33 | sudo chmod 777 /usr/local/bin/magick 34 | sudo dpkg --remove --force-remove-reinstreq nodejs 35 | stack update 36 | 37 | curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - &&\ 38 | sudo dpkg --remove --force-remove-reinstreq libnode72:amd64 39 | sudo apt-get install -y nodejs 40 | 41 | corepack enable 42 | corepack prepare yarn@stable --activate 43 | 44 | - run: 45 | name: Resolve/Update Dependencies 46 | command: | 47 | stack --no-terminal setup 48 | rm -fr $(stack path --dist-dir) $(stack path --local-install-root) 49 | stack --no-terminal build --fast -j1 --ghc-options -Werror --ghc-options -Wwarn=x-partial 50 | stack build hlint 51 | stack build hpc-lcov 52 | yarn install 53 | 54 | - run: 55 | name: Run Tests and Generate Coverage 56 | command: | 57 | yarn test --maxWorkers=4 58 | stack --no-terminal test --coverage 59 | $(stack exec which hpc-lcov) 60 | 61 | - coveralls/upload 62 | 63 | - save_cache: 64 | name: Cache Dependencies (Haskell) 65 | key: v1.4-courseography-haskell-{{ checksum "courseography.cabal" }}-{{ checksum "stack.yaml.lock" }} 66 | paths: 67 | - "/root/.stack" 68 | - ".stack-work" 69 | 70 | - run: 71 | name: Run lint-staged checks 72 | command: | 73 | npx lint-staged --diff="upstream/master...$(git branch --show-current)" 74 | 75 | - run: 76 | name: Generate documentation 77 | command: | 78 | stack exec haddock -- -o doc -h --optghc=-iapp --optghc=-XOverloadedStrings --optghc=-XPartialTypeSignatures --optghc=-XScopedTypeVariables --ignore-all-exports app/Main.hs 79 | 80 | - store_artifacts: 81 | path: doc 82 | destination: docs 83 | # The resource_class feature allows configuring CPU and RAM resources for each job. Different resource classes are available for different executors. https://circleci.com/docs/2.0/configuration-reference/#resourceclass 84 | resource_class: large 85 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Initial pre-commit and style checks 2 | 363a10775a2abd87b5db9a764e29f50cc602fd3a 3 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Proposed Changes 2 | 3 | _(Describe your changes here. Also describe the motivation for your changes: what problem do they solve, or how do they improve the application or codebase? If this pull request fixes an open issue, [use a keyword to link this pull request to the issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword).)_ 4 | 5 | ... 6 | 7 |
8 | Screenshots of your changes (if applicable) 9 | 10 |
11 | 12 | ## Type of Change 13 | 14 | _(Write an `X` or a brief description next to the type or types that best describe your changes.)_ 15 | 16 | | Type | Applies? | 17 | | --------------------------------------------------------------------------------------- | -------- | 18 | | 🚨 _Breaking change_ (fix or feature that would cause existing functionality to change) | | 19 | | ✨ _New feature_ (non-breaking change that adds functionality) | | 20 | | 🐛 _Bug fix_ (non-breaking change that fixes an issue) | | 21 | | 🎨 _User interface change_ (change to user interface; provide screenshots) | | 22 | | ♻️ _Refactoring_ (internal change to codebase, without changing functionality) | | 23 | | 🚦 _Test update_ (change that _only_ adds or modifies tests) | | 24 | | 📦 _Dependency update_ (change that updates a dependency) | | 25 | | 🔧 _Internal_ (change that _only_ affects developers or continuous integration) | | 26 | 27 | ## Checklist 28 | 29 | _(Complete each of the following items for your pull request. Indicate that you have completed an item by changing the `[ ]` into a `[x]` in the raw text, or by clicking on the checkbox in the rendered description on GitHub.)_ 30 | 31 | Before opening your pull request: 32 | 33 | - [ ] I have performed a self-review of my changes. 34 | - Check that all changed files included in this pull request are intentional changes. 35 | - Check that all changes are relevant to the purpose of this pull request, as described above. 36 | - [ ] I have added tests for my changes, if applicable. 37 | - This is **required** for all bug fixes and new features. 38 | - [ ] I have updated the project documentation, if applicable. 39 | - This is **required** for new features. 40 | - [ ] If this is my first contribution, I have added myself to the list of contributors. 41 | - [ ] I have updated the project Changelog (this is required for all changes). 42 | 43 | After opening your pull request: 44 | 45 | - [ ] I have verified that the CircleCI checks have passed. 46 | - [ ] I have [requested a review](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/requesting-a-pull-request-review) from a project maintainer. 47 | 48 | ## Questions and Comments 49 | 50 | _(Include any questions or comments you have regarding your changes.)_ 51 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 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 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "monthly" 12 | groups: 13 | ag-grid: 14 | patterns: 15 | - "ag-grid-*" 16 | babel: 17 | patterns: 18 | - "@babel/*" 19 | eslint: 20 | patterns: 21 | - "eslint" 22 | - "eslint-*" 23 | fort-awesome: 24 | patterns: 25 | - "@fortawesome/*" 26 | jest: 27 | patterns: 28 | - "jest" 29 | - "jest-*" 30 | react: 31 | patterns: 32 | - "react" 33 | - "react-dom" 34 | stylelint: 35 | patterns: 36 | - "stylelint*" 37 | webpack: 38 | patterns: 39 | - "webpack" 40 | - "webpack-*" 41 | - "*-loader" 42 | 43 | - package-ecosystem: "github-actions" 44 | directory: "/" 45 | schedule: 46 | interval: "monthly" 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # cabal sandbox 2 | .cabal-sandbox/ 3 | cabal.sandbox.config 4 | 5 | # local database 6 | *.sqlite3 7 | *.sqlite3-shm 8 | *.sqlite3-wal 9 | 10 | # generated build files 11 | /app/dist/ 12 | 13 | # generated stack files 14 | .stack-work/ 15 | 16 | # generated CSS 17 | /public/style/ 18 | 19 | # generated resources 20 | /graphs/gen/*.svg 21 | /graphs/gen/*.png 22 | 23 | # course videos 24 | /public/videos/ 25 | 26 | # generated documentation 27 | /doc 28 | 29 | # IDE project files 30 | .idea 31 | .vscode 32 | 33 | # Node (Javascript) modules 34 | /node_modules 35 | 36 | # Yarn files 37 | .yarn/* 38 | .yarn/cache 39 | !.yarn/patches 40 | !.yarn/plugins 41 | !.yarn/releases 42 | !.yarn/sdks 43 | !.yarn/versions 44 | 45 | # TODO: eventually remove all public/js files from tracking 46 | /public/js/search 47 | /public/js/grid/app.js 48 | /public/js/post/app.js 49 | /public/js/generate/app.js 50 | /public/js/graph/app.js 51 | /public/js/draw/app.js 52 | /public/js/about/app.js 53 | /public/*.svg 54 | /public/*.png 55 | /public/*.ttf 56 | /public/*.eot 57 | /public/*.woff 58 | /public/*.png 59 | /public/*.woff2 60 | 61 | # coverage directory used by tools like istanbul and jest --coverage 62 | coverage 63 | 64 | # cypress suggested folder structure scaffolding 65 | /cypress/fixtures 66 | /cypress/integration/examples 67 | /cypress/plugins 68 | /cypress/support 69 | 70 | # Python virtual environment 71 | venv 72 | 73 | # Other generated files 74 | .eslintcache 75 | -------------------------------------------------------------------------------- /.hlint.yaml: -------------------------------------------------------------------------------- 1 | # HLint configuration file 2 | # https://github.com/ndmitchell/hlint 3 | ########################## 4 | 5 | # This file contains a template configuration file, which is typically 6 | # placed as .hlint.yaml in the root of your project 7 | 8 | # Specify additional command line arguments 9 | # 10 | # - arguments: [--color, --cpp-simple, -XQuasiQuotes] 11 | 12 | # Control which extensions/flags/modules/functions can be used 13 | # 14 | # - extensions: 15 | # - default: false # all extension are banned by default 16 | # - name: [PatternGuards, ViewPatterns] # only these listed extensions can be used 17 | # - {name: CPP, within: CrossPlatform} # CPP can only be used in a given module 18 | # 19 | # - flags: 20 | # - {name: -w, within: []} # -w is allowed nowhere 21 | # 22 | # - modules: 23 | # - {name: [Data.Set, Data.HashSet], as: Set} # if you import Data.Set qualified, it must be as 'Set' 24 | # - {name: Control.Arrow, within: []} # Certain modules are banned entirely 25 | # 26 | # - functions: 27 | # - {name: unsafePerformIO, within: []} # unsafePerformIO can only appear in no modules 28 | 29 | # Add custom hints for this project 30 | # 31 | # Will suggest replacing "wibbleMany [myvar]" with "wibbleOne myvar" 32 | # - error: {lhs: "wibbleMany [x]", rhs: wibbleOne x} 33 | 34 | # The hints are named by the string they display in warning messages. 35 | # For example, if you see a warning starting like 36 | # 37 | # Main.hs:116:51: Warning: Redundant == 38 | # 39 | # You can refer to that hint with `{name: Redundant ==}` (see below). 40 | 41 | # Turn on hints that are off by default 42 | # 43 | # Ban "module X(module X) where", to require a real export list 44 | # - warn: {name: Use explicit module export list} 45 | # 46 | # Replace a $ b $ c with a . b $ c 47 | # - group: {name: dollar, enabled: true} 48 | # 49 | # Generalise map to fmap, ++ to <> 50 | # - group: {name: generalise, enabled: true} 51 | 52 | # Ignore some builtin hints 53 | # - ignore: {name: Use let} 54 | # - ignore: {name: Use const, within: SpecialModule} # Only within certain modules 55 | - ignore: { name: "Use section" } 56 | - ignore: { name: "Use bimap" } 57 | # Define some custom infix operators 58 | # - fixity: infixr 3 ~^#^~ 59 | 60 | # To generate a suitable file for HLint do: 61 | # $ hlint --default > .hlint.yaml 62 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | app/ 2 | db/ 3 | doc/ 4 | graphs/ 5 | 6 | # TODO: review this file 7 | js/components/draw/ 8 | 9 | public/ 10 | 11 | vendor/ 12 | *.min.* 13 | 14 | .stack-work/ 15 | .yarn/ 16 | .pnp.cjs 17 | .pnp.loader.mjs 18 | 19 | venv/ 20 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 88, 3 | "semi": false, 4 | "arrowParens": "avoid", 5 | "trailingComma": "es5" 6 | } 7 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["stylelint-config-standard-scss"], 3 | "rules": { 4 | "no-descending-specificity": null, 5 | "selector-class-pattern": null, 6 | "selector-id-pattern": null, 7 | "selector-max-compound-selectors": 3, 8 | "selector-max-id": 2, 9 | "selector-no-qualifying-type": null 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | yarnPath: .yarn/releases/yarn-3.3.1.cjs 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | ## Getting Involved 4 | 5 | It's recommended that you get involved if you aren't already. Click [here](https://github.com/Courseography/courseography/wiki/Getting-Involved) to learn more. 6 | 7 | ## Style 8 | 9 | Please ensure that your code follows the same style conventions as outlined [here](https://github.com/Courseography/courseography/wiki#style-guides). 10 | -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 | #Privacy Policy 2 | 3 | Courseography does not store any information about you: none of your actions in the various components of this website are recorded, nor is any of your information accessed through Facebook. 4 | 5 | There is an option for you to post schedules and graphs to your Facebook account. At no point will Courseography post anything to Facebook without your instruction to do so. Courseography does not interact with your Facebook account in any way other than these posts, and logging in and out. Logging into your Facebook account is not required to use any other feature of this website. 6 | 7 | ##Changes to this privacy statement 8 | 9 | We may amend this Privacy Statement from time to time. If we make changes in the way we use personal information, we will notify you by posting an announcement on our website. Users are bound by any changes to the Privacy Statement when he or she uses or otherwise accesses Courseography after such changes have been first posted. 10 | 11 | ##Questions or concerns 12 | 13 | If you have any questions or concerns regarding privacy on our website, please send us a detailed message at courseography@cs.toronto.edu. We will make every effort to resolve your concerns. 14 | 15 | Effective Date: April 15, 2015 16 | -------------------------------------------------------------------------------- /Setup.hs: -------------------------------------------------------------------------------- 1 | import Distribution.Simple 2 | import System.Exit 3 | import System.Process (readProcessWithExitCode) 4 | import Distribution.PackageDescription (emptyHookedBuildInfo) 5 | import System.Directory (doesFileExist, copyFile) 6 | 7 | main = defaultMainWithHooks 8 | simpleUserHooks { preBuild = preBuildChecks } 9 | where 10 | -- | Checks that Imagemagick and LaTeX are available 11 | preBuildChecks _ _ = do 12 | mapM_ checkDependency ["magick", "pdflatex"] 13 | return emptyHookedBuildInfo 14 | 15 | checkDependency :: String -> IO () 16 | checkDependency dependency = do 17 | (result, _, _) <- readProcessWithExitCode dependency ["-version"] "" 18 | case result of 19 | ExitFailure 127 -> putStrLn ("Error Message: " ++ dependency ++ " is NOT available. Please ensure it is on your path.") >> exitFailure 20 | _ -> putStrLn (dependency ++ " has been installed.") 21 | -------------------------------------------------------------------------------- /app/Controllers/Course.hs: -------------------------------------------------------------------------------- 1 | module Controllers.Course 2 | (retrieveCourse, index, courseInfo) where 3 | 4 | import Config (runDb) 5 | import Control.Monad.IO.Class (liftIO) 6 | import qualified Data.Text as T (Text, unlines) 7 | import qualified Database.CourseQueries as CourseHelpers (getDeptCourses) 8 | import Database.Persist (Entity) 9 | import Database.Persist.Sqlite (SqlPersistM, entityVal, selectList) 10 | import Database.Tables as Tables (Courses, coursesCode) 11 | import Happstack.Server (Response, ServerPart, lookText', toResponse) 12 | import Models.Course (returnCourse) 13 | import Util.Happstack (createJSONResponse) 14 | 15 | -- | Takes a course code (e.g. \"CSC108H1\") and sends a JSON representation 16 | -- of the course as a response. 17 | retrieveCourse :: ServerPart Response 18 | retrieveCourse = do 19 | name <- lookText' "name" 20 | courses <- liftIO $ returnCourse name 21 | return $ createJSONResponse courses 22 | 23 | -- | Builds a list of all course codes in the database. 24 | index :: ServerPart Response 25 | index = do 26 | response <- liftIO $ runDb $ do 27 | coursesList :: [Entity Courses] <- selectList [] [] 28 | let codes = map (coursesCode . entityVal) coursesList 29 | return $ T.unlines codes :: SqlPersistM T.Text 30 | return $ toResponse response 31 | 32 | -- | Returns all course info for a given department. 33 | courseInfo :: ServerPart Response 34 | courseInfo = do 35 | dept <- lookText' "dept" 36 | fmap createJSONResponse (CourseHelpers.getDeptCourses dept) 37 | -------------------------------------------------------------------------------- /app/Controllers/Generate.hs: -------------------------------------------------------------------------------- 1 | module Controllers.Generate 2 | (generateResponse, findAndSavePrereqsResponse) where 3 | 4 | import Control.Monad () 5 | import Control.Monad.IO.Class (liftIO) 6 | import Data.Aeson (decode, object, (.=)) 7 | import Data.List (nub) 8 | import Data.Maybe (fromJust, isNothing, mapMaybe) 9 | import qualified Data.Text.Lazy as TL 10 | import Database.CourseQueries (reqsForPost, returnPost) 11 | import DynamicGraphs.GraphOptions (CourseGraphOptions (..), GraphOptions (..)) 12 | import DynamicGraphs.WriteRunDot (generateAndSavePrereqResponse, getBody) 13 | import Happstack.Server 14 | import MasterTemplate 15 | import Scripts 16 | import Text.Blaze ((!)) 17 | import qualified Text.Blaze.Html5 as H 18 | import qualified Text.Blaze.Html5.Attributes as A 19 | import Util.Happstack (createJSONResponse) 20 | 21 | generateResponse :: ServerPart Response 22 | generateResponse = 23 | ok $ toResponse $ 24 | masterTemplate "Courseography - Generate" 25 | [] 26 | (do 27 | header "generate-prerequisites" 28 | generatePrerequisites 29 | ) 30 | generateScripts 31 | 32 | generatePrerequisites :: H.Html 33 | generatePrerequisites = 34 | H.html $ do 35 | H.head $ 36 | H.title "Generate Prerequisites!" 37 | H.div ! A.id "generateRoot" $ "" 38 | 39 | findAndSavePrereqsResponse :: ServerPart Response 40 | findAndSavePrereqsResponse = do 41 | method PUT 42 | requestBody <- getBody 43 | let coursesOptions :: CourseGraphOptions = fromJust $ decode requestBody 44 | 45 | postResults <- liftIO $ mapM (\code -> do 46 | post <- returnPost (TL.toStrict code) 47 | return (TL.toStrict code, post)) 48 | (programs coursesOptions) 49 | 50 | let invalidPrograms = map fst $ filter (isNothing . snd) postResults 51 | validPrograms = mapMaybe snd postResults 52 | 53 | allCourses <- liftIO $ nub <$> 54 | if all (== TL.empty) (courses coursesOptions) 55 | then return $ map TL.pack (concatMap reqsForPost validPrograms) 56 | else return $ courses coursesOptions 57 | 58 | let updatedCoursesOptions = coursesOptions 59 | { courses = map TL.toUpper allCourses 60 | , graphOptions = (graphOptions coursesOptions) 61 | { taken = map TL.toUpper (taken (graphOptions coursesOptions)) 62 | , departments = map TL.toUpper (departments (graphOptions coursesOptions)) 63 | } 64 | } 65 | 66 | if all (== TL.empty) (courses coursesOptions) && not (null invalidPrograms) 67 | then return $ createJSONResponse $ object ["error" .= object ["invalidPrograms" .= invalidPrograms]] 68 | else liftIO $ generateAndSavePrereqResponse updatedCoursesOptions 69 | -------------------------------------------------------------------------------- /app/Controllers/Graph.hs: -------------------------------------------------------------------------------- 1 | module Controllers.Graph (graphResponse, index, getGraphJSON, graphImageResponse) where 2 | 3 | import Control.Monad.IO.Class (liftIO) 4 | import Data.Aeson (object, (.=)) 5 | import Data.Maybe (fromMaybe) 6 | import Happstack.Server (Response, ServerPart, look, lookText', ok, toResponse) 7 | import MasterTemplate (header, masterTemplate) 8 | import Scripts (graphScripts) 9 | import Text.Blaze ((!)) 10 | import qualified Text.Blaze.Html5 as H 11 | import qualified Text.Blaze.Html5.Attributes as A 12 | 13 | import Config (runDb) 14 | import Database.CourseQueries (getGraph) 15 | import Database.Persist.Sqlite (Entity, SelectOpt (Asc), SqlPersistM, selectList, (==.)) 16 | import Database.Tables as Tables (EntityField (GraphDynamic, GraphTitle), Graph, Text) 17 | import Export.GetImages (getActiveGraphImage) 18 | import Response.Image (returnImageData) 19 | import Util.Happstack (createJSONResponse) 20 | 21 | graphResponse :: ServerPart Response 22 | graphResponse = 23 | ok $ toResponse $ 24 | masterTemplate "Courseography - Graph" 25 | [] 26 | (do 27 | header "graph" 28 | H.div ! A.id "container" $ "" 29 | ) 30 | graphScripts 31 | 32 | 33 | index :: ServerPart Response 34 | index = liftIO $ runDb $ do 35 | graphsList :: [Entity Graph] <- selectList [GraphDynamic ==. False] [Asc GraphTitle] 36 | return $ createJSONResponse graphsList :: SqlPersistM Response 37 | 38 | 39 | -- | Looks up a graph using its title then gets the Shape, Text and Path elements 40 | -- for rendering graph (returned as JSON). 41 | getGraphJSON :: ServerPart Response 42 | getGraphJSON = do 43 | graphName <- lookText' "graphName" 44 | response <- liftIO $ getGraph graphName 45 | return $ createJSONResponse $ fromMaybe (object ["texts" .= ([] :: [Text]), 46 | "shapes" .= ([] :: [Text]), 47 | "paths" .= ([] :: [Text])]) response 48 | 49 | 50 | -- | Returns an image of the graph requested by the user, given graphInfo stored in local storage. 51 | graphImageResponse :: ServerPart Response 52 | graphImageResponse = do 53 | graphInfo <- look "JsonLocalStorageObj" 54 | (svgFilename, imageFilename) <- liftIO $ getActiveGraphImage graphInfo 55 | liftIO $ returnImageData svgFilename imageFilename 56 | -------------------------------------------------------------------------------- /app/Css/Constants.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Module : Css.Constants 3 | Description : Defines the constants for the other CSS modules. 4 | -} 5 | module Css.Constants 6 | (-- * Colors 7 | theoryDark, 8 | coreDark, 9 | seDark, 10 | systemsDark, 11 | graphicsDark, 12 | dbwebDark, 13 | numDark, 14 | aiDark, 15 | hciDark, 16 | mathDark, 17 | introDark, 18 | titleColour, 19 | lightGrey, 20 | -- * Graph Styles 21 | nodeFontSize, 22 | hybridFontSize, 23 | boolFontSize, 24 | regionFontSize 25 | ) where 26 | 27 | import Data.Text as T 28 | import Prelude hiding ((**)) 29 | 30 | {- Colors -} 31 | 32 | -- |Defines the color of a grayish blue. 33 | theoryDark :: T.Text 34 | theoryDark = "#B1C8D1" 35 | 36 | -- |Defines the color of a light gray. 37 | coreDark :: T.Text 38 | coreDark = "#C9C9C9" 39 | 40 | -- |Defines the color of a soft red. 41 | seDark :: T.Text 42 | seDark = "#E68080" 43 | 44 | -- |Defines the color of a light violet. 45 | systemsDark :: T.Text 46 | systemsDark = "#C285FF" 47 | 48 | -- |Defines the color of a mostly desaturated dark lime green. 49 | graphicsDark :: T.Text 50 | graphicsDark = "#66A366" 51 | 52 | -- |Defines the color of a strong pink. 53 | dbwebDark :: T.Text 54 | dbwebDark = "#C42B97" 55 | 56 | -- |Defines the color of a very light green. 57 | numDark :: T.Text 58 | numDark = "#B8FF70" 59 | 60 | -- |Defines the color of a very light blue. 61 | aiDark :: T.Text 62 | aiDark = "#80B2FF" 63 | 64 | -- |Defines the color of a soft lime green. 65 | hciDark :: T.Text 66 | hciDark = "#91F27A" 67 | 68 | -- |Defines the color of a slightly desaturated violet. 69 | mathDark :: T.Text 70 | mathDark = "#8A67BE" 71 | 72 | -- |Defines the color of a moderate cyan. 73 | introDark :: T.Text 74 | introDark = "#5DD5B8" 75 | 76 | -- |Defines the color of a very dark blue. 77 | titleColour :: T.Text 78 | titleColour = "#072D68" 79 | 80 | -- |Defines the color of a light gray. 81 | lightGrey :: T.Text 82 | lightGrey = "#CCCCCC" 83 | 84 | 85 | {- Graph styles -} 86 | 87 | -- |Defines node font size, 12 in pixels. 88 | nodeFontSize :: Num a => a 89 | nodeFontSize = 12 90 | 91 | -- |Defines hybrid font size, 7 in pixels. 92 | hybridFontSize :: Double 93 | hybridFontSize = 7 94 | 95 | 96 | -- |Defines bool font size, 6 in pixels. 97 | boolFontSize :: Num a => a 98 | boolFontSize = 6 99 | 100 | -- |Defines region font size, 14 in pixels. 101 | regionFontSize :: Num a => a 102 | regionFontSize = 13 103 | -------------------------------------------------------------------------------- /app/Database/CourseInsertion.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Module : Database.CourseInsertion 3 | Description : Functions that insert/update course information in the 4 | database. 5 | 6 | This module contains a bunch of functions related to inserting information 7 | into the database. These functions are used as helpers for the WebParsing module. 8 | -} 9 | 10 | module Database.CourseInsertion 11 | (insertCourse, 12 | saveGraphJSON) where 13 | 14 | import Config (runDb) 15 | import Control.Monad.IO.Class (liftIO) 16 | import qualified Data.Aeson as Aeson 17 | import qualified Data.Text as T 18 | import Database.Persist.Class (selectKeysList) 19 | import Database.Persist.Sqlite (SqlPersistM, insert, insertMany_, insert_, selectFirst, (==.)) 20 | import Database.Tables hiding (breadth, distribution, paths, shapes, texts) 21 | import Happstack.Server (Response, ServerPart, lookBS, lookText', toResponse) 22 | 23 | -- | Inserts SVG graph data into Texts, Shapes, and Paths tables 24 | saveGraphJSON :: ServerPart Response 25 | saveGraphJSON = do 26 | jsonStr <- lookBS "jsonData" 27 | nameStr <- lookText' "nameData" 28 | let jsonObj = Aeson.decode jsonStr :: Maybe SvgJSON 29 | case jsonObj of 30 | Nothing -> return $ toResponse ("Error" :: String) 31 | Just (SvgJSON texts shapes paths) -> do 32 | _ <- liftIO $ runDb $ insertGraph nameStr texts shapes paths 33 | return $ toResponse ("Success" :: String) 34 | where 35 | insertGraph :: T.Text -> [Text] -> [Shape] -> [Path] -> SqlPersistM () 36 | insertGraph nameStr_ texts shapes paths = do 37 | gId <- insert $ Graph nameStr_ 256 256 False 38 | insertMany_ $ map (\text -> text {textGraph = gId}) texts 39 | insertMany_ $ map (\shape -> shape {shapeGraph = gId}) shapes 40 | insertMany_ $ map (\path -> path {pathGraph = gId}) paths 41 | 42 | --contains' :: PersistEntity m => T.Text -> SqlPersistM m 43 | --contains field query = Filter field (Left $ T.concat ["%", query, "%"]) (BackendSpecificFilter "LIKE") 44 | 45 | -- Get Key of correspondig record in Distribution column 46 | getDistributionKey :: T.Text -> SqlPersistM (Maybe (Key Distribution)) 47 | getDistributionKey description_ = do 48 | keyListDistribution :: [Key Distribution] <- selectKeysList [ DistributionDescription ==. description_ ] [] 49 | -- option: keyListDistribution :: [DistributionId] <- selectKeysList [ DistributionDescription `contains'` description] [] 50 | return $ case keyListDistribution of 51 | [] -> Nothing 52 | _ -> Just (head keyListDistribution) 53 | 54 | getBreadthKey :: T.Text -> SqlPersistM (Maybe (Key Breadth)) 55 | getBreadthKey description_ = do 56 | keyListBreadth :: [Key Breadth] <- selectKeysList [ BreadthDescription ==. description_ ] [] 57 | -- option: selectKeysList [ BreadthDescription `contains'` description] [] 58 | return $ case keyListBreadth of 59 | [] -> Nothing 60 | _ -> Just (head keyListBreadth) 61 | 62 | -- | Inserts course into the Courses table. 63 | insertCourse :: (Courses, T.Text, T.Text) -> SqlPersistM () 64 | insertCourse (course, breadth, distribution) = do 65 | maybeCourse <- selectFirst [CoursesCode ==. coursesCode course] [] 66 | breadthKey <- getBreadthKey breadth 67 | distributionKey <- getDistributionKey distribution 68 | case maybeCourse of 69 | Nothing -> insert_ $ course {coursesBreadth = breadthKey, 70 | coursesDistribution = distributionKey} 71 | Just _ -> return () 72 | -------------------------------------------------------------------------------- /app/Database/CourseVideoSeed.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Module : Database.CourseVideoSeed 3 | Description : Contains the data and functions for seeding the courseVideos. 4 | -} 5 | module Database.CourseVideoSeed 6 | (courseVideos, seedVideos) where 7 | 8 | import Config (runDb) 9 | import Data.Text (Text) 10 | import Database.Persist.Sqlite (SqlPersistM, updateWhere, (=.), (==.)) 11 | import Database.Tables hiding (Text) 12 | 13 | -- | Defines the constant list of ordered pairs pertaining to the routes for 14 | -- course videos. The first Text variable in each ordered pair is the course. 15 | -- The second List of Text variable is the routes to the videos. 16 | courseVideos :: [(Text, [Text])] 17 | courseVideos = [ 18 | ("CSC240H1", ["/static/videos/csc240.mp4"]), 19 | ("CSC336H1", ["/static/videos/csc336.mp4"]), 20 | ("CSC436H1", ["/static/videos/csc436.mp4"]), 21 | ("CSC438H1", ["/static/videos/csc438.mp4"]), 22 | ("CSC456H1", ["/static/videos/csc456.mp4"]), 23 | ("CSC463H1", ["/static/videos/csc463.mp4"])] 24 | 25 | seedVideo :: (Text, [Text]) -> SqlPersistM () 26 | seedVideo (code, videos) = 27 | updateWhere [CoursesCode ==. code] [CoursesVideoUrls =. videos] 28 | 29 | -- | Sets the video routes of all course rows. 30 | seedVideos :: IO () 31 | seedVideos = runDb $ mapM_ seedVideo courseVideos 32 | -------------------------------------------------------------------------------- /app/Database/DataType.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE DeriveGeneric, TemplateHaskell #-} 2 | 3 | {-| 4 | Module : Database.DataType 5 | Description : Contains a single enumeration type, ShapeType. 6 | 7 | This is a small module that contains a single enumeration type. 8 | Note that we can't include this in "Database.Tables" because of 9 | a restriction on Template Haskell. 10 | -} 11 | module Database.DataType where 12 | 13 | import Data.Aeson 14 | import Database.Persist.TH 15 | import GHC.Generics 16 | 17 | -- | Defines a datatype of a shape, used in Shape json table. 18 | data ShapeType = BoolNode | Node | Hybrid | Region 19 | deriving (Show, Read, Eq, Generic) 20 | 21 | -- | Results from [derivePersistField](https://hackage.haskell.org/package/persistent-template-2.5.1.6/docs/Database-Persist-TH.html#v:derivePersistField) 22 | -- call, as does PersistField, most importantly, it allows the data type to be 23 | -- a column in the database. 24 | derivePersistField "ShapeType" 25 | 26 | -- | Results from call of [ToJSON](https://hackage.haskell.org/package/aeson-1.1.0.0/docs/Data-Aeson.html#t:ToJSON) 27 | -- . 28 | instance ToJSON ShapeType 29 | 30 | -- | Results from call of [FromJSON](https://hackage.haskell.org/package/aeson-1.1.0.0/docs/Data-Aeson.html#t:FromJSON) 31 | -- . 32 | instance FromJSON ShapeType 33 | 34 | data PostType = Specialist | Major | Minor | Focus | Certificate | Other 35 | deriving (Show, Read, Eq, Generic) 36 | derivePersistField "PostType" 37 | 38 | instance ToJSON PostType 39 | instance FromJSON PostType 40 | -------------------------------------------------------------------------------- /app/Database/Database.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Module : Database.Database 3 | Description : Main module for database course seeding. 4 | 5 | The main module for parsing course information from the web and 6 | inserting it into the database. Run when @cabal run database@ is executed. 7 | -} 8 | 9 | module Database.Database 10 | (populateCalendar, setupDatabase) where 11 | 12 | import Config (databasePath, runDb) 13 | import Control.Monad.IO.Class (liftIO) 14 | import Data.Maybe (fromMaybe) 15 | import Data.Text as T (findIndex, length, reverse, take, unpack) 16 | import Database.CourseVideoSeed (seedVideos) 17 | import Database.Persist.Sqlite (insert_, runMigration) 18 | import Database.Tables 19 | import System.Directory (createDirectoryIfMissing) 20 | import WebParsing.ArtSciParser (parseCalendar) 21 | 22 | 23 | distTableSetUpStr :: String 24 | distTableSetUpStr = "Distribution table set up" 25 | breathTableSetUpStr :: String 26 | breathTableSetUpStr = "breadth table set up" 27 | 28 | 29 | -- | Creates the database if it doesn't exist and runs migrations. 30 | setupDatabase :: IO () 31 | setupDatabase = do 32 | -- Create db folder if it doesn't exist 33 | dbPath <- liftIO databasePath 34 | let ind = (T.length dbPath -) . fromMaybe 0 . T.findIndex (=='/') . T.reverse $ dbPath 35 | db = T.unpack $ T.take ind dbPath 36 | createDirectoryIfMissing True db 37 | runDb $ runMigration migrateAll 38 | 39 | -- | Sets up the course information from Artsci Calendar 40 | populateCalendar :: IO () 41 | populateCalendar = do 42 | populateStaticInfo 43 | parseCalendar 44 | 45 | -- | Sets up the tables and seeds the videos for the database. 46 | populateStaticInfo :: IO () 47 | populateStaticInfo = do 48 | setupDistributionTable 49 | print distTableSetUpStr 50 | setupBreadthTable 51 | print breathTableSetUpStr 52 | seedVideos 53 | 54 | -- | Sets up the Distribution table. 55 | setupDistributionTable :: IO () 56 | setupDistributionTable = runDb $ do 57 | insert_ $ Distribution "Humanities" 58 | insert_ $ Distribution "Social Science" 59 | insert_ $ Distribution "Science" 60 | 61 | -- | Sets up the Breadth table. 62 | setupBreadthTable :: IO () 63 | setupBreadthTable = runDb $ do 64 | insert_ $ Breadth "Creative and Cultural Representations (1)" 65 | insert_ $ Breadth "Thought, Belief, and Behaviour (2)" 66 | insert_ $ Breadth "Society and its Institutions (3)" 67 | insert_ $ Breadth "Living Things and Their Environment (4)" 68 | insert_ $ Breadth "The Physical and Mathematical Universes (5)" 69 | insert_ $ Breadth "No Breadth" 70 | -------------------------------------------------------------------------------- /app/Database/README.md: -------------------------------------------------------------------------------- 1 | Information 2 | =========== 3 | 4 | Summary 5 | ------- 6 | 7 | Contains modules that interact with the database, as well as some data types. 8 | 9 | Notable Files 10 | ------------- 11 | 12 | Tables is where the database schema is located. 13 | -------------------------------------------------------------------------------- /app/Database/Requirement.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Module : Database.Requirement 3 | Description : Requirement 4 | 5 | Module containing data type that represents a "Requirement". 6 | 7 | We will use parsed data to create instances of this type. 8 | -} 9 | 10 | module Database.Requirement ( 11 | Req (..), 12 | Modifier (..) 13 | ) where 14 | 15 | data Req = None 16 | | J String String 17 | | ReqAnd [Req] 18 | | ReqOr [Req] 19 | | Fces Float Modifier 20 | | Grade String Req 21 | | Gpa Float String 22 | | Program String 23 | | Raw String deriving (Eq, Show) 24 | 25 | data Modifier = Department String 26 | | Level String 27 | | ModAnd [Modifier] 28 | | ModOr [Modifier] 29 | | Requirement Req deriving (Eq, Show) 30 | -------------------------------------------------------------------------------- /app/DynamicGraphs/CourseFinder.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Module : DynamicGraphs.CourseFinder 3 | Description : Retrieve course information needed to generate graphs. 4 | 5 | This module contains the logic requests for information needed to 6 | generate graphs before retrieving and structuring the necessary data. 7 | -} 8 | module DynamicGraphs.CourseFinder (lookupCourses) where 9 | 10 | import Control.Monad.Trans.Class (lift) 11 | import Control.Monad.Trans.State (StateT, execStateT, modify) 12 | import qualified Data.Map.Strict as Map 13 | import qualified Data.Set as Set 14 | import qualified Data.Text.Lazy as T 15 | import Database.CourseQueries (prereqsForCourse) 16 | import Database.Requirement (Modifier (..), Req (..)) 17 | import DynamicGraphs.GraphOptions (GraphOptions (..)) 18 | import WebParsing.ReqParser (parseReqs) 19 | 20 | lookupCourses :: GraphOptions -> [T.Text] -> IO (Map.Map T.Text Req) 21 | lookupCourses options courses = 22 | execStateT (mapM_ (lookupCourse options) courses) Map.empty 23 | 24 | lookupCourse :: GraphOptions -> T.Text -> StateT (Map.Map T.Text Req) IO () 25 | lookupCourse options code = do 26 | prereqResults <- lift $ prereqsForCourse $ T.toStrict code 27 | case prereqResults of 28 | Left _ -> return () 29 | Right (courseCode, prereqStr) -> do 30 | let prereqs = parseReqs (T.unpack $ T.fromStrict prereqStr) 31 | modify $ Map.insert (T.fromStrict courseCode) prereqs 32 | lookupReqs options prereqs 33 | 34 | lookupReqs :: GraphOptions -> Req -> StateT (Map.Map T.Text Req) IO () 35 | lookupReqs options (J name _) = do 36 | if Set.member name (Set.fromList $ map T.unpack (taken options)) || 37 | not (Set.member (take 3 name) $ Set.fromList $ map T.unpack (departments options)) 38 | -- This course has been taken or is not a department we want to include; we don't need its prerequisites 39 | then return () 40 | else lookupCourse options $ T.pack name 41 | lookupReqs options (ReqAnd parents) = mapM_ (lookupReqs options) parents 42 | lookupReqs options (ReqOr parents) = 43 | if any hasTaken parents 44 | -- We've taken at least one of parents, so this entire ReqOr is satisfied 45 | then return () 46 | else mapM_ (lookupReqs options) parents 47 | where 48 | hasTaken :: Req -> Bool 49 | hasTaken (J name _) = Set.member name (Set.fromList $ map T.unpack (taken options)) 50 | hasTaken _ = False 51 | lookupReqs options (Fces _ (Requirement parent)) = lookupReqs options parent 52 | lookupReqs options (Grade _ parent) = lookupReqs options parent 53 | -- This will catch None, Raw, and Fces with non-course modifiers 54 | lookupReqs _ _ = return () 55 | -------------------------------------------------------------------------------- /app/DynamicGraphs/GraphNodeUtils.hs: -------------------------------------------------------------------------------- 1 | module DynamicGraphs.GraphNodeUtils 2 | ( stringifyModAnd 3 | , formatModOr 4 | , concatModOr 5 | , maybeHead 6 | , paddingSpaces 7 | ) where 8 | 9 | import Database.Requirement (Modifier (..), Req (..)) 10 | 11 | -- | Converts the given number of credits and list of modifiers into a string 12 | -- | in readable English 13 | -- | Assumes each modifier constructor appears in modifiers at most once 14 | -- | The ModOr constructor may appear more than once, but each occurrence 15 | -- | of ModOr contains exactly one constructor for all its elements 16 | -- | and such constructor does not appear anywhere else in ModAnd 17 | stringifyModAnd :: Float -> [Modifier] -> String 18 | stringifyModAnd creds modifiers = let 19 | dept = maybeHead [x | Department x <- modifiers] 20 | deptFormatted = case dept of 21 | Nothing -> "" 22 | Just x -> ' ' : x 23 | depts = maybeHead [xs | ModOr xs@((Department _):_) <- modifiers] 24 | deptsFormatted = case depts of 25 | Nothing -> "" 26 | Just xs -> ' ' : concatModOr xs 27 | level = maybeHead [x | Level x <- modifiers] 28 | levelFormatted = case level of 29 | Nothing -> "" 30 | Just x -> " at the " ++ x ++ " level" 31 | levels = maybeHead [xs | ModOr xs@((Level _):_) <- modifiers] 32 | levelsFormatted = case levels of 33 | Nothing -> "" 34 | Just xs -> " at the " ++ concatModOr xs ++ " level" 35 | raw = maybeHead [x | Requirement (Raw x) <- modifiers] 36 | rawFormatted = case raw of 37 | Nothing -> "" 38 | Just x -> " from " ++ x 39 | raws = maybeHead [xs | ModOr xs@((Requirement (Raw _)):_) <- modifiers] 40 | rawsFormatted = case raws of 41 | Nothing -> "" 42 | Just xs -> " from " ++ concatModOr xs 43 | 44 | in show creds ++ deptFormatted ++ deptsFormatted ++ " FCEs" 45 | ++ levelFormatted ++ levelsFormatted ++ rawFormatted ++ rawsFormatted 46 | 47 | -- | Formats a ModOr into Fces string 48 | -- | Assumes all modifiers in the ModOr have the same constructor 49 | formatModOr :: Float -> [Modifier] -> String 50 | formatModOr creds mods@((Department _):_) = show creds ++ " " ++ concatModOr mods ++ " FCEs" 51 | formatModOr creds mods@((Level _):_) = show creds ++ " FCEs at the " ++ concatModOr mods ++ " level" 52 | formatModOr creds mods@((Requirement (Raw _)):_) = show creds ++ " FCEs from " ++ concatModOr mods 53 | formatModOr _ _ = "" -- we should never get here 54 | 55 | -- | Joins a list of modifiers in ModOr together with a "/" or "or" 56 | -- | Assumes all modifiers in the list have the same constructor 57 | concatModOr :: [Modifier] -> String 58 | concatModOr [Level x] = x 59 | concatModOr ((Level x):xs) = x ++ "/" ++ concatModOr xs 60 | concatModOr [Department x] = x 61 | concatModOr ((Department x):xs) = x ++ "/" ++ concatModOr xs 62 | concatModOr [Requirement (Raw x)] = x 63 | concatModOr ((Requirement (Raw x)):xs) = x ++ " or " ++ concatModOr xs 64 | concatModOr _ = "" -- we should never get here 65 | 66 | -- | Returns Just the first element of the given list, or Nothing if the list is empty 67 | maybeHead :: [a] -> Maybe a 68 | maybeHead [] = Nothing 69 | maybeHead (x:_) = Just x 70 | 71 | paddingSpaces :: Int -> [Char] 72 | paddingSpaces n = Prelude.replicate n ' ' 73 | -------------------------------------------------------------------------------- /app/DynamicGraphs/GraphOptions.hs: -------------------------------------------------------------------------------- 1 | module DynamicGraphs.GraphOptions where 2 | 3 | import Data.Aeson (FromJSON (parseJSON), withObject, (.!=), (.:?)) 4 | import qualified Data.Text.Lazy as T 5 | 6 | data GraphOptions = 7 | GraphOptions { taken :: [T.Text], -- courses to exclude from graph 8 | departments :: [T.Text], -- department prefixes to include 9 | excludedDepth :: Int, -- depth to recurse on courses from excluded departments 10 | maxDepth :: Int, -- total recursive depth to recurse on (depth of the overall graph) 11 | courseNumPrefix :: [Int], -- filter based on course number (most useful for filtering based on year) 12 | distribution :: [T.Text], -- distribution to include: like "artsci", or "engineering" 13 | location :: [T.Text], -- location of courses to include: like "utsg", or "utsc" 14 | includeRaws :: Bool, -- True to include nodes which are raw values 15 | includeGrades :: Bool -- True to include grade nodes 16 | } deriving (Show) 17 | 18 | data CourseGraphOptions = CourseGraphOptions { courses :: [T.Text], programs :: [T.Text], graphOptions :: GraphOptions } 19 | deriving (Show) 20 | 21 | defaultGraphOptions :: GraphOptions 22 | defaultGraphOptions = 23 | GraphOptions [] -- taken 24 | [] -- departments 25 | 0 -- excludedDepth 26 | (-1) -- maxDepth 27 | [] -- courseNumPrefix 28 | [] -- distribution 29 | [] -- location 30 | True -- includeRaws 31 | True -- includeGrades 32 | 33 | instance FromJSON CourseGraphOptions where 34 | parseJSON = withObject "Expected Object for GraphOptions" $ \o -> do 35 | rootCourses <- o .:? "courses" .!= [] 36 | rootPrograms <- o.:? "programs" .!= [] 37 | takenCourses <- o .:? "taken" .!= [] 38 | dept <- o .:? "departments" .!= [] 39 | excludedCourseDepth <- o .:? "excludedDepth" .!= 0 40 | maxGraphDepth <- o .:? "maxDepth" .!= (-1) 41 | courseNumPref <- o .:? "courseNumPrefix" .!= [] 42 | distrib <- o .:? "distribution" .!= [] 43 | includedLocation <- o .:? "location" .!= [] 44 | incRaws <- o .:? "includeRaws" .!= True 45 | incGrades <- o .:? "includeGrades" .!= True 46 | let options = GraphOptions takenCourses 47 | dept 48 | excludedCourseDepth 49 | maxGraphDepth 50 | courseNumPref 51 | distrib 52 | includedLocation 53 | incRaws 54 | incGrades 55 | return $ CourseGraphOptions rootCourses rootPrograms options 56 | -------------------------------------------------------------------------------- /app/DynamicGraphs/WriteRunDot.hs: -------------------------------------------------------------------------------- 1 | module DynamicGraphs.WriteRunDot where 2 | 3 | import Control.Monad (forM_) 4 | import Control.Monad.IO.Class (liftIO) 5 | import qualified Data.ByteString as B 6 | import qualified Data.ByteString.Lazy as L 7 | import Data.GraphViz hiding (Str) 8 | import Data.Hash.MD5 (Str (Str), md5s) 9 | import Data.List (sort) 10 | import Data.Maybe (fromMaybe) 11 | import qualified Data.Text as T 12 | import Data.Text.Encoding (decodeUtf8) 13 | import Database.CourseQueries (getGraph) 14 | import DynamicGraphs.GraphGenerator (coursesToPrereqGraph, coursesToPrereqGraphExcluding, 15 | graphProfileHash) 16 | import DynamicGraphs.GraphOptions (CourseGraphOptions (..), GraphOptions (..)) 17 | import Happstack.Server (ServerPart, askRq) 18 | import Happstack.Server.SimpleHTTP (Response) 19 | import Happstack.Server.Types (takeRequestBody, unBody) 20 | import Svg.Parser (parseDynamicSvg) 21 | import System.Directory (createDirectoryIfMissing) 22 | import System.FilePath (combine, normalise) 23 | import Util.Happstack (createJSONResponse) 24 | 25 | doDots :: PrintDotRepr dg n => [(FilePath, dg n)] -> IO () 26 | doDots cases = do 27 | forM_ cases createImage 28 | putStrLn "Look in graphs/gen to see the created graphs" 29 | 30 | generatePrereqsForCourses :: (FilePath, [String]) -> IO () 31 | generatePrereqsForCourses (output, rootCourses) = do 32 | graph <- coursesToPrereqGraph rootCourses 33 | _ <- createImage (output, graph) 34 | putStrLn $ "Generated prerequisite graph for " 35 | ++ show rootCourses 36 | ++ " in graphs/gen" 37 | 38 | getBody :: ServerPart L.ByteString 39 | getBody = do 40 | req <- askRq 41 | body <- liftIO $ takeRequestBody req 42 | case body of 43 | Just rqbody -> return . unBody $ rqbody 44 | Nothing -> return "" 45 | 46 | generateAndSavePrereqResponse :: CourseGraphOptions -> IO Response 47 | generateAndSavePrereqResponse coursesOptions = do 48 | cached <- getGraph graphHash 49 | case cached of 50 | Just cachedGraph -> return $ createJSONResponse cachedGraph 51 | Nothing -> do 52 | graph <- coursesToPrereqGraphExcluding (courses coursesOptions) (graphOptions coursesOptions) 53 | bString <- graphToByteString graph 54 | -- Parse the generated SVG and store it in the database. 55 | parseDynamicSvg graphHash $ decodeUtf8 bString 56 | storedGraph <- getGraph graphHash 57 | return $ createJSONResponse $ fromMaybe graphNotFound storedGraph 58 | where 59 | graphHash :: T.Text 60 | graphHash = hash coursesOptions 61 | graphNotFound = error "Graph should have been generated but was not found" 62 | 63 | -- | Hash function to uniquely identify the graph layout. 64 | hash :: CourseGraphOptions -> T.Text 65 | hash coursesOptions = hashFunction (key, graphProfileHash) 66 | where key = coursesOptions { 67 | courses = sort $ courses coursesOptions, 68 | graphOptions = options { 69 | taken = sort $ taken options, 70 | departments = sort $ departments options, 71 | distribution = sort $ distribution options, 72 | location = sort $ location options, 73 | courseNumPrefix = sort $ courseNumPrefix options 74 | } 75 | } 76 | where options = graphOptions coursesOptions 77 | hashFunction :: (CourseGraphOptions, String) -> T.Text 78 | hashFunction = T.pack . ("graph_" ++) . md5s . Str . show 79 | 80 | graphToByteString :: PrintDotRepr dg n => dg n -> IO B.ByteString 81 | graphToByteString graph = graphvizWithHandle Dot graph Svg B.hGetContents 82 | 83 | createImage :: PrintDotRepr dg n => (FilePath, dg n) -> IO FilePath 84 | createImage (n, g) = do 85 | _ <- createDirectoryIfMissing False filepath 86 | createImageInDir filepath n Svg g 87 | where 88 | filepath = normalise "graphs/gen" 89 | 90 | -- | Here runGraphvizCommand Dot creates the final graph given the input DotGraph object g and connects it 91 | -- with the file path(by combining directory d and filename n) to make the final graph in the required directory. 92 | createImageInDir :: PrintDotRepr dg n 93 | => FilePath -- ^ directory to write in 94 | -> FilePath -- ^ filename 95 | -> GraphvizOutput -- ^ filetype to write in 96 | -> dg n -- ^ graph to draw 97 | -> IO FilePath 98 | createImageInDir d n o g = Data.GraphViz.addExtension (runGraphvizCommand Dot g) o (combine d n) 99 | -------------------------------------------------------------------------------- /app/Export/ImageConversion.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Module : Export.ImageConversion 3 | Description : Includes functions for converting SVG files to PNG. 4 | -} 5 | module Export.ImageConversion 6 | (createImageFile) where 7 | 8 | import GHC.IO.Handle.Types 9 | import System.Process (ProcessHandle, createProcess, shell, waitForProcess) 10 | 11 | -- | Opens a new process to convert an SVG (inName) to a PNG (outName) 12 | -- Note: hGetContents can be used to read Handles. Useful when trying to read from 13 | -- stdout. 14 | createImageFile :: String -> String -> IO () 15 | createImageFile inName outName = do 16 | (_, _, _, pid) <- convertToImage inName outName 17 | putStrLn "Waiting for process..." 18 | _ <- waitForProcess pid 19 | putStrLn "Process Complete" 20 | 21 | -- | Converts an SVG file to a PNG file. Note that ImageMagick's 'magick' command 22 | -- can take in file descriptors. 23 | convertToImage :: String -> String -> IO 24 | (Maybe Handle, 25 | Maybe Handle, 26 | Maybe Handle, 27 | ProcessHandle) 28 | convertToImage inName outName = 29 | createProcess $ shell $ "magick " ++ inName ++ " " ++ outName 30 | -------------------------------------------------------------------------------- /app/Export/LatexGenerator.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Module : Export.LatexGenerator 3 | Description : Contains functions for creating LaTeX text. 4 | -} 5 | module Export.LatexGenerator 6 | (generateTex) where 7 | 8 | import Text.LaTeX 9 | import Text.LaTeX.Packages.Fancyhdr 10 | import Text.LaTeX.Packages.Geometry 11 | import Text.LaTeX.Packages.Graphicx 12 | 13 | -- | Create a TEX file named texName that includes all of the images in 14 | -- imageNames 15 | generateTex :: [String] -> String -> IO () 16 | generateTex imageNames texName = execLaTeXT (buildTex imageNames) >>= renderFile texName 17 | 18 | -- | Combine the preamble and the document text into a single block of latex 19 | -- code. The document text contains code to insert all of the images in 20 | -- imageNames. 21 | buildTex :: Monad m => [String] -> LaTeXT_ m 22 | buildTex imageNames = do 23 | preamble 24 | document (body imageNames) 25 | 26 | -- | Defines documentclass and packages used. 27 | preamble :: Monad m => LaTeXT_ m 28 | preamble = do 29 | documentclass [] article 30 | usepackage [] graphicx 31 | usepackage [] geometry 32 | applyGeometry [GLandscape True, GWidth (In 9)] 33 | let mySettings = defaultHdrSettings {leftHeader = "Graph and Timetables", rightHeader = "courseography.cdf.toronto.edu"} 34 | applyHdrSettings mySettings 35 | raw "\\pagenumbering{gobble}" 36 | 37 | -- | Adds an includegraphics command for each image in imageNames. If an empty 38 | -- list of imageNames was provided, the body will be empty. 39 | body :: Monad m => [String] -> LaTeXT_ m 40 | body [] = "" 41 | body (imageName:imageNames) = do 42 | center $ includegraphics [IGWidth (CustomMeasure linewidth)] imageName 43 | body imageNames 44 | -------------------------------------------------------------------------------- /app/Export/PdfGenerator.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Module : Export.PdfGenerator 3 | Description : Contains functions for generating a PDF from LaTeX text. 4 | -} 5 | module Export.PdfGenerator 6 | (createPDF) where 7 | 8 | import Data.List.Utils (replace) 9 | import GHC.IO.Handle.Types 10 | import System.Directory (removeFile) 11 | import System.Process (ProcessHandle, createProcess, shell, waitForProcess) 12 | 13 | -- | Opens a new process to create a PDF from a TEX (texName) and deletes 14 | -- the tex file and extra files created by pdflatex 15 | createPDF :: String -> IO () 16 | createPDF texName = do 17 | (_, _, _, pid) <- convertTexToPDF texName 18 | putStrLn "Waiting for a process..." 19 | _ <- waitForProcess pid 20 | let auxFile = replace ".tex" ".aux" texName 21 | logFile = replace ".tex" ".log" texName 22 | mapM_ removeFile [auxFile, logFile, texName] 23 | putStrLn "Process Complete" 24 | 25 | -- | Create a process to use the pdflatex program to create a PDF from a TEX 26 | -- file (texName). The process is run in nonstop mode and so it will not block 27 | -- if an error occurs. The resulting PDF will have the same filename as texName. 28 | convertTexToPDF :: String -> IO 29 | (Maybe Handle, 30 | Maybe Handle, 31 | Maybe Handle, 32 | ProcessHandle) 33 | convertTexToPDF texName = 34 | createProcess $ shell $ "pdflatex -interaction=nonstopmode " ++ texName 35 | -------------------------------------------------------------------------------- /app/Export/README.md: -------------------------------------------------------------------------------- 1 | Information 2 | =========== 3 | 4 | Summary 5 | ------- 6 | Includes files for exporting graphs and timetables, effectively containing 7 | all of the code used when the "export" button is pressed. 8 | -------------------------------------------------------------------------------- /app/Export/TimetableImageCreator.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Module : Export.TimetableImageCreator 3 | Description : Primarily defines a function used to render SVGs with times. 4 | -} 5 | module Export.TimetableImageCreator 6 | (renderTable, renderTableHelper, times) where 7 | 8 | import Data.List (intersperse) 9 | import qualified Data.Text as T 10 | import Diagrams.Backend.SVG 11 | import Diagrams.Prelude 12 | 13 | days :: [T.Text] 14 | days = ["Mon", "Tue", "Wed", "Thu", "Fri"] 15 | 16 | -- |A list of lists of Texts, which has the "times" from 8:00 to 12:00, and 17 | -- 1:00 to 8:00.times 18 | times :: [[T.Text]] 19 | times = map (\x -> [T.pack (show x ++ ":00")]) ([8..12] ++ [1..8] :: [Int]) 20 | 21 | blue3 :: Colour Double 22 | blue3 = sRGB24read "#437699" 23 | 24 | pink1 :: Colour Double 25 | pink1 = sRGB24read "#DB94B8" 26 | 27 | pomegranate :: Colour Double 28 | pomegranate = sRGB24read "#F20C00" 29 | 30 | cellWidth :: Double 31 | cellWidth = 2 32 | 33 | timeCellWidth :: Double 34 | timeCellWidth = 1.2 35 | 36 | cellHeight :: Double 37 | cellHeight = 0.4 38 | 39 | cellPaddingHeight :: Double 40 | cellPaddingHeight = 0.1 41 | 42 | fs :: Double 43 | fs = 14 44 | 45 | cell :: Diagram B 46 | cell = rect cellWidth cellHeight 47 | 48 | cellPadding :: Diagram B 49 | cellPadding = rect cellWidth cellPaddingHeight 50 | 51 | timeCell :: Diagram B 52 | timeCell = rect timeCellWidth cellHeight # lw none 53 | 54 | timeCellPadding :: Diagram B 55 | timeCellPadding = rect timeCellWidth cellPaddingHeight # lw none 56 | 57 | cellText :: T.Text -> Diagram B 58 | cellText s = font "Trebuchet MS" $ text (T.unpack s) # fontSizeO (1024/900 * fs) 59 | 60 | -- | Creates and accumulates cells according to the number of course. 61 | makeCell :: Int -> [T.Text] -> Diagram B 62 | makeCell maxCourse sList = 63 | let actualCourse = length sList 64 | emptyCellNum = if maxCourse == 0 then 1 else maxCourse - actualCourse 65 | extraCell = replicate emptyCellNum [cellPadding # fc white # lc white, cellText "" # fc white <> cell # fc white # lc white] 66 | in vsep 0.030 $ 67 | concat $ map (\x -> [cellPadding # fc background # lc background, cellText x # fc white <> cell # fc background # lc background]) sList ++ extraCell 68 | where 69 | background = getBackground sList 70 | 71 | getBackground :: [T.Text] -> Colour Double 72 | getBackground s 73 | | null s = white 74 | | length s == 1 = blue3 75 | | otherwise = pomegranate 76 | 77 | header :: T.Text -> Diagram B 78 | header session = hcat (makeSessionCell session : map makeHeaderCell days) # centerX === headerBorder 79 | 80 | makeSessionCell :: T.Text -> Diagram B 81 | makeSessionCell s = 82 | timeCellPadding === (cellText s <> timeCell) 83 | 84 | makeHeaderCell :: T.Text -> Diagram B 85 | makeHeaderCell s = 86 | (cellPadding # lw none # fc white # lc white) === (cellText s <> cell # lw none) 87 | 88 | makeTimeCell :: T.Text -> Diagram B 89 | makeTimeCell s = 90 | timeCellPadding === (cellText s <> timeCell) 91 | 92 | makeRow :: [[T.Text]] -> Diagram B 93 | makeRow ([x]:xs) = 94 | let maxCourse = maximum (map length xs) 95 | in (# centerX) . hcat $ 96 | makeTimeCell x : map (makeCell maxCourse) xs 97 | makeRow _ = error "invalid timetable format" 98 | 99 | headerBorder :: Diagram B 100 | headerBorder = hrule 11.2 # lw medium # lc pink1 101 | 102 | rowBorder :: Diagram B 103 | rowBorder = hrule 11.2 # lw thin # lc pink1 104 | 105 | makeTable :: [[[T.Text]]] -> T.Text -> Diagram B 106 | makeTable s session = vsep 0.04 $ header session: intersperse rowBorder (map makeRow s) 107 | 108 | -- |Creates a timetable by zipping the time and course tables. 109 | renderTable :: String -> T.Text -> T.Text -> IO () 110 | renderTable filename courses session = do 111 | let courseTable = partition5 $ map (\x -> [x | not (T.null x)]) $ T.splitOn "_" courses 112 | renderTableHelper filename (zipWith (:) times courseTable) session 113 | where 114 | partition5 [] = [] 115 | partition5 lst = take 5 lst : partition5 (drop 5 lst) 116 | 117 | -- |Renders an SVG with a width of 1024, though the documentation doesn't 118 | -- specify the units, it is assumed that these are pixels. 119 | renderTableHelper :: String -> [[[T.Text]]] -> T.Text -> IO () 120 | renderTableHelper filename schedule session = do 121 | let g = makeTable schedule session 122 | renderSVG filename (mkWidth 1024) g 123 | -------------------------------------------------------------------------------- /app/Main.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Description: Entry point for Courseography. 3 | 4 | This module implements a 'main' method which takes a command-line 5 | argument and executes the corresponding IO action. 6 | 7 | If no argument is provided, the default action is 'server', which 8 | starts the server. 9 | -} 10 | module Main where 11 | 12 | import Data.List (intercalate) 13 | import qualified Data.Map.Strict as Map 14 | import Data.Maybe (fromMaybe) 15 | import System.Environment (getArgs) 16 | import System.IO (hPutStrLn, stderr) 17 | 18 | -- internal dependencies 19 | import Database.Database (populateCalendar, setupDatabase) 20 | import Server (runServer) 21 | import Svg.Parser (parsePrebuiltSvgs) 22 | import Util.Documentation (generateDocs) 23 | import WebParsing.UtsgJsonParser (parseTimetable) 24 | 25 | -- dynamic graph generation 26 | import DynamicGraphs.WriteRunDot (generatePrereqsForCourses) 27 | 28 | -- | A map of command-line arguments to their corresponding IO actions. 29 | taskMap :: Map.Map String ([String] -> IO ()) 30 | taskMap = Map.fromList [ 31 | ("server", const runServer), 32 | ("database-calendar", const populateCalendar), 33 | ("database-timetable", const parseTimetable), 34 | ("database-graphs", const parsePrebuiltSvgs), 35 | ("docs", const generateDocs), 36 | ("generate", generate), 37 | ("database-setup", const setupDatabase)] 38 | 39 | -- | Courseography entry point. 40 | main :: IO () 41 | main = do 42 | args <- getArgs 43 | let (taskName, rest) = 44 | case args of 45 | [] -> ("server", []) 46 | (command : remaining) -> (command, remaining) 47 | fromMaybe putUsage (Map.lookup taskName taskMap) rest 48 | 49 | 50 | -- | Print usage message to user (when main gets an incorrect argument). 51 | putUsage :: [String] -> IO () 52 | putUsage _ = hPutStrLn stderr usageMsg 53 | where 54 | taskNames = Map.keys taskMap 55 | usageMsg = "Unrecognized argument. Available arguments:\n" ++ 56 | intercalate "\n" taskNames 57 | 58 | generate :: [String] -> IO () 59 | generate (name : courses) = generatePrereqsForCourses (name, courses) 60 | generate _ = hPutStrLn 61 | stderr 62 | "Generate Usage: generate ..." 63 | -------------------------------------------------------------------------------- /app/MasterTemplate.hs: -------------------------------------------------------------------------------- 1 | module MasterTemplate 2 | (masterTemplate, header) where 3 | 4 | import qualified Data.Text as T 5 | import Text.Blaze ((!)) 6 | import qualified Text.Blaze.Html5 as H 7 | import qualified Text.Blaze.Html5.Attributes as A 8 | import Text.Blaze.Internal (textValue) 9 | import Util.Blaze 10 | 11 | 12 | masterTemplate :: T.Text -> [H.Html] -> H.Html -> H.Html -> H.Html 13 | masterTemplate title headers body scripts = 14 | H.docTypeHtml $ do 15 | H.head $ do 16 | H.meta ! A.httpEquiv "Content-Type" 17 | ! A.content "text/html;charset=utf-8" 18 | H.title (H.toHtml title) 19 | H.link ! A.rel "icon" ! A.type_ "image/png" 20 | ! A.href "/static/res/ico/favicon.png" 21 | sequence_ headers 22 | toStylesheet "/static/style/app.css" 23 | H.body $ do 24 | body 25 | scripts 26 | 27 | -- Insert the header of the Grid and Graph. This contains the year of the timetable, and 28 | -- a link back to the Graph. 29 | header :: T.Text -> H.Html 30 | header page = 31 | H.nav ! A.class_ "row header" $ do 32 | H.div ! A.class_ "nav-left" $ do 33 | H.a ! A.href "/graph" $ do 34 | H.img ! A.id "courseography-header" ! A.src "/static/res/img/logo.png" 35 | ! H.customAttribute "context" (textValue page) 36 | H.div ! A.class_ "nav-middle" $ do 37 | H.ul ! A.id "nav-links" $ do 38 | if page == "graph" 39 | then H.li ! A.id "nav-graph" ! A.class_ "selected-page" $ toLink "/graph" "Graph" 40 | else H.li ! A.id "nav-graph" $ toLink "/graph" "Graph" 41 | if page == "grid" 42 | then H.li ! A.class_ "selected-page" $ toLink "/grid" "Grid" 43 | else H.li $ toLink "/grid" "Grid" 44 | if page == "generate-prerequisites" 45 | then H.li ! A.class_ "selected-page" $ toLink "/generate" "Generate (beta)" 46 | else H.li ! A.id "nav-generate" $ toLink "/generate" "Generate (beta)" 47 | -- H.li $ toLink "/timesearch" "Search" 48 | -- H.li $ toLink "/draw" "Draw" 49 | -- TODO: re-enable after handling new first-year courses 50 | -- H.li $ toLink "post" "Check My POSt!" 51 | if page == "about" 52 | then H.li ! A.class_ "selected-page" $ toLink "/about" "About" 53 | else H.li $ toLink "/about" "About" 54 | H.div ! A.class_ "nav-right" $ do 55 | if page `elem` ["graph", "grid"] 56 | then H.button ! A.id "nav-export" $ do 57 | H.img ! A.src "/static/res/ico/export.png" ! A.alt "Export" 58 | else "" 59 | -------------------------------------------------------------------------------- /app/Models/Course.hs: -------------------------------------------------------------------------------- 1 | module Models.Course 2 | (buildCourse, 3 | buildMeetTimes, 4 | returnCourse) where 5 | 6 | import Config (runDb) 7 | import qualified Data.Text as T (Text, append, filter, take, toUpper) 8 | import Database.Persist.Sqlite (Entity, SqlPersistM, entityKey, entityVal, get, selectFirst, 9 | selectList, (<-.), (==.)) 10 | import Database.Tables as Tables 11 | 12 | -- | Queries the database for all matching lectures, tutorials, 13 | meetingQuery :: [T.Text] -> SqlPersistM [MeetTime'] 14 | meetingQuery meetingCodes = do 15 | allMeetings <- selectList [MeetingCode <-. map (T.take 6) meetingCodes] [] 16 | mapM buildMeetTimes allMeetings 17 | 18 | -- | Queries the database for all information about @course@, 19 | -- constructs and returns a Course value. 20 | returnCourse :: T.Text -> IO (Maybe Course) 21 | returnCourse lowerStr = runDb $ do 22 | let courseStr = T.toUpper lowerStr 23 | -- TODO: require the client to pass the full course code 24 | let fullCodes = [courseStr, T.append courseStr "H1", T.append courseStr "Y1"] 25 | sqlCourse :: (Maybe (Entity Courses)) <- selectFirst [CoursesCode <-. fullCodes] [] 26 | case sqlCourse of 27 | Nothing -> return Nothing 28 | Just course -> do 29 | meetings <- meetingQuery fullCodes 30 | Just <$> buildCourse meetings 31 | (entityVal course) 32 | 33 | -- | Queries the database for the breadth description 34 | getDescriptionB :: Maybe (Key Breadth) -> SqlPersistM (Maybe T.Text) 35 | getDescriptionB Nothing = return Nothing 36 | getDescriptionB (Just key) = do 37 | maybeBreadth <- get key 38 | return $ fmap breadthDescription maybeBreadth 39 | 40 | -- | Queries the database for the distribution description 41 | getDescriptionD :: Maybe (Key Distribution) -> SqlPersistM (Maybe T.Text) 42 | getDescriptionD Nothing = return Nothing 43 | getDescriptionD (Just key) = do 44 | maybeDistribution <- get key 45 | return $ fmap distributionDescription maybeDistribution 46 | 47 | -- | Builds a Course structure from a tuple from the Courses table. 48 | -- Some fields still need to be added in. 49 | buildCourse :: [MeetTime'] -> Courses -> SqlPersistM Course 50 | buildCourse allMeetings course = do 51 | cBreadth <- getDescriptionB (coursesBreadth course) 52 | cDistribution <- getDescriptionD (coursesDistribution course) 53 | return $ Course cBreadth 54 | -- TODO: Remove the filter and allow double-quotes 55 | (fmap (T.filter (/='\"')) (coursesDescription course)) 56 | (fmap (T.filter (/='\"')) (coursesTitle course)) 57 | (coursesPrereqString course) 58 | (Just allMeetings) 59 | (coursesCode course) 60 | (coursesExclusions course) 61 | cDistribution 62 | (coursesCoreqs course) 63 | (coursesVideoUrls course) 64 | 65 | -- | Queries the database for all times corresponding to a given meeting. 66 | buildMeetTimes :: Entity Meeting -> SqlPersistM Tables.MeetTime' 67 | buildMeetTimes meet = do 68 | allTimes :: [Entity Times] <- selectList [TimesMeeting ==. entityKey meet] [] 69 | parsedTime <- mapM (buildTime . entityVal) allTimes 70 | return $ Tables.MeetTime' (entityVal meet) parsedTime 71 | -------------------------------------------------------------------------------- /app/Response.hs: -------------------------------------------------------------------------------- 1 | module Response (module X) where 2 | 3 | import Response.About as X 4 | import Response.Draw as X 5 | import Response.Image as X 6 | import Response.Loading as X 7 | import Response.NotFound as X 8 | -------------------------------------------------------------------------------- /app/Response/About.hs: -------------------------------------------------------------------------------- 1 | module Response.About 2 | (aboutResponse) where 3 | 4 | import Happstack.Server 5 | import MasterTemplate 6 | import Scripts (aboutScripts) 7 | import Text.Blaze ((!)) 8 | import qualified Text.Blaze.Html5 as H 9 | import qualified Text.Blaze.Html5.Attributes as A 10 | 11 | aboutResponse :: ServerPart Response 12 | aboutResponse = 13 | ok $ toResponse $ 14 | masterTemplate "Courseography - About" 15 | [] 16 | (do 17 | header "about" 18 | H.div ! A.id "aboutDiv" $ "" 19 | ) 20 | aboutScripts 21 | -------------------------------------------------------------------------------- /app/Response/Draw.hs: -------------------------------------------------------------------------------- 1 | module Response.Draw 2 | (drawResponse) where 3 | 4 | import Control.Monad (forM_) 5 | import Happstack.Server 6 | import MasterTemplate 7 | import Scripts 8 | import Text.Blaze ((!)) 9 | import qualified Text.Blaze.Html5 as H 10 | import qualified Text.Blaze.Html5.Attributes as A 11 | 12 | drawResponse :: ServerPart Response 13 | drawResponse = 14 | ok $ toResponse $ 15 | masterTemplate "Courseography - Draw!" 16 | [] 17 | (do 18 | header "draw" 19 | drawContent 20 | modePanel 21 | H.div ! A.id "react-graph" $ "" 22 | ) 23 | drawScripts 24 | 25 | drawContent :: H.Html 26 | drawContent = H.div ! A.id "about-div" $ "Draw a Graph" 27 | 28 | modePanel :: H.Html 29 | modePanel = H.div ! A.id "side-panel-wrap" $ do 30 | H.div ! A.id "node-mode" ! A.class_ "mode clicked" $ "NODE (n)" 31 | H.input ! A.id "course-code" 32 | ! A.class_ "course-code" 33 | ! A.name "course-code" 34 | ! A.placeholder "Course Code" 35 | ! A.autocomplete "off" 36 | ! A.type_ "text" 37 | ! A.size "10" 38 | H.div ! A.id "add-text" ! A.class_ "button" $ "ADD" 39 | H.div ! A.id "path-mode" ! A.class_ "mode" $ "PATH (p)" 40 | H.div ! A.id "region-mode" ! A.class_ "mode" $ "REGION (r)" 41 | H.div ! A.id "finish-region" ! A.class_ "button" $ "finish (f)" 42 | H.div ! A.id "change-mode" ! A.class_ "mode" $ "SELECT/MOVE (m)" 43 | H.div ! A.id "erase-mode" ! A.class_ "mode" $ "ERASE (e)" 44 | H.input ! A.id "select-colour" 45 | ! A.class_ "jscolor" 46 | ! A.value "ff7878" -- pastelRed color as defined in Css.Constants 47 | ! A.size "15" 48 | H.table ! A.id "colour-table" $ forM_ (replicate 2 $ replicate 5 "" :: [[H.Html]]) 49 | (H.tr . mapM_ (H.td . H.toHtml)) 50 | H.div ! A.id "save-graph" ! A.class_ "button" $ "SAVE" 51 | H.input ! A.id "area-of-study" 52 | ! A.class_ "course-code" 53 | ! A.name "course-code" 54 | ! A.placeholder "Enter area of study." 55 | ! A.autocomplete "off" 56 | ! A.type_ "text" 57 | ! A.size "30" 58 | H.div ! A.id "submit-graph-name" ! A.class_ "button" $ "Search for department" 59 | H.div ! A.id "json-data" ! A.class_ "json-data" $ "" 60 | -------------------------------------------------------------------------------- /app/Response/Image.hs: -------------------------------------------------------------------------------- 1 | module Response.Image 2 | (timetableImageResponse, returnImageData) where 3 | 4 | import Control.Monad.IO.Class (liftIO) 5 | import qualified Data.ByteString as BS 6 | import qualified Data.ByteString.Base64 as BEnc 7 | import qualified Data.Text as T 8 | import Export.GetImages (getTimetableImage) 9 | import Happstack.Server 10 | import System.Directory (removeFile) 11 | 12 | -- | Returns an image of the timetable requested by the user. 13 | timetableImageResponse :: T.Text -> T.Text -> ServerPart Response 14 | timetableImageResponse courses session = do 15 | (svgFilename, imageFilename) <- liftIO $ getTimetableImage courses session 16 | liftIO $ returnImageData svgFilename imageFilename 17 | 18 | -- | Creates and converts an SVG file to an image file, deletes them both and 19 | -- returns the image data as a response. 20 | returnImageData :: String -> String -> IO Response 21 | returnImageData svgFilename imageFilename = do 22 | imageData <- BS.readFile imageFilename 23 | _ <- removeFile imageFilename 24 | _ <- removeFile svgFilename 25 | let encodedData = BEnc.encode imageData 26 | return $ toResponse encodedData 27 | -------------------------------------------------------------------------------- /app/Response/Loading.hs: -------------------------------------------------------------------------------- 1 | module Response.Loading 2 | (loadingResponse) where 3 | 4 | import Happstack.Server 5 | import MasterTemplate 6 | import Text.Blaze ((!)) 7 | import qualified Text.Blaze.Html5 as H 8 | import qualified Text.Blaze.Html5.Attributes as A 9 | 10 | loadingResponse :: ServerPart Response 11 | loadingResponse = do 12 | size <- lookText' "size" 13 | ok $ toResponse $ 14 | masterTemplate "Courseography - Loading..." 15 | [] 16 | (do 17 | header "Loading..." 18 | if size == "small" 19 | then smallLoadingIcon 20 | else largeLoadingIcon 21 | ) 22 | "" 23 | 24 | {- Insert a large loading icon into the page -} 25 | largeLoadingIcon :: H.Html 26 | largeLoadingIcon = H.div ! A.id "loading-icon" $ do 27 | H.img ! A.id "c-logo" ! A.src "/static/res/img/C-logo.png" 28 | H.img ! A.id "compass" ! A.class_ "spinner" ! A.src "/static/res/img/compass.png" 29 | 30 | {- Insert a small loading icon into the page -} 31 | smallLoadingIcon :: H.Html 32 | smallLoadingIcon = H.div ! A.id "loading-icon" $ do 33 | H.img ! A.id "c-logo-small" ! A.src "/static/res/img/C-logo-small.png" 34 | H.img ! A.id "compass-small" ! A.class_ "spinner" ! A.src "/static/res/img/compass-small.png" 35 | -------------------------------------------------------------------------------- /app/Response/NotFound.hs: -------------------------------------------------------------------------------- 1 | module Response.NotFound 2 | (notFoundResponse) where 3 | 4 | import Happstack.Server 5 | import Text.Blaze ((!)) 6 | import qualified Text.Blaze.Html5 as H 7 | import qualified Text.Blaze.Html5.Attributes as A 8 | import Util.Blaze 9 | 10 | notFoundResponse :: ServerPart Response 11 | notFoundResponse = 12 | notFound $ toResponse $ 13 | H.html $ do 14 | H.head $ do 15 | H.title "Courseography - 404!" 16 | H.meta ! A.httpEquiv "Content-Type" 17 | ! A.content "text/html;charset=utf-8" 18 | toStylesheet "/static/style/app.css" 19 | 20 | H.body notFoundContent 21 | 22 | notFoundContent :: H.Html 23 | notFoundContent = 24 | H.div ! A.id "contentDiv" $ do 25 | H.h2 "404 Page Not Found!" 26 | H.p "Sorry, the path you have traversed has no destination node." 27 | H.p "The page might have been moved or deleted, or the little dragon running our server might have gone to have smores." 28 | H.p "You can use the links below to get back on the grid or graph." 29 | H.ul ! A.id "links" $ do 30 | H.li $ toLink "/graph" "Graph" 31 | H.li $ toLink "/grid" "Grid" 32 | -------------------------------------------------------------------------------- /app/Response/README.md: -------------------------------------------------------------------------------- 1 | Responses 2 | ========= 3 | 4 | This directory contains all of the Haskell files responsible for creating server responses. 5 | These modules should all be exported by `hs/Response.hs`. 6 | 7 | All of the responses contain a value `???Response` of type `ServerPart Response`, 8 | or a function that returns such a value. 9 | These are the values which are the actual responses sent to the client. 10 | Many use the [blaze-html](https://hackage.haskell.org/package/blaze-html) 11 | library to generate an HTML response. Others use plain [happstack functions](https://hackage.haskell.org/package/happstack-server-7.4.4/docs/Happstack-Server-Response.html) 12 | to serve responses of different types. 13 | -------------------------------------------------------------------------------- /app/Routes.hs: -------------------------------------------------------------------------------- 1 | module Routes 2 | (routeResponses) where 3 | 4 | import Control.Monad (MonadPlus (mplus), msum) 5 | import Controllers.Course as CoursesController (courseInfo, index, retrieveCourse) 6 | import Controllers.Generate as GenerateController (findAndSavePrereqsResponse, generateResponse) 7 | import Controllers.Graph as GraphsController (getGraphJSON, graphImageResponse, graphResponse, 8 | index) 9 | import Controllers.Timetable as TimetableController 10 | import Database.CourseInsertion (saveGraphJSON) 11 | import Happstack.Server (Browsing (DisableBrowsing), Response, ServerPart, ServerPartT, 12 | ToMessage (toResponse), dir, noTrailingSlash, nullDir, seeOther, 13 | serveDirectory) 14 | import Response (aboutResponse, drawResponse, loadingResponse, notFoundResponse) 15 | 16 | routeResponses :: String -> ServerPartT IO Response 17 | routeResponses staticDir = 18 | msum (map strictMatchDir strictRoutes ++ 19 | [dir "static" $ serveDirectory DisableBrowsing [] staticDir, 20 | nullDir >> seeOther ("graph" :: String) (toResponse ("Redirecting to /graph" :: String)), 21 | notFoundResponse]) 22 | 23 | strictRoutes :: [(String, ServerPart Response)] 24 | strictRoutes = [ 25 | ("grid", TimetableController.gridResponse), 26 | ("graph", GraphsController.graphResponse), 27 | ("graph-generate", GenerateController.findAndSavePrereqsResponse), 28 | ("image", graphImageResponse), 29 | ("timetable-image", TimetableController.exportTimetableImageResponse), 30 | ("timetable-pdf", TimetableController.exportTimetablePDFResponse), 31 | ("draw", drawResponse), 32 | ("about", aboutResponse), 33 | ("graphs", GraphsController.index), 34 | ("generate", generateResponse), 35 | ("get-json-data", getGraphJSON), 36 | ("course", CoursesController.retrieveCourse), 37 | ("courses", CoursesController.index), 38 | ("course-info", CoursesController.courseInfo), 39 | ("calendar", TimetableController.calendarResponse), 40 | ("loading", loadingResponse), 41 | ("save-json", saveGraphJSON) 42 | ] 43 | 44 | strictMatchDir :: (String, ServerPart Response) -> ServerPartT IO Response 45 | strictMatchDir (pathname, response) = 46 | mplus (do noTrailingSlash -- enforce no trailing slash in the URI 47 | dir pathname nullDir -- enforce that no segments occur after pathname 48 | response) 49 | (do dir pathname nullDir -- if a trailing slash exists, redirect 50 | seeOther ("/" ++ pathname) (toResponse ("Redirecting to /" ++ pathname))) 51 | -------------------------------------------------------------------------------- /app/Scripts.hs: -------------------------------------------------------------------------------- 1 | module Scripts ( 2 | graphScripts, timetableScripts, drawScripts, postScripts, searchScripts, generateScripts, aboutScripts, 3 | ) 4 | where 5 | 6 | import Text.Blaze ((!)) 7 | import qualified Text.Blaze.Html5 as H 8 | import qualified Text.Blaze.Html5.Attributes as A 9 | import Util.Blaze 10 | 11 | graphScripts :: H.Html 12 | graphScripts = H.script ! A.src "/static/js/graph/app.js" $ "" 13 | 14 | timetableScripts :: H.Html 15 | timetableScripts = H.script ! A.src "/static/js/grid/app.js" $ "" 16 | 17 | drawScripts :: H.Html 18 | drawScripts = do 19 | mapM_ toScript 20 | ["/static/js/draw/variables.js", 21 | "/static/js/draw/path.js", 22 | "/static/js/draw/setup.js", 23 | "/static/js/vendor/jscolor.min.js"] 24 | H.script ! A.src "/static/js/draw/app.js" $ "" 25 | 26 | postScripts :: H.Html 27 | postScripts = do 28 | mapM_ toScript [] 29 | H.script ! A.src "/static/js/post/app.js" $ "" 30 | 31 | searchScripts :: H.Html 32 | searchScripts = do 33 | H.script ! A.src "/static/js/search/app.js" $ "" 34 | 35 | generateScripts :: H.Html 36 | generateScripts = do 37 | H.script ! A.src "/static/js/generate/app.js" $ "" 38 | 39 | aboutScripts :: H.Html 40 | aboutScripts = do 41 | H.script ! A.src "/static/js/about/app.js" $ "" 42 | -------------------------------------------------------------------------------- /app/Server.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE CPP, OverloadedStrings #-} 2 | 3 | {-| 4 | Description: Configure and run the server for Courseography. 5 | This module defines the configuration for the server, including logging. 6 | It also defines all of the allowed server routes, and the corresponding 7 | responses. 8 | -} 9 | module Server 10 | (runServer) where 11 | 12 | import Config (logFilePath, serverConf) 13 | import Control.Concurrent (forkIO, killThread) 14 | import Control.Monad (when) 15 | import Data.String (fromString) 16 | import Filesystem.Path.CurrentOS as Path 17 | import Happstack.Server hiding (host) 18 | import Routes (routeResponses) 19 | import System.Directory (getCurrentDirectory) 20 | import System.IO (BufferMode (LineBuffering), hSetBuffering, stderr, stdout) 21 | import System.Log.Handler.Simple (fileHandler) 22 | import System.Log.Logger (Priority (INFO), rootLoggerName, setHandlers, setLevel, 23 | updateGlobalLogger) 24 | 25 | runServer :: IO () 26 | runServer = do 27 | configureLogger 28 | staticDir <- getStaticDir 29 | 30 | -- Start the HTTP server 31 | server <- serverConf 32 | httpThreadId <- forkIO $ simpleHTTP server $ do 33 | decodeBody (defaultBodyPolicy "/tmp/" 4096 4096 4096) 34 | routeResponses staticDir 35 | waitForTermination 36 | killThread httpThreadId 37 | where 38 | -- | Global logger configuration. 39 | configureLogger :: IO () 40 | configureLogger = do 41 | -- Use line buffering to ensure logging messages are printed correctly 42 | hSetBuffering stdout LineBuffering 43 | hSetBuffering stderr LineBuffering 44 | -- Set log level to INFO so requests are logged to stdout 45 | updateGlobalLogger rootLoggerName $ setLevel INFO 46 | -- Log to file if a file path is provided 47 | logFile <- logFilePath 48 | when (logFile /= "") $ do 49 | fileH <- fileHandler logFile INFO 50 | updateGlobalLogger rootLoggerName $ setHandlers [fileH] 51 | 52 | 53 | -- | Return the directory where all static files are stored. 54 | -- Note: the type here is System.IO.FilePath, not FileSystem.Path.FilePath. 55 | getStaticDir :: IO Prelude.FilePath 56 | getStaticDir = do 57 | cwd <- getCurrentDirectory 58 | --let parentDir = Path.parent $ Path.decodeString cwd 59 | --return $ Path.encodeString $ Path.append parentDir $ fromString "public/" 60 | return $ Path.encodeString $ Path.append (Path.decodeString cwd) $ fromString "public/" 61 | -------------------------------------------------------------------------------- /app/Svg/Database.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Description: Helpers for interacting with the graph database. Incomplete. 3 | 4 | This module is responsible for inserting values associated with the graphs 5 | into the database. These functions are just helpers for the main function 6 | in "Svg.Parser", though it is possible to put all of the data retrieval code in 7 | here as well at some point in the future. 8 | -} 9 | 10 | module Svg.Database 11 | (insertGraph, insertElements, deleteGraph) where 12 | 13 | import qualified Data.Text as T 14 | import Database.Persist.Sqlite 15 | import Database.Tables hiding (graphDynamic, graphHeight, graphWidth, paths, shapes, texts) 16 | 17 | -- | Insert a new graph into the database, returning the key of the new graph. 18 | insertGraph :: T.Text -- ^ The title of the graph that is being inserted. 19 | -> Double -- ^ The width dimension of the graph 20 | -> Double -- ^ The height dimension of the graph 21 | -> Bool -- ^ True if graph is dynamically generated 22 | -> SqlPersistM GraphId -- ^ The unique identifier of the inserted graph. 23 | insertGraph graphName graphWidth graphHeight graphDynamic = do 24 | runMigration migrateAll 25 | insert (Graph graphName graphWidth graphHeight graphDynamic) 26 | 27 | -- | Insert graph components into the database. 28 | insertElements :: ([Path], [Shape], [Text]) -> SqlPersistM () 29 | insertElements (paths, shapes, texts) = do 30 | mapM_ insert_ shapes 31 | mapM_ insert_ paths 32 | mapM_ insert_ texts 33 | 34 | -- | Delete a graph with the given graph ID from the database. 35 | deleteGraph :: Key Graph -> SqlPersistM () 36 | deleteGraph gId = do 37 | deleteWhere [TextGraph ==. gId] 38 | deleteWhere [ShapeGraph ==. gId] 39 | deleteWhere [PathGraph ==. gId] 40 | deleteWhere [GraphId ==. gId] 41 | -------------------------------------------------------------------------------- /app/Svg/README.md: -------------------------------------------------------------------------------- 1 | Information 2 | =========== 3 | 4 | Summary 5 | ------- 6 | 7 | app/Svg contains modules for interacting with the SVG files, for instance 8 | putting the SVG files on the graph page. 9 | 10 | Notable Files 11 | ------------- 12 | 13 | Parser.hs is the main graph module, using the other modules it outputs the 14 | final svg files in @public\/res\/graphs\/gen@. 15 | -------------------------------------------------------------------------------- /app/Util/Blaze.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Module : Util.Blaze 3 | Description : Contains methods for setting various HTML attributes. 4 | -} 5 | module Util.Blaze 6 | (toStylesheet, 7 | toScript, 8 | toLink, 9 | mdToHTML) where 10 | 11 | import qualified Data.Text as T 12 | import Data.Text.Lazy (Text) 13 | import Text.Blaze ((!)) 14 | import qualified Text.Blaze.Html5 as H 15 | import qualified Text.Blaze.Html5.Attributes as A 16 | import Text.Markdown (def, markdown) 17 | 18 | -- |Sets the html attributes to the href of the style sheet. 19 | toStylesheet :: T.Text -> H.Html 20 | toStylesheet href = H.link ! A.rel "stylesheet" 21 | ! A.type_ "text/css" 22 | ! A.href (H.textValue href) 23 | 24 | -- |Sets the script attributes. 25 | toScript :: T.Text -> H.Html 26 | toScript src = H.script ! A.src (H.textValue src) $ "" 27 | 28 | -- |Creates a link by setting the href attribute. 29 | toLink :: T.Text -> T.Text -> H.Html 30 | toLink link content = H.a ! A.href (H.textValue link) 31 | $ H.toHtml content 32 | 33 | -- | mdToHTML takes in the contents of a file written in Mark Down and converts it to 34 | -- blaze-HTML. 35 | mdToHTML :: Text -> H.Html 36 | mdToHTML = markdown def 37 | -------------------------------------------------------------------------------- /app/Util/Documentation.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Module : Util.Documentation 3 | Description : Defines function to generate local Haddock documentation. 4 | 5 | This module contains a shell command to run Haddock to generate documentation 6 | for the Haskell source files of Courseography. 7 | -} 8 | module Util.Documentation where 9 | 10 | import System.Directory (createDirectoryIfMissing) 11 | import System.Process (callCommand) 12 | 13 | -- | Path to documentation directory 14 | docPath :: String 15 | docPath = "doc" 16 | 17 | -- | Generate documentation for Courseography. 18 | generateDocs :: IO () 19 | generateDocs = do 20 | putStrLn "Generating documentation..." 21 | createDirectoryIfMissing True docPath 22 | callCommand $ unwords [ 23 | "stack exec haddock --", 24 | "-o", 25 | docPath, 26 | "-h", 27 | "--optghc=-iapp", 28 | "--optghc=-XOverloadedStrings", 29 | "--optghc=-XPartialTypeSignatures", 30 | "--optghc=-XScopedTypeVariables", 31 | "--ignore-all-exports", 32 | "app/Main.hs" 33 | ] 34 | -------------------------------------------------------------------------------- /app/Util/Happstack.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Module : Util.Happstack 3 | Description : Contains a single method for creating a JSON response. 4 | -} 5 | module Util.Happstack 6 | (createJSONResponse) where 7 | 8 | import Data.Aeson (ToJSON, encode) 9 | import qualified Data.ByteString.Char8 as BS 10 | import Happstack.Server 11 | 12 | -- | Creates a JSON response. 13 | createJSONResponse :: ToJSON a => a -> Response 14 | createJSONResponse x = toResponseBS (BS.pack "application/json") (encode x) 15 | -------------------------------------------------------------------------------- /app/Util/README.md: -------------------------------------------------------------------------------- 1 | Utility Modules 2 | =============== 3 | 4 | This directory contains modules which define utility functions for Courseography. 5 | These functions should not be specific to Courseography, but instead 6 | general-purpose and useful in other contexts. 7 | Some files are simply convenient helpers built on other libraries (like `Util.Blaze`), 8 | and are named accordingly. 9 | -------------------------------------------------------------------------------- /app/WebParsing/Ligature.hs: -------------------------------------------------------------------------------- 1 | {- | Utilities for expanding ligatures in unicode text. 2 | 3 | Currently this module expands the following ligatures:ff,fi,fl,ffi,ffl,ſt,st 4 | 5 | From https://hackage.haskell.org/package/ligature. 6 | -} 7 | module WebParsing.Ligature where 8 | 9 | import Prelude hiding (concatMap) 10 | 11 | import Data.Text 12 | 13 | -- | If a character is a ligature, expand it to several characters 14 | expandLigature :: Char -> Text 15 | expandLigature '\xFB00' = cons 'f' (cons 'f' empty) 16 | expandLigature '\xFB01' = cons 'f' (cons 'i' empty) 17 | expandLigature '\xFB02' = cons 'f' (cons 'l' empty) 18 | expandLigature '\xFB03' = cons 'f' (cons 'f' (cons 'i' empty)) 19 | expandLigature '\xFB04' = cons 'f' (cons 'f' (cons 'l' empty)) 20 | expandLigature '\xFB05' = cons 'f' (cons 't' empty) 21 | expandLigature '\xFB06' = cons 's' (cons 't' empty) 22 | expandLigature c = singleton c 23 | 24 | -- | Expand all ligatures in the text 25 | expand :: Text -> Text 26 | expand = concatMap expandLigature 27 | -------------------------------------------------------------------------------- /app/WebParsing/ParsecCombinators.hs: -------------------------------------------------------------------------------- 1 | module WebParsing.ParsecCombinators 2 | (getCourseFromTag, 3 | findCourseFromTag, 4 | text, 5 | parseUntil) where 6 | 7 | import qualified Data.Text as T 8 | import qualified Text.Parsec as P 9 | import Text.Parsec.Text (Parser) 10 | 11 | 12 | getCourseFromTag :: T.Text -> T.Text 13 | getCourseFromTag courseTag = 14 | let course = P.parse findCourseFromTag "(source)" courseTag 15 | in 16 | case course of 17 | Right courseName -> courseName 18 | Left _ -> "" 19 | 20 | findCourseFromTag :: Parser T.Text 21 | findCourseFromTag = do 22 | _ <- P.string "/course/" 23 | parsed <- P.many1 P.anyChar 24 | return $ T.pack parsed 25 | 26 | parseUntil :: Parser a -> Parser T.Text 27 | parseUntil parser = do 28 | parsed <- P.manyTill P.anyChar (P.try parser) 29 | return $ T.pack parsed 30 | 31 | text :: T.Text -> Parser T.Text 32 | text someText = do 33 | parsed <- mapM P.char (T.unpack someText) 34 | return $ T.pack parsed 35 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "useBuiltIns": "usage", 7 | "corejs": { "version": "3.30", "proposals": true } 8 | } 9 | ], 10 | "@babel/preset-react" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /backend-test/Controllers/ControllerTests.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Description: Contoroller test suites module. 3 | 4 | Module that contains the test suites for all the controllers. 5 | 6 | -} 7 | 8 | module Controllers.ControllerTests 9 | ( controllerTests ) where 10 | 11 | import Test.HUnit (Test (..)) 12 | 13 | import Controllers.CourseControllerTests (courseControllerTestSuite) 14 | import Controllers.GraphControllerTests (graphControllerTestSuite) 15 | 16 | -- Single test encompassing all controller test suites 17 | controllerTests :: Test 18 | controllerTests = TestList [courseControllerTestSuite, graphControllerTestSuite] 19 | -------------------------------------------------------------------------------- /backend-test/Controllers/GraphControllerTests.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Description: Graph Controller module tests. 3 | 4 | Module that contains the tests for the functions in the Graph Controller module. 5 | 6 | -} 7 | 8 | module Controllers.GraphControllerTests 9 | ( graphControllerTestSuite 10 | ) where 11 | 12 | import Config (runDb) 13 | import Controllers.Graph (index) 14 | import qualified Data.ByteString.Lazy.Char8 as BL 15 | import qualified Data.Text as T 16 | import Database.Persist.Sqlite (SqlPersistM, insert_) 17 | import Database.Tables (Graph (..)) 18 | import Happstack.Server (rsBody) 19 | import Test.HUnit (Test (..), assertEqual) 20 | import TestHelpers (clearDatabase, runServerPart) 21 | 22 | -- | List of test cases as (label, input graphs, expected output) 23 | indexTestCases :: [(String, [T.Text], String)] 24 | indexTestCases = 25 | [ ("No graphs", 26 | [], 27 | "[]" 28 | ), 29 | 30 | ("One graph", 31 | ["Statistics"], 32 | "[{\"dynamic\":false,\"height\":0,\"id\":1,\"title\":\"Statistics\",\"width\":0}]" 33 | ), 34 | 35 | ("Multiple graphs", 36 | ["Computer Science", "Statistics", "Physics"], 37 | "[{\"dynamic\":false,\"height\":0,\"id\":1,\"title\":\"Computer Science\",\"width\":0},{\"dynamic\":false,\"height\":0,\"id\":3,\"title\":\"Physics\",\"width\":0},{\"dynamic\":false,\"height\":0,\"id\":2,\"title\":\"Statistics\",\"width\":0}]" ) 38 | ] 39 | 40 | -- | Helper function to insert graphs into the database 41 | insertGraphs :: [T.Text] -> SqlPersistM () 42 | insertGraphs = mapM_ insertGraph 43 | where 44 | insertGraph title = insert_ (Graph title 0 0 False ) 45 | 46 | -- | Run a test case (case, input, expected output) on the index function. 47 | runIndexTest :: String -> [T.Text] -> String -> Test 48 | runIndexTest label graphs expected = 49 | TestLabel label $ TestCase $ do 50 | runDb $ do 51 | clearDatabase 52 | insertGraphs graphs 53 | response <- runServerPart Controllers.Graph.index 54 | let actual = BL.unpack $ rsBody response 55 | assertEqual ("Unexpected response body for " ++ label) expected actual 56 | 57 | -- | Run all the index test cases 58 | runIndexTests :: [Test] 59 | runIndexTests = map (\(label, graphs, expected) -> runIndexTest label graphs expected) indexTestCases 60 | 61 | -- | Test suite for Graph Controller Module 62 | graphControllerTestSuite :: Test 63 | graphControllerTestSuite = TestLabel "Graph Controller tests" $ TestList runIndexTests 64 | -------------------------------------------------------------------------------- /backend-test/Database/CourseQueriesTests.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Description: CourseQueries module tests. 3 | 4 | Module that contains the tests for the functions in the CourseQueries module. 5 | 6 | -} 7 | 8 | module Database.CourseQueriesTests 9 | ( courseQueriesTestSuite 10 | ) where 11 | 12 | import Config (runDb) 13 | import Control.Monad.IO.Class (liftIO) 14 | import qualified Data.Text as T 15 | import Data.Time (getCurrentTime) 16 | import Database.CourseQueries (reqsForPost) 17 | import Database.DataType (PostType(..)) 18 | import Database.Persist.Sqlite (insert_) 19 | import Database.Tables (Post(..)) 20 | import Test.HUnit (Test(..), assertEqual) 21 | import TestHelpers (clearDatabase) 22 | 23 | -- | List of test cases as (label, requirements to insert, input program, expected output) 24 | reqsForPostTestCases :: [(String, T.Text, T.Text, String)] 25 | reqsForPostTestCases = 26 | [ ("No program", "", "", "[]") 27 | , ("Valid program", "/CSC199H1/", "ASMAJ1689", "[\"CSC199H1\"]") 28 | , ("Invalid program", "", "ABCDE1234", "[]") 29 | ] 30 | 31 | -- | Run a test case (case, requirements, input, expected output) on the reqsForPost function. 32 | runReqsForPostTest :: String -> T.Text -> T.Text -> String -> Test 33 | runReqsForPostTest label reqsToInsert program expected = 34 | TestLabel label $ TestCase $ do 35 | currentTime <- liftIO getCurrentTime 36 | let testPost = Post Major "Computer Science" program "Sample post description" reqsToInsert currentTime currentTime 37 | 38 | runDb $ do 39 | clearDatabase 40 | insert_ testPost 41 | 42 | let requirements = reqsForPost testPost 43 | let actual = show requirements 44 | assertEqual ("Unexpected response body for " ++ label) expected actual 45 | 46 | -- | Run all the reqsForPost test cases 47 | runReqsForPostTests :: [Test] 48 | runReqsForPostTests = map (\(label, reqsToInsert, program, expected) -> runReqsForPostTest label reqsToInsert program expected) reqsForPostTestCases 49 | 50 | -- | Test suite for CourseQueries Module 51 | courseQueriesTestSuite :: Test 52 | courseQueriesTestSuite = TestLabel "Course Queries tests" $ TestList runReqsForPostTests 53 | -------------------------------------------------------------------------------- /backend-test/Database/DatabaseTests.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Description: Database test suites module. 3 | 4 | Module that contains the test suites for all the database functions. 5 | 6 | -} 7 | 8 | module Database.DatabaseTests 9 | ( databaseTests ) where 10 | 11 | import Test.HUnit (Test (..)) 12 | import Database.CourseQueriesTests (courseQueriesTestSuite) 13 | 14 | -- Single test encompassing all database test suites 15 | databaseTests :: Test 16 | databaseTests = TestList [courseQueriesTestSuite] 17 | -------------------------------------------------------------------------------- /backend-test/Main.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Description: Main module for testing. 3 | 4 | Module that acts as interface for testing multiple test suites using cabal. 5 | 6 | -} 7 | 8 | module Main where 9 | 10 | import Control.Monad (when) 11 | import Config (databasePath) 12 | import Data.Text (unpack) 13 | import Database.Database(setupDatabase) 14 | import System.Directory (removeFile) 15 | import System.Environment (setEnv, unsetEnv) 16 | import qualified System.Exit as Exit 17 | import Test.HUnit (Test (..), failures, runTestTT) 18 | import RequirementTests.RequirementTests (requirementTests) 19 | import Controllers.ControllerTests (controllerTests) 20 | import Database.DatabaseTests (databaseTests) 21 | import SvgTests.SvgTests (svgTests) 22 | 23 | tests :: IO Test 24 | tests = do 25 | return $ TestList [requirementTests, controllerTests, svgTests, databaseTests] 26 | 27 | main :: IO () 28 | main = do 29 | setEnv "APP_ENV" "test" 30 | setupDatabase 31 | testSuites <- tests 32 | count <- runTestTT testSuites 33 | when (failures count > 0) Exit.exitFailure 34 | path <- databasePath 35 | removeFile $ unpack path 36 | unsetEnv "APP_ENV" 37 | -------------------------------------------------------------------------------- /backend-test/RequirementTests/ModifierTests.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Description: Test modifier string formatting using HUnit Testing Framework. 3 | 4 | Module containing test cases for Modifier string formatters. 5 | 6 | -} 7 | 8 | module RequirementTests.ModifierTests 9 | ( modifierTestSuite ) where 10 | 11 | import Database.Requirement 12 | import DynamicGraphs.GraphNodeUtils (concatModOr, stringifyModAnd) 13 | import Test.HUnit (Test (..), assertEqual) 14 | 15 | -- Function to facilitate test case creation given a string, Req tuple 16 | createTest :: (Eq a, Show a) => (a -> String) -> String -> [(a, String)] -> Test 17 | createTest function label input = TestLabel label $ TestList $ map (\(x, y) -> 18 | TestCase $ assertEqual ("for (" ++ y ++ ")") 19 | y (function x)) input 20 | 21 | -- Global FCEs value so the expected output has the same FCEs as the partial function in createTest 22 | globalFces :: Float 23 | globalFces = 1.0 24 | 25 | concatModOrInputs :: [([Modifier], String)] 26 | concatModOrInputs = [ 27 | ([Department "CSC", Department "BCB"], "CSC/BCB") 28 | , ([Department "CSC", Department "BCB", Department "Statistics"], "CSC/BCB/Statistics") 29 | , ([Level "300", Level "400"], "300/400") 30 | , ([Requirement (Raw "lorem"), Requirement (Raw "ipsum")], "lorem or ipsum") 31 | ] 32 | 33 | simpleModAndInputs :: [([Modifier], String)] 34 | simpleModAndInputs = [ 35 | ([Department "CSC", Level "300"], show globalFces ++ " CSC FCEs at the 300 level") 36 | , ([Department "CSC", Requirement (Raw "some raw text")], show globalFces ++ " CSC FCEs from some raw text") 37 | , ([Level "300+", Requirement (Raw "some raw text")], show globalFces ++ " FCEs at the 300+ level from some raw text") 38 | , ([Department "CSC", Level "300+", Requirement (Raw "some raw text")], show globalFces ++ " CSC FCEs at the 300+ level from some raw text") 39 | , ([ModOr [Level "300", Level "400"], Department "CSC"], show globalFces ++ " CSC FCEs at the 300/400 level") 40 | ] 41 | 42 | modandModOrInputs :: [([Modifier], String)] 43 | modandModOrInputs = [ 44 | ([ModOr [Level "300", Level "400"], Department "CSC"], show globalFces ++ " CSC FCEs at the 300/400 level") 45 | , ([Level "300+", ModOr [Department "CSC", Department "BCB", Department "Statistics"]], show globalFces ++ " CSC/BCB/Statistics FCEs at the 300+ level") 46 | , ([ModOr [Level "300", Level "400"], ModOr [Department "CSC", Department "BCB"]], show globalFces ++ " CSC/BCB FCEs at the 300/400 level") 47 | , ([ModOr [Level "300", Level "400"], ModOr [Department "CSC", Department "BCB"], Requirement (Raw "some raw text")], show globalFces ++ " CSC/BCB FCEs at the 300/400 level from some raw text") 48 | ] 49 | 50 | concatModOrTests :: Test 51 | concatModOrTests = createTest concatModOr "joining ModOr with a delimiter" concatModOrInputs 52 | 53 | simpleModAndTests :: Test 54 | simpleModAndTests = createTest (stringifyModAnd globalFces) "ModAnd not containing ModOrs" simpleModAndInputs 55 | 56 | modandModOrTests :: Test 57 | modandModOrTests = createTest (stringifyModAnd globalFces) "ModAnd containing ModOrs" modandModOrInputs 58 | 59 | -- functions for running tests in REPL 60 | modifierTestSuite :: Test 61 | modifierTestSuite = TestLabel "ReqParser tests" $ TestList [concatModOrTests, simpleModAndTests, modandModOrTests] 62 | -------------------------------------------------------------------------------- /backend-test/RequirementTests/PostParserTests.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Description: Test Post Parsers using HUnit Testing Framework. 3 | 4 | Module containing test cases for Post Parsers. 5 | 6 | -} 7 | 8 | module RequirementTests.PostParserTests 9 | ( postTestSuite ) where 10 | 11 | import Data.Bifunctor (second) 12 | import qualified Data.Text as T 13 | import Database.DataType (PostType (..)) 14 | import Test.HUnit (Test (..), assertEqual) 15 | import qualified Text.Parsec as Parsec 16 | import WebParsing.PostParser (getPostType, postInfoParser) 17 | 18 | -- Function to facilitate test case creation given a string, Req tuple 19 | createTest :: (Show a, Eq a, Show b, Eq b) => (a -> b) -> String -> [(a, b)] -> Test 20 | createTest function label input = TestLabel label $ TestList $ map (\(x, y) -> 21 | TestCase $ assertEqual ("for (" ++ show x ++ "),") 22 | y (function x)) input 23 | 24 | -- | Input and output pair of each post 25 | -- | Output is in the order of (postDepartment, postCode, postName) 26 | postInfoInputs :: [(T.Text, (T.Text, T.Text))] 27 | postInfoInputs = [ 28 | ("Music Major (Arts Program) - ASMAJ2276", 29 | ("Music Major (Arts Program)", "ASMAJ2276")) 30 | , ("Focus in Artificial Intelligence (Major) - ASFOC1689K", 31 | ("Focus in Artificial Intelligence (Major)", "ASFOC1689K")) 32 | , ("Cell & Molecular Biology Specialist: Focus in Molecular Networks of the Cell - ASSPE1003A", 33 | ("Cell & Molecular Biology Specialist: Focus in Molecular Networks of the Cell", "ASSPE1003A")) 34 | , ("Focus in Medical Anthropology (Specialist: Society, Culture and Language) - ASFOC2112B", 35 | ("Focus in Medical Anthropology (Specialist: Society, Culture and Language)", "ASFOC2112B")) 36 | , ("Anthropology Specialist (Society, Culture, and Language) (Arts Program) - ASSPE2112", 37 | ("Anthropology Specialist (Society, Culture, and Language) (Arts Program)", "ASSPE2112")) 38 | , ("Christianity and Culture: Major Program in Religious Education (Arts Program) - ASMAJ1021", 39 | ("Christianity and Culture: Major Program in Religious Education (Arts Program)", "ASMAJ1021")) 40 | , ("Minor in French Language (Arts Program) - ASMIN0120", 41 | ("Minor in French Language (Arts Program)", "ASMIN0120")) 42 | , ("Minor Program in Christianity and Education (Arts Program) - ASMIN1014", 43 | ("Minor Program in Christianity and Education (Arts Program)", "ASMIN1014")) 44 | , ("Psychology Research Specialist - Thesis (Science Program) - ASSPE1958)", 45 | ("Psychology Research Specialist - Thesis (Science Program)", "ASSPE1958")) 46 | , ("Cognitive Science Major - Arts (Language and Cognition Stream) (Arts Program) - ASMAJ1445B", 47 | ("Cognitive Science Major - Arts (Language and Cognition Stream) (Arts Program)", "ASMAJ1445B")) 48 | , ("Certificate in Business Fundamentals - ASCER2400", 49 | ("Certificate in Business Fundamentals", "ASCER2400")) 50 | , ("Focus in Finance - ASFOC2431B", 51 | ("Focus in Finance", "ASFOC2431B")) 52 | , ("Focus in Green Chemistry", 53 | ("Focus in Green Chemistry", "")) 54 | , ("Biological Physics Specialist", 55 | ("Biological Physics Specialist", "")) 56 | ] 57 | 58 | getPostTypeInputs :: [((T.Text, T.Text), PostType)] 59 | getPostTypeInputs = [ 60 | (("ASSPE1958", "Psychology Specialist"), Specialist) 61 | , (("ASMAJ2276", "Music Major"), Major) 62 | , (("ASMIN0120", "Minor in French"), Minor) 63 | , (("ASFOC1689B", "Focus in AI"), Focus) 64 | , (("ASCER2400", "Certificate in Business"), Certificate) 65 | , (("", "Psychology Specialist"), Specialist) 66 | , (("", "Music Major"), Major) 67 | , (("", "Minor in French"), Minor) 68 | , (("", "Focus in AI"), Focus) 69 | , (("", "Certificate in Business"), Certificate) 70 | ] 71 | 72 | postInfoTests :: Test 73 | postInfoTests = createTest (Parsec.parse postInfoParser "") "Post requirements" $ map (second Right) postInfoInputs 74 | 75 | getPostTypeTests :: Test 76 | getPostTypeTests = createTest (uncurry getPostType) "Post requirements" getPostTypeInputs 77 | 78 | -- functions for running tests in REPL 79 | postTestSuite :: Test 80 | postTestSuite = TestLabel "PostParser tests" $ TestList [postInfoTests, getPostTypeTests] 81 | -------------------------------------------------------------------------------- /backend-test/RequirementTests/PreProcessingTests.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Description: Test some pre-processing functions that clean up the text before running the parsers 3 | 4 | -} 5 | 6 | module RequirementTests.PreProcessingTests 7 | ( preProcTestSuite ) where 8 | 9 | import Data.Text as T hiding (map) 10 | import Test.HUnit (Test (..), assertEqual) 11 | import Text.HTML.TagSoup (Tag (..)) 12 | import WebParsing.PostParser (pruneHtml) 13 | 14 | createTest :: (Eq a, Show a, Eq b, Show b) => (a -> b) -> String -> [(a, b)] -> Test 15 | createTest function label input = TestLabel label $ TestList $ map (\(x, y) -> 16 | TestCase $ assertEqual ("for (" ++ show y ++ ")") 17 | y (function x)) input 18 | 19 | 20 | pruneHtmlInputs :: [([Tag T.Text], [Tag T.Text])] 21 | pruneHtmlInputs = [ 22 | ( [TagOpen "h1" [("class", "c1 c2")], TagText "reqs", TagClose "h1"] 23 | , [TagOpen "h1" [], TagText"reqs", TagClose "h1"] 24 | ), 25 | ( [TagOpen "h2" [("style", "some: styles")], TagText "reqs", TagClose "h2"] 26 | , [TagOpen "h2" [("style", "some: styles")], TagText "reqs", TagClose "h2"] 27 | ), 28 | ( [TagOpen "h3" [("class", "c1 c2"), ("style", "some: styles")], TagText "reqs", TagClose "h3"] 29 | , [TagOpen "h3" [("style", "some: styles")], TagText "reqs", TagClose "h3"] 30 | ), 31 | ( [TagOpen "h4" [], TagOpen "a" [("href", "/CSC401H1")], TagText "CSC401H1", TagClose "a", TagClose "h4"] 32 | , [TagOpen "h4" [], TagText "CSC401H1", TagClose "h4"] 33 | ) 34 | ] 35 | 36 | pruneHtmlTests :: Test 37 | pruneHtmlTests = createTest pruneHtml "filtering out html attributes" pruneHtmlInputs 38 | 39 | -- functions for running tests in REPL 40 | preProcTestSuite :: Test 41 | preProcTestSuite = TestLabel "Pre-processing tests" $ TestList [pruneHtmlTests] 42 | -------------------------------------------------------------------------------- /backend-test/RequirementTests/RequirementTests.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Description: Requirement test suites module. 3 | 4 | Module that contains the test suites for all the requirement tests. 5 | 6 | -} 7 | 8 | module RequirementTests.RequirementTests 9 | ( requirementTests ) where 10 | 11 | import Test.HUnit (Test (..)) 12 | import RequirementTests.ModifierTests (modifierTestSuite) 13 | import RequirementTests.PostParserTests (postTestSuite) 14 | import RequirementTests.PreProcessingTests (preProcTestSuite) 15 | import RequirementTests.ReqParserTests (reqTestSuite) 16 | 17 | -- Single test encompassing all requirement test suites 18 | requirementTests :: Test 19 | requirementTests = TestList [reqTestSuite, postTestSuite, preProcTestSuite, modifierTestSuite] 20 | -------------------------------------------------------------------------------- /backend-test/SvgTests/SvgTests.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Description: SVG test suites module. 3 | 4 | Module that contains the test suites for all the SVG tests. 5 | 6 | -} 7 | 8 | module SvgTests.SvgTests (svgTests) where 9 | 10 | import Test.HUnit (Test (..)) 11 | import SvgTests.IntersectionTests (intersectionTestSuite) 12 | 13 | -- Single test encompassing all svg test suites 14 | svgTests :: Test 15 | svgTests = TestList [intersectionTestSuite] 16 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | # Server Configuration Settings 2 | port: 8000 3 | logMessage: "Happstack.Server.AccessLog.Combined" 4 | logFile: "" # The file path to log server access to, e.g. "path/to/courseography.log" 5 | 6 | # Database Path 7 | databasePath: "db/database.sqlite3" 8 | 9 | # File Path Strings 10 | graphPath: "./graphs/" 11 | genCssPath: "./public/style/" 12 | 13 | # URLs 14 | timetableUrl: "https://ttb.utoronto.ca/" 15 | timetableApiUrl: "https://api.easi.utoronto.ca/ttb/getPageableCourses" 16 | fasCalendarUrl: "https://artsci.calendar.utoronto.ca/" 17 | programsUrl: "https://artsci.calendar.utoronto.ca/listing-program-subject-areas" 18 | 19 | # Term dates 20 | fallStartDate: 2024-09-03 21 | fallEndDate: 2024-12-03 22 | winterStartDate: 2025-01-06 23 | winterEndDate: 2025-04-04 24 | outDay: 2026-01-01 25 | 26 | # Holidays 27 | holidaysList: 28 | - 20241014T 29 | - 20241028T 30 | - 20241029T 31 | - 20241030T 32 | - 20241031T 33 | - 20241101T 34 | - 20250217T 35 | - 20250218T 36 | - 20240219T 37 | - 20250220T 38 | - 20250221T 39 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:8000", 3 | "ignoreTestFiles": ["/**/examples/*.js"] 4 | } 5 | -------------------------------------------------------------------------------- /cypress/integration/graph_spec/bool_spec.js: -------------------------------------------------------------------------------- 1 | describe("Boolean tests", () => { 2 | beforeEach(() => { 3 | cy.visit("/graph") 4 | }) 5 | 6 | it("should initially be 'bool' and 'inactive'", () => { 7 | cy.get('[data-testid="and(csc209,csc258)"]') 8 | .should("have.class", "bool") 9 | .should("have.class", "inactive") 10 | }) 11 | it("shouldn't do anything when you click or hover over it", () => { 12 | cy.get('[data-testid="and(csc209,csc258)"]') 13 | .should("have.class", "bool") 14 | .should("have.class", "inactive") 15 | cy.get('[data-testid="and(csc209,csc258)"]').click() 16 | cy.get('[data-testid="and(csc209,csc258)"]') 17 | .should("have.class", "bool") 18 | .should("have.class", "inactive") 19 | cy.get('[data-testid="and(csc209,csc258)"]').trigger("mouseover") 20 | cy.get('[data-testid="and(csc209,csc258)"]') 21 | .should("have.class", "bool") 22 | .should("have.class", "inactive") 23 | }) 24 | 25 | it("AND should be 'active' when prereq parents are met", () => { 26 | cy.get('[data-testid="and(csc209,csc258)"]').contains("and") 27 | cy.get('[data-testid="csc258"]').click() 28 | cy.get('[data-testid="csc209"]').click() 29 | cy.get('[data-testid="and(csc209,csc258)"]').should("have.class", "active") 30 | cy.get('[data-testid="csc367"]').should("have.class", "takeable") 31 | }) 32 | it("OR should be 'active' when 1+ of the prereq parents are met", () => { 33 | cy.get('[data-testid="and(csc463,csc373)"]').contains("or") 34 | cy.get('[data-testid="csc373"]').click() 35 | cy.get('[data-testid="csc463"]').click() 36 | cy.get('[data-testid="and(csc463,csc373)"]').should("have.class", "active") 37 | cy.get('[data-testid="csc438"]').should("have.class", "takeable") 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /cypress/integration/graph_spec/graph_spec.js: -------------------------------------------------------------------------------- 1 | describe("Graph", () => { 2 | beforeEach(() => { 3 | cy.visit("/graph") 4 | }) 5 | 6 | describe("Boolean tests", () => { 7 | it("should initially be 'bool' and 'inactive'", () => { 8 | cy.get(`[data-testid="and(csc209,csc258)"]`) 9 | .should("have.class", "bool") 10 | .should("have.class", "inactive") 11 | }) 12 | it("shouldn't do anything when you click or hover over it", () => { 13 | cy.get(`[data-testid="and(csc209,csc258)"]`) 14 | .should("have.class", "bool") 15 | .should("have.class", "inactive") 16 | cy.get(`[data-testid="and(csc209,csc258)"]`).click() 17 | cy.get(`[data-testid="and(csc209,csc258)"]`) 18 | .should("have.class", "bool") 19 | .should("have.class", "inactive") 20 | cy.get(`[data-testid="and(csc209,csc258)"]`).trigger("mouseover") 21 | cy.get(`[data-testid="and(csc209,csc258)"]`) 22 | .should("have.class", "bool") 23 | .should("have.class", "inactive") 24 | }) 25 | 26 | it("AND should be 'active' when prereq parents are met", () => { 27 | cy.get(`[data-testid="and(csc209,csc258)"]`).contains("and") 28 | cy.get(`[data-testid="csc258"]`).click() 29 | cy.get(`[data-testid="csc209"]`).click() 30 | cy.get(`[data-testid="and(csc209,csc258)"]`).should("have.class", "active") 31 | cy.get(`[data-testid="csc367"]`).should("have.class", "takeable") 32 | }) 33 | it("OR should be 'active' when 1+ of the prereq parents are met", () => { 34 | cy.get(`[data-testid="and(csc463,csc373)"]`).contains("or") 35 | cy.get(`[data-testid="csc373"]`).click() 36 | cy.get(`[data-testid="csc463"]`).click() 37 | cy.get(`[data-testid="and(csc463,csc373)"]`).should("have.class", "active") 38 | cy.get(`[data-testid="csc438"]`).should("have.class", "takeable") 39 | }) 40 | }) 41 | 42 | it("'missing' class when the mouse is hovering over a child class", () => { 43 | cy.get(`[data-testid="csc458"]`).trigger("mouseover") 44 | cy.get(`[data-testid="csc458"]`).should("have.class", "missing") 45 | cy.get(`[data-testid='h(csc263265)']`).should("have.class", "missing") 46 | cy.get(`[data-testid='and(csc209,csc258)']`).should("have.class", "missing") 47 | cy.get(`[data-testid='csc209']`).should("have.class", "missing") 48 | cy.get(`[data-testid='csc258']`).should("have.class", "missing") 49 | cy.get(`[data-testid='csc108']`).should("have.class", "missing") 50 | 51 | // unaffected, despite sharing the same prereqs as CSC458 52 | cy.get(`[data-testid='csc385']`).should("have.class", "inactive") 53 | }) 54 | 55 | describe("Info Box", () => { 56 | it("clicking the info box should produce a pop up modal", () => { 57 | cy.visit("/graph") 58 | cy.contains("CSC108").trigger("mouseover") 59 | cy.contains("Info").click() 60 | 61 | cy.contains("CSC108 Introduction to Computer Programming") 62 | // force click because it's not visible 63 | cy.get(".ReactModal__Overlay").click({ force: true }) 64 | }) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /eslint.config.cjs: -------------------------------------------------------------------------------- 1 | const { includeIgnoreFile } = require("@eslint/compat") 2 | const path = require("node:path") 3 | const globals = require("globals") 4 | const js = require("@eslint/js") 5 | const reactPlugin = require("eslint-plugin-react") 6 | const eslintPluginPrettierRecommended = require("eslint-plugin-prettier/recommended") 7 | const babelParser = require("@babel/eslint-parser") 8 | 9 | const ignorePath = path.resolve(__dirname, ".prettierignore") 10 | 11 | module.exports = [ 12 | includeIgnoreFile(ignorePath), 13 | js.configs.recommended, 14 | reactPlugin.configs.flat.recommended, 15 | eslintPluginPrettierRecommended, 16 | { 17 | languageOptions: { 18 | globals: { 19 | ...globals.browser, 20 | ...globals.node, 21 | ...globals.jest, 22 | ...globals.es2015, 23 | $: "readonly", 24 | getURLParameter: "readonly", 25 | }, 26 | parser: babelParser, 27 | }, 28 | rules: { 29 | "no-console": "off", 30 | "react/no-find-dom-node": "off", 31 | "react/no-render-return-value": "off", 32 | "react/no-string-refs": "off", 33 | }, 34 | settings: { 35 | react: { 36 | version: "detect", 37 | }, 38 | }, 39 | }, 40 | ] 41 | -------------------------------------------------------------------------------- /js/components/about/about.js: -------------------------------------------------------------------------------- 1 | import aboutContent from "../../../README.md" 2 | 3 | document.addEventListener("DOMContentLoaded", () => { 4 | const container = document.getElementById("aboutDiv") 5 | container.innerHTML = aboutContent 6 | }) 7 | -------------------------------------------------------------------------------- /js/components/common/Disclaimer.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | /** 4 | * A React component representing the disclaimer popup 5 | */ 6 | export default class Disclaimer extends React.Component { 7 | constructor(props) { 8 | super(props) 9 | this.state = { 10 | hidden: localStorage.getItem("hide-disclaimer") === "true", 11 | } 12 | } 13 | 14 | handleClose = () => { 15 | this.setState({ hidden: true }) 16 | } 17 | 18 | handleCheck = event => { 19 | if (event.target.checked) { 20 | localStorage.setItem("hide-disclaimer", "true") 21 | } else { 22 | localStorage.setItem("hide-disclaimer", "false") 23 | } 24 | } 25 | 26 | render() { 27 | const timetable = ( 28 | Official Timetable 29 | ) 30 | const calendar = ( 31 | Academic Calendar 32 | ) 33 | 34 | if (this.state.hidden) { 35 | return null 36 | } else { 37 | return ( 38 |
39 | 42 |
43 |

Disclaimer

44 |

45 | Please make sure to confirm your course selections and prerequisites with 46 | official sources like the {timetable} and {calendar} as they are more 47 | reliable and up-to-date.{" "} 48 |

49 | 52 | 61 |
62 |
63 | ) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /js/components/common/__tests__/ConvertToLink.test.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { CourseModal } from "../react_modal.js.jsx" 3 | 4 | /** 5 | * Test for convertToLink. 6 | */ 7 | describe("ConvertToLink", () => { 8 | let actual 9 | let content 10 | let expected 11 | const wrapper = new CourseModal({}) 12 | 13 | describe("The content has no course names", () => { 14 | beforeEach(() => { 15 | content = "The application of logic and proof techniques." 16 | }) 17 | test("The content is returned as all strings", () => { 18 | actual = wrapper.convertToLink(content) 19 | expected = [content] 20 | expect(actual).toEqual(expected) 21 | }) 22 | }) 23 | 24 | describe("The content has one course name", () => { 25 | beforeEach(() => { 26 | content = "Certain topics briefly mentioned in CSC165H1 may be covered." 27 | }) 28 | test("The content is returned as one link tag and several strings", () => { 29 | actual = wrapper.convertToLink(content) 30 | expected = [ 31 | "Certain topics briefly mentioned in ", 32 | this.clickCourseLink("CSC165H1")} 36 | > 37 | CSC165H1 38 | , 39 | " may be covered.", 40 | ] 41 | expect(JSON.stringify(actual)).toEqual(JSON.stringify(expected)) 42 | }) 43 | }) 44 | 45 | describe("The content has one course name followed by a symbol, such as ')' ", () => { 46 | beforeEach(() => { 47 | content = "(60% or higher in CSC111H1)" 48 | }) 49 | test("The content is returned as one link tag and several strings \ 50 | (including the symbol after the course name", () => { 51 | actual = wrapper.convertToLink(content) 52 | expected = [ 53 | "(60% or higher in ", 54 | this.clickCourseLink("CSC111H1")} 58 | > 59 | CSC111H1 60 | , 61 | ")", 62 | ] 63 | expect(JSON.stringify(actual)).toEqual(JSON.stringify(expected)) 64 | }) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /js/components/common/__tests__/TimetableLoading.test.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { render, screen, cleanup } from "@testing-library/react" 3 | import { Description } from "../react_modal.js.jsx" 4 | 5 | describe("Displays correct content based on timetable availability", () => { 6 | beforeEach(() => cleanup()) 7 | 8 | it("displays a timetable when there is only one session", async () => { 9 | const courseInfo = { 10 | course: { 11 | name: "CSC108H1", 12 | description: "sample description", 13 | prereqStr: [], 14 | distribution: null, 15 | breadth: "The Physical and Mathematical Universes (5)", 16 | }, 17 | sessions: { 18 | F: [ 19 | { 20 | activity: "LEC0101", 21 | availability: "50 out of 100 available", 22 | instructor: "Beyonce", 23 | room: ["BA "], 24 | time: ["Monday 13 - 14", "Wednesday 13 - 14", "Friday 13 - 14"], 25 | waitlist: "0 students", 26 | }, 27 | ], 28 | S: [], 29 | Y: [], 30 | }, 31 | } 32 | 33 | render() 34 | await screen.findByText(/CSC108H1/) 35 | await screen.findByText(/LEC0101/) 36 | await screen.findByText("sample description") 37 | await screen.findByText("Beyonce") 38 | }) 39 | 40 | it("displays a timetable when there is more than one session", async () => { 41 | const courseInfo = { 42 | course: { 43 | name: "CSC108H1", 44 | description: "sample description", 45 | prereqStr: [], 46 | distribution: null, 47 | breadth: "The Physical and Mathematical Universes (5)", 48 | }, 49 | sessions: { 50 | F: [ 51 | { 52 | activity: "LEC0101", 53 | availability: "50 out of 100 available", 54 | instructor: "Beyonce", 55 | room: ["BA "], 56 | time: ["Monday 13 - 14", "Wednesday 13 - 14", "Friday 13 - 14"], 57 | waitlist: "0 students", 58 | }, 59 | ], 60 | S: [ 61 | { 62 | activity: "LEC0202", 63 | availability: "100 out of 200 available", 64 | instructor: "David. Liu", 65 | room: ["BA "], 66 | time: ["Monday 13 - 14", "Wednesday 13 - 14", "Friday 13 - 14"], 67 | waitlist: "0 students", 68 | }, 69 | ], 70 | Y: [], 71 | }, 72 | } 73 | 74 | render() 75 | await screen.findAllByText(/CSC108H1/) 76 | await screen.findByText(/LEC0101/) 77 | await screen.findByText(/LEC0202/) 78 | await screen.findByText("David. Liu") 79 | }) 80 | 81 | it("displays reminder when there's no timetable information", async () => { 82 | const courseInfo = { 83 | course: { 84 | name: "CSC108H1", 85 | description: "sample description", 86 | prereqStr: [], 87 | distribution: null, 88 | breadth: "The Physical and Mathematical Universes (5)", 89 | }, 90 | sessions: { 91 | F: [], 92 | S: [], 93 | Y: [], 94 | }, 95 | } 96 | 97 | render() 98 | await screen.findByText("No timetable information available") 99 | }) 100 | }) 101 | -------------------------------------------------------------------------------- /js/components/common/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Retrieves a course from file. 3 | * @param {string} courseName The course code. This + '.txt' is the name of the file. 4 | * @returns {Promise} Promise object representing the JSON object containing course information. 5 | */ 6 | export function getCourse(courseName) { 7 | "use strict" 8 | 9 | return fetch("course?name=" + courseName) 10 | .then(response => response.json()) 11 | .catch(error => { 12 | throw error 13 | }) 14 | } 15 | 16 | /** 17 | * Retrieves a post from the server. 18 | * @param {string} postCode The post code on the art&sci timetable. 19 | * @param {string} lastModified The last time the client called this function in UTC time 20 | * @returns {Promise} Promise object representing the JSON object containing post information and 21 | * a boolean of whether the data was modified since last time 22 | */ 23 | export function getPost(postCode, lastModified) { 24 | "use strict" 25 | 26 | return fetch("post?code=" + postCode, { 27 | headers: { 28 | "If-Modified-Since": lastModified, 29 | }, 30 | }) 31 | .then(async response => { 32 | if (response.status === 304) return { modified: false } 33 | 34 | const responseJson = await response.json() 35 | 36 | return { 37 | title: responseJson.postDepartment, 38 | description: responseJson.postDescription, 39 | requirements: responseJson.postRequirements, 40 | courseList: getCourseList(responseJson.postRequirements), 41 | modified: true, 42 | modifiedTime: response.headers.get("Last-modified"), 43 | } 44 | }) 45 | .catch(error => { 46 | throw error 47 | }) 48 | } 49 | 50 | /** 51 | * Parses the course codes from a requirement text and converts them to lower case 52 | * @param {string} requirements The requirement text containing the course codes 53 | * @returns {Array} Promise object representing an array of required or related courses. 54 | */ 55 | function getCourseList(requirements) { 56 | const courseCodeRegex = /[A-Z]{3}[0-9]{3}(?=[HY][135])/g 57 | const courseList = requirements.match(courseCodeRegex) || [] 58 | return courseList.map(course => course.toLowerCase()) 59 | } 60 | -------------------------------------------------------------------------------- /js/components/draw/main.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { createRoot } from 'react-dom/client' 3 | import { Graph } from "../graph/Graph" 4 | 5 | document.addEventListener("DOMContentLoaded", () => { 6 | const container = document.getElementById("react-graph") 7 | const root = createRoot(container) 8 | root.render() 9 | }) 10 | -------------------------------------------------------------------------------- /js/components/generate/generate.jsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client" 2 | import GenerateForm from "./GenerateForm.js" 3 | 4 | const container = document.getElementById("generateRoot") 5 | const root = createRoot(container) 6 | root.render() 7 | -------------------------------------------------------------------------------- /js/components/graph/Bool.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import PropTypes from "prop-types" 3 | 4 | /** Function representing a boolean node (and/or) */ 5 | export default function Bool(props) { 6 | const ellipseAttrs = { 7 | cx: props.JSON.pos[0], 8 | cy: props.JSON.pos[1], 9 | rx: "9.8800001", 10 | ry: "7.3684001", 11 | } 12 | return ( 13 | 18 | 19 | {props.JSON.text.map(function (textTag, i) { 20 | const textAttrs = { 21 | x: ellipseAttrs.cx, 22 | y: textTag.pos[1], 23 | } 24 | return ( 25 | 26 | {props.logicalType} 27 | 28 | ) 29 | })} 30 | 31 | ) 32 | } 33 | 34 | Bool.propTypes = { 35 | className: PropTypes.string, 36 | JSON: PropTypes.object, 37 | inEdges: PropTypes.array, 38 | logicalType: PropTypes.string, 39 | outEdges: PropTypes.array, 40 | parents: PropTypes.array, 41 | status: PropTypes.string, 42 | } 43 | -------------------------------------------------------------------------------- /js/components/graph/Button.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import PropTypes from "prop-types" 3 | 4 | export default function Button(props) { 5 | return ( 6 | 18 | ) 19 | } 20 | 21 | Button.propTypes = { 22 | divId: PropTypes.string, 23 | mouseDown: PropTypes.func, 24 | mouseUp: PropTypes.func, 25 | onMouseEnter: PropTypes.func, 26 | onMouseLeave: PropTypes.func, 27 | disabled: PropTypes.bool, 28 | text: PropTypes.string, 29 | children: PropTypes.node, 30 | } 31 | -------------------------------------------------------------------------------- /js/components/graph/Container.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import PropTypes from "prop-types" 3 | import Disclaimer from "../common/Disclaimer" 4 | import { Graph } from "./Graph" 5 | import FocusBar from "./FocusBar" 6 | 7 | export default class Container extends React.Component { 8 | constructor(props) { 9 | super(props) 10 | this.state = { 11 | currFocus: null, 12 | fceCount: 0, 13 | graphName: "", 14 | graphs: [], 15 | } 16 | this.graph = React.createRef() 17 | } 18 | 19 | UNSAFE_componentWillMount() { 20 | this.getLocalGraph() 21 | } 22 | 23 | componentDidMount() { 24 | fetch("/graphs") 25 | .then(res => res.json()) 26 | .then( 27 | graphsData => { 28 | this.setState({ 29 | graphs: graphsData.sort((a, b) => (a.id > b.id ? 1 : -1)), 30 | }) 31 | }, 32 | () => { 33 | throw "No graphs in database" 34 | } 35 | ) 36 | 37 | // Enable "Export" link 38 | document.getElementById("nav-export")?.addEventListener("click", () => { 39 | this.graph.current.openExportModal() 40 | }) 41 | } 42 | 43 | updateGraph = graphName => { 44 | this.setState({ graphName: graphName.replace("-", " ") }) 45 | } 46 | 47 | getLocalGraph = () => { 48 | // Gets graph from local storage, if it exists 49 | let graphName 50 | const params = new URL(document.location).searchParams 51 | const urlSpecifiedGraph = params.get("dept") 52 | 53 | // HACK: Temporary workaround for giving the statistics department a 54 | // link to our graph. 55 | // Should be replaced with a more general solution. 56 | if (urlSpecifiedGraph === "sta") { 57 | graphName = "Statistics" 58 | } else if (urlSpecifiedGraph !== null) { 59 | graphName = "Computer Science" 60 | } else { 61 | graphName = localStorage.getItem("active-graph") || "Computer Science" 62 | } 63 | this.setState({ graphName: graphName }) 64 | return graphName 65 | } 66 | 67 | setFCECount = credits => { 68 | this.setState({ fceCount: credits }) 69 | } 70 | 71 | incrementFCECount = credits => { 72 | this.setState({ fceCount: this.state.fceCount + credits }) 73 | } 74 | 75 | highlightFocus = id => { 76 | if (this.state.currFocus === id) { 77 | this.setState({ 78 | currFocus: null, 79 | }) 80 | } else { 81 | this.setState({ 82 | currFocus: id, 83 | }) 84 | } 85 | } 86 | 87 | render() { 88 | return ( 89 |
90 | 91 | 105 | 110 |
111 | ) 112 | } 113 | } 114 | 115 | Container.propTypes = { 116 | start_blank: PropTypes.bool, 117 | edit: PropTypes.bool, 118 | } 119 | -------------------------------------------------------------------------------- /js/components/graph/Edge.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types" 2 | import React from "react" 3 | 4 | /** 5 | * Function-based component representing an edge from a Node/Bool to a Node/Bool 6 | */ 7 | export default function Edge(props) { 8 | let pathDescription = "M" 9 | props.points.forEach(p => { 10 | pathDescription += p[0] + "," + p[1] + " " 11 | }) 12 | 13 | return ( 14 | ${props.target}`} 19 | markerEnd="url(#arrowHead)" 20 | /> 21 | ) 22 | } 23 | 24 | Edge.propTypes = { 25 | className: PropTypes.string, 26 | /** Array of points for the edge. A straight edge will have 2. Each turn in the edge means another point*/ 27 | points: PropTypes.array, 28 | /** Node from which the edge is drawn*/ 29 | source: PropTypes.string, 30 | /** Node that the edge is pointing to */ 31 | target: PropTypes.string, 32 | /** Status of this edge */ 33 | status: PropTypes.string, 34 | /** Transform of this edge */ 35 | transform: PropTypes.string, 36 | } 37 | -------------------------------------------------------------------------------- /js/components/graph/FocusBar.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import PropTypes from "prop-types" 3 | import FocusTab from "./FocusTab.js" 4 | 5 | // These lists are in reverse order to what ends up appearing on the screen 6 | const computerScienceFocusLabels = [ 7 | ["ASFOC1689J", "Web Technologies"], 8 | ["ASFOC1689I", "Theory of Computation"], 9 | ["ASFOC1689A", "Scientific Computing"], 10 | ["ASFOC1689H", "Human\u2011Computer Interaction"], 11 | ["ASFOC1689G", "Game Design"], 12 | ["ASFOC1689D", "Computer Vision"], 13 | ["ASFOC1689F", "Computer Systems"], 14 | ["ASFOC1689C", "Computational Linguistics"], 15 | ["ASFOC1689B", "Artificial Intelligence"], 16 | ] 17 | 18 | /** 19 | * React component representing the focus menu bar 20 | */ 21 | export default class FocusBar extends React.Component { 22 | constructor(props) { 23 | super(props) 24 | this.state = { 25 | open: false, 26 | } 27 | } 28 | 29 | /** 30 | * Changes whether the focus bar is open or not 31 | */ 32 | toggleFocusBar = () => { 33 | this.setState({ open: !this.state.open }) 34 | } 35 | 36 | /** 37 | * Creates the menu items of the focus bar using the FocusTab component 38 | * @returns an array of FocusTab components 39 | */ 40 | generateFocusTabs = () => { 41 | return computerScienceFocusLabels.map(([focusId, focusTitle]) => { 42 | const selected = this.props.currFocus === focusId 43 | 44 | return ( 45 | 52 | ) 53 | }) 54 | } 55 | 56 | render() { 57 | if (!this.props.focusBarEnabled) { 58 | return null 59 | } else { 60 | return ( 61 |
62 | 65 |
66 | {this.state.open && this.generateFocusTabs()} 67 |
68 |
69 | ) 70 | } 71 | } 72 | } 73 | 74 | FocusBar.propTypes = { 75 | focusBarEnabled: PropTypes.bool, 76 | highlightFocus: PropTypes.func, 77 | currFocus: PropTypes.string, 78 | } 79 | -------------------------------------------------------------------------------- /js/components/graph/FocusTab.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import PropTypes from "prop-types" 3 | import { FocusModal } from "../common/react_modal.js.jsx" 4 | 5 | /** 6 | * React component representing an item on the focus menu bar 7 | */ 8 | export default class FocusTab extends React.Component { 9 | constructor(props) { 10 | super(props) 11 | this.state = { 12 | showFocusModal: false, 13 | } 14 | } 15 | 16 | /** 17 | * Change whether the modal popup describing this focus is shown 18 | * @param {bool} value 19 | */ 20 | toggleFocusModal = value => { 21 | this.setState({ 22 | showFocusModal: value, 23 | }) 24 | } 25 | 26 | render() { 27 | return ( 28 |
29 | 35 |
36 | this.toggleFocusModal(false)} 40 | /> 41 | {this.props.selected && ( 42 | 48 | )} 49 |
50 |
51 | ) 52 | } 53 | } 54 | 55 | FocusTab.propTypes = { 56 | focusName: PropTypes.string, 57 | highlightFocus: PropTypes.func, 58 | selected: PropTypes.bool, 59 | pId: PropTypes.string, 60 | } 61 | -------------------------------------------------------------------------------- /js/components/graph/GraphDropdown.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import PropTypes from "prop-types" 3 | 4 | export default class GraphDropdown extends React.Component { 5 | render() { 6 | let className = "hidden" 7 | let graphTabLeft = 0 8 | if (this.props.graphs.length !== 0 && document.querySelector("#nav-graph")) { 9 | const navGraph = document.querySelector("#nav-graph") 10 | if (this.props.graphs.length === 0) { 11 | navGraph.classList.remove("show-dropdown-arrow") 12 | } else { 13 | if (!navGraph.classList.contains("show-dropdown-arrow")) { 14 | navGraph.classList.add("show-dropdown-arrow") 15 | } 16 | if (this.props.showGraphDropdown) { 17 | graphTabLeft = navGraph.getBoundingClientRect().left 18 | className = "graph-dropdown-display" 19 | } 20 | } 21 | } 22 | 23 | return ( 24 |
    31 | {this.props.graphs.map((graph, i) => { 32 | return ( 33 |
  • this.props.updateGraph(graph.title)} 37 | data-testid={"test-graph-" + i} 38 | > 39 | {graph.title} 40 |
  • 41 | ) 42 | })} 43 |
44 | ) 45 | } 46 | } 47 | 48 | GraphDropdown.defaultProps = { 49 | graphs: [], 50 | } 51 | 52 | GraphDropdown.propTypes = { 53 | showGraphDropdown: PropTypes.bool, 54 | onMouseMove: PropTypes.func, 55 | onMouseLeave: PropTypes.func, 56 | graphs: PropTypes.array, 57 | updateGraph: PropTypes.func, 58 | } 59 | -------------------------------------------------------------------------------- /js/components/graph/InfoBox.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import PropTypes from "prop-types" 3 | 4 | export default class InfoBox extends React.Component { 5 | render() { 6 | const className = this.props.showInfoBox 7 | ? "tooltip-group-display" 8 | : "tooltip-group-hidden" 9 | 10 | const rectAttrs = { 11 | id: this.props.nodeId + "-tooltip" + "-rect", 12 | x: this.props.xPos, 13 | y: this.props.yPos, 14 | rx: "4", 15 | ry: "4", 16 | fill: "white", 17 | stroke: "black", 18 | strokeWidth: "2", 19 | width: "60", 20 | height: "30", 21 | } 22 | 23 | const textAttrs = { 24 | id: this.props.nodeId + "-tooltip" + "-text", 25 | x: this.props.xPos + 60 / 2 - 18, 26 | y: this.props.yPos + 30 / 2 + 6, 27 | } 28 | 29 | return ( 30 | 37 | 38 | Info 39 | 40 | ) 41 | } 42 | } 43 | 44 | InfoBox.propTypes = { 45 | showInfoBox: PropTypes.bool, 46 | nodeId: PropTypes.string, 47 | xPos: PropTypes.number, 48 | yPos: PropTypes.number, 49 | onClick: PropTypes.func, 50 | onMouseEnter: PropTypes.func, 51 | onMouseLeave: PropTypes.func, 52 | } 53 | -------------------------------------------------------------------------------- /js/components/graph/Node.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types" 2 | import React from "react" 3 | 4 | /** React component class representing a Node on the graph 5 | * 6 | * Status and Selected Props: 7 | * - status: holds the current status message (see below) 8 | * - selected: whether the node is selected, based on the status message 9 | * 10 | * Unselected status messages: 11 | * - takeable: all prerequisites are satisfied (or no prereqs/parents) 12 | * - inactive: missing some prerequisites 13 | * 14 | * Selected status messages: 15 | * - active: all prerequisities are satisfied 16 | * - overridden: missing some prerequisities (will have a red border) 17 | * 18 | * On Hover status message: 19 | * - missing: means that this node is a prerequisite node that is not satisfied (red border) 20 | * 21 | * Types of nodes: 22 | * - Course nodes are nodes that represent a certain course 23 | * - Hybrid nodes are the smaller, grey nodes on the graph that represent another course node 24 | * farther away. They can only be either 'active' or 'inactive' 25 | */ 26 | export default function Node(props) { 27 | const getDataTestId = () => { 28 | if (props.hybrid) { 29 | return `h(${props.parents.join(",")})` 30 | } 31 | return props.JSON.id_ 32 | } 33 | 34 | let ellipse = null 35 | const newClassName = props.className + " " + props.status 36 | if (props.highlightFocus || props.highlightDeps) { 37 | const attrs = props.JSON 38 | const width = parseFloat(attrs.width) / 2 39 | const height = parseFloat(attrs.height) / 2 40 | const isCombo = props.JSON.id_.length > 8 41 | ellipse = ( 42 | 50 | ) 51 | } 52 | 53 | const gAttrs = { 54 | textRendering: "geometricPrecision", 55 | shapeRendering: "geometricPrecision", 56 | onKeyDown: props.onKeyDown, 57 | onWheel: props.onWheel, 58 | onMouseEnter: props.onMouseEnter, 59 | onMouseLeave: props.onMouseLeave, 60 | onClick: props.onClick, 61 | } 62 | 63 | const rectAttrs = { 64 | height: props.JSON.height, 65 | width: props.JSON.width, 66 | x: props.JSON.pos[0], 67 | y: props.JSON.pos[1], 68 | } 69 | 70 | if (props.className === "node") { 71 | rectAttrs["rx"] = "8" 72 | rectAttrs["ry"] = "8" 73 | } 74 | 75 | const rectStyle = { 76 | fill: props.JSON.fill, 77 | } 78 | 79 | const textXOffset = props.JSON.pos[0] + props.JSON.width / 2 80 | 81 | return ( 82 | 89 | {ellipse} 90 | 97 | {props.JSON.text.map(function (textTag, i) { 98 | const textAttrs = { 99 | x: textXOffset, 100 | y: textTag.pos[1], 101 | } 102 | return ( 103 | 104 | {textTag.text} 105 | 106 | ) 107 | })} 108 | 109 | ) 110 | } 111 | 112 | Node.propTypes = { 113 | className: PropTypes.string, 114 | editMode: PropTypes.bool, 115 | focused: PropTypes.bool, 116 | highlightDeps: PropTypes.bool, 117 | highlightFocus: PropTypes.bool, 118 | hybrid: PropTypes.bool, 119 | JSON: PropTypes.object, 120 | onClick: PropTypes.func, 121 | onMouseEnter: PropTypes.func, 122 | onMouseLeave: PropTypes.func, 123 | onWheel: PropTypes.func, 124 | onKeyDown: PropTypes.func, 125 | status: PropTypes.string, 126 | transform: PropTypes.string, 127 | parents: PropTypes.array, 128 | nodeDropshadowFilter: PropTypes.string, 129 | } 130 | -------------------------------------------------------------------------------- /js/components/graph/__mocks__/focusData.js: -------------------------------------------------------------------------------- 1 | export default { 2 | postCode: "ASFOC1689B", 3 | postDepartment: "Focus in Artificial Intelligence (Specialist)", 4 | postDescription: 5 | "(3.5 credits) Artificial Intelligence (AI) is aimed at understanding " + 6 | "and replicating the computational processes underlying intelligent behaviour.", 7 | postName: "Focus", 8 | postRequirements: ` 9 | Completion Requirements: 10 | Required Courses: 11 | 1.0 credit from the following: CSC336H1, MAT235Y1/ MAT237Y1/ MAT257Y1, APM236H1, MAT224H1/ MAT247H1, STA238H1/ STA248H1/ STA261H1, STA302H1, STA347H1 12 | 2.5 credits from the following, so that courses are from at least two of the four areas: 13 | 14 | CSC401H1, CSC485H1 15 | CSC320H1, CSC420H1 16 | CSC413H1/ CSC421H1/ CSC321H1, CSC311H1/ CSC411H1/ STA314H1, CSC412H1/ STA414H1 17 | CSC304H1, CSC384H1, CSC486H1 18 | Suggested Related Courses: 19 | CSC324H1, COG250Y1, PSY270H1, PHL232H1, PHL342H1`, 20 | } 21 | -------------------------------------------------------------------------------- /js/components/graph/__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {} 2 | -------------------------------------------------------------------------------- /js/components/graph/__mocks__/testContainerData.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | height: 516, 4 | width: 754, 5 | id: 19, 6 | title: "(unofficial) Aboriginal", 7 | }, 8 | { 9 | height: 1137, 10 | width: 1083, 11 | id: 14, 12 | title: "(unofficial) East Asian Studies", 13 | }, 14 | { 15 | height: 549, 16 | width: 1040, 17 | id: 2, 18 | title: "(unofficial) Statistics", 19 | }, 20 | { 21 | height: 707.919189453125, 22 | width: 1196, 23 | id: 1, 24 | title: "Computer Science", 25 | }, 26 | ] 27 | -------------------------------------------------------------------------------- /js/components/graph/__tests__/Button.test.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Button from "../Button" 3 | import { render, screen, act } from "@testing-library/react" 4 | 5 | describe("Button", () => { 6 | it("should match snapshot", async () => { 7 | const buttonText = "Reset" 8 | const buttonProps = { 9 | divId: "reset-button", 10 | text: buttonText, 11 | disabled: true, 12 | } 13 | await act(async () => render( 11 | `; 12 | -------------------------------------------------------------------------------- /js/components/graph/__tests__/__snapshots__/Edge.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Edge should match snapshot 1`] = ` 4 | 10 | `; 11 | -------------------------------------------------------------------------------- /js/components/graph/__tests__/__snapshots__/FocusBar.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`FocusBar should match shallow snapshot 1`] = ` 4 | { 5 | "asFragment": [Function], 6 | "baseElement": 7 | 8 | 9 | 34 | 35 | 36 |
40 | 41 | 42 |
45 |
46 |
49 | 54 |
57 |
58 |
59 | , 60 | "container":
61 |
64 | 69 |
72 |
73 |
, 74 | "debug": [Function], 75 | "findAllByAltText": [Function], 76 | "findAllByDisplayValue": [Function], 77 | "findAllByLabelText": [Function], 78 | "findAllByPlaceholderText": [Function], 79 | "findAllByRole": [Function], 80 | "findAllByTestId": [Function], 81 | "findAllByText": [Function], 82 | "findAllByTitle": [Function], 83 | "findByAltText": [Function], 84 | "findByDisplayValue": [Function], 85 | "findByLabelText": [Function], 86 | "findByPlaceholderText": [Function], 87 | "findByRole": [Function], 88 | "findByTestId": [Function], 89 | "findByText": [Function], 90 | "findByTitle": [Function], 91 | "getAllByAltText": [Function], 92 | "getAllByDisplayValue": [Function], 93 | "getAllByLabelText": [Function], 94 | "getAllByPlaceholderText": [Function], 95 | "getAllByRole": [Function], 96 | "getAllByTestId": [Function], 97 | "getAllByText": [Function], 98 | "getAllByTitle": [Function], 99 | "getByAltText": [Function], 100 | "getByDisplayValue": [Function], 101 | "getByLabelText": [Function], 102 | "getByPlaceholderText": [Function], 103 | "getByRole": [Function], 104 | "getByTestId": [Function], 105 | "getByText": [Function], 106 | "getByTitle": [Function], 107 | "queryAllByAltText": [Function], 108 | "queryAllByDisplayValue": [Function], 109 | "queryAllByLabelText": [Function], 110 | "queryAllByPlaceholderText": [Function], 111 | "queryAllByRole": [Function], 112 | "queryAllByTestId": [Function], 113 | "queryAllByText": [Function], 114 | "queryAllByTitle": [Function], 115 | "queryByAltText": [Function], 116 | "queryByDisplayValue": [Function], 117 | "queryByLabelText": [Function], 118 | "queryByPlaceholderText": [Function], 119 | "queryByRole": [Function], 120 | "queryByTestId": [Function], 121 | "queryByText": [Function], 122 | "queryByTitle": [Function], 123 | "rerender": [Function], 124 | "unmount": [Function], 125 | } 126 | `; 127 | -------------------------------------------------------------------------------- /js/components/graph/__tests__/__snapshots__/Graph.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`BoolGroup BoolGroup should match shallow snapshot 1`] = ` 4 | 7 | `; 8 | 9 | exports[`EdgeGroup should match shallow snapshot 1`] = ` 10 | 13 | `; 14 | 15 | exports[`Graph rendering RegionGroup should match shallow snapshot 1`] = ` 16 | 19 | 28 | 38 | Systems 39 | 40 | 41 | `; 42 | 43 | exports[`NodeGroup should match shallow snapshot 1`] = ` 44 | 47 | `; 48 | -------------------------------------------------------------------------------- /js/components/graph/__tests__/__snapshots__/GraphDropdown.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`GraphDropdown should match snapshot 1`] = ` 4 |