monash-study-kit 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,843 @@
1
+ """monash:Monash 学习助手命令行。
2
+
3
+ monash setup # 第一次用:登录 Ed 和 Moodle、选课、接进 Claude
4
+ monash login # Moodle 登录过期了:打开专用登录窗口
5
+ monash login ed # 换 Ed 令牌
6
+ monash status # 登录状态、上次同步
7
+ monash doctor # 出问题时:一项项检查并告诉你怎么修
8
+ monash help # 能做的事,每条附示例问法
9
+ monash sync # 立刻同步 Ed + Moodle(Claude 开着时每小时自动一次)
10
+ monash courses # 选要跟踪的课
11
+
12
+ monash todo # 本周待办:Moodle 截止/漏交 + Ed 没做完的 lesson、公告、未读回复
13
+ monash due [FIT2102] # 截止日期
14
+ monash grep "monad" [FIT2102] # 全文搜课件、字幕稿,带页码/时间戳
15
+ monash moodle … # 更多 Moodle 查询:grades、news、assignments、find、get、messages…
16
+ monash ed … # Ed 查询:new、threads、search、show、following、lessons、quiz
17
+
18
+ monash media # 录播:取 YouTube 字幕、下载 Zoom 录像并转写(要装 [media])
19
+ monash open # 打开课件文件夹
20
+ monash config # 看/改设置
21
+ monash update # 更新到最新版
22
+ monash connect cursor # 接进别的 AI 软件:codex、cursor、vscode、windsurf、gemini
23
+ monash uninstall # 从 Claude 和其他 AI 软件里移除(可选删除数据)
24
+
25
+ 大部分查询加 --json 输出 JSON(输出被管道接走时自动是 JSON,--text 强制文本)。
26
+ 退出码:0 成功,1 参数不对,2 需要登录,3 连不上 Moodle/Ed 或对方出错,4 找不到课程/帖子,130 被中断。
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import getpass
32
+ import os
33
+ import subprocess
34
+ import sys
35
+ import urllib.error
36
+ import webbrowser
37
+
38
+ from . import __version__, edlib, jobs
39
+ from . import features as F
40
+ from .edlib import EdAuthError
41
+ from .moodlelib import FILES_DIR, MoodleAuthError, MoodleClient, MoodleError, db_connect
42
+ from .output import emit_json, local_time, relative, table, want_json
43
+ from .paths import HOME, ensure_private_dir, load_settings, save_settings
44
+ from .syncer import course_code, course_folder
45
+
46
+
47
+ def _fmt_secs(s: int | None) -> str:
48
+ if s is None:
49
+ return "未知"
50
+ return f"{s // 3600} 小时 {s % 3600 // 60} 分" if s >= 3600 else f"{s // 60} 分"
51
+
52
+
53
+ def _yes(prompt: str, default: bool = True) -> bool:
54
+ ans = input(prompt + (" [Y/n] " if default else " [y/N] ")).strip().lower()
55
+ return default if not ans else ans in ("y", "yes", "是")
56
+
57
+
58
+ # ---------------------------------------------------------------- 登录
59
+
60
+ def login_moodle(interactive_only: bool = False) -> bool:
61
+ from .browser_login import LoginFailed, ensure_login, login_moodle as window_login
62
+ try:
63
+ r = window_login(interactive=True) if interactive_only else ensure_login()
64
+ except LoginFailed as e:
65
+ print(f"Moodle 没登录成功:{e}")
66
+ return False
67
+ print(f"✓ Moodle 已登录(会话还剩 {_fmt_secs(r['time_remaining'])};Claude 开着时会自动续期)")
68
+ return True
69
+
70
+
71
+ def login_ed() -> bool:
72
+ print("Ed 用“API 令牌”登录(不会过期,随时可以在 Ed 设置页删掉作废):")
73
+ print(f" 1. 在浏览器里打开 {edlib.TOKEN_PAGE}(正在帮你打开)")
74
+ print(" 2. 点 Create Token,名字随便写(比如 claude),复制生成的令牌")
75
+ print(" 3. 粘贴到下面(不会显示出来),回车")
76
+ webbrowser.open(edlib.TOKEN_PAGE)
77
+ for _ in range(3):
78
+ token = getpass.getpass("Ed 令牌:").strip()
79
+ if not token:
80
+ print("没有输入,跳过 Ed。之后可以运行 monash login ed")
81
+ return False
82
+ if any(c.isspace() for c in token):
83
+ print("令牌里不应该有空格或换行,再粘贴一次")
84
+ continue
85
+ try:
86
+ user = edlib.EdClient(token=token).me().get("user", {})
87
+ except Exception as e: # noqa: BLE001 格式不对 Ed 回 400,删掉的回 401
88
+ print(f"Ed 不认这个令牌({getattr(e, 'code', e)}),再试一次")
89
+ continue
90
+ edlib.save_token(token)
91
+ print(f"✓ Ed 已登录:{user.get('name')} <{user.get('email')}>")
92
+ return True
93
+ return False
94
+
95
+
96
+ def cmd_login(args):
97
+ if args.what == "ed":
98
+ return 0 if login_ed() else 1
99
+ return 0 if login_moodle(interactive_only=args.window) else 2
100
+
101
+
102
+ # ---------------------------------------------------------------- 选课
103
+
104
+ def _pick(items: list[str], chosen: set[int], title: str) -> set[int] | None:
105
+ print(f"\n{title}")
106
+ for i, label in enumerate(items, 1):
107
+ print(f" {'[x]' if i - 1 in chosen else '[ ]'} {i:>2}. {label}")
108
+ ans = input("输入要跟踪的编号(空格分隔,如 1 3 4;直接回车保持不变):").strip()
109
+ if not ans:
110
+ return None
111
+ out = set()
112
+ for tok in ans.replace(",", " ").split():
113
+ if "-" in tok and tok.replace("-", "").isdigit():
114
+ a, b = tok.split("-", 1)
115
+ out.update(range(int(a) - 1, int(b)))
116
+ elif tok.isdigit():
117
+ out.add(int(tok) - 1)
118
+ return {i for i in out if 0 <= i < len(items)}
119
+
120
+
121
+ def choose_courses(interactive: bool = True) -> None:
122
+ """Moodle 默认跟踪本学期的课,Ed 默认跟踪 active 的课;interactive 时让人改。"""
123
+ try:
124
+ client, con = MoodleClient(), db_connect()
125
+ courses = sorted(jobs.load_courses(client, con), key=lambda c: -(c.get("startdate") or 0))
126
+ tracked = {c["id"] for c in jobs.tracked_courses(con, courses)}
127
+ if interactive:
128
+ picked = _pick([course_folder(c) for c in courses],
129
+ {i for i, c in enumerate(courses) if c["id"] in tracked}, "Moodle 课程(★ 同步课件):")
130
+ if picked is not None:
131
+ con.execute("UPDATE courses SET tracked=0")
132
+ con.executemany("UPDATE courses SET tracked=1 WHERE id=?", [(courses[i]["id"],) for i in picked])
133
+ con.commit()
134
+ tracked = {courses[i]["id"] for i in picked}
135
+ print("Moodle 跟踪:" + ("、".join(course_code(c) or course_folder(c) for c in courses if c["id"] in tracked) or "无"))
136
+ except MoodleAuthError:
137
+ print("(Moodle 没登录,先跳过 Moodle 选课)")
138
+ if not jobs.have_ed_token():
139
+ return
140
+ from . import edsync
141
+ try:
142
+ ed = edlib.EdClient()
143
+ conn = edlib.db_connect()
144
+ ecs = ed.courses()
145
+ edsync.upsert_courses(conn, ecs)
146
+ if not conn.execute("SELECT 1 FROM courses WHERE tracked=1").fetchone():
147
+ edsync.auto_track(conn, ecs)
148
+ rows = [dict(r) for r in conn.execute("SELECT id, code, name, status, tracked FROM courses "
149
+ "ORDER BY status != 'active', id DESC")]
150
+ if interactive:
151
+ picked = _pick([f"{edsync.short_code(r['code'])} {r['name']} ({r['status']})" for r in rows],
152
+ {i for i, r in enumerate(rows) if r["tracked"]}, "Ed 课程(★ 同步帖子和 Lessons):")
153
+ if picked is not None:
154
+ conn.execute("UPDATE courses SET tracked=0")
155
+ conn.executemany("UPDATE courses SET tracked=1 WHERE id=?", [(rows[i]["id"],) for i in picked])
156
+ conn.commit()
157
+ names = [edsync.short_code(r["code"]) for r in conn.execute("SELECT code FROM courses WHERE tracked=1")]
158
+ print("Ed 跟踪:" + ("、".join(names) or "无"))
159
+ except EdAuthError as e:
160
+ print(f"(Ed:{e})")
161
+
162
+
163
+ def cmd_courses(args):
164
+ choose_courses(interactive=True)
165
+
166
+
167
+ # ---------------------------------------------------------------- setup / status / sync
168
+
169
+ def cmd_setup(args):
170
+ from . import claude_setup
171
+ if args.what == "claude":
172
+ desktop = claude_setup.install_desktop()
173
+ code = claude_setup.install_code()
174
+ return 0 if desktop or code else 1
175
+ print(f"Monash 学习助手 {__version__}\n数据都放在:{HOME}\n")
176
+ print("== 1/4 Ed ==")
177
+ if jobs.have_ed_token():
178
+ print("✓ 已经有 Ed 令牌(要换就运行 monash login ed)")
179
+ else:
180
+ login_ed()
181
+ print("\n== 2/4 Moodle ==")
182
+ from .browser_login import session_ok
183
+ if session_ok():
184
+ print("✓ Moodle 已经是登录状态")
185
+ else:
186
+ print("接下来会打开一个单独的浏览器窗口,在里面正常登录 Monash(Okta + MFA)。")
187
+ print("这个窗口用自己的配置目录,和你平时用的浏览器互不影响。")
188
+ input("准备好了按回车…")
189
+ login_moodle()
190
+ print("\n== 3/4 选课 ==")
191
+ choose_courses(interactive=_yes("默认跟踪本学期的课。要自己挑吗?", default=False))
192
+ print("\n== 4/4 接进 Claude ==")
193
+ claude_setup.install_code()
194
+ claude_setup.install_desktop()
195
+ print()
196
+ if _yes("现在先同步一次吗?(第一次要几分钟;不同步的话,Claude 开着时也会在后台自己同步)"):
197
+ cmd_sync(argparse.Namespace(dry_run=False, courses=[]))
198
+ print("\n完成!在 Claude 里问问看:“这周我有什么要交的?”")
199
+ return 0
200
+
201
+
202
+ def cmd_status(args):
203
+ st = jobs.read_status()
204
+ out = {"home": str(HOME), "files": str(FILES_DIR), "last_sync": jobs.last_sync_at(st),
205
+ "errors": st.get("errors") or {}}
206
+ try:
207
+ out["moodle_session_left"] = MoodleClient().time_remaining()
208
+ except MoodleAuthError as e:
209
+ out["moodle_problem"] = str(e)
210
+ out["ed_token"] = jobs.have_ed_token()
211
+ out["calendar_fallback"] = bool(F.load_calendar_url())
212
+ if want_json(args):
213
+ return emit_json(out)
214
+ print(f"数据目录:{out['home']}")
215
+ if "moodle_session_left" in out:
216
+ print(f"Moodle:已登录,会话还剩 {_fmt_secs(out['moodle_session_left'])}")
217
+ else:
218
+ print(f"Moodle:未登录({out['moodle_problem']})→ monash login"
219
+ + (";截止日期暂时用日历订阅链接" if out["calendar_fallback"] else ""))
220
+ print(f"Ed:{'已设置令牌' if out['ed_token'] else '没有令牌 → monash login ed'}")
221
+ print(f"上次同步:{local_time(out['last_sync'], '%Y-%m-%d %H:%M') if out['last_sync'] else '还没同步过'}")
222
+ for k, v in out["errors"].items():
223
+ print(f" ! {k}:{v}")
224
+
225
+
226
+ def cmd_doctor(args):
227
+ from . import doctor
228
+ report = doctor.run(deep=args.deep)
229
+ if want_json(args):
230
+ return emit_json(report)
231
+ print(doctor.render(report))
232
+
233
+
234
+ def cmd_help(args):
235
+ from . import help_menu
236
+ print(help_menu.render(cli=True))
237
+
238
+
239
+ def cmd_sync(args):
240
+ if args.courses or args.dry_run:
241
+ client, con = MoodleClient(), db_connect()
242
+ courses = jobs.load_courses(client, con)
243
+ only = [F.find_course(courses, r) for r in args.courses] if args.courses else None
244
+ st = jobs.sync_moodle(only=only, dry_run=args.dry_run)
245
+ print(f"新下载 {st['downloaded']} 个文件,{st['bytes'] / 1e6:.1f} MB")
246
+ return
247
+ st = jobs.sync_all()
248
+ m, e, idx = st.get("moodle") or {}, st.get("ed") or {}, st.get("index") or {}
249
+ print("\n同步完成:" + ",".join(x for x in [
250
+ f"Ed 新帖 {e.get('new_threads', 0)}、新回复 {e.get('new_replies', 0)}" if "ed" not in st["errors"] else "",
251
+ f"Moodle 新文件 {m.get('downloaded', 0)} 个({m.get('bytes', 0) / 1e6:.1f} MB)"
252
+ + (f"、下架 {m['removed']} 个" if m.get("removed") else "") if "moodle" not in st["errors"] else "",
253
+ f"索引了 {idx.get('indexed', 0)} 个文件" if idx else ""] if x))
254
+ for k, v in (st.get("errors") or {}).items():
255
+ print(f" ! {k}:{v}")
256
+
257
+
258
+ def cmd_open(args):
259
+ FILES_DIR.mkdir(parents=True, exist_ok=True)
260
+ path = str(FILES_DIR)
261
+ if sys.platform == "darwin":
262
+ subprocess.run(["open", path])
263
+ elif os.name == "nt":
264
+ os.startfile(path) # noqa: S606
265
+ else:
266
+ subprocess.run(["xdg-open", path])
267
+ print(path)
268
+
269
+
270
+ def parse_setting(key: str, raw: str, current):
271
+ """按默认值的类型解析。数字一律先当小数读:阿德莱德是 UTC+9.5,auto_sync_hours 也可以写 0.5。"""
272
+ if isinstance(current, bool):
273
+ if raw.lower() in ("true", "yes", "1", "on"):
274
+ return True
275
+ if raw.lower() in ("false", "no", "0", "off"):
276
+ return False
277
+ raise ValueError(f"{key} 只能是 true 或 false")
278
+ if isinstance(current, (int, float)):
279
+ try:
280
+ num = float(raw)
281
+ except ValueError:
282
+ raise ValueError(f"{key} 要填数字,比如 monash config {key} {current}") from None
283
+ return int(num) if num.is_integer() else num
284
+ return raw
285
+
286
+
287
+ def cmd_config(args):
288
+ if not args.key:
289
+ for k, v in load_settings().items():
290
+ print(f"{k} = {v}")
291
+ print("\n改:monash config download_videos true")
292
+ return
293
+ cur = load_settings()
294
+ if args.key not in cur:
295
+ raise ValueError(f"没有这个设置:{args.key}(可用:{', '.join(cur)})")
296
+ if args.value is None:
297
+ print(cur[args.key])
298
+ return
299
+ val = parse_setting(args.key, args.value, cur[args.key])
300
+ save_settings({args.key: val})
301
+ print(f"{args.key} = {val}(Claude Desktop 要重启一下才会用上新设置)")
302
+
303
+
304
+ CONNECT_TARGETS = ["claude", "claude-desktop", "claude-code", "codex", "cursor", "vscode", "windsurf", "gemini"]
305
+
306
+
307
+ def cmd_connect(args):
308
+ from . import claude_setup, clients
309
+ if not args.client:
310
+ print("可以接进这些 AI 软件(monash connect <名字>):\n")
311
+ rows = [["claude", "Claude Desktop + Claude Code", "运行 monash setup claude 也一样"]]
312
+ for c in clients.CLIENTS.values():
313
+ state = "已接入" if clients.is_connected(c.key) else ("检测到" if clients.detected(c) else "")
314
+ rows.append([c.key, c.name, state])
315
+ for key, name, state in rows:
316
+ print(f" {key:<10} {name:<30} {state}")
317
+ print("\n--print 只打印要加的配置,不改文件;--remove 移除。")
318
+ return 0
319
+ if args.client.startswith("claude"):
320
+ if args.print or args.remove:
321
+ print("Claude 用 monash setup claude 接入、monash uninstall 移除", file=sys.stderr)
322
+ return EXIT_USAGE
323
+ desktop = args.client != "claude-code" and claude_setup.install_desktop()
324
+ code = args.client != "claude-desktop" and claude_setup.install_code()
325
+ if args.client == "claude-code" and not code:
326
+ print("没找到 claude 命令。先装好 Claude Code 再运行一次")
327
+ return 0 if desktop or code else 1
328
+ client = clients.CLIENTS[args.client]
329
+ if args.print:
330
+ print(f"# {client.path()}")
331
+ print(clients.snippet(client, claude_setup.server_command()))
332
+ return 0
333
+ if args.remove:
334
+ print(f"已从 {client.name} 移除 monash" if clients.disconnect(args.client) else f"{client.name} 里本来就没有 monash")
335
+ return 0
336
+ return 0 if clients.connect(args.client) else 1
337
+
338
+
339
+ def cmd_uninstall(args):
340
+ from . import claude_setup, clients
341
+ path = claude_setup.desktop_config_path()
342
+ if path and path.exists():
343
+ if claude_setup.desktop_running():
344
+ print("先完全退出 Claude Desktop 再运行(开着改配置会被覆盖)")
345
+ return 1
346
+ if claude_setup.remove_from_desktop_config(path):
347
+ print("已从 Claude Desktop 移除 monash")
348
+ import shutil
349
+ if shutil.which("claude"):
350
+ subprocess.run(["claude", "mcp", "remove", "-s", "user", "monash"], capture_output=True)
351
+ print("已从 Claude Code 移除 monash")
352
+ for key, c in clients.CLIENTS.items():
353
+ try:
354
+ if clients.disconnect(key):
355
+ print(f"已从 {c.name} 移除 monash")
356
+ except OSError:
357
+ pass
358
+ if _yes(f"要删除所有数据吗(课件、登录状态、登录窗口的配置)?{HOME}", default=False):
359
+ shutil.rmtree(HOME, ignore_errors=True)
360
+ print("已删除")
361
+ print("最后运行 uv tool uninstall monash-study-kit 卸载程序本身")
362
+
363
+
364
+ # 从 PyPI 装。PyPI 上暂时装不到(刚合进 main、还没发版那几分钟,或者 PyPI 抽风)时退回 main 的 zip
365
+ PACKAGE = "monash-study-kit"
366
+ SOURCE = "https://github.com/Waldo0926/monash-study-kit/archive/refs/heads/main.zip"
367
+
368
+
369
+ def update_commands(media: bool) -> list[list[str]]:
370
+ extra = "[media]" if media else ""
371
+ base = ["uv", "tool", "install", "--reinstall", "--refresh"]
372
+ return [base + [PACKAGE + extra], base + [f"{PACKAGE}{extra} @ {SOURCE}" if media else SOURCE]]
373
+
374
+
375
+ def cmd_update(args):
376
+ import importlib.util
377
+ import shutil
378
+ media = importlib.util.find_spec("faster_whisper") is not None
379
+ cmds = update_commands(media)
380
+ if os.name == "nt" or not shutil.which("uv"):
381
+ # Windows 上正在运行的 monash.exe(包括 Claude 开着的那个)不能被覆盖,要先退出 Claude 再在新窗口里跑
382
+ print("先完全退出 Claude Desktop(托盘图标 → Quit),然后在新的 PowerShell 窗口里运行:\n")
383
+ print(" " + " ".join(f'"{c}"' if " " in c else c for c in cmds[0]))
384
+ return 0
385
+ r = subprocess.run(cmds[0])
386
+ if r.returncode != 0:
387
+ print("\nPyPI 上没装成,改从 GitHub 装:")
388
+ r = subprocess.run(cmds[1])
389
+ if r.returncode == 0:
390
+ from . import update_check
391
+ update_check.clear()
392
+ print("\n已更新。重启 Claude Desktop 后生效。")
393
+ return r.returncode
394
+
395
+
396
+ def cmd_mcp(args):
397
+ from . import mcp_server
398
+ mcp_server.main()
399
+
400
+
401
+ def cmd_media(args):
402
+ from . import recordings, transcribe
403
+ client, con = MoodleClient(), db_connect()
404
+ jobs.load_courses(client, con)
405
+ st = recordings.sync_recordings(client, con)
406
+ print(f"Ed 录播:Zoom 录像 {st['zoom']} 个,YouTube 字幕 {st['youtube']} 份,已有 {st['skipped']}"
407
+ + (f",{len(st['errors'])} 个错误" if st["errors"] else ""))
408
+ transcribe.run(con, limit=args.limit, match=args.match, dry_run=args.dry_run,
409
+ **({"model_name": args.model} if args.model else {}))
410
+ from . import content_index
411
+ content_index.index(con)
412
+
413
+
414
+ # ---------------------------------------------------------------- Moodle 查询
415
+
416
+ def _courses_for(client, con, refs: list[str] | None) -> list[dict]:
417
+ courses = jobs.load_courses(client, con)
418
+ if refs:
419
+ return [F.find_course(courses, r) for r in refs]
420
+ return jobs.tracked_courses(con, courses)
421
+
422
+
423
+ def cmd_todo(args):
424
+ from . import todo
425
+ try:
426
+ client = MoodleClient()
427
+ courses = _courses_for(client, db_connect(), None)
428
+ except MoodleAuthError:
429
+ client, courses = None, []
430
+ data = todo.build(client, courses, args.days)
431
+ if want_json(args):
432
+ return emit_json(data)
433
+ for n in data["notes"]:
434
+ print(f"! {n}")
435
+ print(f"== Moodle 截止({data['days']} 天内)==")
436
+ _print_due(data["due"]) if data["due"] else print("(没有)")
437
+ if data["possibly_missing"]:
438
+ print("\n== 可能漏交(已过截止、没交、没分)==")
439
+ for a in data["possibly_missing"]:
440
+ print(f"{local_time(a['due'])} [{a['course']}] {a['name']}")
441
+ if data["ed_lessons"]:
442
+ todo_ls = [x for x in data["ed_lessons"] if x["status"] != "attempted"]
443
+ half = [x for x in data["ed_lessons"] if x["status"] == "attempted"]
444
+ print("\n== Ed 还没开始的 lesson(到目前进度的下一周)==")
445
+ for x in todo_ls:
446
+ print(f"○ [{x['course']}] {x['title']}")
447
+ if half:
448
+ print(f"另外 {len(half)} 个做了一半:monash ed lessons 课号 --status attempted")
449
+ if data.get("new_files"):
450
+ print(f"\n== 最近 {data['days']} 天新上的课件 ==")
451
+ for f in data["new_files"]:
452
+ print(f"{local_time(f['synced_at'])} [{f['course']}] {f['path'].split('/', 1)[-1]}")
453
+ if data["announcements"]:
454
+ print(f"\n== 最近 {data['days']} 天的公告 ==")
455
+ for n in data["announcements"]:
456
+ print(f"{local_time(n['time'])} [{n['course']}] {n['title']} ({n['ref'] or 'Moodle'})")
457
+ if data["ed_unread_replies"]:
458
+ print("\n== Ed 上有未读新回复 ==")
459
+ for t in data["ed_unread_replies"]:
460
+ print(f"{t['ref']:<13} +{t['new']} {t['title']}{' ★我发的/关注的' if t['mine_or_watched'] else ''}")
461
+
462
+
463
+ KIND_ZH = {"due": "截止", "due date": "截止", "closes": "关闭", "is due": "截止", "opens": "开放",
464
+ "cut-off date": "最终截止", "grading due": "批改截止", "expected": "预计完成", "event": "事件",
465
+ "course": "课程事件", "user": "个人", "site": "全站"}
466
+
467
+
468
+ def _print_due(items):
469
+ rows = [{**i, "when": local_time(i["due"]), "left": relative(i["due"]),
470
+ "what": i["activity"] or i["name"], "kind": KIND_ZH.get(i["kind"], i["kind"])} for i in items]
471
+ if items and all(i.get("source") == "ical" for i in items):
472
+ print("(Moodle 没登录,以下来自日历订阅链接,没有提交状态;monash login 后恢复)")
473
+ table(rows, [("when", "时间", 16), ("left", "剩余", 8), ("course", "课程", 8), ("kind", "类型", 6),
474
+ ("what", "内容", 60)])
475
+
476
+
477
+ def _client_or_none():
478
+ try:
479
+ return MoodleClient()
480
+ except MoodleAuthError:
481
+ return None
482
+
483
+
484
+ def cmd_due(args):
485
+ client = _client_or_none()
486
+ course = None
487
+ if args.course:
488
+ # 没登录时只能按课号过滤日历订阅链接里的事件
489
+ course = F.find_course(jobs.load_courses(client, db_connect()), args.course) if client \
490
+ else {"id": None, "shortname": args.course.upper(), "fullname": args.course.upper()}
491
+ items = F.due(client, args.days, course)
492
+ return emit_json(items) if want_json(args) else _print_due(items)
493
+
494
+
495
+ def cmd_grep(args):
496
+ from . import content_index as CI
497
+ hits = CI.search(db_connect(), args.query, args.course.upper() if args.course else None, args.limit)
498
+ if want_json(args):
499
+ return emit_json(hits)
500
+ if not hits:
501
+ print("没搜到。还没同步过的话先 monash sync。")
502
+ for h in hits:
503
+ where = f"{h['loc']}" + (f"(视频 {h['video']})" if h["video"] else "")
504
+ print(f"[{h['course'] or '?'}] {h['title']} @ {where}\n {h['snippet']}\n")
505
+
506
+
507
+ def cmd_m_overview(args):
508
+ client, con = MoodleClient(), db_connect()
509
+ left = client.time_remaining()
510
+ due = F.due(client, 14)
511
+ alerts = F.alerts(client, 5)
512
+ courses = _courses_for(client, con, None)
513
+ news, missing = [], []
514
+ for c in courses:
515
+ news += F.news(client, c, 2)
516
+ try:
517
+ missing += [a for a in F.assignments(client, c) if a["missing"]]
518
+ except Exception as e: # noqa: BLE001
519
+ print(f"! {course_code(c)} 作业汇总没拿到:{e}", file=sys.stderr)
520
+ news = sorted(news, key=lambda x: -(x.get("time_ts") or 0))[:5]
521
+ if want_json(args):
522
+ return emit_json({"session_time_remaining": left, "due": due, "alerts": alerts, "news": news,
523
+ "possibly_missing": missing})
524
+ print(f"会话还剩 {_fmt_secs(left)}\n\n== 近 14 天截止 ==")
525
+ _print_due(due)
526
+ print(f"\n== 通知 == 未读通知 {alerts['unread_notifications']},未读消息 {alerts['unread_messages']}")
527
+ if missing:
528
+ print("\n== 可能漏交(已过截止、没交、没分)==")
529
+ for a in missing:
530
+ print(f"{local_time(a['due'])} [{a['course']}] {a['name']}")
531
+ print("\n== 最新公告 ==")
532
+ for n in news:
533
+ print(f"{local_time(n['time'])} [{n['course']}] {n['title']}")
534
+
535
+
536
+ def cmd_m_calendar(args):
537
+ url = F.calendar_url(MoodleClient(), refresh=args.refresh) if args.refresh or not F.load_calendar_url() \
538
+ else F.load_calendar_url()
539
+ shown = url if args.show else F.mask_calendar_url(url)
540
+ if want_json(args):
541
+ return emit_json({"url": shown})
542
+ print(shown)
543
+ if not args.show:
544
+ print("(--show 显示完整链接。它等于一把只读日历钥匙,别发到公开的地方)")
545
+ print("订阅:苹果日历 → 文件 → 新建日历订阅;Google 日历 → 其他日历 → 通过网址添加。")
546
+
547
+
548
+ def cmd_m_assignments(args):
549
+ client, con = MoodleClient(), db_connect()
550
+ items = []
551
+ for c in _courses_for(client, con, [args.course] if args.course else None):
552
+ items += F.assignments(client, c)
553
+ if args.missing:
554
+ items = [i for i in items if i["missing"]]
555
+ if want_json(args):
556
+ return emit_json(items)
557
+ rows = [{**i, "when": local_time(i["due"]), "flag": "⚠ 可能漏交" if i["missing"] else ""} for i in items]
558
+ table(rows, [("course", "课程", 8), ("when", "截止", 16), ("status", "提交", 22), ("grade", "成绩", 8),
559
+ ("flag", "", 10), ("name", "作业", 60)])
560
+ if any(i["missing"] for i in items):
561
+ print("\n⚠ = 已过截止、没交、也没分。面试、现场展示之类本来就不用在线交,自己核对一下。")
562
+
563
+
564
+ def cmd_m_grades(args):
565
+ client, con = MoodleClient(), db_connect()
566
+ if not args.course:
567
+ data = F.grades_overview(client)
568
+ return emit_json(data) if want_json(args) else table(data, [("grade", "总分", 10), ("course", "课程", 70)])
569
+ course = F.find_course(jobs.load_courses(client, con), args.course)
570
+ data = F.grades(client, course["id"], args.graded_only)
571
+ if want_json(args):
572
+ return emit_json(data)
573
+ table(data, [("item", "项目", 40), ("grade", "得分", 10), ("range", "满分", 10), ("percentage", "百分比", 8),
574
+ ("feedback", "反馈", 60)])
575
+
576
+
577
+ def cmd_m_news(args):
578
+ client, con = MoodleClient(), db_connect()
579
+ out = []
580
+ for c in _courses_for(client, con, [args.course] if args.course else None):
581
+ out += F.news(client, c, args.limit)
582
+ out.sort(key=lambda x: -(x.get("time_ts") or 0))
583
+ if want_json(args):
584
+ return emit_json(out)
585
+ for n in out:
586
+ print(f"\n■ [{n['course']}] {n['title']} · {n['author']} · {local_time(n['time'])}")
587
+ text = n["text"] if args.full else n["text"][:400] + ("…" if len(n["text"]) > 400 else "")
588
+ print(" " + text.replace("\n", "\n "))
589
+ print(f" {n['url']}")
590
+
591
+
592
+ def cmd_m_search(args):
593
+ client, con = MoodleClient(), db_connect()
594
+ cid = F.find_course(jobs.load_courses(client, con), args.course)["id"] if args.course else 1
595
+ data = F.forum_search(client, args.query, cid, args.limit)
596
+ if want_json(args):
597
+ return emit_json(data)
598
+ for p in data:
599
+ print(f"\n■ {p['subject']} · {p['author']} · {local_time(p['time'])}")
600
+ print(" " + (p["text"][:300] + ("…" if len(p["text"]) > 300 else "")).replace("\n", "\n "))
601
+ print(f" {p['url']}")
602
+ if not data:
603
+ print("没有搜到。Monash 大部分课的讨论在 Ed 上(monash ed search …)。")
604
+
605
+
606
+ def cmd_m_find(args):
607
+ client, con = MoodleClient(), db_connect()
608
+ data = F.find(client, args.query, _courses_for(client, con, [args.course] if args.course else None),
609
+ limit=args.limit)
610
+ if want_json(args):
611
+ return emit_json(data)
612
+ table(data, [("course", "课程", 8), ("type", "类型", 9), ("id", "id", 8), ("name", "名称", 45),
613
+ ("where", "位置", 60)])
614
+ print("\n下载:monash moodle get <id> 打开:monash moodle open <id>")
615
+
616
+
617
+ def cmd_m_get(args):
618
+ from pathlib import Path
619
+ client, con = MoodleClient(), db_connect()
620
+ receipts = F.get(client, args.ref, Path(args.to).expanduser(), args.force, jobs.load_courses(client, con))
621
+ if want_json(args):
622
+ return emit_json(receipts)
623
+ for r in receipts:
624
+ print(f"↓ {r['path']} ({(r['bytes'] or 0) / 1e6:.2f} MB, {r['content_type']})" if r.get("path")
625
+ else f"↗ {r['url']}({r['note']})")
626
+
627
+
628
+ def cmd_m_open(args):
629
+ from .moodlelib import BASE_URL
630
+ ref = args.ref
631
+ if ref.startswith("http"):
632
+ url = ref
633
+ elif ref.isdigit() and len(ref) > 5 and not args.week:
634
+ url = f"{BASE_URL}/mod/resource/view.php?id={ref}"
635
+ else:
636
+ client, con = MoodleClient(), db_connect()
637
+ course = F.find_course(jobs.load_courses(client, con), ref)
638
+ url = f"{BASE_URL}/course/view.php?id={course['id']}"
639
+ if args.week:
640
+ hits = F.find(client, f"week {args.week}", [course], types={"section"})
641
+ url = hits[0]["url"] if hits else url
642
+ print(url)
643
+ webbrowser.open(url)
644
+
645
+
646
+ def cmd_m_alerts(args):
647
+ data = F.alerts(MoodleClient(), args.limit)
648
+ if want_json(args):
649
+ return emit_json(data)
650
+ print(f"未读通知 {data['unread_notifications']},未读消息 {data['unread_messages']}")
651
+ for n in data["notifications"]:
652
+ print(f"{' ' if n['read'] else '● '}{local_time(n['time'])} {n['subject']}")
653
+
654
+
655
+ def cmd_m_messages(args):
656
+ client = MoodleClient()
657
+ if args.conversation:
658
+ data = F.conversation_messages(client, args.conversation, args.limit)
659
+ if want_json(args):
660
+ return emit_json(data)
661
+ for m in data["messages"]:
662
+ print(f"{local_time(m['time'])} {m['from']}:{m['text']}")
663
+ return
664
+ data = F.conversations(client, args.limit)
665
+ if want_json(args):
666
+ return emit_json(data)
667
+ rows = [{**c, "flag": f"● {c['unread']}" if c["unread"] else "",
668
+ "when": local_time((c["last"] or {}).get("time")),
669
+ "preview": (f"{c['last']['from']}:{c['last']['text']}" if c["last"] else "").replace("\n", " ")}
670
+ for c in data]
671
+ table(rows, [("id", "id", 8), ("flag", "未读", 5), ("name", "对话", 24), ("when", "最后一条", 16),
672
+ ("preview", "内容", 60)])
673
+
674
+
675
+ def cmd_m_ls(args):
676
+ con = db_connect()
677
+ course_id = next((r["id"] for r in con.execute("SELECT id, code FROM courses ORDER BY id DESC")
678
+ if (r["code"] or "") == args.course.upper() or str(r["id"]) == args.course), None)
679
+ if course_id is None:
680
+ raise LookupError(f"本地没有课程 {args.course}(先 monash sync)")
681
+ q, params = "SELECT path, size FROM files WHERE course_id=? AND removed_at IS NULL", [course_id]
682
+ if args.week:
683
+ q += " AND path LIKE ?"
684
+ params.append(f"%/Week {int(args.week):02d}%")
685
+ rows = con.execute(q + " ORDER BY path", params).fetchall()
686
+ for r in rows:
687
+ print(f"{(r['size'] or 0) / 1e6:7.1f} MB {r['path'].split('/', 1)[1]}")
688
+ print(f"\n{len(rows)} 个文件,在 {FILES_DIR}")
689
+
690
+
691
+ # ---------------------------------------------------------------- 参数
692
+
693
+ # 退出码。argparse 默认参数错误退 2,会和"需要登录"撞车,所以 _Parser 改成退 1。
694
+ EXIT_OK, EXIT_USAGE, EXIT_AUTH, EXIT_NETWORK, EXIT_NOT_FOUND, EXIT_INTERRUPTED = 0, 1, 2, 3, 4, 130
695
+ EXIT_CODES = {
696
+ EXIT_OK: "成功",
697
+ EXIT_USAGE: "参数不对(看 stderr 的提示)",
698
+ EXIT_AUTH: "需要登录:monash login(Moodle)或 monash login ed",
699
+ EXIT_NETWORK: "连不上 Moodle/Ed,或对方返回了错误(稍后再试)",
700
+ EXIT_NOT_FOUND: "找不到课程或帖子",
701
+ EXIT_INTERRUPTED: "被 Ctrl-C 中断",
702
+ }
703
+
704
+
705
+ class _Parser(argparse.ArgumentParser):
706
+ """参数错误退 EXIT_USAGE(argparse 默认退 2)。子命令的解析器也会用这个类。"""
707
+
708
+ def error(self, message):
709
+ self.print_usage(sys.stderr)
710
+ self.exit(EXIT_USAGE, f"{self.prog}: 错误: {message}\n")
711
+
712
+
713
+ def build_parser() -> argparse.ArgumentParser:
714
+ from . import cli_ed
715
+ ap = _Parser(prog="monash", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
716
+ ap.add_argument("--version", action="version", version=f"monash-study-kit {__version__}")
717
+ sub = ap.add_subparsers(dest="cmd")
718
+
719
+ def cmd(parent, name, fn, json_flag=False, **kw):
720
+ p = parent.add_parser(name, **kw)
721
+ if json_flag:
722
+ g = p.add_mutually_exclusive_group()
723
+ g.add_argument("--json", action="store_true", help="输出 JSON(管道里默认就是)")
724
+ g.add_argument("--text", action="store_true", help="输出文本(终端里默认就是)")
725
+ p.set_defaults(fn=fn)
726
+ return p
727
+
728
+ p = cmd(sub, "setup", cmd_setup, help="第一次用:登录、选课、接进 Claude")
729
+ p.add_argument("what", nargs="?", choices=["claude"], help="claude:只做“接进 Claude”这一步")
730
+ p = cmd(sub, "login", cmd_login, help="登录 Moodle(默认)或设置 Ed 令牌")
731
+ p.add_argument("what", nargs="?", choices=["moodle", "ed"], default="moodle")
732
+ p.add_argument("--window", action="store_true", help="直接开窗口,不先试后台自动登录")
733
+ cmd(sub, "status", cmd_status, json_flag=True, help="登录状态、上次同步")
734
+ cmd(sub, "help", cmd_help, help="能做的事和示例问法(Claude 里怎么问、对应哪条命令)")
735
+ p = cmd(sub, "doctor", cmd_doctor, json_flag=True, help="体检:一项项查哪里不对,告诉你怎么修")
736
+ p.add_argument("--deep", action="store_true",
737
+ help="再去 Moodle 和 Ed 拉一遍,和日历订阅、课程结构、本地文件对照(要一两分钟)")
738
+ p = cmd(sub, "sync", cmd_sync, help="同步 Ed + Moodle + 全文索引")
739
+ p.add_argument("courses", nargs="*", help="只同步这几门 Moodle 课")
740
+ p.add_argument("--dry-run", action="store_true", help="只列出 Moodle 会下载什么")
741
+ cmd(sub, "courses", cmd_courses, help="选要跟踪的课")
742
+ p = cmd(sub, "todo", cmd_todo, json_flag=True, help="本周待办")
743
+ p.add_argument("--days", type=int, default=7)
744
+ p = cmd(sub, "due", cmd_due, json_flag=True, help="截止日期")
745
+ p.add_argument("course", nargs="?"); p.add_argument("--days", type=int, default=14)
746
+ p = cmd(sub, "grep", cmd_grep, json_flag=True, help="全文搜课件和字幕稿")
747
+ p.add_argument("query"); p.add_argument("course", nargs="?"); p.add_argument("--limit", type=int, default=15)
748
+ p = cmd(sub, "media", cmd_media, help="录播:YouTube 字幕、Zoom 录像下载和转写(要装 [media])")
749
+ p.add_argument("--limit", type=int, default=0, help="这次最多转写几个视频")
750
+ p.add_argument("--match", help="只转写路径里含这段文字的视频")
751
+ p.add_argument("--model", help="whisper 模型(默认 small.en;慢的电脑可以用 base.en)")
752
+ p.add_argument("--dry-run", action="store_true")
753
+ cmd(sub, "open", cmd_open, help="打开课件文件夹")
754
+ p = cmd(sub, "config", cmd_config, help="看/改设置")
755
+ p.add_argument("key", nargs="?"); p.add_argument("value", nargs="?")
756
+ p = cmd(sub, "connect", cmd_connect, help="接进别的 AI 软件:Codex、Cursor、VS Code、Windsurf、Gemini CLI")
757
+ p.add_argument("client", nargs="?", choices=CONNECT_TARGETS, help="不写就列出所有软件和接入状态")
758
+ g = p.add_mutually_exclusive_group()
759
+ g.add_argument("--print", action="store_true", help="只打印要加进配置文件的那段,不改文件")
760
+ g.add_argument("--remove", action="store_true", help="从这个软件里移除 monash")
761
+ cmd(sub, "uninstall", cmd_uninstall, help="从 Claude 和其他 AI 软件移除,可选删除数据")
762
+ cmd(sub, "update", cmd_update, help="更新到最新版")
763
+ cmd(sub, "mcp", cmd_mcp, help="MCP 服务器(Claude 启动它,不用手动跑)")
764
+
765
+ m = sub.add_parser("moodle", help="Moodle 查询").add_subparsers(dest="mcmd", required=True)
766
+ cmd(m, "overview", cmd_m_overview, json_flag=True, help="近期截止、通知、公告、可能漏交")
767
+ p = cmd(m, "due", cmd_due, json_flag=True); p.add_argument("course", nargs="?")
768
+ p.add_argument("--days", type=int, default=14)
769
+ p = cmd(m, "calendar", cmd_m_calendar, json_flag=True, help="iCal 订阅链接")
770
+ p.add_argument("--refresh", action="store_true"); p.add_argument("--show", action="store_true")
771
+ p = cmd(m, "assignments", cmd_m_assignments, json_flag=True, help="作业汇总")
772
+ p.add_argument("course", nargs="?"); p.add_argument("--missing", action="store_true", help="只看可能漏交的")
773
+ p = cmd(m, "grades", cmd_m_grades, json_flag=True); p.add_argument("course", nargs="?")
774
+ p.add_argument("--graded-only", action="store_true")
775
+ p = cmd(m, "news", cmd_m_news, json_flag=True); p.add_argument("course", nargs="?")
776
+ p.add_argument("--limit", type=int, default=5); p.add_argument("--full", action="store_true")
777
+ p = cmd(m, "search", cmd_m_search, json_flag=True, help="搜 Moodle 论坛"); p.add_argument("query")
778
+ p.add_argument("course", nargs="?"); p.add_argument("--limit", type=int, default=30)
779
+ p = cmd(m, "find", cmd_m_find, json_flag=True, help="找章节和活动"); p.add_argument("query")
780
+ p.add_argument("course", nargs="?"); p.add_argument("--limit", type=int, default=20)
781
+ p = cmd(m, "get", cmd_m_get, json_flag=True, help="下载单个资源"); p.add_argument("ref")
782
+ p.add_argument("--to", default="."); p.add_argument("--force", action="store_true")
783
+ p = cmd(m, "open", cmd_m_open, help="在浏览器里打开课程(或某周)"); p.add_argument("ref")
784
+ p.add_argument("week", nargs="?")
785
+ p = cmd(m, "alerts", cmd_m_alerts, json_flag=True); p.add_argument("--limit", type=int, default=10)
786
+ p = cmd(m, "messages", cmd_m_messages, json_flag=True, help="站内私信(只读)")
787
+ p.add_argument("conversation", nargs="?", type=int); p.add_argument("--limit", type=int, default=20)
788
+ p = cmd(m, "ls", cmd_m_ls, help="本地已同步的课件"); p.add_argument("course"); p.add_argument("week", nargs="?")
789
+
790
+ cli_ed.add_commands(sub.add_parser("ed", help="Ed 查询").add_subparsers(dest="ecmd", required=True))
791
+ return ap
792
+
793
+
794
+ def _print_update_notice() -> None:
795
+ """有新版本就在 stderr 提一行(只在终端里,别混进脚本和 agent 读的输出)。"""
796
+ from . import update_check
797
+ msg = update_check.notice()
798
+ if msg and sys.stderr.isatty():
799
+ print(f"\n↑ {msg}", file=sys.stderr)
800
+
801
+
802
+ def main(argv: list[str] | None = None) -> int:
803
+ for stream in (sys.stdout, sys.stderr):
804
+ try:
805
+ if stream.isatty():
806
+ stream.reconfigure(errors="replace") # 老终端的代码页打不出的字符别让程序崩掉
807
+ else:
808
+ stream.reconfigure(encoding="utf-8") # 管道/重定向:Windows 默认是本地代码页,中文会丢
809
+ except (AttributeError, ValueError):
810
+ pass
811
+ ap = build_parser()
812
+ args = ap.parse_args(argv)
813
+ ensure_private_dir(HOME) # 库里有同学的名字和私密帖,只给自己读
814
+ quiet = args.cmd in ("mcp", "update", "doctor") # mcp 的 stdout/stderr 归 Claude;另两个自己会说版本
815
+ if not quiet:
816
+ from . import update_check
817
+ update_check.refresh_in_background()
818
+ if not getattr(args, "fn", None):
819
+ ap.print_help()
820
+ print("\n不知道能做什么?运行 monash help 看功能大全和示例问法。")
821
+ return EXIT_OK
822
+ try:
823
+ code = args.fn(args) or EXIT_OK
824
+ if not quiet:
825
+ _print_update_notice()
826
+ return code
827
+ except (MoodleAuthError, EdAuthError) as e:
828
+ print(f"{e}", file=sys.stderr)
829
+ return EXIT_AUTH
830
+ except LookupError as e: # 包括 edquery.NotFound
831
+ print(e, file=sys.stderr)
832
+ return EXIT_NOT_FOUND
833
+ except ValueError as e:
834
+ print(e, file=sys.stderr)
835
+ return EXIT_USAGE
836
+ except (MoodleError, urllib.error.URLError, TimeoutError, ConnectionError) as e:
837
+ # HTTPError 也是 URLError:Ed 的 401 已经变成 EdAuthError,走到这里的是 5xx 重试完之类
838
+ print(f"连不上 Moodle/Ed,或对方出错:{e}", file=sys.stderr)
839
+ return EXIT_NETWORK
840
+ except KeyboardInterrupt:
841
+ return EXIT_INTERRUPTED
842
+ except BrokenPipeError:
843
+ return EXIT_OK