├── .envrc.sample ├── .eslintrc.js ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ └── feature_proposal.yml └── workflows │ └── ci.yaml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .nvmrc ├── .prettierrc.json ├── .stylelintrc.cjs ├── .vim └── coc-settings.json ├── .vscode └── launch.json ├── LICENSE ├── Makefile ├── README.md ├── bin └── publishFirefoxVersion.sh ├── commitlint.config.js ├── img ├── dist │ ├── options-page-dark.png │ ├── options-page-light.png │ ├── thumbnail1.png │ └── thumbnail2.png ├── icon.ai ├── src │ ├── deadline-coloring-1.png │ ├── deadline-coloring-2.png │ ├── options-page-dark.png │ └── options-page-light.png └── thumbnail.ai ├── jest.config.js ├── package.json ├── public ├── _locales │ ├── en │ │ └── messages.json │ └── ja │ │ └── messages.json ├── icons │ ├── icon.svg │ ├── icon_128.png │ ├── icon_16.png │ ├── icon_32.png │ └── icon_48.png └── options.html ├── renovate.json ├── src ├── background.ts ├── contentScript │ ├── main.ts │ ├── reportTemplate.ts │ └── showRelativeGradesPosition.ts ├── manifest.ts ├── methods │ ├── ReportTemplateGenerator.test.ts │ ├── ReportTemplateGenerator.tsx │ ├── checkAssignmentDeadline.ts │ ├── checkLang.ts │ ├── checkPagePubDeadline.ts │ ├── colorizeDeadline.ts │ ├── createLinkToOptions.ts │ ├── dragAndDrop.ts │ ├── evalDiff.ts │ ├── filterCourses.ts │ ├── handleReportTemplateForm.ts │ ├── openCodeInRespon.ts │ ├── removeLinkBalloon.ts │ ├── showRelativeGradesPosition.ts │ ├── syncReportText.ts │ └── usermemo.ts ├── network │ └── storage.ts ├── optionsPage │ ├── app.module.scss │ ├── app.tsx │ ├── components │ │ ├── Header │ │ │ ├── index.module.scss │ │ │ └── index.tsx │ │ ├── Notice │ │ │ ├── index.module.scss │ │ │ └── index.tsx │ │ └── ReleaseNote │ │ │ ├── index.module.scss │ │ │ └── index.tsx │ ├── index.tsx │ ├── legacyHandler.ts │ └── styles │ │ └── mixins.scss ├── style │ ├── colorizeDeadline.scss │ ├── options.scss │ └── originalButton.scss └── types │ ├── filterCources.ts │ ├── index.d.ts │ └── storage.ts ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.envrc.sample: -------------------------------------------------------------------------------- 1 | export AMO_JWT_ISSUER= 2 | export AMO_JWT_SECRET= 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | browser: true, 6 | es6: true, 7 | }, 8 | parser: "@typescript-eslint/parser", 9 | parserOptions: { 10 | ecmaVersion: 2019, 11 | sourceType: "module", 12 | ecmaFeatures: { 13 | jsx: true, 14 | }, 15 | }, 16 | extends: [ 17 | "eslint:recommended", 18 | "plugin:import/recommended", 19 | "plugin:import/typescript", 20 | "plugin:@typescript-eslint/recommended", 21 | "prettier", 22 | ], 23 | globals: { 24 | Atomics: "readonly", 25 | SharedArrayBuffer: "readonly", 26 | }, 27 | plugins: ["import", "@typescript-eslint"], 28 | rules: { 29 | eqeqeq: "error", 30 | "no-console": "warn", 31 | "@typescript-eslint/no-var-requires": 0, 32 | "@typescript-eslint/no-namespace": 0, 33 | "import/order": [ 34 | 2, 35 | { 36 | alphabetize: { caseInsensitive: true, order: "asc" }, 37 | groups: [["builtin", "external"], "parent", ["sibling", "index"]], 38 | "newlines-between": "always", 39 | }, 40 | ], 41 | "import/first": 2, 42 | "import/export": 1, 43 | }, 44 | } 45 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @mkobayashime 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_proposal.yml: -------------------------------------------------------------------------------- 1 | name: Feature Proposal 2 | description: Suggest an idea for this project 3 | title: "[Feature Proposal]: " 4 | labels: [enhancement] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out this feature proposal! 10 | - type: textarea 11 | id: feature-proposal 12 | attributes: 13 | label: Feature Proposal 14 | description: A clear and concise description of what the feature is. 15 | validations: 16 | required: true 17 | - type: textarea 18 | id: motivation 19 | attributes: 20 | label: Motivation 21 | description: Please outline the motivation for the proposal. 22 | validations: 23 | required: true 24 | - type: textarea 25 | id: alternatives 26 | attributes: 27 | label: Alternatives 28 | description: A clear and concise description of any alternative solutions or features you've considered. 29 | validations: 30 | required: false 31 | - type: textarea 32 | id: additional-context 33 | attributes: 34 | label: Additional Context 35 | description: Add any other context or screenshots about the feature proposal here. 36 | validations: 37 | required: false 38 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | env: 10 | MAKE_YARN_FROZEN_LOCKFILE: 1 11 | 12 | jobs: 13 | Lint: 14 | name: Lint 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | with: 19 | fetch-depth: 0 20 | - name: Use Node.js 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: "18" 24 | - uses: actions/cache@v3 25 | with: 26 | path: "**/node_modules" 27 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 28 | - name: Install dependencies 29 | run: make node_modules 30 | - uses: wagoid/commitlint-github-action@v5 31 | - name: Run Lint 32 | run: make lint 33 | - name: Run Prettier 34 | run: make format.check 35 | - name: Run typecheck 36 | run: make typecheck 37 | 38 | build: 39 | name: Build 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@v3 43 | - name: Use Node.js 44 | uses: actions/setup-node@v3 45 | with: 46 | node-version: "18" 47 | - uses: actions/cache@v3 48 | with: 49 | path: "**/node_modules" 50 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 51 | - name: Build for Chrome 52 | run: make build.chrome 53 | - name: Build for Firefox 54 | run: make build.firefox 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # production 5 | /dist 6 | /packaged 7 | 8 | # misc 9 | .DS_Store 10 | 11 | npm-debug.log* 12 | 13 | .env 14 | .env.js 15 | .envrc 16 | 17 | size-plugin.json 18 | 19 | web-ext-artifacts 20 | dist-firefox 21 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn run commitlint --edit ${1} 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn run lint-staged 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "tabWidth": 2, 4 | "singleQuote": false, 5 | "trailingComma": "es5", 6 | "semi": false, 7 | "overrides": [ 8 | { 9 | "files": ["*.tsx", "*.ts"], 10 | "options": { 11 | "parser": "typescript" 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.stylelintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["stylelint-config-standard", "stylelint-config-standard-scss"], 3 | rules: { 4 | "selector-class-pattern": null, 5 | // FIXME: Disallow ASAP 6 | "no-descending-specificity": null, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /.vim/coc-settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": ["manaba", "tsukuba", "respon", "usermemo", "autosave"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "chrome", 6 | "request": "launch", 7 | "name": "Launch Chrome against manaba", 8 | "url": "https://manaba.tsukuba.ac.jp/ct/home", 9 | "runtimeArgs": ["--load-extension=./dist"] 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Masaki Kobayashi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | eslint = yarn run eslint --ignore-path .gitignore 2 | prettier = yarn run prettier --ignore-path .gitignore 3 | stylelint = yarn run stylelint --ignore-path .gitignore 4 | typecheck = yarn run tsc --noEmit 5 | web-ext = yarn run web-ext -s dist 6 | 7 | node_modules: package.json yarn.lock 8 | ifeq ($(MAKE_YARN_FROZEN_LOCKFILE), 1) 9 | yarn install --frozen-lockfile 10 | else 11 | yarn install 12 | endif 13 | @touch node_modules 14 | 15 | .PHONY: format 16 | format: node_modules 17 | $(prettier) --write . 18 | 19 | .PHONY: format.check 20 | format.check: node_modules 21 | $(prettier) --check . 22 | 23 | .PHONY: lint 24 | lint: node_modules 25 | $(eslint) . 26 | $(stylelint) '**/*.{css,scss}' 27 | 28 | .PHONY: lint.fix 29 | lint.fix: node_modules 30 | $(eslint) --fix . 31 | $(stylelint) --fix '**/*.{css,scss}' 32 | 33 | .PHONY: autofix 34 | autofix: format lint.fix 35 | 36 | .PHONY: typecheck 37 | typecheck: node_modules 38 | $(typecheck) 39 | 40 | .PHONY: typecheck.watch 41 | typecheck.watch: node_modules 42 | $(typecheck) --watch 43 | 44 | .PHONY: test 45 | test: node_modules 46 | yarn run test 47 | 48 | .PHONY: test.watch 49 | test.watch: node_modules 50 | yarn run test:watch 51 | 52 | .PHONY: dev.chrome 53 | dev.chrome: node_modules 54 | NODE_ENV=development BROWSER_ENV=chrome yarn run webpack --watch 55 | 56 | .PHONY: dev.firefox 57 | dev.firefox: node_modules 58 | NODE_ENV=development BROWSER_ENV=firefox yarn run webpack --watch 59 | 60 | .PHONY: build.chrome 61 | build.chrome: node_modules clear 62 | NODE_ENV=production BROWSER_ENV=chrome yarn run webpack 63 | 64 | .PHONY: build.firefox 65 | build.firefox: node_modules clear 66 | NODE_ENV=production BROWSER_ENV=firefox yarn run webpack 67 | 68 | .PHONY: lint.web-ext 69 | lint.web-ext: node_modules 70 | $(web-ext) lint --self-hosted 71 | 72 | .PHONY: sign.firefox 73 | sign.firefox: node_modules 74 | $(web-ext) sign --channel unlisted --api-key=${AMO_JWT_ISSUER} --api-secret=${AMO_JWT_SECRET} 75 | 76 | .PHONY: publish.firefox 77 | publish.firefox: build.firefox sign.firefox 78 | rm -rf dist-firefox 79 | git clone git@github.com:manaba-enhanced-for-tsukuba/dist-firefox.git dist-firefox 80 | ./bin/publishFirefoxVersion.sh 81 | 82 | .PHONY: clear 83 | clear: node_modules 84 | yarn run rimraf dist 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # manaba Enhanced for Tsukuba 4 | 5 | ![Chrome Web Store](https://img.shields.io/chrome-web-store/users/fldngcbchlbfgbccilklplmhljilhfch) 6 | ![Chrome Web Store](https://img.shields.io/chrome-web-store/stars/fldngcbchlbfgbccilklplmhljilhfch) 7 | 8 | 9 | Make your manaba a little bit more comfortable. 10 | 11 | [Chrome](https://chrome.google.com/webstore/detail/manaba-enhanced-for-tsuku/fldngcbchlbfgbccilklplmhljilhfch) / [Firefox](https://github.com/manaba-enhanced-for-tsukuba/dist-firefox) 12 | 13 | **For students of University of Tsukuba.** 14 | 15 |
16 | 17 | ![Screenshot](./img/dist/thumbnail1.png) 18 | 19 | ## Supported Univ. / 対応大学 20 | 21 | - University of Tsukuba / 筑波大学 22 | 23 | If you want to use it in your univ., please follow the [instruction](https://github.com/mkobayashime/manaba-enhanced#how-to-add-support-for-your-univ) below. 24 | 25 | ## Features 26 | 27 | 1. Color assignments with colors according to the time remaining 28 | 29 | Red: 1 day remaining 30 | Yellow: 3 days remaining 31 | Green: 7 days remaining 32 | 33 | 1. Auto-save report text 34 | 35 | 1. Highlight the publication deadline of course news and course contents 36 | 37 | 1. Display the relative position of the grades in the courses 38 | 39 | 1. Filter courses in mypage by terms/modules 40 | 41 | 1. Generate $\LaTeX$ template for reports 42 | 43 | 1. Drag & Drop file upload 44 | 45 | 1. Remove the confirmation dialogue of external links 46 | 47 | 1. Add a context menu to open the attend code in Respon 48 | 49 | ## 機能 50 | 51 | 1. 締め切りまでの時間による課題一覧の色分け 52 | 53 | 赤: 期限まであと 1 日 54 | 黄: 期限まであと 3 日 55 | 緑: 期限まであと 7 日 56 | 57 | 1. レポート入力画面でのレポート自動保存 58 | 59 | 1. コースニュースやコンテンツの公開期限を強調表示 60 | 61 | 1. 成績の位置を百分率で表示 62 | 63 | 1. コースのモジュール別フィルタリング 64 | 65 | 1. レポートの $\LaTeX$ テンプレートを生成 66 | 67 | 1. ドラッグアンドドロップでのファイルアップロード 68 | 69 | 1. 外部リンククリック時の確認ダイアログ排除 70 | 71 | 1. 出席コードを Respon で開く右クリックメニューを追加 72 | 73 | ## Disclaimer / 免責事項 74 | 75 | This is an unofficial software and has nothing to do with the administration of the University of Tsukuba. 76 | 77 | We will not be held responsible for any damages and troubles caused by this software. 78 | 79 | これは大学非公式のソフトウェアであり、筑波大学とは無関係です 80 | 81 | 私達はこのソフトウェアを利用したことによるいかなる損害、トラブルに対する責任を負いません 82 | 83 | ## Development 84 | 85 | ```sh 86 | make # Install dependencies 87 | 88 | make dev.chrome # Run in dev mode for Chrome 89 | make dev.firefox # ...and for Firefox 90 | 91 | make build.chrome # Production build for Chrome 92 | make build.firefox # ...and for Firefox 93 | 94 | make format # Run Prettier 95 | 96 | make lint # Run ESLint 97 | 98 | make typecheck # Run typecheck 99 | ``` 100 | 101 | ## How to add support for your univ. 102 | 103 | If your univ. is using manaba and there is _Assignments_ tab in the mypage, please follow the instruction below to use this extension. 104 | 105 | 1. Fork this repository. 106 | 1. Replace `matches` of `content_scripts` in `manifest.json` with the url of manaba of your univ.. 107 | 1. Build and install in Chrome. 108 | 109 | あなたの大学が manaba を導入しており、マイページに「未提出課題」タブが存在する場合、以下の手順で対応が可能です。 110 | 111 | 1. このレポジトリをフォークする。 112 | 1. `manifest.json`に記述されている`content_scripts`の`matches`をあなたの大学の manaba の URL に変更する。 113 | 1. ビルドし Chrome にインストールする。 114 | 115 | ## Contribution 116 | 117 | Suggestions and pull requests are welcomed! 118 | 119 | ## License 120 | 121 | [MIT License](./LICENSE) 122 | -------------------------------------------------------------------------------- /bin/publishFirefoxVersion.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | version=$(jq -r '.version' package.json) 6 | 7 | if [[ -z "$version" ]]; then 8 | echo 'Fatal: Could not retrieve version from package.json.' 1>&2 9 | exit 1 10 | fi 11 | 12 | xpi_file=$(realpath "web-ext-artifacts/manaba_enhanced_for_tsukuba-$version.xpi") 13 | 14 | if [[ ! -f "$xpi_file" ]]; then 15 | echo "Fatal: xpi file for version $version not found." 1>&2 16 | exit 1 17 | fi 18 | 19 | cd dist-firefox 20 | 21 | ./bin/publishVersion.sh "$version" "$xpi_file" 22 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["@commitlint/config-conventional"], 3 | } 4 | -------------------------------------------------------------------------------- /img/dist/options-page-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manaba-enhanced-for-tsukuba/manaba-enhanced/fd0128e645e8dc9ca5b7b2bdd7bf27e1fa4d1d5f/img/dist/options-page-dark.png -------------------------------------------------------------------------------- /img/dist/options-page-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manaba-enhanced-for-tsukuba/manaba-enhanced/fd0128e645e8dc9ca5b7b2bdd7bf27e1fa4d1d5f/img/dist/options-page-light.png -------------------------------------------------------------------------------- /img/dist/thumbnail1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manaba-enhanced-for-tsukuba/manaba-enhanced/fd0128e645e8dc9ca5b7b2bdd7bf27e1fa4d1d5f/img/dist/thumbnail1.png -------------------------------------------------------------------------------- /img/dist/thumbnail2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manaba-enhanced-for-tsukuba/manaba-enhanced/fd0128e645e8dc9ca5b7b2bdd7bf27e1fa4d1d5f/img/dist/thumbnail2.png -------------------------------------------------------------------------------- /img/icon.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manaba-enhanced-for-tsukuba/manaba-enhanced/fd0128e645e8dc9ca5b7b2bdd7bf27e1fa4d1d5f/img/icon.ai -------------------------------------------------------------------------------- /img/src/deadline-coloring-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manaba-enhanced-for-tsukuba/manaba-enhanced/fd0128e645e8dc9ca5b7b2bdd7bf27e1fa4d1d5f/img/src/deadline-coloring-1.png -------------------------------------------------------------------------------- /img/src/deadline-coloring-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manaba-enhanced-for-tsukuba/manaba-enhanced/fd0128e645e8dc9ca5b7b2bdd7bf27e1fa4d1d5f/img/src/deadline-coloring-2.png -------------------------------------------------------------------------------- /img/src/options-page-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manaba-enhanced-for-tsukuba/manaba-enhanced/fd0128e645e8dc9ca5b7b2bdd7bf27e1fa4d1d5f/img/src/options-page-dark.png -------------------------------------------------------------------------------- /img/src/options-page-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manaba-enhanced-for-tsukuba/manaba-enhanced/fd0128e645e8dc9ca5b7b2bdd7bf27e1fa4d1d5f/img/src/options-page-light.png -------------------------------------------------------------------------------- /img/thumbnail.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manaba-enhanced-for-tsukuba/manaba-enhanced/fd0128e645e8dc9ca5b7b2bdd7bf27e1fa4d1d5f/img/thumbnail.ai -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | "^.+\\.(t|j)sx?$": ["@swc/jest"], 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "manaba-enhanced-for-tsukuba", 3 | "version": "3.3.1", 4 | "description": "Make your manaba a little bit more comfortable", 5 | "maintainers": [ 6 | { 7 | "name": "Masaki Kobayashi", 8 | "email": "contact@mkobayashi.me" 9 | }, 10 | { 11 | "name": "Yuzuki Arai", 12 | "email": "arai@yudukikun5120.me" 13 | } 14 | ], 15 | "contributors": [ 16 | { 17 | "name": "Yusei Ito", 18 | "email": "me@yuseiito.com" 19 | } 20 | ], 21 | "private": true, 22 | "devDependencies": { 23 | "@commitlint/cli": "17.4.0", 24 | "@commitlint/config-conventional": "17.4.0", 25 | "@swc/core": "1.3.25", 26 | "@swc/jest": "0.2.24", 27 | "@tsconfig/recommended": "1.0.1", 28 | "@types/chrome": "0.0.206", 29 | "@types/jest": "29.2.5", 30 | "@types/lodash-es": "4.17.6", 31 | "@types/node": "18.19.45", 32 | "@types/react": "18.0.26", 33 | "@types/react-dom": "18.0.10", 34 | "@typescript-eslint/eslint-plugin": "5.48.0", 35 | "@typescript-eslint/parser": "5.48.0", 36 | "copy-webpack-plugin": "11.0.0", 37 | "css-loader": "6.7.3", 38 | "dayjs": "1.11.7", 39 | "eslint": "8.31.0", 40 | "eslint-config-prettier": "8.6.0", 41 | "eslint-plugin-import": "2.26.0", 42 | "file-loader": "6.2.0", 43 | "husky": "8.0.3", 44 | "jest": "29.3.1", 45 | "lint-staged": "13.1.0", 46 | "lodash-es": "4.17.21", 47 | "mini-css-extract-plugin": "2.7.2", 48 | "prettier": "2.8.2", 49 | "react": "18.2.0", 50 | "react-dom": "18.2.0", 51 | "rimraf": "3.0.2", 52 | "sass": "1.57.1", 53 | "sass-loader": "13.2.0", 54 | "style-loader": "3.3.1", 55 | "stylelint": "14.16.1", 56 | "stylelint-config-standard": "29.0.0", 57 | "stylelint-config-standard-scss": "6.1.0", 58 | "ts-loader": "9.4.2", 59 | "typescript": "4.9.4", 60 | "web-ext": "7.4.0", 61 | "webpack": "5.75.0", 62 | "webpack-cli": "5.0.1", 63 | "zip-webpack-plugin": "4.0.1" 64 | }, 65 | "scripts": { 66 | "prepare": "husky install", 67 | "test": "jest", 68 | "test:watch": "jest --watch" 69 | }, 70 | "lint-staged": { 71 | "*": [ 72 | "prettier --check" 73 | ], 74 | "*.{js,jsx,ts,tsx}": [ 75 | "eslint" 76 | ] 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /public/_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "generate_report_template": { 3 | "message": "Generate Report Template", 4 | "description": "Generate Report Template" 5 | }, 6 | "report_template": { 7 | "message": "Report Template", 8 | "description": "Report Template" 9 | }, 10 | "course_name": { 11 | "message": "Course Name", 12 | "description": "Course Name" 13 | }, 14 | "report_title": { 15 | "message": "Report Title", 16 | "description": "Report Title" 17 | }, 18 | "student_name": { 19 | "message": "Student Name", 20 | "description": "Student Name" 21 | }, 22 | "deadline": { 23 | "message": "Deadline", 24 | "description": "Deadline" 25 | }, 26 | "description": { 27 | "message": "Description", 28 | "description": "Description" 29 | }, 30 | "report_template_introduction_section_title": { 31 | "message": "Introduction" 32 | }, 33 | "report_template_student_code": { 34 | "message": "(Student Code: )" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /public/_locales/ja/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "generate_report_template": { 3 | "message": "レポートの雛形を生成", 4 | "description": "レポートの雛形を生成する" 5 | }, 6 | "report_template": { 7 | "message": "レポート雛形", 8 | "description": "レポートの雛形" 9 | }, 10 | "course_name": { 11 | "message": "科目名", 12 | "description": "科目名" 13 | }, 14 | "report_title": { 15 | "message": "レポートのタイトル", 16 | "description": "レポートのタイトル" 17 | }, 18 | "student_name": { 19 | "message": "学生名", 20 | "description": "学生名" 21 | }, 22 | "deadline": { 23 | "message": "締切日", 24 | "description": "締切日" 25 | }, 26 | "description": { 27 | "message": "概要", 28 | "description": "概要" 29 | }, 30 | "report_template_introduction_section_title": { 31 | "message": "はじめに" 32 | }, 33 | "report_template_student_code": { 34 | "message": "(学籍番号:〈学籍番号〉)" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /public/icons/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 11 | 12 | 14 | 16 | 18 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /public/icons/icon_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manaba-enhanced-for-tsukuba/manaba-enhanced/fd0128e645e8dc9ca5b7b2bdd7bf27e1fa4d1d5f/public/icons/icon_128.png -------------------------------------------------------------------------------- /public/icons/icon_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manaba-enhanced-for-tsukuba/manaba-enhanced/fd0128e645e8dc9ca5b7b2bdd7bf27e1fa4d1d5f/public/icons/icon_16.png -------------------------------------------------------------------------------- /public/icons/icon_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manaba-enhanced-for-tsukuba/manaba-enhanced/fd0128e645e8dc9ca5b7b2bdd7bf27e1fa4d1d5f/public/icons/icon_32.png -------------------------------------------------------------------------------- /public/icons/icon_48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manaba-enhanced-for-tsukuba/manaba-enhanced/fd0128e645e8dc9ca5b7b2bdd7bf27e1fa4d1d5f/public/icons/icon_48.png -------------------------------------------------------------------------------- /public/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | manaba Enhanced 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "automerge": true, 4 | "automergeStrategy": "merge-commit", 5 | "rangeStrategy": "pin", 6 | "packageRules": [ 7 | { 8 | "matchUpdateTypes": ["patch"], 9 | "groupName": "patch upgrades" 10 | }, 11 | { 12 | "matchPackagePatterns": ["node"], 13 | "rangeStrategy": "replace" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | import { getStorage, setStorage, onStorageChanged } from "./network/storage" 4 | 5 | const removeAttachmentHeader = ( 6 | details: chrome.webRequest.WebResponseHeadersDetails 7 | ) => { 8 | const headers = details.responseHeaders 9 | 10 | const contentDispositionHeaderIndex = headers?.findIndex( 11 | (header) => header.name.toLowerCase() === "content-disposition" 12 | ) 13 | if ( 14 | !headers || 15 | !contentDispositionHeaderIndex || 16 | contentDispositionHeaderIndex === -1 17 | ) { 18 | return 19 | } 20 | 21 | const contentDisposition = headers[contentDispositionHeaderIndex].value 22 | if (contentDisposition?.startsWith("attachment;")) { 23 | headers.splice(contentDispositionHeaderIndex, 1) 24 | return { responseHeaders: headers } 25 | } 26 | 27 | return 28 | } 29 | 30 | const disableForceFileSaving = () => { 31 | chrome.webRequest.onHeadersReceived.addListener( 32 | removeAttachmentHeader, 33 | { urls: ["*://manaba.tsukuba.ac.jp/*"] }, 34 | ["blocking", "responseHeaders"] 35 | ) 36 | } 37 | 38 | const stopDisablingForceFileSaving = () => { 39 | chrome.webRequest.onHeadersReceived.removeListener(removeAttachmentHeader) 40 | } 41 | 42 | onStorageChanged({ 43 | kind: "sync", 44 | callback: ({ featuresDisableForceFileSaving }) => { 45 | if (featuresDisableForceFileSaving?.newValue === true) { 46 | disableForceFileSaving() 47 | } else if (featuresDisableForceFileSaving?.newValue === false) { 48 | stopDisablingForceFileSaving() 49 | } 50 | }, 51 | }) 52 | 53 | chrome.runtime.onInstalled.addListener((details) => { 54 | if (["install", "update"].includes(details.reason)) { 55 | const query = new URLSearchParams({ 56 | event: details.reason, 57 | }) 58 | 59 | chrome.tabs.create({ 60 | url: `${chrome.runtime.getURL("options.html")}?${query.toString()}`, 61 | }) 62 | } 63 | 64 | getStorage({ 65 | kind: "sync", 66 | keys: null, 67 | callback: (storage) => { 68 | setStorage({ 69 | kind: "sync", 70 | items: { 71 | featuresAssignmentsColoring: 72 | storage.featuresAssignmentsColoring ?? true, 73 | featuresDeadlineHighlighting: 74 | storage.featuresDeadlineHighlighting ?? true, 75 | featuresAutoSaveReports: storage.featuresAutoSaveReports ?? true, 76 | featuresRemoveConfirmation: 77 | storage.featuresRemoveConfirmation ?? true, 78 | featuresFilterCourses: storage.featuresFilterCourses ?? true, 79 | featuresDragAndDrop: storage.featuresDragAndDrop ?? true, 80 | featuresReportTemplate: storage.featuresReportTemplate ?? true, 81 | featuresDisableForceFileSaving: 82 | storage.featuresDisableForceFileSaving ?? true, 83 | featuresRelativeGradesPosition: 84 | storage.featuresRelativeGradesPosition ?? false, 85 | }, 86 | }) 87 | 88 | if (storage.featuresDisableForceFileSaving !== false) { 89 | disableForceFileSaving() 90 | } 91 | }, 92 | }) 93 | 94 | chrome.contextMenus.create({ 95 | id: "respon", 96 | type: "normal", 97 | contexts: ["selection"], 98 | title: "Open this code in Respon", 99 | }) 100 | }) 101 | 102 | chrome.runtime.onMessage.addListener((message) => { 103 | if (message.action === "openOptionsPage") { 104 | chrome.runtime.openOptionsPage() 105 | } 106 | }) 107 | 108 | chrome.contextMenus.onClicked.addListener((info, tab) => { 109 | if ( 110 | info.menuItemId === "respon" && 111 | tab && 112 | tab.url && 113 | tab.url.includes("manaba.tsukuba.ac.jp") 114 | ) { 115 | if (tab.id) { 116 | chrome.tabs.sendMessage(tab.id, { kind: "open-in-respon" }) 117 | } 118 | } 119 | }) 120 | 121 | chrome.commands.onCommand.addListener((cmd: string, tab: chrome.tabs.Tab) => { 122 | switch (cmd) { 123 | case "manaba-enhanced:open-in-respon": { 124 | if (tab.id) chrome.tabs.sendMessage(tab.id, { kind: "open-in-respon" }) 125 | break 126 | } 127 | case "manaba-enhanced:open-assignments-page": { 128 | chrome.tabs.create({ 129 | active: true, 130 | index: tab.index + 1, 131 | url: "https://manaba.tsukuba.ac.jp/ct/home_library_query", 132 | }) 133 | break 134 | } 135 | } 136 | }) 137 | 138 | /* The listener for report template generator */ 139 | chrome.runtime.onMessage.addListener(({ url, filename }) => { 140 | chrome.downloads.download({ 141 | url, 142 | filename, 143 | conflictAction: "overwrite", 144 | saveAs: true, 145 | }) 146 | return true 147 | }) 148 | -------------------------------------------------------------------------------- /src/contentScript/main.ts: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | import dayjs from "dayjs" 4 | 5 | import checkAssignmentDeadline from "../methods/checkAssignmentDeadline" 6 | import { checkLang } from "../methods/checkLang" 7 | import checkPagePubDeadline from "../methods/checkPagePubDeadline" 8 | import { colorizeDeadline } from "../methods/colorizeDeadline" 9 | import createLinkToOptions from "../methods/createLinkToOptions" 10 | import { dragAndDrop } from "../methods/dragAndDrop" 11 | import { filterCourses } from "../methods/filterCourses" 12 | import openCodeInRespon from "../methods/openCodeInRespon" 13 | import removeLinkBalloon from "../methods/removeLinkBalloon" 14 | import { syncReportText, clearStorage } from "../methods/syncReportText" 15 | import { setUsermemoShortcuts } from "../methods/usermemo" 16 | import { getBytesInUse, getStorage } from "../network/storage" 17 | import type { StorageSync } from "../types/storage" 18 | 19 | import colorizeDeadlineStyles from "./../style/colorizeDeadline.scss" 20 | import originalButtonStyles from "./../style/originalButton.scss" 21 | 22 | window.addEventListener("DOMContentLoaded", () => { 23 | getStorage({ 24 | kind: "sync", 25 | keys: null, 26 | callback: main, 27 | }) 28 | }) 29 | 30 | const insertStyle = ({ 31 | styleString, 32 | id, 33 | }: { 34 | styleString: string 35 | id?: string 36 | }) => { 37 | const style = document.createElement("style") 38 | style.innerHTML = styleString 39 | if (id) style.id = id 40 | document.head.appendChild(style) 41 | } 42 | 43 | const withDocumentHead = async (storageSync: Partial) => { 44 | const url = window.location.href 45 | 46 | insertStyle({ 47 | styleString: colorizeDeadlineStyles.toString(), 48 | }) 49 | insertStyle({ 50 | styleString: originalButtonStyles.toString(), 51 | }) 52 | 53 | if (storageSync.featuresAssignmentsColoring) { 54 | const now = dayjs() 55 | const lang = checkLang() 56 | 57 | if (url.includes("home_library_query")) { 58 | colorizeDeadline({ checkStatus: false, now, lang }) 59 | } else if ( 60 | url.endsWith("query") || 61 | url.endsWith("survey") || 62 | url.endsWith("report") 63 | ) { 64 | colorizeDeadline({ checkStatus: true, now, lang }) 65 | } 66 | } 67 | 68 | if (storageSync.featuresAutoSaveReports) { 69 | if (url.includes("report")) { 70 | const submitBtn = document.querySelector( 71 | "input[name='action_ReportStudent_submitdone']" 72 | ) 73 | if (submitBtn) { 74 | syncReportText() 75 | 76 | const bytesInUse = await getBytesInUse({ kind: "local" }) 77 | if (bytesInUse > 4500000) { 78 | clearStorage() 79 | } 80 | } 81 | } 82 | } 83 | } 84 | 85 | const main = (storageSync: Partial) => { 86 | if (document.head) { 87 | withDocumentHead(storageSync) 88 | } else { 89 | let headFound = false 90 | new MutationObserver(() => { 91 | if (!headFound && document.head) { 92 | headFound = true 93 | withDocumentHead(storageSync) 94 | } 95 | }).observe(document.documentElement, { childList: true }) 96 | } 97 | 98 | createLinkToOptions() 99 | 100 | if (storageSync.featuresRemoveConfirmation) { 101 | removeLinkBalloon() 102 | } 103 | 104 | if (storageSync.featuresFilterCourses) { 105 | const coursesContainer = 106 | document.getElementsByClassName("mycourses-body")[0] 107 | 108 | if (coursesContainer) { 109 | filterCourses() 110 | } 111 | } 112 | 113 | if (storageSync.featuresDeadlineHighlighting) { 114 | const pageLimitView = document.getElementsByClassName( 115 | "pagelimitview" 116 | )[0] as HTMLElement 117 | if (pageLimitView) { 118 | checkPagePubDeadline(pageLimitView) 119 | } 120 | 121 | const stdlist = document.getElementsByClassName("stdlist")[0] 122 | if (stdlist) { 123 | checkAssignmentDeadline() 124 | } 125 | } 126 | 127 | if (storageSync.featuresDragAndDrop) { 128 | dragAndDrop() 129 | } 130 | 131 | if (window.location.href.includes("usermemo")) { 132 | setUsermemoShortcuts() 133 | } 134 | 135 | chrome.runtime.onMessage.addListener((msg) => { 136 | switch (msg.kind) { 137 | case "open-in-respon": { 138 | const selectedText = window.getSelection()?.toString() 139 | if (selectedText) openCodeInRespon(selectedText) 140 | break 141 | } 142 | } 143 | }) 144 | } 145 | -------------------------------------------------------------------------------- /src/contentScript/reportTemplate.ts: -------------------------------------------------------------------------------- 1 | import { ReportTemplateGenerator } from "../methods/ReportTemplateGenerator" 2 | import { getStorage } from "../network/storage" 3 | 4 | getStorage({ 5 | kind: "sync", 6 | keys: "featuresReportTemplate", 7 | callback: ({ featuresReportTemplate }) => { 8 | if (featuresReportTemplate) renderReportTemplate() 9 | }, 10 | }) 11 | 12 | const renderReportTemplate = () => { 13 | getStorage({ 14 | kind: "sync", 15 | keys: ["reportTemplate", "reportFilename"], 16 | callback: ({ reportTemplate, reportFilename }) => { 17 | const reportTemplateGenerator = new ReportTemplateGenerator( 18 | reportFilename || "", 19 | reportTemplate || "" 20 | ) 21 | reportTemplateGenerator.appendReportGeneratorRow() 22 | }, 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /src/contentScript/showRelativeGradesPosition.ts: -------------------------------------------------------------------------------- 1 | import { showRelativeGradesPosition } from "../methods/showRelativeGradesPosition" 2 | import { getStorage } from "../network/storage" 3 | 4 | getStorage({ 5 | kind: "sync", 6 | keys: "featuresRelativeGradesPosition", 7 | callback: ({ featuresRelativeGradesPosition }) => { 8 | if (featuresRelativeGradesPosition) showRelativeGradesPosition() 9 | }, 10 | }) 11 | -------------------------------------------------------------------------------- /src/manifest.ts: -------------------------------------------------------------------------------- 1 | const packageJson = require("../package.json") 2 | 3 | const browserEnv = process.env.BROWSER_ENV 4 | 5 | const generateManifest = () => { 6 | if (!packageJson.version) { 7 | throw new Error("Version not found in package.json") 8 | } 9 | 10 | return JSON.stringify( 11 | { 12 | manifest_version: 2, 13 | name: "manaba Enhanced for Tsukuba", 14 | version: packageJson.version, 15 | description: "Make your manaba a little bit more comfortable", 16 | icons: { 17 | 16: "icons/icon_16.png", 18 | 32: "icons/icon_32.png", 19 | 48: "icons/icon_48.png", 20 | 128: "icons/icon_128.png", 21 | }, 22 | permissions: [ 23 | "storage", 24 | "contextMenus", 25 | "webRequest", 26 | "webRequestBlocking", 27 | "downloads", 28 | "*://manaba.tsukuba.ac.jp/*", 29 | ], 30 | options_ui: { 31 | page: "options.html", 32 | browser_style: true, 33 | open_in_tab: true, 34 | }, 35 | background: { 36 | scripts: ["background.js"], 37 | persistent: true, 38 | }, 39 | content_scripts: [ 40 | { 41 | matches: ["https://manaba.tsukuba.ac.jp/*"], 42 | run_at: "document_start", 43 | js: ["contentScript/main.js"], 44 | }, 45 | { 46 | matches: ["https://manaba.tsukuba.ac.jp/*"], 47 | include_globs: ["https://manaba.tsukuba.ac.jp/ct/course_*_report_*"], 48 | js: ["contentScript/reportTemplate.js"], 49 | }, 50 | { 51 | matches: ["https://manaba.tsukuba.ac.jp/*"], 52 | include_globs: ["https://manaba.tsukuba.ac.jp/ct/course_*_grade"], 53 | js: ["contentScript/showRelativeGradesPosition.js"], 54 | }, 55 | ], 56 | commands: { 57 | "manaba-enhanced:open-in-respon": { 58 | suggested_key: { 59 | default: "Alt+R", 60 | }, 61 | description: "Open selected Respon code in Respon", 62 | }, 63 | "manaba-enhanced:open-assignments-page": { 64 | description: "Open unsubmitted assignments page", 65 | }, 66 | }, 67 | default_locale: "ja", 68 | ...(browserEnv === "firefox" 69 | ? { 70 | browser_specific_settings: { 71 | gecko: { 72 | id: "{9FD229B7-1BD6-4095-965E-BE30EBFAD42E}", 73 | update_url: 74 | "https://raw.githubusercontent.com/manaba-enhanced-for-tsukuba/dist-firefox/main/updates.json", 75 | }, 76 | }, 77 | } 78 | : {}), 79 | }, 80 | null, 81 | 2 82 | ) 83 | } 84 | 85 | module.exports = generateManifest() 86 | -------------------------------------------------------------------------------- /src/methods/ReportTemplateGenerator.test.ts: -------------------------------------------------------------------------------- 1 | import { ReportTemplateGenerator } from "./ReportTemplateGenerator" 2 | 3 | describe("ReportTemplateGenerator module", () => { 4 | const reportInfo = { 5 | courseName: `The Great Course`, 6 | reportTitle: `The Great Report/2`, 7 | studentName: `Tsukuba Taro`, 8 | deadline: new Date(`2021-01-01 00:00:00`), 9 | description: `Show how great the university of Tsukuba is.`, 10 | } 11 | const getReportTemplate = (userTemplate: string) => 12 | new ReportTemplateGenerator("", userTemplate, reportInfo).template 13 | const getFilename = (userFilename: string) => 14 | new ReportTemplateGenerator(userFilename, "", reportInfo).filename 15 | 16 | it("replaces expressions in report template with report info", () => 17 | expect( 18 | getReportTemplate( 19 | `The report whose title is {{report-title}} is due to {{deadline}}` 20 | ) 21 | ).toBe( 22 | `% This file is generated by manaba Enhanced. 23 | The report whose title is The Great Report/2 is due to 2021/1/1 0:00:00` 24 | )) 25 | 26 | it("replaces expressions in filenames with report info", () => 27 | expect( 28 | getFilename(`{{student-name}}_{{course-name}}_{{report-title}}.tex`) 29 | ).toBe(`Tsukuba Taro_The Great Course_The Great Report_2.tex`)) 30 | 31 | it("removes slashes from a filename", () => 32 | expect( 33 | getFilename(`{{student-name}}/{{course-name}}/{{report-title}}.tex`) 34 | ).toBe(`Tsukuba Taro_The Great Course_The Great Report_2.tex`)) 35 | 36 | test("templates should not be empty even if user template is empty", () => 37 | expect(getReportTemplate("")).toBeTruthy()) 38 | 39 | test("filenames should not be empty even if user filename is empty", () => 40 | expect(getFilename("")).toBeTruthy()) 41 | }) 42 | -------------------------------------------------------------------------------- /src/methods/ReportTemplateGenerator.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react" 2 | import ReactDOM from "react-dom" 3 | 4 | class ReportTemplateGenerator { 5 | constructor( 6 | rawFilename: string, 7 | rawTemplate: string, 8 | public readonly reportInfo: Pick< 9 | ReportInfo, 10 | "courseName" | "deadline" | "description" | "reportTitle" | "studentName" 11 | > = new ReportInfo(document) 12 | ) { 13 | this.filename = this.parseFilename( 14 | rawFilename || ReportTemplateGenerator.defaultFilename 15 | ) 16 | this.template = ReportTemplateGenerator.addSignature( 17 | this.parseTemplate(rawTemplate || ReportTemplateGenerator.defaultTemplate) 18 | ) 19 | } 20 | 21 | public readonly filename: string 22 | public readonly template: string 23 | 24 | private GenerateButton = ({ 25 | template, 26 | filename, 27 | }: { 28 | template: string 29 | filename: string 30 | }) => ( 31 | 38 | ) 39 | 40 | private downloadReportTemplate = (template: string, filename: string) => { 41 | const blob = new Blob([template], { 42 | type: "application/x-tex", 43 | }) 44 | const url = URL.createObjectURL(blob) 45 | chrome.runtime.sendMessage({ 46 | url, 47 | filename, 48 | }) 49 | } 50 | 51 | public appendReportGeneratorRow = () => { 52 | const tbodyQueryString = ".stdlist-reportV2 tbody" 53 | const tbody = document.querySelector(tbodyQueryString) 54 | if (!tbody) return 55 | 56 | const containerRow = document.createElement("tr") 57 | tbody.appendChild(containerRow) 58 | 59 | ReactDOM.render( 60 | 67 | } 68 | />, 69 | containerRow 70 | ) 71 | } 72 | 73 | private injectReportInfoIntoRawText = ( 74 | rawText: string, 75 | reportInfo: Pick< 76 | ReportInfo, 77 | "courseName" | "deadline" | "description" | "reportTitle" | "studentName" 78 | > 79 | ) => 80 | Object.entries(reportInfo).reduce( 81 | (acc, [key, value]) => 82 | acc.replaceAll( 83 | `{{${this.camelcaseToKebabcase(key)}}}`, 84 | typeof value === "string" ? value : value.toLocaleString() 85 | ), 86 | rawText 87 | ) 88 | 89 | private parseFilename = (rawFilename: string) => 90 | this.injectReportInfoIntoRawText(rawFilename, this.reportInfo) 91 | .replaceAll(/[\\/:*?"<>|]/g, "_") 92 | .trim() 93 | 94 | private parseTemplate = (rawTemplate: string) => 95 | this.injectReportInfoIntoRawText(rawTemplate, this.reportInfo) 96 | 97 | private camelcaseToKebabcase = (camelcase: string) => 98 | camelcase.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, "$1-$2").toLowerCase() 99 | 100 | static addSignature = (template: string) => 101 | `% This file is generated by manaba Enhanced. 102 | ${template}` 103 | 104 | static defaultFilename = `{{student-name}}_{{course-name}}_{{report-title}}.tex` 105 | 106 | static defaultTemplate = `% The deadline is {{deadline}}. 107 | 108 | \\documentclass{ltjsarticle} 109 | \\usepackage{listings} 110 | 111 | \\begin{document} 112 | \\title{{{course-name}}\\\\{{report-title}}} 113 | \\author{{{student-name}}\\\\${chrome.i18n.getMessage( 114 | "report_template_student_code" 115 | )}} 116 | \\lstset{ 117 | numbers=left, 118 | frame=single, 119 | breaklines=true, 120 | } 121 | 122 | \\maketitle 123 | 124 | % {{description}} 125 | 126 | % \\section{${chrome.i18n.getMessage( 127 | "report_template_introduction_section_title" 128 | )}} 129 | 130 | \\end{document} 131 | ` 132 | } 133 | 134 | class ReportInfo { 135 | constructor(private document: Document) { 136 | this.courseName = 137 | this.courseNameElement?.title ?? chrome.i18n.getMessage("course_name") 138 | this.reportTitle = 139 | this.reportTitleElement?.innerText ?? 140 | chrome.i18n.getMessage("report_title") 141 | this.description = 142 | this.descriptionElement?.innerText ?? 143 | chrome.i18n.getMessage("description") 144 | this.studentName = 145 | this.screennameElement?.innerText ?? 146 | chrome.i18n.getMessage("student_name") 147 | this.deadline = new Date( 148 | this.deadlineElement?.innerText.substring(0, 16) ?? 149 | chrome.i18n.getMessage("deadline") 150 | ) 151 | } 152 | 153 | public courseName 154 | public reportTitle 155 | public studentName 156 | public deadline 157 | public description 158 | 159 | private courseNameElement = this.document.getElementById("coursename") 160 | private screennameElement = this.document.getElementById("screenname") 161 | private reportTitleElement = 162 | this.document.querySelector( 163 | ".stdlist-reportV2 .title th" 164 | ) 165 | private tdElements = this.document.querySelectorAll( 166 | ".stdlist-reportV2 td" 167 | ) 168 | private descriptionElement = this.tdElements[0] 169 | private deadlineElement = this.tdElements[2] 170 | } 171 | 172 | const RowContent = ({ 173 | header, 174 | data, 175 | }: { 176 | header: string 177 | data: ReactElement | string 178 | }) => ( 179 | <> 180 | {header} 181 | {data} 182 | 183 | ) 184 | 185 | export { ReportTemplateGenerator, RowContent } 186 | -------------------------------------------------------------------------------- /src/methods/checkAssignmentDeadline.ts: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | import dayjs from "dayjs" 4 | import customParseFormat from "dayjs/plugin/customParseFormat" 5 | 6 | import { checkLang } from "./checkLang" 7 | import evalDiff from "./evalDiff" 8 | 9 | dayjs.extend(customParseFormat) 10 | 11 | const checkAssignmentDeadline = (): void => { 12 | let notSubmitted = false 13 | let deadlineString = "" 14 | let deadlineTh: HTMLElement 15 | 16 | const ths = Array.from( 17 | document.querySelectorAll(".stdlist th") 18 | ) as HTMLElement[] 19 | for (const th of ths) { 20 | if (th.innerText === "状態" || th.innerText === "Status") { 21 | if (th.nextElementSibling) { 22 | const innerText = (th.nextElementSibling as HTMLElement).innerText 23 | if ( 24 | innerText.includes("提出していません") || 25 | innerText.includes("Not submitted") 26 | ) { 27 | notSubmitted = true 28 | } 29 | } 30 | } 31 | if (th.innerText === "受付終了日時" || th.innerText === "End") { 32 | if (th.nextElementSibling) { 33 | deadlineString = (th.nextElementSibling as HTMLElement).innerText 34 | deadlineTh = th 35 | } 36 | } 37 | } 38 | 39 | const validateDeadlineString = (string: string) => { 40 | const match = new RegExp("(\\d{4}-+\\d{2}-+\\d{2} \\d{2}:+\\d{2})", "g") 41 | return match.test(string) 42 | } 43 | 44 | if (notSubmitted && validateDeadlineString(deadlineString)) { 45 | const now = dayjs() 46 | const deadline = dayjs(deadlineString, "YYYY-MM-DD HH:mm") 47 | 48 | const lang = checkLang() 49 | 50 | const createMessage = ( 51 | text: string, 52 | msgStatus: "normal" | "caution" | "danger" 53 | ) => { 54 | const message = document.createElement("span") 55 | message.innerText = text 56 | message.style.marginLeft = "1em" 57 | message.style.padding = ".2em .5em" 58 | if (msgStatus === "normal") { 59 | message.style.backgroundColor = "#d3ebd3" 60 | message.style.color = "#244f24" 61 | } else if (msgStatus === "caution") { 62 | message.style.backgroundColor = "#fff4d1" 63 | message.style.color = "#433200" 64 | } else if (msgStatus === "danger") { 65 | message.style.backgroundColor = "#ffdce0" 66 | message.style.color = "#5d000b" 67 | } 68 | if (deadlineTh && deadlineTh.nextElementSibling) { 69 | deadlineTh.nextElementSibling.appendChild(message) 70 | } 71 | } 72 | 73 | const diff = evalDiff(now, deadline) 74 | 75 | if (diff.value > 0) { 76 | switch (diff.unit) { 77 | case "day": 78 | switch (lang) { 79 | case "ja": 80 | createMessage( 81 | `あと${diff.value}日`, 82 | diff.value > 2 ? "normal" : "caution" 83 | ) 84 | break 85 | case "en": 86 | createMessage( 87 | diff.value > 1 88 | ? `${diff.value} days remaining` 89 | : `${diff.value} day remaining`, 90 | diff.value > 2 ? "normal" : "caution" 91 | ) 92 | break 93 | } 94 | break 95 | case "hour": 96 | switch (lang) { 97 | case "ja": 98 | createMessage(`あと${diff.value}時間`, "danger") 99 | break 100 | case "en": 101 | createMessage( 102 | diff.value > 1 103 | ? `${diff.value} hours remaining` 104 | : `${diff.value} hour remaining`, 105 | "danger" 106 | ) 107 | break 108 | } 109 | break 110 | case "minute": 111 | switch (lang) { 112 | case "ja": 113 | createMessage(`あと${diff.value}分`, "danger") 114 | break 115 | case "en": 116 | createMessage( 117 | diff.value > 1 118 | ? `${diff.value} minutes remaining` 119 | : `${diff.value} minute remaining`, 120 | "danger" 121 | ) 122 | break 123 | } 124 | } 125 | } else { 126 | switch (lang) { 127 | case "ja": 128 | createMessage("受付終了", "danger") 129 | break 130 | case "en": 131 | createMessage("Deadline is over", "danger") 132 | break 133 | } 134 | } 135 | } 136 | } 137 | 138 | export default checkAssignmentDeadline 139 | -------------------------------------------------------------------------------- /src/methods/checkLang.ts: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | declare namespace checkLang { 4 | type langCode = "ja" | "en" 5 | } 6 | 7 | const checkLangByDOMElement = ( 8 | mylang: HTMLElement 9 | ): checkLang.langCode | undefined => { 10 | if (mylang.classList.contains("mylang-ja")) return "ja" 11 | if (mylang.classList.contains("mylang-en")) return "en" 12 | /* The fallback for the case where the service changes the class name */ 13 | if (mylang.innerText.includes("日本語")) return "ja" 14 | if (mylang.innerText.includes("English")) return "en" 15 | } 16 | 17 | const checkLang = (): checkLang.langCode => { 18 | const checkLangElement = document.getElementById("mylang") 19 | return (checkLangElement && checkLangByDOMElement(checkLangElement)) ?? "ja" 20 | } 21 | 22 | export { checkLang } 23 | -------------------------------------------------------------------------------- /src/methods/checkPagePubDeadline.ts: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | import dayjs from "dayjs" 4 | import customParseFormat from "dayjs/plugin/customParseFormat" 5 | 6 | import { checkLang } from "./checkLang" 7 | import evalDiff from "./evalDiff" 8 | 9 | dayjs.extend(customParseFormat) 10 | 11 | const checkPagePubDeadline = (div: HTMLElement): void => { 12 | const match = new RegExp( 13 | "(\\d{4}-+\\d{2}-+\\d{2} \\d{2}:+\\d{2}:+\\d{2})", 14 | "g" 15 | ) 16 | 17 | const timeStrings = div.innerText.match(match) 18 | 19 | if (timeStrings && timeStrings.length === 2) { 20 | const deadlineString = timeStrings[1] 21 | 22 | const now = dayjs() 23 | const deadline = dayjs(deadlineString, "YYYY-MM-DD HH:mm:ss") 24 | 25 | const lang = checkLang() 26 | 27 | const createMessage = (text: string, caution: boolean) => { 28 | const message = document.createElement("span") 29 | message.innerText = text 30 | message.style.marginLeft = "1em" 31 | message.style.padding = ".2em .5em" 32 | if (!caution) { 33 | message.style.backgroundColor = "#d3ebd3" 34 | message.style.color = "#244f24" 35 | } else { 36 | message.style.backgroundColor = "#ffdce0" 37 | message.style.color = "#5d000b" 38 | } 39 | div.appendChild(message) 40 | } 41 | 42 | const diff = evalDiff(now, deadline) 43 | 44 | if (diff.value > 0) { 45 | switch (diff.unit) { 46 | case "day": 47 | switch (lang) { 48 | case "ja": 49 | createMessage( 50 | `あと${diff.value}日`, 51 | diff.value > 7 ? false : true 52 | ) 53 | break 54 | case "en": 55 | createMessage( 56 | diff.value > 1 57 | ? `${diff.value} days remaining` 58 | : `${diff.value} day remaining`, 59 | diff.value > 7 ? false : true 60 | ) 61 | break 62 | } 63 | break 64 | case "hour": 65 | switch (lang) { 66 | case "ja": 67 | createMessage(`あと${diff.value}時間`, true) 68 | break 69 | case "en": 70 | createMessage( 71 | diff.value > 1 72 | ? `${diff.value} hours remaining` 73 | : `${diff.value} hour remaining`, 74 | true 75 | ) 76 | break 77 | } 78 | break 79 | case "minute": 80 | switch (lang) { 81 | case "ja": 82 | createMessage(`あと${diff.value}分`, true) 83 | break 84 | case "en": 85 | createMessage( 86 | diff.value > 1 87 | ? `${diff.value} minutes remaining` 88 | : `${diff.value} minute remaining`, 89 | true 90 | ) 91 | break 92 | } 93 | break 94 | } 95 | } else { 96 | switch (lang) { 97 | case "ja": 98 | createMessage("公開終了", true) 99 | break 100 | case "en": 101 | createMessage("Deadline is over", true) 102 | break 103 | } 104 | } 105 | } 106 | } 107 | 108 | export default checkPagePubDeadline 109 | -------------------------------------------------------------------------------- /src/methods/colorizeDeadline.ts: -------------------------------------------------------------------------------- 1 | import dayjs, { Dayjs } from "dayjs" 2 | import customParseFormat from "dayjs/plugin/customParseFormat" 3 | 4 | import { checkLang } from "./checkLang" 5 | 6 | dayjs.extend(customParseFormat) 7 | 8 | const trans = { 9 | ja: { 10 | notSubmitted: "未提出", 11 | closed: "受付終了", 12 | }, 13 | en: { 14 | notSubmitted: "Not submitted", 15 | closed: "Closed", 16 | }, 17 | } 18 | 19 | const enum AsgNegColumn { 20 | Status = 6, 21 | Deadline = 2, 22 | } 23 | 24 | const getAsgAttrs = (row: HTMLElement, column: AsgNegColumn) => 25 | (row.childNodes[row.childNodes.length - column] as HTMLElement).innerText 26 | 27 | const getAsgRowElements = (document: Document) => 28 | document.querySelectorAll(".row0, .row1, .row") 29 | 30 | const addClassNameToRow = (row: HTMLElement, deadline: Dayjs, now: Dayjs) => { 31 | const className = classNameFromDiffDays(deadline.diff(now, "day")) 32 | if (className) row.classList.add(className) 33 | } 34 | 35 | const shouldColorize = (status: string | undefined, lang: checkLang.langCode) => 36 | !status || 37 | (status.includes(trans[lang].notSubmitted) && 38 | !status.includes(trans[lang].closed)) 39 | 40 | const classNameFromDiffDays = (diffDays: number): string | undefined => 41 | [ 42 | { days: 1, className: "one-day-before" }, 43 | { days: 3, className: "three-days-before" }, 44 | { days: 7, className: "seven-days-before" }, 45 | ].find(({ days }) => diffDays < days)?.className 46 | 47 | const colorizeDeadline = ({ 48 | checkStatus = false, 49 | now, 50 | lang, 51 | }: { 52 | checkStatus?: boolean 53 | now: Dayjs 54 | lang: checkLang.langCode 55 | }): void => 56 | Array.from(getAsgRowElements(document)) 57 | .map((row) => ({ 58 | row, 59 | deadline: getAsgAttrs(row, AsgNegColumn.Deadline), 60 | status: checkStatus ? getAsgAttrs(row, AsgNegColumn.Status) : undefined, 61 | })) 62 | .filter(({ deadline }) => deadline) 63 | .map((attrs) => ({ 64 | ...attrs, 65 | deadline: dayjs(attrs.deadline, "YYYY-MM-DD HH:mm"), 66 | })) 67 | .filter(({ status }) => shouldColorize(status, lang)) 68 | .forEach(({ row, deadline }) => addClassNameToRow(row, deadline, now)) 69 | 70 | export { colorizeDeadline } 71 | -------------------------------------------------------------------------------- /src/methods/createLinkToOptions.ts: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | const createLinkToOptions = (): void => { 4 | const myLinksContainer = document.getElementById("mylinks") 5 | 6 | const linkContainer = document.createElement("span") 7 | 8 | const linkElm = document.createElement("a") 9 | linkElm.href = chrome.runtime.getURL("options.html") 10 | linkElm.target = "_blank" 11 | linkElm.rel = "noopener noreferer" 12 | linkElm.innerText = "Enhanced" 13 | linkElm.style.cursor = "pointer" 14 | 15 | linkElm.addEventListener( 16 | "click", 17 | (e) => { 18 | e.preventDefault() 19 | 20 | chrome.runtime.sendMessage({ 21 | action: "openOptionsPage", 22 | }) 23 | }, 24 | { passive: false } 25 | ) 26 | 27 | linkContainer.appendChild(linkElm) 28 | 29 | const separater = document.createElement("span") 30 | separater.innerText = "|" 31 | separater.classList.add("mylinks-sep") 32 | 33 | if (myLinksContainer) { 34 | myLinksContainer.insertBefore( 35 | linkContainer, 36 | Array.from(myLinksContainer.children).pop() as Node 37 | ) 38 | myLinksContainer.insertBefore( 39 | separater, 40 | Array.from(myLinksContainer.children).pop() as Node 41 | ) 42 | } 43 | } 44 | 45 | export default createLinkToOptions 46 | -------------------------------------------------------------------------------- /src/methods/dragAndDrop.ts: -------------------------------------------------------------------------------- 1 | declare const manaba: { 2 | submit_with_button: (form: any, buttonName: string) => void 3 | } 4 | 5 | const injectScript = (func: any) => { 6 | const scriptElement = document.createElement("script") 7 | scriptElement.appendChild(document.createTextNode(`;(${func})();`)) 8 | ;(document.body || document.head || document.documentElement).appendChild( 9 | scriptElement 10 | ) 11 | } 12 | 13 | const main = (): void => { 14 | const fileInput = document.querySelector(".form-input-file") as 15 | | HTMLInputElement 16 | | undefined 17 | const formWrapper = document.querySelector(".form") as 18 | | HTMLDivElement 19 | | undefined 20 | 21 | const addMessage = (formItemsWrapper: Element): void => { 22 | const messageElement = document.createElement("p") 23 | messageElement.innerText = 24 | "ドラッグアンドドロップでファイルを追加することができます" 25 | formItemsWrapper.insertBefore( 26 | messageElement, 27 | formItemsWrapper.childNodes[0] 28 | ) 29 | } 30 | 31 | if (fileInput && formWrapper) { 32 | const formItemsWrapper = document.querySelector(".report-form") 33 | formItemsWrapper && addMessage(formItemsWrapper) 34 | 35 | formWrapper.addEventListener( 36 | "dragenter", 37 | (e) => { 38 | e.preventDefault() 39 | 40 | formWrapper.style.backgroundColor = "#e1f5fe" 41 | }, 42 | false 43 | ) 44 | 45 | formWrapper.addEventListener("dragover", (e) => { 46 | e.preventDefault() 47 | }) 48 | 49 | formWrapper.addEventListener( 50 | "dragleave", 51 | (e) => { 52 | const { top, bottom, left, right } = formWrapper.getBoundingClientRect() 53 | if ( 54 | e.clientY < top || 55 | e.clientY >= bottom || 56 | e.clientX < left || 57 | e.clientX >= right 58 | ) { 59 | e.preventDefault() 60 | formWrapper.style.backgroundColor = "" 61 | } 62 | }, 63 | false 64 | ) 65 | 66 | formWrapper.addEventListener( 67 | "drop", 68 | (e) => { 69 | e.preventDefault() 70 | 71 | if (e.dataTransfer?.files.length) { 72 | formWrapper.style.backgroundColor = "" 73 | 74 | fileInput.files = e.dataTransfer.files 75 | 76 | if (manaba.submit_with_button) { 77 | manaba.submit_with_button( 78 | document.querySelector("form"), 79 | "action_ReportStudent_submitdone" 80 | ) 81 | } 82 | } else { 83 | formWrapper.style.backgroundColor = "#ffebee" 84 | } 85 | }, 86 | false 87 | ) 88 | } 89 | } 90 | 91 | const dragAndDrop = (): void => { 92 | injectScript(main) 93 | } 94 | 95 | export { dragAndDrop } 96 | -------------------------------------------------------------------------------- /src/methods/evalDiff.ts: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | import dayjs from "dayjs" 4 | 5 | interface diff { 6 | value: number 7 | unit: "day" | "hour" | "minute" | "" 8 | } 9 | 10 | const evalDiff = (now: dayjs.Dayjs, deadline: dayjs.Dayjs): diff => { 11 | const diffDays = deadline.diff(now, "day") 12 | if (diffDays > 0) { 13 | return { value: diffDays, unit: "day" } 14 | } else { 15 | const diffHours = deadline.diff(now, "hour") 16 | if (diffHours > 0) { 17 | return { value: diffHours, unit: "hour" } 18 | } else { 19 | const diffMinutes = deadline.diff(now, "minute") + 1 20 | if (diffMinutes >= 0) { 21 | return { value: diffMinutes, unit: "minute" } 22 | } else { 23 | return { value: -1, unit: "" } 24 | } 25 | } 26 | } 27 | } 28 | 29 | export default evalDiff 30 | -------------------------------------------------------------------------------- /src/methods/filterCourses.ts: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | import { getStorage, setStorage } from "../network/storage" 4 | import type { SeasonCode, ModuleCode } from "../types/filterCources" 5 | 6 | import { checkLang } from "./checkLang" 7 | 8 | let lang: checkLang.langCode 9 | 10 | const viewModeValues = ["list", "thumbnail"] as const 11 | type ViewMode = (typeof viewModeValues)[number] 12 | const ViewMode = { 13 | is: (str: string): str is ViewMode => viewModeValues.includes(str as any), 14 | } 15 | 16 | const translations = { 17 | ja: { 18 | spring: "春", 19 | autumn: "秋", 20 | allModules: "すべてのモジュール", 21 | courseInfoRegex: /^([春秋])([abc]+)/i, 22 | }, 23 | en: { 24 | spring: "Spring", 25 | autumn: "Autumn", 26 | allModules: "All modules", 27 | courseInfoRegex: /^(Spring|Autumn)\s([abc]+)/i, 28 | }, 29 | } 30 | 31 | export const filterCourses = (): void => { 32 | lang = checkLang() 33 | 34 | const moduleSelector = createModuleSelector() 35 | 36 | getStorage({ 37 | kind: "sync", 38 | keys: "filterConfigForModule", 39 | callback: (storage) => { 40 | if (storage.filterConfigForModule) { 41 | moduleSelector.value = storage.filterConfigForModule as ModuleCode 42 | applyFilter(storage.filterConfigForModule) 43 | } 44 | }, 45 | }) 46 | 47 | moduleSelector.addEventListener("change", (e) => { 48 | if (e.target) { 49 | const curModuleCode = (e.target as HTMLSelectElement).value as ModuleCode 50 | applyFilter(curModuleCode) 51 | 52 | setStorage({ 53 | kind: "sync", 54 | items: { 55 | filterConfigForModule: curModuleCode, 56 | }, 57 | }) 58 | } 59 | }) 60 | } 61 | 62 | /** 63 | * Parse module code 64 | * @param {string} moduleCode Module code: {season-module} 65 | */ 66 | const parseModuleCode = ( 67 | moduleCode: ModuleCode 68 | ): { season: SeasonCode; module: ModuleCode } => ({ 69 | season: moduleCode.split("-")[0] as SeasonCode, 70 | module: moduleCode.split("-")[1] as ModuleCode, 71 | }) 72 | 73 | /** 74 | * Convert season code to the text for UI according to the display language 75 | * @param {string} lang 76 | * @param {string} seasonCode "spring" or "autumn" 77 | * @return {string} "春", "Spring", etc... 78 | */ 79 | const seasonCodeToText = ( 80 | lang: checkLang.langCode, 81 | seasonCode: SeasonCode 82 | ): string => translations[lang][seasonCode] 83 | 84 | const createModuleSelector = () => { 85 | const selectorsContainer = document.querySelector( 86 | ".my-infolist-mycourses .showmore" 87 | ) 88 | 89 | const moduleSelector = document.createElement("select") 90 | moduleSelector.name = "select" 91 | 92 | const moduleCodes: ModuleCode[] = [ 93 | "all", 94 | "spring-a", 95 | "spring-b", 96 | "spring-c", 97 | "autumn-a", 98 | "autumn-b", 99 | "autumn-c", 100 | ] 101 | 102 | moduleCodes.forEach((moduleCode: ModuleCode) => { 103 | const optionDom = document.createElement("option") 104 | optionDom.value = moduleCode 105 | optionDom.innerText = moduleCodeToText(moduleCode) 106 | if (moduleCode === "all") optionDom.setAttribute("selected", "true") 107 | 108 | moduleSelector.appendChild(optionDom) 109 | }) 110 | 111 | if (selectorsContainer) 112 | selectorsContainer.insertBefore( 113 | moduleSelector, 114 | selectorsContainer.childNodes[0] 115 | ) 116 | 117 | return moduleSelector 118 | } 119 | 120 | const getViewModeQuery = () => { 121 | const currentModeElement = document.querySelector( 122 | ".my-infolist-mycourses .current a" 123 | ) 124 | if (!currentModeElement || !currentModeElement.href) return 125 | 126 | return new URL(currentModeElement.href).searchParams 127 | } 128 | 129 | const applyFilter = (moduleCode: ModuleCode): void => { 130 | const coursesListContainer = 131 | document.querySelector(".courselist tbody") 132 | const coursesThumbnailContainer = document.querySelector( 133 | ".mycourses-body .section" 134 | ) 135 | const coursesContainer = coursesListContainer ?? coursesThumbnailContainer 136 | const viewMode = getViewModeQuery()?.get("chglistformat") 137 | 138 | if (!coursesContainer || !viewMode || !ViewMode.is(viewMode)) return 139 | 140 | const courses = getCourses(coursesContainer, viewMode) 141 | 142 | let isOddRow = true 143 | 144 | const handleOddRow = (course: HTMLElement) => { 145 | isOddRow 146 | ? course.classList.replace("row0", "row1") 147 | : course.classList.replace("row1", "row0") 148 | isOddRow = !isOddRow 149 | } 150 | 151 | const showCourse = (viewMode: ViewMode, course: HTMLElement) => { 152 | course.style.display = viewMode === "list" ? "table-row" : "block" 153 | if (viewMode === "list") handleOddRow(course) 154 | } 155 | 156 | const hideCourse = (course: HTMLElement) => (course.style.display = "none") 157 | 158 | if (moduleCode !== "all") { 159 | const parsedModuleCode = parseModuleCode(moduleCode) 160 | 161 | courses.forEach((course) => { 162 | if (viewMode === "list") course.style.display = "table-row" 163 | 164 | const courseInfoString = getCourseInfoString(viewMode, course) 165 | 166 | if (/^.+\s.+$/.test(courseInfoString)) { 167 | const courseInfo = parseCourseInfoString(courseInfoString, lang) 168 | 169 | courseInfo && 170 | courseInfo.season[parsedModuleCode.season] && 171 | courseInfo.module.includes(parsedModuleCode.module) 172 | ? showCourse(viewMode, course) 173 | : hideCourse(course) 174 | } else showCourse(viewMode, course) 175 | }) 176 | } else courses.forEach((course) => showCourse(viewMode, course)) 177 | } 178 | 179 | const getCourses = (coursesContainer: HTMLElement, viewMode: ViewMode) => 180 | (Array.from(coursesContainer.children) as HTMLElement[]).filter( 181 | (_, i) => 182 | i !== 183 | rowIndexToBeExcluded(viewMode, coursesContainer.childElementCount - 1) 184 | ) 185 | 186 | const rowIndexToBeExcluded = (viewMode: ViewMode, lastIndex: number) => 187 | viewMode === "list" ? 0 : lastIndex 188 | 189 | const getCourseInfoString = (viewmode: ViewMode, course: HTMLElement) => { 190 | switch (viewmode) { 191 | case "list": 192 | return (course.children[2] as HTMLElement).innerText 193 | case "thumbnail": { 194 | const courseInfoStringElm = course.querySelector( 195 | ".courseitemdetail-date span" 196 | ) 197 | return courseInfoStringElm ? courseInfoStringElm.title : "" 198 | } 199 | } 200 | } 201 | 202 | /** 203 | * Parse course info string on the UI 204 | * @param {string} courseInfoString Something like "秋A 水5,6" or "Spring AB Mon. 2" 205 | * @return {{ season: Object., module: Array., dayOfWeek: Object., period: Array.}} 206 | */ 207 | const parseCourseInfoString = ( 208 | courseInfoString: string, 209 | lang: checkLang.langCode 210 | ): { 211 | season: { [key in SeasonCode]: boolean } 212 | module: string[] 213 | } | void => { 214 | const courseInfoRegex = translations[lang].courseInfoRegex 215 | const match = courseInfoString.match(courseInfoRegex) 216 | if (!courseInfoRegex.test(courseInfoString) || !match) return 217 | 218 | const [, season, module] = match 219 | 220 | return { 221 | season: { 222 | spring: season.includes(translations[lang].spring), 223 | autumn: season.includes(translations[lang].autumn), 224 | }, 225 | module: module.split("").map((str) => str.toLowerCase()), 226 | } 227 | } 228 | 229 | const moduleCodeToText = (moduleCode: ModuleCode) => { 230 | if (moduleCode === "all") return translations[lang].allModules 231 | 232 | const parsedModuleCode = parseModuleCode(moduleCode) 233 | const season = seasonCodeToText(lang, parsedModuleCode.season) 234 | 235 | return `${season}${parsedModuleCode.module.toUpperCase()}` 236 | } 237 | -------------------------------------------------------------------------------- /src/methods/handleReportTemplateForm.ts: -------------------------------------------------------------------------------- 1 | import { getStorage, setStorage } from "../network/storage" 2 | 3 | import { ReportTemplateGenerator } from "./ReportTemplateGenerator" 4 | 5 | class ReportTemplateFormHandler { 6 | public start = () => { 7 | const { 8 | reportTemplateForm, 9 | reportTemplateTextarea, 10 | reportFilenameTextarea, 11 | } = this.searchReportTemplateFormDOM() 12 | if ( 13 | !reportTemplateForm || 14 | !reportTemplateTextarea || 15 | !reportFilenameTextarea 16 | ) 17 | return 18 | 19 | this.placeholdDefaultTemplate( 20 | reportTemplateTextarea, 21 | reportFilenameTextarea 22 | ) 23 | this.renderUserReportTemplate( 24 | reportTemplateTextarea, 25 | reportFilenameTextarea 26 | ) 27 | this.storeUserReportTemplate( 28 | reportTemplateForm, 29 | reportTemplateTextarea, 30 | reportFilenameTextarea 31 | ) 32 | } 33 | 34 | private searchReportTemplateFormDOM = () => { 35 | const reportTemplateForm = document.querySelector( 36 | "#save-report-template" 37 | ) 38 | if (!reportTemplateForm) 39 | return { 40 | reportTemplateForm, 41 | reportTemplateTextarea: undefined, 42 | reportFilenameTextarea: undefined, 43 | } 44 | 45 | const reportTemplateTextarea = 46 | reportTemplateForm?.querySelector("#report-template") 47 | const reportFilenameTextarea = 48 | reportTemplateForm?.querySelector("#report-filename") 49 | 50 | return { 51 | reportTemplateForm, 52 | reportTemplateTextarea, 53 | reportFilenameTextarea, 54 | } 55 | } 56 | 57 | private storeUserReportTemplate = ( 58 | reportTemplateForm: HTMLFormElement, 59 | reportTemplateTextarea: HTMLTextAreaElement, 60 | reportFilenameTextarea: HTMLTextAreaElement 61 | ) => 62 | reportTemplateForm.addEventListener("submit", (e) => { 63 | e.preventDefault() 64 | const reportTemplate = reportTemplateTextarea.value 65 | const reportFilename = reportFilenameTextarea.value 66 | setStorage({ 67 | kind: "sync", 68 | items: { reportTemplate, reportFilename }, 69 | callback: () => { 70 | alert( 71 | "Successfully saved!\nYour settings will be synced across your Chrome." 72 | ) 73 | }, 74 | }) 75 | }) 76 | 77 | private renderUserReportTemplate = ( 78 | reportTemplateTextarea: HTMLTextAreaElement, 79 | reportFilenameTextarea: HTMLTextAreaElement 80 | ) => 81 | getStorage({ 82 | kind: "sync", 83 | keys: ["reportTemplate", "reportFilename"], 84 | callback: ({ reportTemplate, reportFilename }) => { 85 | if (reportTemplate) reportTemplateTextarea.value = reportTemplate 86 | if (reportFilename) reportFilenameTextarea.value = reportFilename 87 | }, 88 | }) 89 | 90 | private placeholdDefaultTemplate = ( 91 | reportTemplateTextarea: HTMLTextAreaElement, 92 | reportFilenameTextarea: HTMLTextAreaElement 93 | ) => { 94 | reportTemplateTextarea.placeholder = ReportTemplateGenerator.defaultTemplate 95 | reportFilenameTextarea.placeholder = ReportTemplateGenerator.defaultFilename 96 | } 97 | } 98 | 99 | export { ReportTemplateFormHandler } 100 | -------------------------------------------------------------------------------- /src/methods/openCodeInRespon.ts: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | const trimCode = (str: string) => 4 | str 5 | .replace(/[0-9]/g, (s) => String.fromCharCode(s.charCodeAt(0) - 0xfee0)) 6 | .replace(/[^0-9]/g, "") 7 | 8 | const validateCode = (code: string) => new RegExp("(\\d{9})", "g").test(code) 9 | 10 | /** 11 | * Open Respon code in the new tab 12 | * @param {string} code Respon code like "123 456 789" 13 | */ 14 | const openCodeInRespon = (code: string): void => { 15 | const trimmedCode = trimCode(code) 16 | if (validateCode(trimmedCode)) { 17 | window.open( 18 | `https://atmnb.tsukuba.ac.jp/attend/tsukuba?code=${trimmedCode}` 19 | ) 20 | } 21 | } 22 | 23 | export default openCodeInRespon 24 | -------------------------------------------------------------------------------- /src/methods/removeLinkBalloon.ts: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | const removeLinkBalloon = (): void => { 4 | const links = document.getElementsByTagName("a") 5 | 6 | const urlClamp = (url: string) => { 7 | if (url.length > 100) { 8 | return `${url.substr(0, 75)}...` 9 | } else { 10 | return url 11 | } 12 | } 13 | 14 | const hrefBalloonKeyword = "link_iframe_balloon?url=" 15 | 16 | Array.from(links).map((link) => { 17 | if (link.href.includes(hrefBalloonKeyword)) { 18 | const linkNew = document.createElement("a") 19 | 20 | try { 21 | const url = decodeURIComponent( 22 | link.href.slice( 23 | link.href.indexOf(hrefBalloonKeyword) + hrefBalloonKeyword.length 24 | ) 25 | ) 26 | 27 | linkNew.href = url 28 | linkNew.innerHTML = !link.innerHTML.includes("http") 29 | ? link.innerHTML 30 | : urlClamp(url) 31 | linkNew.target = "_blank" 32 | linkNew.rel = "noopener noreferrer" 33 | 34 | if (link.parentElement) link.parentElement.insertBefore(linkNew, link) 35 | link.remove() 36 | } catch (err) { 37 | console.error(err) 38 | } 39 | } 40 | }) 41 | } 42 | 43 | export default removeLinkBalloon 44 | -------------------------------------------------------------------------------- /src/methods/showRelativeGradesPosition.ts: -------------------------------------------------------------------------------- 1 | const showRelativeGradesPosition = () => 2 | document.querySelectorAll(".gradebar").forEach((gradebar) => { 3 | const barWidth = gradebar.getAttribute("width") 4 | const barSpanElement = gradebar.querySelector("span") 5 | const siblingBarWidth = gradebar.nextElementSibling?.getAttribute("width") 6 | if (!barWidth || !barSpanElement) return 7 | 8 | const floorRate = siblingBarWidth ? parseInt(siblingBarWidth) : 0 9 | const rate = parseInt(barWidth) 10 | 11 | barSpanElement.textContent = `${floorRate} - ${floorRate + rate}%` 12 | }) 13 | 14 | export { showRelativeGradesPosition } 15 | -------------------------------------------------------------------------------- /src/methods/syncReportText.ts: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | import { throttle } from "lodash-es" 4 | 5 | import { getBytesInUse, getStorage, setStorage } from "../network/storage" 6 | 7 | const syncReportText = (): void => { 8 | const textarea = document.getElementsByTagName("textarea")[0] 9 | 10 | const getId = () => { 11 | const url = window.location.href 12 | return url.substr(url.indexOf("manaba.tsukuba.ac.jp/ct/") + 24) 13 | } 14 | 15 | getStorage({ 16 | kind: "local", 17 | keys: "reportText", 18 | callback: (storage) => { 19 | const savedText = storage.reportText?.[getId()]?.text ?? "" 20 | textarea.value = savedText 21 | }, 22 | }) 23 | 24 | const writeReportText = throttle((id, text) => { 25 | getStorage({ 26 | kind: "local", 27 | keys: "reportText", 28 | callback: (storage) => { 29 | setStorage({ 30 | kind: "local", 31 | items: { 32 | reportText: { 33 | ...storage.reportText, 34 | [id]: { 35 | text, 36 | modified: Date.now(), 37 | }, 38 | }, 39 | }, 40 | }) 41 | }, 42 | }) 43 | }, 2000) 44 | 45 | if (textarea) { 46 | textarea.addEventListener("input", () => { 47 | writeReportText(getId(), textarea.value) 48 | }) 49 | } 50 | } 51 | 52 | const clearStorage = (): void => { 53 | let curOldestKey: string 54 | let curMinModified = 99999999999999 55 | 56 | getStorage({ 57 | kind: "local", 58 | keys: "reportText", 59 | callback: ({ reportText }) => { 60 | if (!reportText) return 61 | 62 | for (const key of Object.keys(reportText)) { 63 | if (reportText[key].modified < curMinModified) { 64 | curOldestKey = key 65 | curMinModified = reportText[key].modified 66 | } 67 | } 68 | delete reportText[curOldestKey] 69 | setStorage({ kind: "local", items: { reportText } }) 70 | 71 | getBytesInUse({ kind: "local" }).then((bytesInUse) => { 72 | if (bytesInUse > 4500000) { 73 | clearStorage() 74 | } 75 | }) 76 | }, 77 | }) 78 | } 79 | 80 | export { syncReportText, clearStorage } 81 | -------------------------------------------------------------------------------- /src/methods/usermemo.ts: -------------------------------------------------------------------------------- 1 | export const setUsermemoShortcuts = () => { 2 | window.addEventListener("keydown", (e) => { 3 | if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { 4 | const submitButton = document.querySelector( 5 | 'input[name="action_Usermemo_update"]' 6 | ) 7 | if (submitButton) submitButton.click() 8 | } 9 | }) 10 | } 11 | -------------------------------------------------------------------------------- /src/network/storage.ts: -------------------------------------------------------------------------------- 1 | import type { StorageKind, StorageSync, StorageLocal } from "../types/storage" 2 | 3 | export function getStorage(params: { 4 | kind: "sync" 5 | keys: K 6 | callback: (storage: Partial>) => void 7 | }): void 8 | export function getStorage>(params: { 9 | kind: "sync" 10 | keys: K 11 | callback: (storage: Partial>) => void 12 | }): void 13 | export function getStorage(params: { 14 | kind: "sync" 15 | keys: null 16 | callback: (storage: Partial) => void 17 | }): void 18 | export function getStorage(params: { 19 | kind: "local" 20 | keys: K 21 | callback: (storage: Partial>) => void 22 | }): void 23 | export function getStorage>(params: { 24 | kind: "local" 25 | keys: K 26 | callback: (storage: Partial>) => void 27 | }): void 28 | export function getStorage(params: { 29 | kind: "local" 30 | keys: null 31 | callback: (storage: Partial) => void 32 | }): void 33 | export function getStorage< 34 | KS extends keyof StorageSync, 35 | KL extends keyof StorageLocal 36 | >( 37 | params: 38 | | { 39 | kind: "sync" 40 | keys: KS | KS[] | null 41 | callback: (storage: Partial) => void 42 | } 43 | | { 44 | kind: "local" 45 | keys: KL | KL[] | null 46 | callback: (storage: Partial) => void 47 | } 48 | ): void { 49 | if (params.kind === "sync") { 50 | chrome.storage.sync.get(params.keys, (storage: Partial) => { 51 | params.callback(storage) 52 | }) 53 | } else { 54 | chrome.storage.local.get(params.keys, (storage: Partial) => { 55 | params.callback(storage) 56 | }) 57 | } 58 | } 59 | 60 | export const setStorage = ({ 61 | kind, 62 | items, 63 | callback, 64 | }: 65 | | { 66 | kind: Extract 67 | items: Partial 68 | callback?: () => void 69 | } 70 | | { 71 | kind: Extract 72 | items: Partial 73 | callback?: () => void 74 | }): void => { 75 | if (kind === "sync") { 76 | chrome.storage.sync.set(items, callback) 77 | } else { 78 | chrome.storage.local.set(items, callback) 79 | } 80 | } 81 | 82 | export const onStorageChanged = ({ 83 | kind, 84 | callback, 85 | }: 86 | | { 87 | kind: Extract 88 | callback: ( 89 | changed: Partial<{ 90 | [key in keyof StorageSync]: { 91 | newValue?: boolean 92 | oldValue?: boolean 93 | } 94 | }> 95 | ) => void 96 | } 97 | | { 98 | kind: Extract 99 | callback: ( 100 | changed: Partial<{ 101 | [key in keyof StorageSync]: { 102 | newValue?: boolean 103 | oldValue?: boolean 104 | } 105 | }> 106 | ) => void 107 | }): void => { 108 | chrome.storage.onChanged.addListener((changed, changedKind) => { 109 | if (changedKind === kind) { 110 | callback(changed) 111 | } 112 | }) 113 | } 114 | 115 | export const getBytesInUse = ({ 116 | kind, 117 | }: { 118 | kind: StorageKind 119 | }): Promise => 120 | new Promise((resolve) => { 121 | if (kind === "sync") { 122 | chrome.storage.sync.getBytesInUse(resolve) 123 | } 124 | chrome.storage.local.getBytesInUse(resolve) 125 | }) 126 | -------------------------------------------------------------------------------- /src/optionsPage/app.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | margin-top: 3rem; 3 | margin-bottom: 3rem; 4 | } 5 | -------------------------------------------------------------------------------- /src/optionsPage/app.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo } from "react" 2 | 3 | import styles from "./app.module.scss" 4 | import { Header } from "./components/Header" 5 | import { NoticeEventType, Notice } from "./components/Notice" 6 | import { ReleaseNote } from "./components/ReleaseNote" 7 | import { startLegacyHandler } from "./legacyHandler" 8 | import "../style/options.scss" 9 | 10 | export const App = () => { 11 | useEffect(() => { 12 | startLegacyHandler() 13 | }, []) 14 | 15 | const noticeEvent = useMemo(() => { 16 | const query = new URLSearchParams(window.location.search) 17 | return query.get("event") 18 | }, []) 19 | 20 | return ( 21 |
22 | {noticeEvent && NoticeEventType.is(noticeEvent) && ( 23 | 24 | )} 25 |
26 |
27 |
28 |

