├── .github └── workflows │ ├── build.yml │ ├── codeql.yml │ └── format.yml ├── .gitignore ├── Changelog.md ├── LICENSE ├── Readme.md ├── canvas_app.py ├── canvas_mgr.py ├── config_mgr.py ├── doc └── Readme_ZH.md ├── models.py ├── public └── Readme.md ├── requirements.txt └── updater.py /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: ['*'] 6 | pull_request: 7 | branches: ['*'] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Set up Python 3.7 19 | uses: actions/setup-python@v3 20 | with: 21 | python-version: '3.7' 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 26 | - name: Test run 27 | run: | 28 | timeout 3 uvicorn canvas_app:app --port 9283 || [ $? -eq 124 ] 29 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: 'CodeQL' 13 | 14 | on: 15 | push: 16 | branches: ['*'] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: ['*'] 20 | 21 | jobs: 22 | analyze: 23 | name: Analyze 24 | # Runner size impacts CodeQL analysis time. To learn more, please see: 25 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 26 | # - https://gh.io/supported-runners-and-hardware-resources 27 | # - https://gh.io/using-larger-runners 28 | # Consider using larger runners for possible analysis time improvements. 29 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 30 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} 31 | permissions: 32 | actions: read 33 | contents: read 34 | security-events: write 35 | 36 | strategy: 37 | fail-fast: false 38 | matrix: 39 | language: ['python'] 40 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift' ] 41 | # Use only 'java' to analyze code written in Java, Kotlin or both 42 | # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both 43 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 44 | 45 | steps: 46 | - name: Checkout repository 47 | uses: actions/checkout@v4 48 | 49 | # Initializes the CodeQL tools for scanning. 50 | - name: Initialize CodeQL 51 | uses: github/codeql-action/init@v3 52 | with: 53 | languages: ${{ matrix.language }} 54 | # If you wish to specify custom queries, you can do so here or in a config file. 55 | # By default, queries listed here will override any specified in a config file. 56 | # Prefix the list here with "+" to use these queries and those in the config file. 57 | 58 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 59 | # queries: security-extended,security-and-quality 60 | 61 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). 62 | # If this step fails, then you should remove it and run the build manually (see below) 63 | - name: Autobuild 64 | uses: github/codeql-action/autobuild@v3 65 | 66 | # ℹ️ Command-line programs to run using the OS shell. 67 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 68 | 69 | # If the Autobuild fails above, remove it and uncomment the following three lines. 70 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 71 | 72 | # - run: | 73 | # echo "Run, Build Application using script" 74 | # ./location_of_script_within_repo/buildscript.sh 75 | 76 | - name: Perform CodeQL Analysis 77 | uses: github/codeql-action/analyze@v3 78 | with: 79 | category: '/language:${{matrix.language}}' 80 | -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: format 2 | 3 | on: 4 | push: 5 | branches: ['*'] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: ['*'] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | lint: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: actions/setup-python@v2 19 | - run: pip install black 20 | - run: black --check . 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project Specific 2 | canvas/ 3 | user_conf.json 4 | public/res 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | pip-wheel-metadata/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | *.py,cover 56 | .hypothesis/ 57 | .pytest_cache/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 100 | __pypackages__/ 101 | 102 | # Celery stuff 103 | celerybeat-schedule 104 | celerybeat.pid 105 | 106 | # SageMath parsed files 107 | *.sage.py 108 | 109 | # Environments 110 | .env 111 | .venv 112 | env/ 113 | venv/ 114 | ENV/ 115 | env.bak/ 116 | venv.bak/ 117 | 118 | # Spyder project settings 119 | .spyderproject 120 | .spyproject 121 | 122 | # Rope project settings 123 | .ropeproject 124 | 125 | # mkdocs documentation 126 | /site 127 | 128 | # mypy 129 | .mypy_cache/ 130 | .dmypy.json 131 | dmypy.json 132 | 133 | # Pyre type checker 134 | .pyre/ 135 | 136 | # VS Code 137 | .vscode/ 138 | 139 | # Macos 140 | .DS_Store 141 | 142 | .pyrightconfig.json 143 | -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.0.1 4 | 5 | ### Features 6 | 7 | - Browser/Link Support(#7) 8 | - Auto Updater 9 | 10 | ### Fix 11 | 12 | - Cannot read some courses (#4) 13 | - Incorrect encoding of the configuratrion file (#4) 14 | - Documentation support (#4) 15 | - Add Chinese version and reorganize the readme file 16 | - Fix prefix error(#5) 17 | - Specify the variable type 18 | 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 King 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 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Canvas Helper 2 2 | 3 | [![build](https://github.com/linsyking/CanvasHelper2/actions/workflows/build.yml/badge.svg)](https://github.com/linsyking/CanvasHelper2/actions/workflows/build.yml) 4 | 5 | New generation of Canvas Helper backend. Web-based, support Linux, Windows and MacOS. 6 | 7 | [Chinese Translation](doc/Readme_ZH.md) 8 | 9 | ## Requirements 10 | 11 | - Python >= 3.7 12 | 13 | ## Workflow 14 | 15 | If you only want to run the backend on your machine and use the frontend on our server, do the following: 16 | 17 | 1. Follow [documentation](https://github.com/linsyking/CanvasHelper2#run-backend), run the backend at port `9283` 18 | 2. Go to to configure your CanvasHelper 19 | 3. Go to to see the final result 20 | 4. Deploy Canvas Helper on your desktop with [wiget](https://github.com/linsyking/CanvasHelper2/#use-canvashelper-in-) 21 | 22 | ## Dev Workflow 23 | 24 | If you want to setup frontend by yourself or contribute to this project, you have to do mainly 3 steps: 25 | 26 | 1. Run the backend 27 | 2. Run `CanvasHelper2-conf` and configure CanvasHelper in the browser 28 | 3. Run an HTTP server to host the static HTML files (or develop your own dashboard frontend) 29 | 30 | ## Run backend 31 | 32 | First, clone this repository: 33 | 34 | ```bash 35 | git clone https://github.com/linsyking/CanvasHelper2.git 36 | 37 | cd CanvasHelper2 38 | ``` 39 | 40 | Then install the dependencies. It is recommended to use a virtual environment for installation: 41 | 42 | ```bash 43 | python -m venv env # You may want to change `python` to `python3` or other python binaries 44 | source env/bin/activate # You may want to change the activation script according to your shell 45 | pip install -r requirements.txt 46 | ``` 47 | 48 | If you don't want to change any settings (like CORS), you can directly run: (If you want to use frontend on our server, you must use `9283` port) 49 | 50 | ```bash 51 | uvicorn canvas_app:app --port 9283 52 | ``` 53 | 54 | For development, you probably need to use: 55 | 56 | ```bash 57 | uvicorn canvas_app:app --reload 58 | ``` 59 | 60 | to automatically reload the api when the script is modified. 61 | 62 | If you need to expose the port, you can add option `--host 0.0.0.0`. 63 | 64 | ## Configure CanvasHelper 65 | 66 | If you want to use the frontend on our server, go to: [here](https://canvashelper2.web.app/canvashelper/). (Site might be changed in the future) 67 | 68 | Otherwise, go to [CanvasHelper2-conf](https://github.com/linsyking/CanvasHelper2-conf) for more details. 69 | 70 | ## Preview the result 71 | 72 | If you want to see the result without hosting HTML files, you can directly go to [here](https://canvashelper2.web.app/). 73 | 74 | You can use any http server you like to host the static html file. 75 | 76 | The sample dashboard frontend is at . 77 | 78 | You can clone that repository and host those files by 79 | 80 | ```bash 81 | python3 -m http.server 9282 82 | ``` 83 | 84 | Now go to page to see the result! 85 | 86 | ## Use CanvasHelper in ... 87 | 88 | ### Wallpaper Engine 89 | 90 | Subscribe template wallpaper: . 91 | 92 | After you started the backend locally, it will redirect to the [here](https://canvashelper2.web.app/). You can also change it to your local frontend. 93 | 94 | To start the backend on startup, you can do the following: 95 | 96 | 1. Win+R, type `shell:startup` 97 | 2. In the opened window, create a file called `canvashelper.vbs` 98 | 99 | Its content should be like this: 100 | 101 | ```vbs 102 | Dim WinScriptHost 103 | Set WinScriptHost = CreateObject("WScript.Shell") 104 | WinScriptHost.Run Chr(34) & "C:\XXX\canvashelper.bat" & Chr(34), 0 105 | Set WinScriptHost = Nothing 106 | ``` 107 | 108 | Replace `C:\XXX\canvashelper.bat` with a better path where you store a `bat` file which is used to launch the CanvasHelper. 109 | 110 | **That bat file must be in C drive.** 111 | 112 | 3. Create that `C:\XXX\canvashelper.bat` file with the following content: 113 | 114 | ```cmd 115 | @echo off 116 | 117 | d: 118 | cd D:\Project\CanvasHelper2 119 | uvicorn canvas_app:app --port 9283 120 | ``` 121 | 122 | Replace `d:` and `D:\Project\CanvasHelper2` with your own directory. 123 | 124 | (If your clone directory is in C, then you don't need `d:` to enter drive D) 125 | 126 | After that, your system will run this script on startup. 127 | 128 | **Note: some features in wallpaper engine are not well-supported, including scrolling.** 129 | 130 | ### KDE Wallpaper 131 | 132 | 1. Install [wallpaper-engine-kde-plugin](https://github.com/catsout/wallpaper-engine-kde-plugin). 133 | 2. Download the canvas wallpaper . 134 | 3. You should be able to see the wallpaper. 135 | 4. Add a startup script to run the backend. 136 | 137 | **Note: scrolling is also not supported.** 138 | 139 | Result: 140 | 141 | ![demo](https://user-images.githubusercontent.com/49303317/210978732-68cefd73-75df-4013-a7cb-2010f16ec7dd.png) 142 | 143 | ### KDE Widget 144 | 145 | (Another dashboard frontend) 146 | 147 | *TO-DO* 148 | 149 | ## FAQ 150 | 151 | - What's the difference between CanvasHelper and CanvasHelper 2? 152 | 153 | > CanvasHelper 1 is centralized while CanvasHelper 2 is not. It is completely local so you don't have to connect to our server to use CanvasHelper. 154 | > Moreover, CanvasHelper 2 provides a handy web interface for configuring courses. 155 | > CanvasHelper 2 separates frontend and backend so that you can develop your own dashboard frontend on any operating system/desktop environment. 156 | 157 | - What's the relationship between Canvas Helper backend, frontend, and dashboard? 158 | 159 | > The backend provides several APIs for frontend and dashboard to call; frontend uses the local APIs to configure Canvas Helper. The dashboard also calls the local backend to get the configuration. 160 | 161 | - Do I have to use the sample dashboard frontend? 162 | 163 | > No. You can develop your own dashboard frontend. The sample dashboard frontend uses the HTML output from this backend and displays it in a draggable box. 164 | -------------------------------------------------------------------------------- /canvas_app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from fastapi import FastAPI, Request, UploadFile 4 | from fastapi.responses import FileResponse 5 | from fastapi.middleware.cors import CORSMiddleware 6 | from config_mgr import ConfigMGR 7 | from canvas_mgr import CanvasMGR 8 | import urllib.parse 9 | from models import Position, Check, Course, URL 10 | from fastapi.responses import JSONResponse 11 | from os import path, listdir, remove, mkdir 12 | from updater import update 13 | import json 14 | import logging 15 | from typing import List 16 | 17 | """ 18 | Local function 19 | """ 20 | 21 | ALLOWED_EXTENSION = { 22 | "png", 23 | "jpg", 24 | "jpeg", 25 | "gif", 26 | "svg", 27 | "mp4", 28 | "mkv", 29 | "mov", 30 | "m4v", 31 | "avi", 32 | "wmv", 33 | "webm", 34 | } 35 | 36 | 37 | # INFO: Safety check for file 38 | def check_file(filename): 39 | base_path = "/public/res/" 40 | fullPath = path.normpath(path.join(base_path, filename)) 41 | if ( 42 | not "." in filename 43 | or not filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSION 44 | ): 45 | return "Illegal" 46 | if not fullPath.startswith(base_path): 47 | return "Illegal" 48 | else: 49 | return filename 50 | 51 | 52 | """ 53 | Canvas App 54 | 55 | This file contains all the APIs to access the 56 | configuration file/canvas backend, etc.. 57 | """ 58 | 59 | 60 | app = FastAPI(version="1.0.1", title="Canvas Helper", description="Canvas Helper API.") 61 | 62 | app.add_middleware( 63 | CORSMiddleware, 64 | allow_origins=["*"], 65 | allow_credentials=True, 66 | allow_methods=["*"], 67 | allow_headers=["*"], 68 | ) 69 | 70 | 71 | logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s") 72 | 73 | conf = ConfigMGR() 74 | 75 | # Self Update 76 | update() 77 | 78 | 79 | @app.get( 80 | "/config", 81 | summary="Get the configuration file", 82 | description="Get the configuration file.", 83 | tags=["config"], 84 | ) 85 | async def get_configuration(): 86 | return conf.get_conf() 87 | 88 | 89 | @app.get( 90 | "/config/refresh", 91 | tags=["config"], 92 | summary="Refresh the configuration file", 93 | description="Force to read the configuration file from disk.", 94 | ) 95 | async def refresh_conf(): 96 | conf.force_read() 97 | return JSONResponse(status_code=200, content={"message": "success"}) 98 | 99 | 100 | @app.get( 101 | "/config/key/{key}", 102 | tags=["config"], 103 | summary="Get a specific key from the configuration file", 104 | description="Get a specific key from the configuration file.", 105 | ) 106 | async def get_configuration_key(key: str): 107 | if key not in conf.get_conf(): 108 | return JSONResponse(status_code=404, content={"message": "Key not found"}) 109 | return conf.get_conf()[key] 110 | 111 | 112 | @app.put( 113 | "/config/key/{key}", 114 | tags=["config"], 115 | summary="Update a specific key in the configuration file", 116 | description="Update a specific key in the configuration file.", 117 | ) 118 | async def update_configuration(key: str, request: Request): 119 | body = await request.body() 120 | try: 121 | body_p = json.loads('{"data" : ' + body.decode(encoding="utf-8") + "}") 122 | except: 123 | return JSONResponse(status_code=400, content={"message": "Cannot parse body"}) 124 | conf.set_key_value(key, body_p["data"]) 125 | return JSONResponse(status_code=200, content={"message": "success"}) 126 | 127 | 128 | @app.delete( 129 | "/config/key/{key}", 130 | tags=["config"], 131 | summary="Delete a specific key in the configuration file", 132 | description="Delete a specific key in the configuration file.", 133 | ) 134 | async def delete_configuration(key: str): 135 | if key not in conf.get_conf(): 136 | return JSONResponse(status_code=404, content={"message": "Key not found"}) 137 | conf.remove_key(key) 138 | return JSONResponse(status_code=200, content={"message": "success"}) 139 | 140 | 141 | @app.get( 142 | "/config/verify", 143 | tags=["config"], 144 | summary="Verify the configuration file", 145 | description="Verify the configuration file.", 146 | ) 147 | async def verify_config(): 148 | """ 149 | Verify the configuration 150 | """ 151 | if "bid" not in conf.get_conf(): 152 | return JSONResponse(status_code=404, content={"message": "bid not found"}) 153 | if "url" not in conf.get_conf(): 154 | return JSONResponse(status_code=404, content={"message": "url not found"}) 155 | if "background_image" not in conf.get_conf() and "video" not in conf.get_conf(): 156 | return JSONResponse(status_code=400, content={"message": "background not set"}) 157 | # Test bid 158 | 159 | import requests 160 | 161 | headers = {"Authorization": f'Bearer {conf.get_conf()["bid"]}'} 162 | url = str(conf.get_conf()["url"]) 163 | if url.find("http://") != 0 and url.find("https://") != 0: 164 | # Invalid protocal 165 | url = "https://" + url 166 | conf.set_key_value("url", url) 167 | res = requests.get( 168 | urllib.parse.urljoin(url, "api/v1/accounts"), headers=headers 169 | ).status_code 170 | if res == 200: 171 | return JSONResponse(status_code=200, content={"message": "success"}) 172 | else: 173 | return JSONResponse(status_code=400, content={"message": "verification failed"}) 174 | 175 | 176 | @app.get( 177 | "/courses", 178 | tags=["course"], 179 | summary="Get all the courses", 180 | description="Get all the courses.", 181 | ) 182 | async def get_all_courses(): 183 | if "courses" not in conf.get_conf(): 184 | return [] 185 | return conf.get_conf()["courses"] 186 | 187 | 188 | @app.get( 189 | "/courses/canvas", 190 | tags=["course"], 191 | summary="Get all the courses from canvas", 192 | description="Get all the courses from canvas.", 193 | ) 194 | async def get_all_canvas_courses(): 195 | if "bid" not in conf.get_conf(): 196 | return JSONResponse(status_code=404, content={"message": "bid not found"}) 197 | 198 | import requests 199 | 200 | headers = {"Authorization": f'Bearer {conf.get_conf()["bid"]}'} 201 | res = requests.get( 202 | urllib.parse.urljoin( 203 | str(conf.get_conf()["url"]), "api/v1/dashboard/dashboard_cards" 204 | ), 205 | headers=headers, 206 | ).text 207 | return json.loads(res) 208 | 209 | 210 | @app.delete( 211 | "/courses/{course_id}", 212 | tags=["course"], 213 | summary="Delete a course", 214 | description="Delete a course. It will delete all the course items with the given course id.", 215 | ) 216 | async def delete_course(course_id: int): 217 | if "courses" not in conf.get_conf(): 218 | return JSONResponse(status_code=404, content={"message": "Courses not found"}) 219 | courses = conf.get_conf()["courses"] 220 | all_courses = [] 221 | if not isinstance(courses, List): 222 | return JSONResponse( 223 | status_code=404, content={"message": "Courses type should be list."} 224 | ) 225 | else: 226 | for course in courses: 227 | if course["course_id"] != course_id: 228 | all_courses.append(course) 229 | conf.set_key_value("courses", all_courses) 230 | return JSONResponse(status_code=200, content={"message": "success"}) 231 | 232 | 233 | @app.delete( 234 | "/courses/{course_id}/{type}", 235 | tags=["course"], 236 | summary="Delete a course item", 237 | description="Delete a course item. It will delete the course item with the given course id and type.", 238 | ) 239 | async def delete_course_item(course_id: int, type: str): 240 | if "courses" not in conf.get_conf(): 241 | return JSONResponse(status_code=404, content={"message": "Courses not found"}) 242 | courses = conf.get_conf()["courses"] 243 | all_courses = [] 244 | if not isinstance(courses, List): 245 | JSONResponse( 246 | status_code=404, content={"message": "Courses type should be list"} 247 | ) 248 | else: 249 | for course in courses: 250 | if course["course_id"] != course_id or course["type"] != type: 251 | all_courses.append(course) 252 | conf.set_key_value("courses", all_courses) 253 | return JSONResponse(status_code=200, content={"message": "success"}) 254 | 255 | 256 | @app.post( 257 | "/courses", tags=["course"], summary="Add a course", description="Add a course." 258 | ) 259 | async def create_course(course: Course): 260 | if course.type not in ["ann", "dis", "ass"]: 261 | return JSONResponse(status_code=400, content={"message": "Invalid course type"}) 262 | if course.name == "": 263 | return JSONResponse(status_code=400, content={"message": "Empty course name"}) 264 | course_info = { 265 | "course_id": course.id, 266 | "course_name": course.name, 267 | "type": course.type, 268 | "maxshow": course.maxshow, 269 | "order": course.order, 270 | "msg": course.msg, 271 | } 272 | if "courses" not in conf.get_conf(): 273 | ori_courses = [] 274 | else: 275 | ori_courses = conf.get_conf()["courses"] 276 | # Check if the course already exists 277 | if not isinstance(ori_courses, List): 278 | JSONResponse( 279 | status_code=404, content={"message": "Courses type should be list."} 280 | ) 281 | else: 282 | for c in ori_courses: 283 | if c["course_id"] == course.id and c["type"] == course.type: 284 | return JSONResponse( 285 | status_code=400, content={"message": "Course already exists"} 286 | ) 287 | ori_courses.append(course_info) 288 | conf.set_key_value("courses", ori_courses) 289 | return JSONResponse(status_code=200, content={"message": "success"}) 290 | 291 | 292 | @app.put( 293 | "/courses", 294 | tags=["course"], 295 | summary="Modify a course", 296 | description="Modify a course.", 297 | ) 298 | async def modify_course(index: int, course: Course): 299 | if "courses" not in conf.get_conf(): 300 | return JSONResponse(status_code=404, content={"message": "Courses not found"}) 301 | courses = conf.get_conf()["courses"] 302 | if not isinstance(courses, List): 303 | return JSONResponse( 304 | status_code=404, content={"message": "Courses type should be list"} 305 | ) 306 | if index >= len(courses) or index < 0: 307 | return JSONResponse(status_code=404, content={"message": "Course not found"}) 308 | if course.type not in ["ann", "ass", "dis"]: 309 | return JSONResponse(status_code=400, content={"message": "Invalid course type"}) 310 | if course.name == "": 311 | return JSONResponse(status_code=400, content={"message": "Empty course name"}) 312 | course_info = { 313 | "course_id": course.id, 314 | "course_name": course.name, 315 | "type": course.type, 316 | "maxshow": course.maxshow, 317 | "order": course.order, 318 | "msg": course.msg, 319 | } 320 | # Test if the course already exists 321 | for i in range(len(courses)): 322 | if ( 323 | i != index 324 | and courses[i]["course_id"] == course.id 325 | and courses[i]["type"] == course.type 326 | ): 327 | return JSONResponse( 328 | status_code=400, content={"message": "Course already exists"} 329 | ) 330 | 331 | courses[index] = course_info 332 | conf.set_key_value("courses", courses) 333 | return JSONResponse(status_code=200, content={"message": "success"}) 334 | 335 | 336 | @app.get( 337 | "/canvas/dashboard", 338 | tags=["canvas"], 339 | summary="Get the dashboard", 340 | description="Get the dashboard.", 341 | ) 342 | async def get_dashboard(cache: bool = False, mode: str = "html"): 343 | if cache: 344 | # Use cache 345 | if path.exists("./canvas/cache.json"): 346 | with open( 347 | "./canvas/cache.json", "r", encoding="utf-8", errors="ignore" 348 | ) as f: 349 | obj = json.load(f) 350 | if mode == "html": 351 | return {"data": obj["html"]} 352 | elif mode == "json": 353 | return {"data": obj["json"]} 354 | else: 355 | return JSONResponse( 356 | status_code=400, content={"message": "Mode not supported"} 357 | ) 358 | else: 359 | return JSONResponse(status_code=404, content={"message": "Cache not found"}) 360 | # No cache 361 | canvas = CanvasMGR(mode) 362 | return {"data": canvas.get_response()} 363 | 364 | 365 | @app.post( 366 | "/canvas/check/{name}", 367 | tags=["canvas"], 368 | summary="Check some task", 369 | description="Check some task.", 370 | ) 371 | async def set_check(name: str, check: Check): 372 | """ 373 | Check 374 | 375 | Only 1,2,3 is available 376 | """ 377 | if check.type < 0 or check.type > 3: 378 | return JSONResponse(status_code=400, content={"message": "Invalid check type"}) 379 | all_checks = [{"name": name, "type": check.type}] 380 | if "checks" in conf.get_conf(): 381 | ori_checks = conf.get_conf()["checks"] 382 | if not isinstance(ori_checks, List): 383 | return JSONResponse( 384 | status_code=404, content={"message": "Courses type should be list"} 385 | ) 386 | for ori_check in ori_checks: 387 | if ori_check["name"] != name: 388 | all_checks.append(ori_check) 389 | conf.set_key_value("checks", all_checks) 390 | return JSONResponse(status_code=200, content={"message": "success"}) 391 | 392 | 393 | @app.get( 394 | "/canvas/position", 395 | tags=["canvas"], 396 | summary="Get the position", 397 | description="Get the position.", 398 | ) 399 | async def get_position(): 400 | """ 401 | Get position 402 | """ 403 | if "position" not in conf.configuration: 404 | return JSONResponse(status_code=404, content={"message": "Position not found"}) 405 | return conf.get_conf()["position"] 406 | 407 | 408 | @app.put( 409 | "/canvas/position", 410 | tags=["canvas"], 411 | summary="Set the position", 412 | description="Set the position.", 413 | ) 414 | async def update_position(position: Position): 415 | """ 416 | Set position 417 | """ 418 | conf.set_key_value( 419 | "position", 420 | { 421 | "left": position.left, 422 | "top": position.top, 423 | "width": position.width, 424 | "height": position.height, 425 | }, 426 | ) 427 | return JSONResponse(status_code=200, content={"message": "success"}) 428 | 429 | 430 | @app.post( 431 | "/file/upload", 432 | tags=["file"], 433 | summary="Upload file", 434 | description="Upload file to public/res.", 435 | ) 436 | async def upload_file(file: UploadFile): 437 | if not path.exists("./public/res"): 438 | mkdir("./public/res") 439 | tmp = check_file(file.filename) 440 | if tmp == "Illegal": 441 | return JSONResponse(status_code=404, content={"message": "Illegal file name"}) 442 | with open(f"./public/res/{file.filename}", "wb") as out_file: 443 | out_file.write(file.file.read()) 444 | return JSONResponse(status_code=200, content={"message": "success"}) 445 | 446 | 447 | @app.delete( 448 | "/file", 449 | tags=["file"], 450 | summary="Delete file", 451 | description="Delete file in public/res.", 452 | ) 453 | async def delete_file(name: str): 454 | tmp = check_file(name) 455 | if tmp == "Illegal": 456 | return JSONResponse(status_code=404, content={"message": "Illegal file name"}) 457 | if path.exists(f"./public/res/{name}"): 458 | remove(f"./public/res/{name}") 459 | return JSONResponse(status_code=200, content={"message": "success"}) 460 | else: 461 | return JSONResponse(status_code=404, content={"message": "File not found"}) 462 | 463 | 464 | @app.get( 465 | "/file", 466 | tags=["file"], 467 | summary="Get file list", 468 | description="Get files in public/res.", 469 | ) 470 | async def get_file_list(): 471 | if path.exists("./public/res"): 472 | return {"files": listdir("./public/res")} 473 | else: 474 | mkdir("./public/res") 475 | return {"files": []} 476 | 477 | 478 | @app.get( 479 | "/file/{name}", 480 | tags=["file"], 481 | summary="Get file", 482 | description="Get file in public/res.", 483 | ) 484 | async def get_file(name: str): 485 | if path.exists(f"./public/res/{name}"): 486 | return FileResponse(f"./public/res/{name}") 487 | else: 488 | return JSONResponse(status_code=404, content={"message": "File not found"}) 489 | 490 | 491 | @app.post( 492 | "/browser", 493 | tags=["misc"], 494 | summary="Open URL in web browser", 495 | description="Open URL in web browser.", 496 | ) 497 | async def open_url(data: URL): 498 | import webbrowser 499 | 500 | try: 501 | if data.browser: 502 | res = webbrowser.get(data.browser).open(data.url) 503 | else: 504 | res = webbrowser.open(data.url) 505 | if not res: 506 | raise Exception("Cannot find web browser") 507 | return JSONResponse(status_code=200, content={"message": "Opened"}) 508 | except Exception as e: 509 | logging.warning(e) 510 | return JSONResponse(status_code=400, content={"message": "Failed to open"}) 511 | -------------------------------------------------------------------------------- /canvas_mgr.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from datetime import datetime, timedelta 4 | from math import floor 5 | import requests 6 | import json 7 | import os 8 | 9 | """ 10 | Canvas Manager 11 | 12 | Contact with canvas. 13 | """ 14 | 15 | 16 | class CanvasMGR: 17 | g_out = "" 18 | g_tformat = "relative" 19 | usercheck = [] 20 | bid = "" 21 | ucommand = {} 22 | url = "" 23 | output_mode = "html" 24 | 25 | def __init__(self, output_mode: str = "html") -> None: 26 | if not os.path.exists("canvas"): 27 | os.mkdir("canvas") 28 | # Check whether config file exists 29 | if not os.path.exists("./user_conf.json"): 30 | raise Exception("No configuration file found") 31 | self.output_mode = output_mode 32 | self.reset() 33 | 34 | def reset(self): 35 | self.g_out = "" 36 | self.g_tformat = "relative" 37 | 38 | with open("./user_conf.json", "r", encoding="utf-8", errors="ignore") as f: 39 | self.ucommand = json.load(f) 40 | 41 | self.url = self.ucommand["url"] 42 | self.bid = self.ucommand["bid"] 43 | if self.url[-1] == "/": 44 | self.url = self.url[:-1] 45 | if self.url[:4] != "http": 46 | raise Exception("Invalid url") 47 | 48 | if "checks" in self.ucommand: 49 | self.usercheck = self.ucommand["checks"] 50 | 51 | if "timeformat" in self.ucommand: 52 | self.g_tformat = self.ucommand["timeformat"] 53 | 54 | def dump_out(self): 55 | """ 56 | Dump HTML output 57 | """ 58 | obj = {"html": self.g_out[:-1], "json": "{}"} 59 | with open("./canvas/cache.json", "w", encoding="utf-8", errors="ignore") as f: 60 | json.dump(obj, f, ensure_ascii=False, indent=4) 61 | return self.g_out[:-1] 62 | 63 | def print_own(self, mystr): 64 | """ 65 | Change the value of self.g_out 66 | """ 67 | self.g_out += mystr + "\n" 68 | 69 | def get_response(self): 70 | self.reset() 71 | self.now = datetime.now() 72 | 73 | if "courses" not in self.ucommand: 74 | return "

