.
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 | 
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 | [](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 | 
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 |
--------------------------------------------------------------------------------