Enabled Features

29 |
    30 |
  • 31 | 37 |
    38 | 44 |
    45 |
    46 |
  • 47 |
  • 48 | 54 |
    55 | 61 |
    62 |
    63 |
  • 64 |
  • 65 | 71 |
    72 | 78 |
    79 |
    80 |
  • 81 |
  • 82 | 88 |
    89 | 95 |
    96 |
    97 |
  • 98 |
  • 99 | 102 |
    103 | 109 |
    110 |
    111 |
  • 112 |
  • 113 | 116 |
    117 | 123 |
    124 |
    125 |
  • 126 |
  • 127 | 133 |
    134 | 140 |
    141 |
    142 |
  • 143 |
  • 144 | 147 |
    148 | 154 |
    155 |
    156 |
  • 157 |
  • 158 | 164 |
    165 | 171 |
    172 |
    173 |
  • 174 |
175 |
176 |
177 |

Shortcuts

178 |

179 | Shortcuts are customizable{" "} 180 | { 183 | e.preventDefault() 184 | chrome.tabs.create({ url: "chrome://extensions/shortcuts" }) 185 | return false 186 | }} 187 | > 188 | here 189 | 190 |

191 |
192 |
193 |

Report template

194 |
195 |

196 | You can set your favorite LaTeX template and its filename here. 197 | Report templates are downloadable on the manaba report page. 198 |