No course found!

" 75 | courses = self.ucommand["courses"] 76 | allc = [] 77 | 78 | try: 79 | for course in courses: 80 | allc.append( 81 | apilink( 82 | course, 83 | self.bid, 84 | self.url, 85 | self.usercheck, 86 | g_tformat=self.g_tformat, 87 | ) 88 | ) 89 | except Exception as e: 90 | raise Exception("invalid course", e) 91 | 92 | now_root = self.now.replace(hour=0, minute=0, second=0, microsecond=0) 93 | 94 | sem_begin = datetime.strptime(self.ucommand["semester_begin"], "%Y-%m-%d") 95 | 96 | bdays = (now_root - sem_begin).days 97 | bweeks = floor(bdays / 7) + 1 98 | 99 | if "title" in self.ucommand: 100 | self.print_own(f"

{self.ucommand['title']} - Week {bweeks}

") 101 | else: 102 | self.print_own(f"

Canvas Dashboard - Week {bweeks}

") 103 | 104 | for i in allc: 105 | try: 106 | i.run() 107 | except: 108 | self.print_own(f"

{i.cname} - Error

\n{i.raw}") 109 | 110 | for i in allc: 111 | self.print_own(i.print_out()) 112 | 113 | return self.dump_out() 114 | 115 | 116 | class apilink: 117 | def __init__( 118 | self, course: dict, bid: str, url: str, user_check, g_tformat="relative" 119 | ) -> None: 120 | self.headers = {"Authorization": f"Bearer {bid}"} 121 | 122 | self.course = course["course_id"] 123 | self.cname = course["course_name"] 124 | self.course_type = course["type"] 125 | self.assignment = f"{url}/api/v1/courses/{self.course}/assignment_groups?include[]=assignments&include[]=discussion_topic&exclude_response_fields[]=description&exclude_response_fields[]=rubric&override_assignment_dates=true" 126 | self.announcement = f"{url}/api/v1/courses/{self.course}/discussion_topics?only_announcements=true" 127 | self.discussion = f"{url}/api/v1/courses/{self.course}/discussion_topics?plain_messages=true&exclude_assignment_descriptions=true&exclude_context_module_locked_topics=true&order_by=recent_activity&include=all_dates&per_page=50" 128 | self.other = course 129 | self.output = "" 130 | self.now = datetime.now() 131 | self.g_tformat = g_tformat 132 | self.usercheck = user_check 133 | 134 | def dump_span(self, style, id, text, url: str = ""): 135 | if style == 1: 136 | # Positive 137 | return f'
{text}
\n' 138 | elif style == 2: 139 | # wrong 140 | return f'
{text}
\n' 141 | elif style == 3: 142 | # important 143 | return f'
{text}
\n' 144 | else: 145 | # Not checked 146 | return f'
{text}
\n' 147 | 148 | def num2ch(self, f: int): 149 | s = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] 150 | return s[f] + "." 151 | 152 | def time_format_control(self, rtime: datetime, format): 153 | if rtime < self.now: 154 | return "Expired" 155 | if format == "origin": 156 | return rtime 157 | elif format == "relative": 158 | return self.relative_date(rtime) 159 | else: 160 | # Fallback 161 | return rtime.strftime(format) 162 | 163 | def get_check_status(self, name: str): 164 | # Return type 165 | for i in self.usercheck: 166 | if i["name"] == name: 167 | return i["type"] 168 | return 0 169 | 170 | def relative_date(self, rtime: datetime): 171 | # Generate relative date 172 | delta = rtime.replace( 173 | hour=0, minute=0, second=0, microsecond=0 174 | ) - self.now.replace(hour=0, minute=0, second=0, microsecond=0) 175 | wp = int((delta.days + self.now.weekday()) / 7) 176 | if wp == 0: 177 | # Current week 178 | if delta.days == 0: 179 | return f"Today {rtime.strftime('%H:%M:%S')}" 180 | elif delta.days == 1: 181 | return f"Tomorrow {rtime.strftime('%H:%M:%S')}" 182 | elif delta.days == 2: 183 | return f"The day after tomorrow {rtime.strftime('%H:%M:%S')}" 184 | else: 185 | return f"{self.num2ch(rtime.weekday())} {rtime.strftime('%H:%M:%S')}" 186 | elif wp == 1: 187 | if delta.days == 1: 188 | return f"Tomorrow {rtime.strftime('%H:%M:%S')}" 189 | elif delta.days == 2: 190 | return f"The day after tomorrow {rtime.strftime('%H:%M:%S')}" 191 | return ( 192 | f"Next week {self.num2ch(rtime.weekday())} {rtime.strftime('%H:%M:%S')}" 193 | ) 194 | elif wp == 2: 195 | return f"The week after next week {self.num2ch(rtime.weekday())} {rtime.strftime('%H:%M:%S')}" 196 | else: 197 | return f"{rtime}" 198 | 199 | def send(self, url): 200 | return requests.get(url, headers=self.headers).content.decode( 201 | encoding="utf-8", errors="ignore" 202 | ) 203 | 204 | def _cmp_ass(self, el): 205 | if el["due_at"]: 206 | return el["due_at"] 207 | else: 208 | return el["updated_at"] 209 | 210 | def run(self): 211 | t = self.course_type 212 | if t == "ass": 213 | self.collect_assignment() 214 | elif t == "ann": 215 | self.collect_announcement() 216 | elif t == "dis": 217 | self.collect_discussion() 218 | else: 219 | raise Exception( 220 | f"invalid show type {self.course_type} (only support ass, annc, disc)" 221 | ) 222 | self.add_custom_info() 223 | 224 | def add_custom_info(self): 225 | if "msg" in self.other and self.other["msg"] != "": 226 | # Add custom message 227 | self.output += f'

{self.other["msg"]}

\n' 228 | 229 | def collect_assignment(self): 230 | self.cstate = "Assignment" 231 | asr = self.send(self.assignment) 232 | self.raw = asr 233 | self.ass_data = [] 234 | asr = json.loads(asr) 235 | for big in asr: 236 | a = big["assignments"] 237 | if a: 238 | for k in a: 239 | if k["due_at"]: 240 | dttime = datetime.strptime( 241 | k["due_at"], "%Y-%m-%dT%H:%M:%SZ" 242 | ) + timedelta(hours=8) 243 | if dttime < self.now: 244 | continue 245 | self.ass_data.append(k) 246 | elif k["updated_at"]: 247 | # Fallback to updated 248 | self.ass_data.append(k) 249 | self.ass_data.sort(key=self._cmp_ass, reverse=True) 250 | self.output = f"

{self.cname}: Homework

\n" 251 | maxnum = 10000 252 | if "maxshow" in self.other: 253 | maxnum = int(self.other["maxshow"]) 254 | if maxnum == -1: 255 | maxnum = 10000 256 | if len(self.ass_data) == 0 or maxnum <= 0: 257 | self.output += "None\n" 258 | return 259 | if "order" in self.other and self.other["order"] == "reverse": 260 | self.ass_data.reverse() 261 | for ass in self.ass_data: 262 | if maxnum == 0: 263 | break 264 | maxnum -= 1 265 | submit_msg = "" 266 | if ("has_submitted_submissions" in ass) and ass[ 267 | "has_submitted_submissions" 268 | ]: 269 | submit_msg = "(Submittable)" 270 | if ass["due_at"]: 271 | dttime = datetime.strptime( 272 | ass["due_at"], "%Y-%m-%dT%H:%M:%SZ" 273 | ) + timedelta(hours=8) 274 | tformat = self.g_tformat 275 | if "timeformat" in self.other: 276 | tformat = self.other["timeformat"] 277 | dttime = self.time_format_control(dttime, tformat) 278 | check_type = self.get_check_status(f"ass{ass['id']}") 279 | self.output += self.dump_span( 280 | check_type, 281 | f"ass{ass['id']}", 282 | f"{ass['name']}, Due: {dttime}{submit_msg}", 283 | ass["html_url"], 284 | ) 285 | else: 286 | # No due date homework 287 | check_type = self.get_check_status(f"ass{ass['id']}") 288 | self.output += self.dump_span( 289 | check_type, 290 | f"ass{ass['id']}", 291 | f"{ass['name']}{submit_msg}", 292 | ass["html_url"], 293 | ) 294 | 295 | def collect_announcement(self): 296 | self.cstate = "Announcement" 297 | anr = self.send(self.announcement) 298 | self.raw = anr 299 | anr = json.loads(anr) 300 | self.ann_data = anr 301 | self.output = f"