199 |
200 | 201 | 202 | You can insert the report attributes into the template:{" "} 203 | {"{{course-name}}"}, {"{{deadline}}"},{" "} 204 | {"{{description}}"},{" "} 205 | {"{{report-title}}"} and{" "} 206 | {"{{student-name}}"} by only writing them like "{" "} 207 | \title{"{{{course-name}}}"}". If nothing is 208 | specified, we use the default template. 209 | 210 | 215 |
216 |
217 | 218 | 219 | You can insert the report attributes into the filename:{" "} 220 | {"{{course-name}}"}, {"{{deadline}}"},{" "} 221 | {"{{description}}"},{" "} 222 | {"{{report-title}}"} and{" "} 223 | {"{{student-name}}"} by only writing them like " 224 | {"{{course-name}}"}.tex". If nothing is specified, 225 | we use the default filename. 226 | 227 | 232 |
233 | 234 |
235 |
236 |
237 |

Disclaimer

238 |

239 | This is an unofficial software and has nothing to do with the 240 | administration of the University of Tsukuba. We will not be held 241 | responsible for any damages and troubles caused by this extension. 242 |

243 |
244 | 245 |
246 |
247 | ) 248 | } 249 | -------------------------------------------------------------------------------- /src/optionsPage/components/Header/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/mixins"; 2 | 3 | .wrapper { 4 | @include section-panel; 5 | 6 | display: flex; 7 | flex-direction: column; 8 | align-items: center; 9 | justify-content: center; 10 | } 11 | 12 | .extensionName { 13 | display: flex; 14 | flex-direction: row; 15 | align-items: center; 16 | justify-content: center; 17 | margin-bottom: 1.5rem; 18 | } 19 | 20 | .distro { 21 | display: flex; 22 | column-gap: 1rem; 23 | } 24 | 25 | .logoImage { 26 | height: 6rem; 27 | margin-right: 1.5rem; 28 | } 29 | 30 | .logotype { 31 | font-size: 2.5rem; 32 | } 33 | 34 | .section { 35 | text-align: center; 36 | margin-bottom: 1rem; 37 | 38 | &:last-child { 39 | margin-bottom: 0.5rem; 40 | } 41 | } 42 | 43 | .sectionHeading { 44 | @include bold; 45 | 46 | margin-bottom: 0.5rem; 47 | } 48 | 49 | .linkWithIcon { 50 | display: flex; 51 | flex-direction: row; 52 | align-items: center; 53 | justify-content: center; 54 | margin: 0; 55 | margin-bottom: 0.5rem; 56 | color: var(--color-text-default); 57 | } 58 | 59 | .linkWithIconIcon { 60 | width: 1.5rem; 61 | height: 1.5rem; 62 | margin-right: 0.3rem; 63 | fill: var(--color-text-default); 64 | } 65 | -------------------------------------------------------------------------------- /src/optionsPage/components/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react" 2 | 3 | import styles from "./index.module.scss" 4 | 5 | const Maintainer = ({ name, href }: { name: string; href: string }) => ( 6 | 12 | 23 |

{name}

24 |
25 | ) 26 | 27 | export const Header = memo(() => { 28 | const versionNumber = chrome.runtime.getManifest().version 29 | 30 | return ( 31 |
32 |

33 | logo 34 |

manaba Enhanced

35 |

36 | 77 |
78 |

{versionNumber}

79 | 85 | 93 |

GitHub

94 |
95 |
96 |
97 |

Maintainer

98 | 102 | 106 |
107 |
108 | ) 109 | }) 110 | -------------------------------------------------------------------------------- /src/optionsPage/components/Notice/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/mixins"; 2 | 3 | .wrapper { 4 | @include section-panel; 5 | 6 | border: 2px solid var(--color-primary); 7 | background-color: var(--color-primary-low-three); 8 | } 9 | -------------------------------------------------------------------------------- /src/optionsPage/components/Notice/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react" 2 | 3 | import styles from "./index.module.scss" 4 | 5 | const noticeEventTypeValues = ["install", "update"] as const 6 | export type NoticeEventType = (typeof noticeEventTypeValues)[number] 7 | export const NoticeEventType = { 8 | is: (str: string): str is NoticeEventType => 9 | noticeEventTypeValues.includes(str as never), 10 | } 11 | 12 | export type Props = Readonly<{ 13 | event: NoticeEventType 14 | }> 15 | 16 | export const Notice = memo(({ event }) => { 17 | const versionNumber = chrome.runtime.getManifest().version 18 | 19 | const noticeText = 20 | event === "install" 21 | ? `Thanks for installing manaba Enhanced version ${versionNumber}` 22 | : `manaba Enhanced is updated for version ${versionNumber}` 23 | 24 | return ( 25 |
26 |

{noticeText}

27 |
28 | ) 29 | }) 30 | -------------------------------------------------------------------------------- /src/optionsPage/components/ReleaseNote/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/mixins"; 2 | 3 | .wrapper { 4 | @include section-panel; 5 | } 6 | 7 | .versionWrapper { 8 | &:not(:first-child) { 9 | margin-top: 2rem; 10 | } 11 | } 12 | 13 | .descriptionLine { 14 | margin: 1rem 0; 15 | } 16 | 17 | .older { 18 | display: inline-block; 19 | margin-top: 1rem; 20 | color: var(--color-text-default); 21 | } 22 | -------------------------------------------------------------------------------- /src/optionsPage/components/ReleaseNote/index.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./index.module.scss" 2 | 3 | const versions: Array< 4 | Readonly<{ 5 | version: string 6 | descriptions: string[] 7 | }> 8 | > = [ 9 | { 10 | version: "3.3.0", 11 | descriptions: [ 12 | "Display the relative position of the grades in the courses", 13 | "Shortcut key for opening assignments page can be set", 14 | ], 15 | }, 16 | { 17 | version: "3.2.0", 18 | descriptions: [ 19 | "Customizable report template", 20 | "Publish Firefox version officially", 21 | ], 22 | }, 23 | { 24 | version: "3.1.0", 25 | descriptions: ["Generate LaTeX template for reports"], 26 | }, 27 | { 28 | version: "2.9.0", 29 | descriptions: ["Submit usermemo with Ctrl+Enter / Meta+Enter shortcut"], 30 | }, 31 | { 32 | version: "2.8.0", 33 | descriptions: ["Supported files can be opened in browser without saving"], 34 | }, 35 | { 36 | version: "2.7.0", 37 | descriptions: ["Drag & Drop file uploads"], 38 | }, 39 | { 40 | version: "2.6.0", 41 | descriptions: [ 42 | "manaba Enhanced is now written in TypeScript", 43 | "Stability improvements in course filtering", 44 | ], 45 | }, 46 | ] 47 | 48 | export const ReleaseNote = () => ( 49 |
50 | {versions.map(({ version, descriptions }) => ( 51 |
52 |

{version}

53 | {descriptions.map((str, index) => ( 54 |

55 | {str} 56 |

57 | ))} 58 |
59 | ))} 60 | 66 | Older versions 67 | 68 |
69 | ) 70 | -------------------------------------------------------------------------------- /src/optionsPage/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client" 2 | 3 | import { App } from "./app" 4 | 5 | const Root = () => 6 | 7 | const container = document.getElementById("root") 8 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 9 | const root = createRoot(container!) 10 | root.render() 11 | -------------------------------------------------------------------------------- /src/optionsPage/legacyHandler.ts: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | /** 4 | * Don't add DOM handling procedures to this file. 5 | * Follow the React way instead. 6 | */ 7 | 8 | import { ReportTemplateFormHandler } from "../methods/handleReportTemplateForm" 9 | import { getStorage, setStorage } from "../network/storage" 10 | import type { StorageSync } from "../types/storage" 11 | 12 | import "../style/options.scss" 13 | 14 | export const startLegacyHandler = () => { 15 | ;( 16 | Array.from( 17 | document.getElementsByClassName("checkbox-features") 18 | ) as HTMLInputElement[] 19 | ).map((dom) => { 20 | const key = dom.id as keyof Pick< 21 | StorageSync, 22 | | "featuresAssignmentsColoring" 23 | | "featuresAutoSaveReports" 24 | | "featuresDeadlineHighlighting" 25 | | "featuresRemoveConfirmation" 26 | | "featuresFilterCourses" 27 | | "featuresDragAndDrop" 28 | | "featuresReportTemplate" 29 | | "featuresRelativeGradesPosition" 30 | > 31 | 32 | getStorage({ 33 | kind: "sync", 34 | keys: key, 35 | callback: (storage) => { 36 | const value = storage[key] 37 | if (typeof value === "boolean") dom.checked = value 38 | }, 39 | }) 40 | 41 | dom.addEventListener("change", (event) => { 42 | const target = event.target as HTMLInputElement 43 | setStorage({ 44 | kind: "sync", 45 | items: { 46 | [key]: target.checked, 47 | }, 48 | }) 49 | }) 50 | }) 51 | 52 | new ReportTemplateFormHandler().start() 53 | } 54 | -------------------------------------------------------------------------------- /src/optionsPage/styles/mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin light { 2 | @media (prefers-color-scheme: light) { 3 | @content; 4 | } 5 | } 6 | 7 | @mixin dark { 8 | @media (prefers-color-scheme: dark) { 9 | @content; 10 | } 11 | } 12 | 13 | @mixin main-content-width { 14 | max-width: 50rem; 15 | margin-right: auto; 16 | margin-left: auto; 17 | } 18 | 19 | @mixin section-panel { 20 | @include main-content-width; 21 | 22 | padding: 2rem; 23 | margin-top: 2rem; 24 | margin-bottom: 2rem; 25 | background-color: var(--color-bg-card); 26 | border-radius: 0.5rem; 27 | 28 | @include light { 29 | box-shadow: 0 10px 15px 0 rgb(0 0 0 / 5%); 30 | } 31 | } 32 | 33 | @mixin bold { 34 | font-weight: 700; 35 | } 36 | -------------------------------------------------------------------------------- /src/style/colorizeDeadline.scss: -------------------------------------------------------------------------------- 1 | @use "sass:color"; 2 | 3 | $red-bg: #ffdce0; 4 | $yellow-bg: #fff0c2; 5 | $green-bg: #d3ebd3; 6 | 7 | table.stdlist tr.one-day-before { 8 | background-color: $red-bg !important; 9 | } 10 | 11 | table.stdlist tr.one-day-before.row1 { 12 | background-color: color.adjust($red-bg, $lightness: 2%) !important; 13 | } 14 | 15 | table.stdlist tr.one-day-before:hover, 16 | table.stdlist tr.one-day-before.hilitecolor td { 17 | background-color: color.adjust($red-bg, $lightness: -2%) !important; 18 | } 19 | 20 | .one-day-before a { 21 | color: color.adjust($red-bg, $lightness: -75%) !important; 22 | } 23 | 24 | table.stdlist tr.three-days-before { 25 | background-color: $yellow-bg !important; 26 | } 27 | 28 | table.stdlist tr.three-days-before.row1 { 29 | background-color: color.adjust($yellow-bg, $lightness: 3%) !important; 30 | } 31 | 32 | table.stdlist tr.three-days-before:hover, 33 | table.stdlist tr.three-days-before.hilitecolor td { 34 | background-color: color.adjust($yellow-bg, $lightness: -2%) !important; 35 | } 36 | 37 | .three-days-before a { 38 | color: color.adjust($yellow-bg, $lightness: -75%) !important; 39 | } 40 | 41 | table.stdlist tr.seven-days-before { 42 | background-color: $green-bg !important; 43 | } 44 | 45 | table.stdlist tr.seven-days-before.row1 { 46 | background-color: color.adjust($green-bg, $lightness: 3%) !important; 47 | } 48 | 49 | table.stdlist tr.seven-days-before:hover, 50 | table.stdlist tr.seven-days-before.hilitecolor td { 51 | background-color: color.adjust($green-bg, $lightness: -2%) !important; 52 | } 53 | 54 | .seven-days-before a { 55 | color: color.adjust($green-bg, $lightness: -65%) !important; 56 | } 57 | -------------------------------------------------------------------------------- /src/style/options.scss: -------------------------------------------------------------------------------- 1 | @import "https://fonts.googleapis.com/css2?family=Poppins:wght@400;700&display=swap"; 2 | 3 | @mixin light { 4 | @media (prefers-color-scheme: light) { 5 | @content; 6 | } 7 | } 8 | 9 | @mixin dark { 10 | @media (prefers-color-scheme: dark) { 11 | @content; 12 | } 13 | } 14 | 15 | * { 16 | --color-bg: #eee; 17 | --color-bg-card: #fff; 18 | --color-text-default: #616161; 19 | --color-primary: #4caf50; 20 | --color-primary-low-one: #81c784; 21 | --color-primary-low-two: #c8e6c9; 22 | --color-primary-low-three: #d4f3d5; 23 | --color-primary-high-one: #388e3c; 24 | --color-primary-high-two: #1b5e20; 25 | --color-mono-low: #e0e0e0; 26 | --color-mono-high: #757575; 27 | 28 | box-sizing: border-box; 29 | margin: 0; 30 | padding: 0; 31 | } 32 | 33 | @include dark { 34 | * { 35 | --color-bg: #202124; 36 | --color-bg-card: #292a2d; 37 | --color-text-default: #f5f5f5; 38 | --color-primary-low-one: #468f49; 39 | --color-primary-low-two: #476e4a; 40 | --color-primary-low-three: #223523; 41 | --color-primary-high-one: #81c784; 42 | --color-primary-high-two: #c8e6c9; 43 | --color-mono-low: #757575; 44 | --color-mono-high: #bdbdbd; 45 | } 46 | } 47 | 48 | ul, 49 | li { 50 | list-style: none; 51 | } 52 | 53 | body { 54 | font-size: 16px; 55 | font-family: Poppins, sans-serif; 56 | font-weight: 400; 57 | background-color: var(--color-bg); 58 | color: var(--color-text-default); 59 | opacity: 1 !important; 60 | 61 | .bold { 62 | font-weight: 700; 63 | } 64 | } 65 | 66 | main { 67 | max-width: 50rem; 68 | margin: 0 auto; 69 | } 70 | 71 | section { 72 | padding: 2rem; 73 | margin: 2rem 0; 74 | background-color: var(--color-bg-card); 75 | border-radius: 0.5rem; 76 | 77 | @include light { 78 | box-shadow: 0 10px 15px 0 rgb(0 0 0 / 5%); 79 | } 80 | } 81 | 82 | .section-features, 83 | .section-keys { 84 | ul { 85 | li { 86 | display: flex; 87 | flex-direction: row; 88 | align-items: center; 89 | justify-content: space-between; 90 | gap: 8px; 91 | margin: 2.5rem 0; 92 | 93 | &:last-child { 94 | margin: 2rem 0 0; 95 | } 96 | } 97 | } 98 | } 99 | 100 | .section-keys, 101 | .section-disclaimer { 102 | h2 { 103 | margin-bottom: 1.5rem; 104 | } 105 | 106 | p { 107 | line-height: 2em; 108 | } 109 | 110 | a { 111 | color: var(--color-primary); 112 | } 113 | } 114 | 115 | .section-report-template-preferences { 116 | form { 117 | display: flex; 118 | flex-direction: column; 119 | align-items: left; 120 | justify-content: left; 121 | gap: 2.5rem; 122 | 123 | .input-wrapper { 124 | display: flex; 125 | flex-direction: column; 126 | align-items: flex-start; 127 | justify-content: flex-start; 128 | gap: 28px; 129 | 130 | label { 131 | display: flex; 132 | flex-direction: row; 133 | align-items: center; 134 | gap: 8px; 135 | font-weight: bolder; 136 | } 137 | 138 | small { 139 | line-height: 2em; 140 | } 141 | 142 | input[type="checkbox"] { 143 | width: 1.5rem; 144 | height: 1.5rem; 145 | } 146 | 147 | textarea { 148 | width: 100%; 149 | padding: 1rem; 150 | border: 1px solid var(--color-mono-low); 151 | border-radius: 0.5rem; 152 | background-color: var(--color-bg-card); 153 | color: var(--color-text-default); 154 | font-family: Poppins, sans-serif; 155 | font-size: 1rem; 156 | font-weight: 400; 157 | line-height: 1.5em; 158 | resize: none; 159 | } 160 | } 161 | 162 | input[type="submit"] { 163 | padding: 1rem 1.5rem; 164 | border: 1px solid var(--color-primary); 165 | border-radius: 0.5rem; 166 | background-color: var(--color-primary); 167 | color: var(--color-bg-card); 168 | font-family: Poppins, sans-serif; 169 | font-size: 1rem; 170 | font-weight: 700; 171 | line-height: 1.5em; 172 | cursor: pointer; 173 | transition: all 0.2s ease-in-out; 174 | 175 | &:hover { 176 | background-color: var(--color-primary-low-one); 177 | border: 1px solid var(--color-primary-low-one); 178 | } 179 | } 180 | } 181 | } 182 | 183 | .checkboxLabel { 184 | cursor: pointer; 185 | flex-grow: 1; 186 | } 187 | 188 | .checkbox { 189 | position: relative; 190 | 191 | input[type="checkbox"] { 192 | cursor: pointer; 193 | position: absolute; 194 | width: 100%; 195 | height: 100%; 196 | opacity: 0; 197 | 198 | &:checked + .checkbox-style { 199 | background-color: var(--color-primary-low-two); 200 | 201 | &::after { 202 | left: 1.3rem; 203 | background-color: var(--color-primary); 204 | } 205 | } 206 | } 207 | 208 | .checkbox-style { 209 | position: relative; 210 | width: 2.7rem; 211 | height: 1rem; 212 | background-color: var(--color-mono-low); 213 | border-radius: 0.5rem; 214 | pointer-events: none; 215 | transition: background-color 200ms linear; 216 | 217 | &::after { 218 | content: ""; 219 | position: absolute; 220 | width: 1.5rem; 221 | height: 1.5rem; 222 | left: 0; 223 | top: -25%; 224 | border-radius: 50%; 225 | background-color: var(--color-mono-high); 226 | transition: all 200ms ease-in-out; 227 | } 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /src/style/originalButton.scss: -------------------------------------------------------------------------------- 1 | .manaba-original-button { 2 | padding: 0 16px; 3 | border: 1px solid #a8a8a8; 4 | border-radius: 4px; 5 | background-image: linear-gradient(180deg, #fff 0%, #e2e2e2 100%); 6 | box-shadow: 0 1px 0 0 #dfdfdf; 7 | color: #535353; 8 | cursor: pointer; 9 | 10 | &:hover { 11 | background-image: linear-gradient(0deg, #c4e9ff 0%, #f9fffc 100%); 12 | border-color: #a1c2da; 13 | color: #0e7bda; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/types/filterCources.ts: -------------------------------------------------------------------------------- 1 | export type SeasonCode = "spring" | "autumn" 2 | 3 | export type ModuleCode = 4 | | "all" 5 | | "spring-a" 6 | | "spring-b" 7 | | "spring-c" 8 | | "autumn-a" 9 | | "autumn-b" 10 | | "autumn-c" 11 | -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.scss" { 2 | const content: Record 3 | export default content 4 | } 5 | -------------------------------------------------------------------------------- /src/types/storage.ts: -------------------------------------------------------------------------------- 1 | import type { ModuleCode } from "./filterCources" 2 | 3 | export type StorageKind = "sync" | "local" 4 | 5 | export type StorageSync = Readonly<{ 6 | featuresAssignmentsColoring?: boolean 7 | featuresDeadlineHighlighting?: boolean 8 | featuresAutoSaveReports?: boolean 9 | featuresRemoveConfirmation?: boolean 10 | featuresFilterCourses?: boolean 11 | featuresDragAndDrop: boolean 12 | featuresReportTemplate: boolean 13 | featuresDisableForceFileSaving?: boolean 14 | featuresRelativeGradesPosition?: boolean 15 | filterConfigForModule?: ModuleCode 16 | reportTemplate?: string 17 | reportFilename?: string 18 | }> 19 | 20 | export type StorageLocal = Readonly<{ 21 | reportText?: { 22 | [reportId: string]: { 23 | modified: number 24 | text: string 25 | } 26 | } 27 | }> 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./src/**/*"], 3 | "extends": "@tsconfig/recommended/tsconfig.json", 4 | "compilerOptions": { 5 | "lib": ["DOM", "ES2021"], 6 | "jsx": "react-jsx" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | const CopyWebpackPlugin = require("copy-webpack-plugin") 4 | const MiniCssExtractPlugin = require("mini-css-extract-plugin") 5 | const path = require("path") 6 | const ZipPlugin = require("zip-webpack-plugin") 7 | 8 | process.traceDeprecation = true 9 | 10 | const nodeEnv = process.env.NODE_ENV 11 | const browserEnv = process.env.BROWSER_ENV 12 | 13 | const version = require("./package.json").version 14 | const manifestJson = require("./src/manifest.ts") 15 | 16 | /** @type {import('webpack').Configuration} */ 17 | module.exports = { 18 | mode: nodeEnv === "development" ? "development" : "production", 19 | output: { 20 | path: path.resolve(__dirname, "./dist"), 21 | filename: "[name].js", 22 | }, 23 | entry: { 24 | "contentScript/main": path.resolve( 25 | __dirname, 26 | "src", 27 | "contentScript", 28 | "main.ts" 29 | ), 30 | "contentScript/reportTemplate": path.resolve( 31 | __dirname, 32 | "src", 33 | "contentScript", 34 | "reportTemplate.ts" 35 | ), 36 | "contentScript/showRelativeGradesPosition": path.resolve( 37 | __dirname, 38 | "src", 39 | "contentScript", 40 | "showRelativeGradesPosition.ts" 41 | ), 42 | background: path.resolve(__dirname, "src", "background.ts"), 43 | options: path.resolve(__dirname, "src", "optionsPage", "index.tsx"), 44 | }, 45 | stats: { 46 | all: false, 47 | errors: true, 48 | builtAt: true, 49 | }, 50 | devtool: nodeEnv === "development" ? "source-map" : false, 51 | module: { 52 | rules: [ 53 | { 54 | test: /\.tsx?$/, 55 | use: [ 56 | { 57 | loader: "ts-loader", 58 | options: { 59 | transpileOnly: true, 60 | }, 61 | }, 62 | ], 63 | }, 64 | { 65 | test: /\.css$/, 66 | use: [MiniCssExtractPlugin.loader, "css-loader"], 67 | }, 68 | { 69 | test: /\.scss$/, 70 | exclude: [/\.module\.scss$/, /options\.scss$/], 71 | use: ["css-loader", "sass-loader"], 72 | }, 73 | { 74 | test: [/\.module\.scss$/, /options\.scss$/], 75 | use: ["style-loader", "css-loader", "sass-loader"], 76 | }, 77 | { 78 | test: /\.(png|jpe?g|gif)$/i, 79 | use: [ 80 | { 81 | loader: "file-loader", 82 | options: { 83 | outputPath: "images", 84 | name: "[name].[ext]", 85 | }, 86 | }, 87 | ], 88 | }, 89 | ], 90 | }, 91 | resolve: { 92 | extensions: [".js", ".ts", ".tsx"], 93 | }, 94 | plugins: [ 95 | new CopyWebpackPlugin({ 96 | patterns: [ 97 | { 98 | from: "**/*", 99 | context: "public", 100 | }, 101 | { 102 | from: "package.json", // dummy 103 | to: "manifest.json", 104 | transform: () => manifestJson, 105 | }, 106 | ], 107 | }), 108 | new MiniCssExtractPlugin({ 109 | filename: "[name].css", 110 | }), 111 | ...(nodeEnv === "production" 112 | ? [ 113 | new ZipPlugin({ 114 | path: path.resolve(__dirname, "./packaged"), 115 | filename: `manabaEnhanced-${version}-${ 116 | browserEnv === "firefox" ? "firefox" : "chrome" 117 | }`, 118 | }), 119 | ] 120 | : []), 121 | ], 122 | } 123 | --------------------------------------------------------------------------------