{self.cname}: Announcements

\n" 302 | maxnum = 10000 303 | if "maxshow" in self.other: 304 | maxnum = int(self.other["maxshow"]) 305 | if maxnum == -1: 306 | maxnum = 10000 307 | if len(anr) == 0 or maxnum <= 0: 308 | self.output += "None.\n" 309 | return 310 | if "order" in self.other and self.other["order"] == "reverse": 311 | self.ann_data.reverse() 312 | for an in self.ann_data: 313 | if maxnum == 0: 314 | break 315 | maxnum -= 1 316 | check_type = self.get_check_status(f"ann{an['id']}") 317 | self.output += self.dump_span( 318 | check_type, f"ann{an['id']}", an["title"], an["html_url"] 319 | ) 320 | 321 | def collect_discussion(self): 322 | self.cstate = "Discussion" 323 | dis = self.send(self.discussion) 324 | self.raw = dis 325 | dis = json.loads(dis) 326 | self.dis_data = [] 327 | self.output = f"

{self.cname}: Discussions

\n" 328 | for d in dis: 329 | if d["locked"]: 330 | continue 331 | self.dis_data.append(d) 332 | maxnum = 10000 333 | if "maxshow" in self.other: 334 | maxnum = int(self.other["maxshow"]) 335 | 336 | if maxnum == -1: 337 | maxnum = 10000 338 | if len(self.dis_data) == 0 or maxnum <= 0: 339 | self.output += "None.\n" 340 | return 341 | if "order" in self.other and self.other["order"] == "reverse": 342 | self.dis_data.reverse() 343 | for d in self.dis_data: 344 | if maxnum == 0: 345 | break 346 | maxnum -= 1 347 | check_type = self.get_check_status(f"dis{d['id']}") 348 | self.output += self.dump_span( 349 | check_type, f"dis{d['id']}", d["title"], d["html_url"] 350 | ) 351 | 352 | def print_out(self): 353 | if self.output: 354 | return self.output 355 | else: 356 | return ( 357 | f"

Warning: no output for course {self.cname} (id: {self.course})

" 358 | ) 359 | 360 | 361 | if __name__ == "__main__": 362 | # TEST 363 | cmgr = CanvasMGR() 364 | print(cmgr.get_response()) 365 | -------------------------------------------------------------------------------- /config_mgr.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import json 4 | from os import path 5 | 6 | """ 7 | Configuration Manager 8 | 9 | Configuration is located in ./user_conf.json 10 | It will include: 11 | - Canvas configuration 12 | - Wallpaper configuration 13 | - All courses configuration 14 | """ 15 | 16 | 17 | class ConfigMGR: 18 | configuration = {} 19 | 20 | def __init__(self): 21 | if not path.exists("user_conf.json"): 22 | # Create this configuration file 23 | self.configuration = { 24 | "version": 1, 25 | } 26 | self.write_conf() 27 | else: 28 | self.force_read() 29 | if self.configuration["version"] != 1: 30 | raise Exception("Error: Configuration file version mismatch!") 31 | 32 | def write_conf(self): 33 | """ 34 | Write configuration to the local file. 35 | """ 36 | self.check_health() 37 | with open("./user_conf.json", "w", encoding="utf-8", errors="ignore") as f: 38 | json.dump(self.configuration, f, ensure_ascii=False, indent=4) 39 | 40 | def get_conf(self): 41 | return self.configuration 42 | 43 | def remove_key(self, key: str): 44 | self.configuration.pop(key) 45 | self.write_conf() 46 | 47 | def force_read(self): 48 | """ 49 | Read configuration file. 50 | """ 51 | with open("./user_conf.json", "r", encoding="utf-8", errors="ignore") as f: 52 | self.configuration = json.load(f) 53 | 54 | def check_health(self): 55 | if not self.configuration: 56 | raise Exception("No configuration found") 57 | 58 | def set_key_value(self, key, value): 59 | self.configuration[key] = value 60 | self.write_conf() 61 | 62 | def update_conf(self, conf): 63 | """ 64 | Update the whole configuration 65 | """ 66 | self.configuration = conf 67 | self.write_conf() 68 | 69 | def set_wallpaper_path(self, path): 70 | self.configuration["wallpaper_path"] = path 71 | -------------------------------------------------------------------------------- /doc/Readme_ZH.md: -------------------------------------------------------------------------------- 1 | # Canvas Helper 2 2 | 3 | [![build](https://github.com/linsyking/CanvasHelper2/actions/workflows/build.yml/badge.svg)](https://github.com/linsyking/CanvasHelper2/actions/workflows/build.yml) 4 | 5 | 新一代的Canvas Helper后端。基于网页,支持Linux, Windows和MacOS。 6 | 7 | ## 要求 8 | 9 | - Python >= 3.7 10 | 11 | ## 工作流程 12 | 13 | 如果你只想在本地运行后端,在我们的服务器上使用前端,请执行以下操作: 14 | 15 | 1. 根据[文档](https://github.com/linsyking/CanvasHelper2/blob/main/doc/Readme_ZH.md#run-backend),在`9283`端口运行后端。 16 | 2. 访问来配置你的CanvasHelper 17 | 3. 访问预览结果 18 | 4. 使用[插件](Readme_ZH.md#部署到桌面)在桌面上部署Canvas Helper 19 | 20 | ## 开发流程 21 | 22 | 如果你想使用自己的前端或为这个项目做出贡献,你主要需要做3个步骤: 23 | 24 | 1. 运行后端 25 | 2. 运行`CanvasHelper2-conf`,在浏览器中配置CanvasHelper 26 | 3. 运行HTTP服务器来托管静态HTML文件(或开发自己的dashboard前端) 27 | 28 | ## 运行后端 29 | 30 | 首先,克隆这个仓库: 31 | 32 | ```bash 33 | git clone https://github.com/linsyking/CanvasHelper2.git 34 | 35 | cd CanvasHelper2 36 | ``` 37 | 38 | 安装依赖项: 39 | 40 | ```bash 41 | pip3 install -r requirements.txt 42 | ``` 43 | 44 | 如果你不想改变任何设置(如CORS),你可以直接运行以下代码:(如果你想使用我们的服务器上的前端,你必须使用`9283`端口) 45 | 46 | ```bash 47 | uvicorn canvas_app:app --port 9283 48 | ``` 49 | 50 | 开发者可能需要使用: 51 | 52 | ```bash 53 | uvicorn canvas_app:app --reload 54 | ``` 55 | 56 | 在脚本被修改时自动重新加载API。 57 | 58 | 如果你需要公开端口,你可以添加选项`--host 0.0.0.0`。 59 | 60 | ## 配置CanvasHelper 61 | 62 | 如果你想在我们的服务器上使用前端,请访问: [这里](https://canvashelper2.web.app/canvashelper/)。(网站未来可能会有变动) 63 | 64 | 如果你想在本地运行前端,请访问[CanvasHelper2-conf](https://github.com/linsyking/CanvasHelper2-conf)获取更多详细信息。 65 | 66 | ## 预览结果 67 | 68 | 如果你想在不托管HTML文件的情况下预览结果,你可以直接访问[这里](https://canvashelper2.web.app/)。 69 | 70 | 你可以使用任何您喜欢的http服务器来托管静态html文件。 71 | 72 | 示例dashboard前端位于 73 | 74 | 你可以克隆该存储库并通过 75 | 76 | ```bash 77 | python3 -m http.server 9282 78 | ``` 79 | 80 | 来托管这些文件。 81 | 82 | 现在,你可以访问页面查看结果。 83 | 84 | ## 部署到桌面 85 | 86 | ### Wallpaper Engine 87 | 88 | 订阅模板壁纸: 89 | 90 | 在本地启动后端后,它将重定向到[这里](https://canvashelper2.web.app/)。您也可以将其更改为本地前端。 91 | 92 | 要在启动时自动运行后端,您可以执行以下操作: 93 | 94 | 1. Win+R,输入“shell:startup” 95 | 2. 在打开的窗口中,创建一个名为canvashelper.vbs的文件。 96 | 97 | 其内容应该是这样的: 98 | 99 | ```vbs 100 | Dim WinScriptHost 101 | Set WinScriptHost = CreateObject("WScript.Shell") 102 | WinScriptHost.Run Chr(34) & "C:\XXX\canvashelper.bat" & Chr(34), 0 103 | Set WinScriptHost = Nothing 104 | ``` 105 | 106 | 将`C:\XXX\ CanvasHelper. bat`替换为存放用于启动CanvasHelper的`bat`文件的路径。 107 | 108 | **该bat脚本必须在C盘中** 109 | 110 | 3.创建包含以下内容的`C:\XXX\canvashelper.bat`文件: 111 | 112 | ```cmd 113 | @echo off 114 | 115 | d: 116 | cd D:\Project\CanvasHelper2 117 | uvicorn canvas_app:app --port 9283 118 | ``` 119 | 120 | 将`d:`和`D:\Project\CanvasHelper2`替换为你自己的目录。 121 | 122 | (如果你的克隆的仓库在C盘下,那么你不需要' d: '来进入D盘) 123 | 124 | 之后,系统将在启动时运行此脚本。 125 | 126 | **注意:壁纸引擎中的一些功能不被支持,包括滚动** 127 | 128 | ### KDE Wallpaper 129 | 130 | 1. 安装[wallpaper-engine-kde-plugin](https://github.com/catsout/wallpaper-engine-kde-plugin)。 131 | 2. 下载canvas wallpaper 132 | 3. 你应该能看到墙纸。 133 | 4. 添加一个自启动脚本来运行后端 134 | 135 | **注: 同样不支持滚动** 136 | 137 | 结果: 138 | 139 | ![demo](https://user-images.githubusercontent.com/49303317/210978732-68cefd73-75df-4013-a7cb-2010f16ec7dd.png) 140 | 141 | ### KDE Widget 142 | 143 | (另一个dashboard前端) 144 | 145 | *To-Do* 146 | 147 | ## 常见问题解答 148 | 149 | - CanvasHelper和CanvasHelper 2的区别是什么? 150 | 151 | > CanvasHelper1是中心化的,而CanvasHelper 2不是。它完全是本地的,所以你不需要连接到我们的服务器来使用CanvasHelper。 152 | > 此外,CanvasHelper2提供了一个方便的web界面来配置课程。 153 | > CanvasHelper2将前端和后端分开,这样你就可以在任何操作系统/桌面环境下开发自己的dashboard前端。 154 | 155 | - Canvas Helper后端,前端和dashboard之间的关系是什么? 156 | 157 | > 后端提供了几个api供前端和dashboard调用;前端使用本地api来配置Canvas Helper。dashboard还调用本地后端来获取配置。 158 | 159 | - 我一定要使用样本dashboard吗? 160 | 161 | > 不一定。你可以开发你自己的dashboard前端。这个样本前端使用后端的HTML输出并在一个可拖拽的组建中展示。 162 | -------------------------------------------------------------------------------- /models.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | @Author: King 4 | @Date: 2023-01-04 21:24:24 5 | @Email: linsy_king@sjtu.edu.cn 6 | """ 7 | 8 | from pydantic import BaseModel, Field 9 | from typing import Union 10 | 11 | """ 12 | Models 13 | """ 14 | 15 | 16 | class Position(BaseModel): 17 | left: int = Field(..., description="Left position") 18 | top: int = Field(..., description="Top position") 19 | width: int = Field(..., description="Width") 20 | height: int = Field(..., description="Height") 21 | 22 | 23 | class Check(BaseModel): 24 | type: int 25 | 26 | 27 | class Course(BaseModel): 28 | id: int 29 | name: str 30 | type: str 31 | maxshow: int = -1 32 | order: str = "normal" 33 | msg: str = "" 34 | 35 | 36 | class URL(BaseModel): 37 | url: str 38 | browser: Union[str, None] = None 39 | -------------------------------------------------------------------------------- /public/Readme.md: -------------------------------------------------------------------------------- 1 | # Public folder 2 | 3 | This folder is used to store static file (background images, videos, etc..) 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | fastapi 3 | uvicorn 4 | python-multipart 5 | GitPython 6 | -------------------------------------------------------------------------------- /updater.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | @Author: King 4 | @Date: 2023-02-16 12:12:05 5 | @Email: linsy_king@sjtu.edu.cn 6 | """ 7 | 8 | 9 | import git 10 | import os 11 | import logging 12 | 13 | """ 14 | Update git repo automatically 15 | """ 16 | 17 | 18 | def update(): 19 | try: 20 | repo = git.Repo(os.path.dirname(__file__)) 21 | current = repo.head.commit 22 | logging.info(f"Current version: {current}") 23 | repo.remotes.origin.pull() 24 | new = repo.head.commit 25 | if current != new: 26 | logging.info(f"Updated to {new}") 27 | except Exception as e: 28 | logging.error(e) 29 | logging.error("Cannot update") 30 | 31 | 32 | if __name__ == "__main__": 33 | update() 34 | --------------------------------------------------------------------------------