bb-sync 2.0.0__tar.gz → 2.1.1__tar.gz
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.
- {bb_sync-2.0.0 → bb_sync-2.1.1}/AGENTS.md +3 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/CHANGELOG.md +25 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/PKG-INFO +1 -1
- {bb_sync-2.0.0 → bb_sync-2.1.1}/docs/scripting.md +5 -1
- {bb_sync-2.0.0 → bb_sync-2.1.1}/pyproject.toml +1 -1
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/blackboard/downloader.py +10 -5
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/blackboard/models.py +22 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/core/output.py +56 -9
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/core/service.py +149 -28
- {bb_sync-2.0.0 → bb_sync-2.1.1}/tests/test_cli_e2e.py +26 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/tests/test_units.py +173 -22
- {bb_sync-2.0.0 → bb_sync-2.1.1}/uv.lock +2 -2
- {bb_sync-2.0.0 → bb_sync-2.1.1}/.github/workflows/ci.yml +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/.github/workflows/release.yml +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/.gitignore +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/.pre-commit-config.yaml +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/CONTRIBUTING.md +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/LICENSE +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/README.md +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/config.yaml +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/docs/config.md +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/scripts/debug_course.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/scripts/debug_courses.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/scripts/debug_login.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/__init__.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/__main__.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/blackboard/__init__.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/blackboard/login.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/blackboard/scraper.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/browser/__init__.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/browser/launcher.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/cli.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/commands/__init__.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/commands/auth.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/commands/browser.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/commands/config.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/commands/course.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/commands/doctor.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/commands/due.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/commands/run.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/context.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/core/__init__.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/core/config.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/core/config_store.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/core/doctor.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/core/due.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/core/errors.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/core/help.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/core/update.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/creds.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/paths.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/tests/conftest.py +0 -0
- {bb_sync-2.0.0 → bb_sync-2.1.1}/tests/test_update_e2e.py +0 -0
|
@@ -69,6 +69,9 @@ uv run pyright
|
|
|
69
69
|
### 1. 输出通道:stdout 只放结果,stderr 只放诊断
|
|
70
70
|
|
|
71
71
|
- 所有输出走 `core.output.Console`,**禁止直接 `print`**。
|
|
72
|
+
- 诊断行需要强调时用 `console.line((文本, 样式), ...)` 并引用 `output.STYLE_*` 语义色板,
|
|
73
|
+
不要在调用点新造颜色名;**不要打开 Rich markup**——诊断里到处是 `[new]`、`[作业]`
|
|
74
|
+
这类方括号文本,会被当成标签。着色在非 TTY、`NO_COLOR`、`--json` 下自动关闭。
|
|
72
75
|
- `--json` 时 stdout 有且仅有一个 JSON 文档;出错时 stdout 保持为空,错误走 stderr。
|
|
73
76
|
- 新增命令或字段时,同步更新 `docs/scripting.md` 的 JSON 结构表与对应测试。
|
|
74
77
|
|
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
4
4
|
|
|
5
|
+
## [2.1.1] — 2026-10-31
|
|
6
|
+
|
|
7
|
+
### 变更
|
|
8
|
+
|
|
9
|
+
- `run` / `due` 不再逐课程打印「待办 xN,公告测验/考试 xM」:这几行与末尾的
|
|
10
|
+
「待办明细」定位重复,只有计数、看不到内容。待办结果统一在「待办明细」与
|
|
11
|
+
`due.md` 中呈现;抓取失败仍会即时告警。
|
|
12
|
+
|
|
13
|
+
## [2.1.0] — 2026-10-31
|
|
14
|
+
|
|
15
|
+
### 新增
|
|
16
|
+
|
|
17
|
+
- `bb-sync run` 在「汇总」之后新增「新下载明细」,逐行列出本轮真正新落盘的文件
|
|
18
|
+
及其课程(相对课程目录的路径,作业带 `hwN/`),例如
|
|
19
|
+
`[new] CSC5010 | assignments/hw2/report.pdf`;没有新文件时不打印。`--json run`
|
|
20
|
+
同步增加 `files` 数组(`course` / `category` / `name` / `path`,其中 `path` 为
|
|
21
|
+
绝对路径),脚本可以直接取用。
|
|
22
|
+
- 诊断输出改用语义色板:章节标题加粗、课程代码与 `[due]` / `[new]` 前缀青色、
|
|
23
|
+
新下载绿色、dry-run 与待办黄色、逾期红色、测验 / 考试徽标紫色、关键数字加粗,
|
|
24
|
+
正文从淡灰色恢复为终端默认前景色。颜色只在 `core/output.py` 定义一处;新增
|
|
25
|
+
`Console.line((文本, 样式), ...)` 按片段上色,不启用 Rich markup——诊断里的
|
|
26
|
+
方括号文本(`[new]`、`[作业]`)不会被误判成标签。
|
|
27
|
+
- 着色随终端能力自动开关:输出被重定向、终端不支持颜色、设置 `NO_COLOR`
|
|
28
|
+
(任意非空值)或 `--json` 时都不输出转义序列,stdout 的 JSON 保持纯净。
|
|
29
|
+
|
|
5
30
|
## [2.0.0] — 2026-10-14
|
|
6
31
|
|
|
7
32
|
### 变更(破坏性)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bb-sync
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.1
|
|
4
4
|
Summary: 港中深(CUHK-Shenzhen)Blackboard 课件下载与课程资源自动同步:登录 ADFS SSO,增量下载课件/作业/指导、公告与待办
|
|
5
5
|
Project-URL: Homepage, https://github.com/asdukw/cuhk-shenzhen-blackboard-sync
|
|
6
6
|
Project-URL: Repository, https://github.com/asdukw/cuhk-shenzhen-blackboard-sync
|
|
@@ -12,6 +12,10 @@ bb-sync 按 Unix CLI 惯例严格分流输出:**结果走 stdout,诊断走 s
|
|
|
12
12
|
|
|
13
13
|
> 自动更新检查的提示也走 stderr,不会污染 stdout;`--quiet` 下会跳过检查。
|
|
14
14
|
|
|
15
|
+
彩色只在交互式终端里启用:stdout 被重定向、终端不支持颜色、设置了 `NO_COLOR`
|
|
16
|
+
(任意非空值)或加了 `--json` 时都会自动关闭。所以 `bb-sync run 2>&1 | tee log.txt`
|
|
17
|
+
这类管道里拿到的是纯文本,stdout 的 JSON 更是永远不含转义序列。
|
|
18
|
+
|
|
15
19
|
## `--json`:机器可读输出
|
|
16
20
|
|
|
17
21
|
`--json` 是**全局选项**,位置在子命令之前:
|
|
@@ -35,7 +39,7 @@ bb-sync --json doctor | jq '.ok'
|
|
|
35
39
|
| 命令 | stdout 的 JSON |
|
|
36
40
|
| --- | --- |
|
|
37
41
|
| `course list` | 数组:`[{"id","code","title","folder"}, ...]` |
|
|
38
|
-
| `run` | 对象:`{"downloaded","exists","would_download","empty","failed","courses"}` |
|
|
42
|
+
| `run` | 对象:`{"downloaded","exists","would_download","empty","failed","courses","files"}`;`files` 是本次**真正新落盘**的文件(dry-run 时为空),元素为 `{"course","category","name","path"}`——`name` 相对课程目录、`path` 为绝对路径 |
|
|
39
43
|
| `due` | 对象:`{"path","total","failed","announcement_failed","courses","items"}`;`items` 含课程、标题、类型(`task` / `quiz` / `exam`)与 ISO 日期 |
|
|
40
44
|
| `doctor` | 对象:`{"ok": bool, "checks": [{"label","ok","detail","hint","required"}]}` |
|
|
41
45
|
| `doctor --list` | 对象:`{"checks": ["python","git", ...]}` |
|
|
@@ -192,8 +192,11 @@ def _content_length(headers) -> int | None:
|
|
|
192
192
|
return None
|
|
193
193
|
|
|
194
194
|
|
|
195
|
-
def
|
|
196
|
-
"""条目落盘目录;作业按 hwN 归档,对齐已有目录习惯。
|
|
195
|
+
def category_dir(course_dir: Path, item: FileItem) -> Path:
|
|
196
|
+
"""条目落盘目录;作业按 hwN 归档,对齐已有目录习惯。
|
|
197
|
+
|
|
198
|
+
对外的唯一含义是「这个条目会写到哪里」,汇总明细与真实写入共用它。
|
|
199
|
+
"""
|
|
197
200
|
cat_dir = course_dir / item.category
|
|
198
201
|
if item.category == "assignments" and item.name:
|
|
199
202
|
m = HW_NUM.search(item.name)
|
|
@@ -202,7 +205,7 @@ def _category_dir(course_dir: Path, item: FileItem) -> Path:
|
|
|
202
205
|
return cat_dir
|
|
203
206
|
|
|
204
207
|
|
|
205
|
-
def
|
|
208
|
+
def relative_path(path: Path, course_dir: Path) -> str:
|
|
206
209
|
"""相对课程目录的展示路径;跨盘等取不到相对路径时退回文件名。"""
|
|
207
210
|
try:
|
|
208
211
|
return str(path.relative_to(course_dir)).replace("\\", "/")
|
|
@@ -262,12 +265,12 @@ class DownloadSession:
|
|
|
262
265
|
|
|
263
266
|
状态取值:``downloaded`` / ``exists`` / ``would-download`` / ``empty``。
|
|
264
267
|
"""
|
|
265
|
-
cat_dir =
|
|
268
|
+
cat_dir = category_dir(course_dir, item)
|
|
266
269
|
name, size = self.probe(item)
|
|
267
270
|
|
|
268
271
|
already = _index_match(index, name, size)
|
|
269
272
|
if already is not None:
|
|
270
|
-
return ("exists",
|
|
273
|
+
return ("exists", relative_path(already, course_dir))
|
|
271
274
|
target = cat_dir / name
|
|
272
275
|
if target.exists():
|
|
273
276
|
return ("exists", target.name)
|
|
@@ -344,5 +347,7 @@ __all__ = [
|
|
|
344
347
|
"SIZE_DEDUP_THRESHOLD",
|
|
345
348
|
"SNIFF_BYTES",
|
|
346
349
|
"build_index",
|
|
350
|
+
"category_dir",
|
|
351
|
+
"relative_path",
|
|
347
352
|
"sniff_ext",
|
|
348
353
|
]
|
|
@@ -76,6 +76,24 @@ class DueItem:
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class SavedFile:
|
|
81
|
+
"""本次同步新落盘的一个文件。"""
|
|
82
|
+
|
|
83
|
+
course: str # 课程代码;没有代码时退回课程标题
|
|
84
|
+
category: str # lectures / assignments / tutorials / other
|
|
85
|
+
name: str # 相对课程目录的路径,如 assignments/hw2/report.pdf
|
|
86
|
+
path: str = "" # 绝对路径,便于脚本直接取用
|
|
87
|
+
|
|
88
|
+
def as_dict(self) -> dict[str, str]:
|
|
89
|
+
return {
|
|
90
|
+
"course": self.course,
|
|
91
|
+
"category": self.category,
|
|
92
|
+
"name": self.name,
|
|
93
|
+
"path": self.path,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
79
97
|
@dataclass
|
|
80
98
|
class SyncStats:
|
|
81
99
|
"""一次同步的统计结果。"""
|
|
@@ -86,6 +104,8 @@ class SyncStats:
|
|
|
86
104
|
empty: int = 0
|
|
87
105
|
failed: int = 0
|
|
88
106
|
courses: list[str] = field(default_factory=list)
|
|
107
|
+
#: 本次真正新落盘的文件(不含已存在 / dry-run),供末尾明细与 --json 使用
|
|
108
|
+
files: list[SavedFile] = field(default_factory=list)
|
|
89
109
|
|
|
90
110
|
def bump(self, status: str) -> None:
|
|
91
111
|
key = status.replace("-", "_")
|
|
@@ -100,6 +120,7 @@ class SyncStats:
|
|
|
100
120
|
"empty": self.empty,
|
|
101
121
|
"failed": self.failed,
|
|
102
122
|
"courses": self.courses,
|
|
123
|
+
"files": [saved.as_dict() for saved in self.files],
|
|
103
124
|
}
|
|
104
125
|
|
|
105
126
|
|
|
@@ -139,5 +160,6 @@ __all__ = [
|
|
|
139
160
|
"DueItem",
|
|
140
161
|
"DueStats",
|
|
141
162
|
"FileItem",
|
|
163
|
+
"SavedFile",
|
|
142
164
|
"SyncStats",
|
|
143
165
|
]
|
|
@@ -14,8 +14,13 @@ stderr 日志 / 进度 / 警告 / 错误 人读,或 ``2>/d
|
|
|
14
14
|
1. **结果输出**(``result`` / ``table`` / ``emit``)—— 支持「人读」与「JSON」双模式。
|
|
15
15
|
机器模式(``--json``)下,只有 ``emit`` 会往 stdout 吐**一个** JSON 文档,
|
|
16
16
|
Rich 表格与人类可读文本全部被抑制,保证 stdout 是干净可解析的。
|
|
17
|
-
2. **诊断输出**(``log`` / ``warn`` / ``error`` / ``success`` / ``hint``)——
|
|
18
|
-
|
|
17
|
+
2. **诊断输出**(``log`` / ``line`` / ``warn`` / ``error`` / ``success`` / ``hint``)——
|
|
18
|
+
一律走 stderr,且随 ``--quiet`` 降级。
|
|
19
|
+
|
|
20
|
+
**颜色**:诊断用下面 :data:`STYLE_*` 这套语义色板——16 色 ANSI 基本色,浅色 / 深色
|
|
21
|
+
主题都交给终端映射;颜色只做强调,状态同时用符号和文字表达,重定向或色觉差异下
|
|
22
|
+
依然分得清。着色由 Rich 按终端能力自动开关:非 TTY、设置 ``NO_COLOR`` 或 ``--json``
|
|
23
|
+
时都不会输出转义序列,stdout 永远保持干净。
|
|
19
24
|
"""
|
|
20
25
|
|
|
21
26
|
from __future__ import annotations
|
|
@@ -27,8 +32,21 @@ from typing import Any
|
|
|
27
32
|
|
|
28
33
|
from rich.console import Console as RichConsole
|
|
29
34
|
from rich.table import Table
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
from rich.text import Text
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------- 语义色板
|
|
38
|
+
#
|
|
39
|
+
# 只用 16 色 ANSI 基本色,不写死 256 色 / 真彩色代码:浅色和深色主题都由终端
|
|
40
|
+
# 映射到合适的对比度,不会出现「白底上看不清的亮黄」。命令里只引用这些名字,
|
|
41
|
+
# 不在调用点随手挑颜色,保证整条输出看起来是一套设计。
|
|
42
|
+
STYLE_TEXT = "" # 正文:空样式 = 终端默认前景色,不多发一条转义序列
|
|
43
|
+
STYLE_MUTED = "dim" # 次要信息:路径、计数单位、分隔符
|
|
44
|
+
STYLE_ACCENT = "cyan" # 标识符:课程代码、命令名
|
|
45
|
+
STYLE_SUCCESS = "green" # 成功 / 新下载
|
|
46
|
+
STYLE_WARN = "yellow" # 警告 / 待办
|
|
47
|
+
STYLE_ERROR = "bold red" # 失败 / 逾期
|
|
48
|
+
STYLE_BADGE = "magenta" # 类型徽标:测验 / 考试
|
|
49
|
+
STYLE_HEADING = "bold" # 强调的正文、关键数字
|
|
32
50
|
|
|
33
51
|
|
|
34
52
|
class Console:
|
|
@@ -55,12 +73,31 @@ class Console:
|
|
|
55
73
|
|
|
56
74
|
# ------------------------------------------------------------ 诊断(stderr)
|
|
57
75
|
def log(self, message: str = "", *, level: str = "info") -> None:
|
|
58
|
-
"""常规日志 → stderr。``--quiet`` 时不显示。
|
|
76
|
+
"""常规日志 → stderr。``--quiet`` 时不显示。
|
|
77
|
+
|
|
78
|
+
正文用终端默认前景色;``level="muted"`` 才是淡色次要信息。
|
|
79
|
+
"""
|
|
59
80
|
if self.quiet:
|
|
60
81
|
return
|
|
61
|
-
style = {"info": "
|
|
82
|
+
style = {"info": STYLE_TEXT, "muted": STYLE_MUTED, "step": STYLE_ACCENT}.get(
|
|
83
|
+
level, STYLE_TEXT
|
|
84
|
+
)
|
|
62
85
|
self._err.print(message, style=style, markup=False, highlight=False)
|
|
63
86
|
|
|
87
|
+
def line(self, *segments: tuple[str, str]) -> None:
|
|
88
|
+
"""按 ``(文本, 样式)`` 片段输出一行诊断到 stderr,``--quiet`` 时不显示。
|
|
89
|
+
|
|
90
|
+
为什么不用 Rich 的 markup:诊断里到处都是 ``[new]``、``[作业]`` 这类方括号
|
|
91
|
+
文本,全局打开标签解析会被误判成样式甚至报错。片段式拼接只给需要强调的部分
|
|
92
|
+
上色,其余保持原样。
|
|
93
|
+
"""
|
|
94
|
+
if self.quiet:
|
|
95
|
+
return
|
|
96
|
+
text = Text()
|
|
97
|
+
for chunk, style in segments:
|
|
98
|
+
text.append(chunk, style=style)
|
|
99
|
+
self._err.print(text, markup=False, highlight=False)
|
|
100
|
+
|
|
64
101
|
def step(self, message: str) -> None:
|
|
65
102
|
"""阶段性标题 → stderr,如 ``[scan] 获取课程列表 ...``。"""
|
|
66
103
|
if self.quiet:
|
|
@@ -91,9 +128,9 @@ class Console:
|
|
|
91
128
|
self._err.print(f"[debug] {message}", style="dim", markup=False, highlight=False)
|
|
92
129
|
|
|
93
130
|
def rule(self, title: str = "") -> None:
|
|
94
|
-
"""分隔线 → stderr
|
|
131
|
+
"""分隔线 → stderr:标题加粗,线条淡色(装饰性,不与内容抢注意力)。"""
|
|
95
132
|
if not self.quiet:
|
|
96
|
-
self._err.rule(title, style="
|
|
133
|
+
self._err.rule(Text(title, style=STYLE_HEADING) if title else "", style=STYLE_MUTED)
|
|
97
134
|
|
|
98
135
|
# ------------------------------------------------------------ 结果(stdout)
|
|
99
136
|
def emit(self, data: Any) -> None:
|
|
@@ -148,4 +185,14 @@ class Console:
|
|
|
148
185
|
self._out.print(text, markup=False, highlight=False, soft_wrap=True)
|
|
149
186
|
|
|
150
187
|
|
|
151
|
-
__all__ = [
|
|
188
|
+
__all__ = [
|
|
189
|
+
"STYLE_ACCENT",
|
|
190
|
+
"STYLE_BADGE",
|
|
191
|
+
"STYLE_ERROR",
|
|
192
|
+
"STYLE_HEADING",
|
|
193
|
+
"STYLE_MUTED",
|
|
194
|
+
"STYLE_SUCCESS",
|
|
195
|
+
"STYLE_TEXT",
|
|
196
|
+
"STYLE_WARN",
|
|
197
|
+
"Console",
|
|
198
|
+
]
|
|
@@ -19,12 +19,29 @@ from playwright.sync_api import sync_playwright
|
|
|
19
19
|
|
|
20
20
|
from bb_sync.blackboard import downloader, scraper
|
|
21
21
|
from bb_sync.blackboard.login import ensure_login
|
|
22
|
-
from bb_sync.blackboard.models import
|
|
22
|
+
from bb_sync.blackboard.models import (
|
|
23
|
+
Course,
|
|
24
|
+
DueItem,
|
|
25
|
+
DueStats,
|
|
26
|
+
FileItem,
|
|
27
|
+
SavedFile,
|
|
28
|
+
SyncStats,
|
|
29
|
+
)
|
|
23
30
|
from bb_sync.browser import launcher
|
|
24
31
|
from bb_sync.core import due
|
|
25
32
|
from bb_sync.core.config import Settings
|
|
26
33
|
from bb_sync.core.errors import ConfigError, NetworkError, NotFoundError
|
|
27
|
-
from bb_sync.core.output import
|
|
34
|
+
from bb_sync.core.output import (
|
|
35
|
+
STYLE_ACCENT,
|
|
36
|
+
STYLE_BADGE,
|
|
37
|
+
STYLE_ERROR,
|
|
38
|
+
STYLE_HEADING,
|
|
39
|
+
STYLE_MUTED,
|
|
40
|
+
STYLE_SUCCESS,
|
|
41
|
+
STYLE_TEXT,
|
|
42
|
+
STYLE_WARN,
|
|
43
|
+
Console,
|
|
44
|
+
)
|
|
28
45
|
|
|
29
46
|
|
|
30
47
|
@dataclass
|
|
@@ -149,7 +166,15 @@ def sync_course(
|
|
|
149
166
|
settings = options.settings
|
|
150
167
|
keywords = settings.keywords
|
|
151
168
|
|
|
152
|
-
console.
|
|
169
|
+
console.line(
|
|
170
|
+
("=== ", STYLE_MUTED),
|
|
171
|
+
(course.code or "?", f"{STYLE_ACCENT} {STYLE_HEADING}"),
|
|
172
|
+
(" | ", STYLE_MUTED),
|
|
173
|
+
(course.title, STYLE_HEADING),
|
|
174
|
+
(" → ", STYLE_MUTED),
|
|
175
|
+
(str(course_dir), STYLE_MUTED),
|
|
176
|
+
(" ===", STYLE_MUTED),
|
|
177
|
+
)
|
|
153
178
|
if not options.dry_run:
|
|
154
179
|
course_dir.mkdir(parents=True, exist_ok=True)
|
|
155
180
|
|
|
@@ -159,7 +184,8 @@ def sync_course(
|
|
|
159
184
|
all_items: list[FileItem] = []
|
|
160
185
|
visited: set[str] = set()
|
|
161
186
|
menu = scraper.collect_menu_links(page, course)
|
|
162
|
-
|
|
187
|
+
areas = ", ".join(n for _, n in menu) or "(无)"
|
|
188
|
+
console.line((" 内容区: ", STYLE_MUTED), (areas, STYLE_TEXT))
|
|
163
189
|
for area_url, area_name in menu:
|
|
164
190
|
all_items += scraper.find_files(
|
|
165
191
|
page, area_url, area_name, 1, settings.max_depth, keywords, visited
|
|
@@ -169,9 +195,14 @@ def sync_course(
|
|
|
169
195
|
uniq: dict[str, FileItem] = {}
|
|
170
196
|
for item in all_items:
|
|
171
197
|
uniq.setdefault(item.url, item)
|
|
172
|
-
console.
|
|
198
|
+
console.line(
|
|
199
|
+
(" 发现 ", STYLE_MUTED),
|
|
200
|
+
(str(len(uniq)), STYLE_HEADING),
|
|
201
|
+
(" 个文件", STYLE_MUTED),
|
|
202
|
+
)
|
|
173
203
|
|
|
174
204
|
index = downloader.build_index(course_dir)
|
|
205
|
+
label = course.code or course.title
|
|
175
206
|
for item in uniq.values():
|
|
176
207
|
try:
|
|
177
208
|
status, name = downloads.download_item(item, course_dir, options.dry_run, index)
|
|
@@ -180,17 +211,43 @@ def sync_course(
|
|
|
180
211
|
console.warn(f" [{item.category}] 下载失败: {exc}")
|
|
181
212
|
continue
|
|
182
213
|
stats.bump(status)
|
|
214
|
+
if status == "downloaded":
|
|
215
|
+
_record_saved(stats, label, course_dir, item, name)
|
|
183
216
|
_log_item(console, status, item.category, name)
|
|
184
217
|
|
|
185
218
|
|
|
219
|
+
def _record_saved(
|
|
220
|
+
stats: SyncStats, course_label: str, course_dir: Path, item: FileItem, name: str
|
|
221
|
+
) -> None:
|
|
222
|
+
"""把本次新落盘的文件记入明细,供末尾汇总与 ``--json`` 使用。
|
|
223
|
+
|
|
224
|
+
落盘位置直接复用 :func:`bb_sync.blackboard.downloader.category_dir`,
|
|
225
|
+
与真实写入保持同一套目录规则(作业会落到 ``assignments/hwN/``)。
|
|
226
|
+
"""
|
|
227
|
+
target = downloader.category_dir(course_dir, item) / name
|
|
228
|
+
stats.files.append(
|
|
229
|
+
SavedFile(
|
|
230
|
+
course=course_label,
|
|
231
|
+
category=item.category,
|
|
232
|
+
name=downloader.relative_path(target, course_dir),
|
|
233
|
+
path=str(target),
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
186
238
|
def _log_item(console: Console, status: str, category: str, name: str) -> None:
|
|
187
|
-
"""按状态打印单条下载结果(全部走 stderr)。
|
|
239
|
+
"""按状态打印单条下载结果(全部走 stderr)。
|
|
240
|
+
|
|
241
|
+
状态用符号 + 颜色双重表达(``↓`` 绿 = 新下载、``=`` 淡 = 已存在、
|
|
242
|
+
``?`` 黄 = dry-run),丢掉颜色后依然一眼分得清。
|
|
243
|
+
"""
|
|
244
|
+
badge = (f"[{category}] ", STYLE_MUTED)
|
|
188
245
|
if status == "downloaded":
|
|
189
|
-
console.
|
|
246
|
+
console.line((" ↓ ", STYLE_SUCCESS), badge, (name, STYLE_TEXT))
|
|
190
247
|
elif status == "would-download":
|
|
191
|
-
console.
|
|
248
|
+
console.line((" ? ", STYLE_WARN), badge, (f"{name} (dry-run)", STYLE_TEXT))
|
|
192
249
|
elif status == "exists":
|
|
193
|
-
console.
|
|
250
|
+
console.line((" = ", STYLE_MUTED), badge, (f"{name} (已存在,跳过)", STYLE_MUTED))
|
|
194
251
|
elif status == "empty":
|
|
195
252
|
console.warn(f" [{category}] {name} 内容为空,跳过")
|
|
196
253
|
|
|
@@ -201,12 +258,22 @@ def _print_plan(console: Console, courses: list[Course], root: Path, dry_run: bo
|
|
|
201
258
|
每个课程目录都显示为**绝对路径**——相对路径容易让人误判落盘位置。
|
|
202
259
|
"""
|
|
203
260
|
console.rule("同步计划")
|
|
204
|
-
console.
|
|
205
|
-
console.log("课程目录:")
|
|
261
|
+
console.line(("根目录: ", STYLE_MUTED), (str(root), STYLE_TEXT))
|
|
262
|
+
console.log("课程目录:", level="muted")
|
|
206
263
|
for course in courses:
|
|
207
264
|
label = course.code or course.title
|
|
208
|
-
console.
|
|
209
|
-
|
|
265
|
+
console.line(
|
|
266
|
+
(" ", STYLE_TEXT),
|
|
267
|
+
(f"{label:<10}", STYLE_ACCENT),
|
|
268
|
+
(" → ", STYLE_MUTED),
|
|
269
|
+
(str(root / course.slug), STYLE_MUTED),
|
|
270
|
+
)
|
|
271
|
+
console.line(
|
|
272
|
+
("合计 ", STYLE_MUTED),
|
|
273
|
+
(str(len(courses)), STYLE_HEADING),
|
|
274
|
+
(" 门课程", STYLE_MUTED),
|
|
275
|
+
("(dry-run,不会写入)" if dry_run else "", STYLE_WARN),
|
|
276
|
+
)
|
|
210
277
|
console.log("")
|
|
211
278
|
|
|
212
279
|
|
|
@@ -236,12 +303,6 @@ def _scan_due_items(page, courses: list[Course], console: Console) -> DueScan:
|
|
|
236
303
|
scan.both_failed += 1
|
|
237
304
|
scan.items.extend(todo_items)
|
|
238
305
|
scan.items.extend(announcement_items)
|
|
239
|
-
parts = []
|
|
240
|
-
if todo_ok:
|
|
241
|
-
parts.append(f"待办 x{len(todo_items)}")
|
|
242
|
-
if announcement_items:
|
|
243
|
-
parts.append(f"公告测验/考试 x{len(announcement_items)}")
|
|
244
|
-
console.log(f" {label}: {','.join(parts) if parts else '未读取到内容'}")
|
|
245
306
|
scan.items = due.sort_due_items(scan.items)
|
|
246
307
|
return scan
|
|
247
308
|
|
|
@@ -296,20 +357,30 @@ def run_sync(options: SyncOptions) -> SyncStats:
|
|
|
296
357
|
message = "Congratulations! 没有待办"
|
|
297
358
|
else:
|
|
298
359
|
message = f"待办 {len(due_items)} 项"
|
|
299
|
-
console.
|
|
360
|
+
console.line(
|
|
361
|
+
("[due] ", STYLE_ACCENT),
|
|
362
|
+
(f"{message}(dry-run,未写入 due.md)", STYLE_MUTED),
|
|
363
|
+
)
|
|
300
364
|
elif scan.both_failed == len(courses):
|
|
301
365
|
console.warn("[due] 所有课程待办抓取失败,保留现有 due.md")
|
|
302
366
|
else:
|
|
303
367
|
due.write_due_markdown(due_path, courses, due_items, failed=scan.incomplete)
|
|
304
368
|
if due_items:
|
|
305
|
-
console.
|
|
369
|
+
console.line(
|
|
370
|
+
("[due] ", STYLE_ACCENT), (f"{len(due_items)} 项 → due.md", STYLE_TEXT)
|
|
371
|
+
)
|
|
306
372
|
elif scan.incomplete:
|
|
307
|
-
console.
|
|
373
|
+
console.line(
|
|
374
|
+
("[due] ", STYLE_ACCENT), ("未读取到待办(部分课程失败)→ due.md", STYLE_MUTED)
|
|
375
|
+
)
|
|
308
376
|
else:
|
|
309
|
-
console.
|
|
377
|
+
console.line(
|
|
378
|
+
("[due] ", STYLE_ACCENT), ("Congratulations! 没有待办 → due.md", STYLE_SUCCESS)
|
|
379
|
+
)
|
|
310
380
|
|
|
311
381
|
# 浏览器由 browser_session 收尾:默认已关闭(--keep-open 则留在后台)
|
|
312
382
|
_print_summary(console, stats, options.dry_run)
|
|
383
|
+
_print_saved_files(console, stats.files)
|
|
313
384
|
if scan.both_failed < len(courses):
|
|
314
385
|
_print_due_details(console, due_items, scan.incomplete)
|
|
315
386
|
return stats
|
|
@@ -319,11 +390,44 @@ def _print_summary(console: Console, stats: SyncStats, dry_run: bool) -> None:
|
|
|
319
390
|
"""汇总输出(stderr,人读)。"""
|
|
320
391
|
console.rule("汇总")
|
|
321
392
|
if dry_run:
|
|
322
|
-
console.
|
|
393
|
+
console.line(
|
|
394
|
+
("待下载: ", STYLE_TEXT),
|
|
395
|
+
(str(stats.would_download), f"{STYLE_HEADING} {STYLE_WARN}"),
|
|
396
|
+
(" 个文件(未实际下载)", STYLE_MUTED),
|
|
397
|
+
)
|
|
323
398
|
else:
|
|
324
|
-
console.
|
|
399
|
+
console.line(
|
|
400
|
+
("新下载 ", STYLE_TEXT),
|
|
401
|
+
(str(stats.downloaded), f"{STYLE_HEADING} {STYLE_SUCCESS}"),
|
|
402
|
+
(" 个,已存在跳过 ", STYLE_MUTED),
|
|
403
|
+
(str(stats.exists), STYLE_HEADING),
|
|
404
|
+
(" 个", STYLE_MUTED),
|
|
405
|
+
)
|
|
325
406
|
if stats.failed:
|
|
326
|
-
console.
|
|
407
|
+
console.line(
|
|
408
|
+
("! 失败 ", STYLE_WARN),
|
|
409
|
+
(str(stats.failed), f"{STYLE_HEADING} {STYLE_WARN}"),
|
|
410
|
+
(" 个", STYLE_WARN),
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _print_saved_files(console: Console, files: list[SavedFile]) -> None:
|
|
415
|
+
"""在 ``run`` 最后逐行列出本次新下载的文件(stderr)。
|
|
416
|
+
|
|
417
|
+
行格式 ``[new] 课程 | 相对路径``——路径本身以类别目录开头(``lectures/``、
|
|
418
|
+
``assignments/hw2/``),不必再单独标一次类别。逐文件日志负责「跑的时候看进度」,
|
|
419
|
+
这里负责「跑完了看这一轮到底多了什么」。没有新文件时不打印标题,避免空区块。
|
|
420
|
+
"""
|
|
421
|
+
if not files:
|
|
422
|
+
return
|
|
423
|
+
console.rule("新下载明细")
|
|
424
|
+
for saved in files:
|
|
425
|
+
console.line(
|
|
426
|
+
("[new] ", STYLE_SUCCESS),
|
|
427
|
+
(saved.course, STYLE_ACCENT),
|
|
428
|
+
(" | ", STYLE_MUTED),
|
|
429
|
+
(saved.name, STYLE_TEXT),
|
|
430
|
+
)
|
|
327
431
|
|
|
328
432
|
|
|
329
433
|
def _print_due_details(
|
|
@@ -344,11 +448,23 @@ def _print_due_details(
|
|
|
344
448
|
console.rule("待办明细")
|
|
345
449
|
for item in items:
|
|
346
450
|
due_date = item.due_date.isoformat() if item.due_date else "日期未知"
|
|
451
|
+
date_style = STYLE_TEXT if item.due_date is not None else STYLE_MUTED
|
|
347
452
|
if item.due_date is not None and item.due_date < day:
|
|
348
453
|
due_date = f"{due_date}({due.relative_due(item, day)})"
|
|
454
|
+
date_style = STYLE_ERROR # 逾期 / 已考过:唯一需要马上去看的
|
|
455
|
+
elif item.due_date == day:
|
|
456
|
+
date_style = STYLE_WARN
|
|
349
457
|
label = due.kind_label(item.kind)
|
|
350
458
|
badge = f"【{label}】" if label else ""
|
|
351
|
-
console.
|
|
459
|
+
console.line(
|
|
460
|
+
("[due] ", STYLE_ACCENT),
|
|
461
|
+
(due_date, date_style),
|
|
462
|
+
(" | ", STYLE_MUTED),
|
|
463
|
+
(item.course_label, STYLE_ACCENT),
|
|
464
|
+
(" | ", STYLE_MUTED),
|
|
465
|
+
(badge, STYLE_BADGE),
|
|
466
|
+
(item.title, STYLE_TEXT),
|
|
467
|
+
)
|
|
352
468
|
|
|
353
469
|
|
|
354
470
|
def list_courses(options: SyncOptions) -> list[Course]:
|
|
@@ -408,7 +524,12 @@ def run_due(options: DueOptions) -> DueStats:
|
|
|
408
524
|
)
|
|
409
525
|
|
|
410
526
|
console.rule("待办汇总")
|
|
411
|
-
console.
|
|
527
|
+
console.line(
|
|
528
|
+
("发现 ", STYLE_TEXT),
|
|
529
|
+
(str(stats.total), f"{STYLE_HEADING} {STYLE_WARN if stats.total else STYLE_SUCCESS}"),
|
|
530
|
+
(" 项待办,已写入 ", STYLE_TEXT),
|
|
531
|
+
(stats.path, STYLE_MUTED),
|
|
532
|
+
)
|
|
412
533
|
problems = []
|
|
413
534
|
if stats.failed:
|
|
414
535
|
problems.append(f"{stats.failed} 门课程 To Do 抓取失败")
|
|
@@ -310,6 +310,32 @@ def test_run_keep_open_flag_reaches_service(cli, config_file: Path, monkeypatch)
|
|
|
310
310
|
assert seen == [False, True]
|
|
311
311
|
|
|
312
312
|
|
|
313
|
+
def test_run_json_lists_new_files(cli, config_file: Path, monkeypatch) -> None:
|
|
314
|
+
"""``--json run`` 要带上本次新落盘的文件,脚本才知道多了哪些。"""
|
|
315
|
+
from bb_sync.blackboard.models import SavedFile, SyncStats
|
|
316
|
+
from bb_sync.core import service
|
|
317
|
+
|
|
318
|
+
config_file.write_text("root: ~/courses\ninclude: all\n", encoding="utf-8")
|
|
319
|
+
saved = SavedFile(
|
|
320
|
+
"CSC5010", "lectures", "lectures/Lec 1.pdf", "C:/courses/CSC5010/lectures/Lec 1.pdf"
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
monkeypatch.setattr(service, "run_sync", lambda options: SyncStats(downloaded=1, files=[saved]))
|
|
324
|
+
result = cli(["--json", "run"])
|
|
325
|
+
|
|
326
|
+
assert result.exit_code == 0
|
|
327
|
+
payload = json.loads(result.stdout)
|
|
328
|
+
assert payload["downloaded"] == 1
|
|
329
|
+
assert payload["files"] == [
|
|
330
|
+
{
|
|
331
|
+
"course": "CSC5010",
|
|
332
|
+
"category": "lectures",
|
|
333
|
+
"name": "lectures/Lec 1.pdf",
|
|
334
|
+
"path": "C:/courses/CSC5010/lectures/Lec 1.pdf",
|
|
335
|
+
}
|
|
336
|
+
]
|
|
337
|
+
|
|
338
|
+
|
|
313
339
|
def test_keep_open_documented_in_help(cli) -> None:
|
|
314
340
|
"""跑完自动关是默认行为,保留浏览器必须有显式开关且出现在帮助里。"""
|
|
315
341
|
for args in (["run", "--help"], ["due", "--help"], ["course", "list", "--help"]):
|
|
@@ -1426,6 +1426,23 @@ def test_scan_due_items_merges_quiz_and_tracks_failures(monkeypatch) -> None:
|
|
|
1426
1426
|
assert scan.incomplete == 2
|
|
1427
1427
|
|
|
1428
1428
|
|
|
1429
|
+
class _RecordingConsole:
|
|
1430
|
+
"""记录诊断输出的假 Console:``line`` 的片段按文本拼回整行。"""
|
|
1431
|
+
|
|
1432
|
+
def __init__(self) -> None:
|
|
1433
|
+
self.messages: list[str] = []
|
|
1434
|
+
self.rules: list[str] = []
|
|
1435
|
+
|
|
1436
|
+
def log(self, message: str = "", **kwargs: object) -> None:
|
|
1437
|
+
self.messages.append(message)
|
|
1438
|
+
|
|
1439
|
+
def line(self, *segments: tuple[str, str]) -> None:
|
|
1440
|
+
self.messages.append("".join(chunk for chunk, _style in segments))
|
|
1441
|
+
|
|
1442
|
+
def rule(self, title: str = "") -> None:
|
|
1443
|
+
self.rules.append(title)
|
|
1444
|
+
|
|
1445
|
+
|
|
1429
1446
|
def test_print_due_details_one_item_per_line() -> None:
|
|
1430
1447
|
from datetime import date
|
|
1431
1448
|
from typing import cast
|
|
@@ -1434,18 +1451,7 @@ def test_print_due_details_one_item_per_line() -> None:
|
|
|
1434
1451
|
from bb_sync.core import service
|
|
1435
1452
|
from bb_sync.core.output import Console
|
|
1436
1453
|
|
|
1437
|
-
|
|
1438
|
-
def __init__(self) -> None:
|
|
1439
|
-
self.messages: list[str] = []
|
|
1440
|
-
self.rules: list[str] = []
|
|
1441
|
-
|
|
1442
|
-
def log(self, message: str) -> None:
|
|
1443
|
-
self.messages.append(message)
|
|
1444
|
-
|
|
1445
|
-
def rule(self, title: str = "") -> None:
|
|
1446
|
-
self.rules.append(title)
|
|
1447
|
-
|
|
1448
|
-
fake = FakeConsole()
|
|
1454
|
+
fake = _RecordingConsole()
|
|
1449
1455
|
console = cast(Console, fake)
|
|
1450
1456
|
service._print_due_details(
|
|
1451
1457
|
console,
|
|
@@ -1468,24 +1474,169 @@ def test_print_due_details_one_item_per_line() -> None:
|
|
|
1468
1474
|
]
|
|
1469
1475
|
|
|
1470
1476
|
|
|
1471
|
-
def
|
|
1477
|
+
def test_sync_course_records_new_files_with_course_and_category(
|
|
1478
|
+
monkeypatch, tmp_path: Path
|
|
1479
|
+
) -> None:
|
|
1480
|
+
"""新落盘的文件要连同课程、类别、相对路径一起记入明细。"""
|
|
1481
|
+
from bb_sync.blackboard.models import Course, FileItem, SyncStats
|
|
1482
|
+
from bb_sync.core import service
|
|
1483
|
+
from bb_sync.core.config import Settings
|
|
1484
|
+
from bb_sync.core.output import Console
|
|
1485
|
+
|
|
1486
|
+
course = Course(bb_id="_1_1", title="CSC5010: Artificial Intelligence")
|
|
1487
|
+
course.slug = "CSC5010_AI"
|
|
1488
|
+
item = FileItem(name="Homework 1", url="https://bb/bbcswebdav/hw1", category="assignments")
|
|
1489
|
+
|
|
1490
|
+
monkeypatch.setattr(
|
|
1491
|
+
service.scraper, "collect_menu_links", lambda page, course: [("https://bb/area", "内容")]
|
|
1492
|
+
)
|
|
1493
|
+
monkeypatch.setattr(service.scraper, "find_files", lambda *args, **kwargs: [item])
|
|
1494
|
+
|
|
1495
|
+
class FakeDownloads:
|
|
1496
|
+
def download_item(self, item, course_dir, dry_run, index):
|
|
1497
|
+
return ("downloaded", "Homework 1.pdf")
|
|
1498
|
+
|
|
1499
|
+
stats = SyncStats()
|
|
1500
|
+
options = service.SyncOptions(
|
|
1501
|
+
settings=Settings(root=str(tmp_path), announcements=False),
|
|
1502
|
+
config_path=tmp_path / "config.yaml",
|
|
1503
|
+
root=tmp_path,
|
|
1504
|
+
console=Console(quiet=True),
|
|
1505
|
+
)
|
|
1506
|
+
course_dir = tmp_path / "CSC5010_AI"
|
|
1507
|
+
service.sync_course(None, course, course_dir, options, stats, FakeDownloads()) # type: ignore[arg-type]
|
|
1508
|
+
|
|
1509
|
+
assert stats.downloaded == 1
|
|
1510
|
+
saved = stats.files[0]
|
|
1511
|
+
assert saved.course == "CSC5010"
|
|
1512
|
+
assert saved.category == "assignments"
|
|
1513
|
+
# 作业按 hwN 归档,明细里给的是相对课程目录的路径
|
|
1514
|
+
assert saved.name == "assignments/hw1/Homework 1.pdf"
|
|
1515
|
+
assert Path(saved.path) == course_dir / "assignments" / "hw1" / "Homework 1.pdf"
|
|
1516
|
+
|
|
1517
|
+
|
|
1518
|
+
def test_saved_files_are_not_recorded_when_skipped(monkeypatch, tmp_path: Path) -> None:
|
|
1519
|
+
"""已存在 / dry-run 的文件不算「新下载」,不进明细。"""
|
|
1520
|
+
from bb_sync.blackboard.models import Course, FileItem, SyncStats
|
|
1521
|
+
from bb_sync.core import service
|
|
1522
|
+
from bb_sync.core.config import Settings
|
|
1523
|
+
from bb_sync.core.output import Console
|
|
1524
|
+
|
|
1525
|
+
course = Course(bb_id="_1_1", title="CSC5010: Artificial Intelligence")
|
|
1526
|
+
item = FileItem(name="Lec 1.pdf", url="https://bb/bbcswebdav/lec1", category="lectures")
|
|
1527
|
+
monkeypatch.setattr(
|
|
1528
|
+
service.scraper, "collect_menu_links", lambda page, course: [("https://bb/area", "内容")]
|
|
1529
|
+
)
|
|
1530
|
+
monkeypatch.setattr(service.scraper, "find_files", lambda *args, **kwargs: [item])
|
|
1531
|
+
|
|
1532
|
+
class FakeDownloads:
|
|
1533
|
+
def download_item(self, item, course_dir, dry_run, index):
|
|
1534
|
+
return ("exists", "lectures/Lec 1.pdf")
|
|
1535
|
+
|
|
1536
|
+
stats = SyncStats()
|
|
1537
|
+
options = service.SyncOptions(
|
|
1538
|
+
settings=Settings(root=str(tmp_path), announcements=False),
|
|
1539
|
+
config_path=tmp_path / "config.yaml",
|
|
1540
|
+
root=tmp_path,
|
|
1541
|
+
console=Console(quiet=True),
|
|
1542
|
+
)
|
|
1543
|
+
service.sync_course(None, course, tmp_path / "c", options, stats, FakeDownloads()) # type: ignore[arg-type]
|
|
1544
|
+
|
|
1545
|
+
assert stats.exists == 1
|
|
1546
|
+
assert stats.files == []
|
|
1547
|
+
|
|
1548
|
+
|
|
1549
|
+
def test_print_saved_files_one_item_per_line() -> None:
|
|
1550
|
+
from typing import cast
|
|
1551
|
+
|
|
1552
|
+
from bb_sync.blackboard.models import SavedFile
|
|
1553
|
+
from bb_sync.core import service
|
|
1554
|
+
from bb_sync.core.output import Console
|
|
1555
|
+
|
|
1556
|
+
fake = _RecordingConsole()
|
|
1557
|
+
console = cast(Console, fake)
|
|
1558
|
+
service._print_saved_files(
|
|
1559
|
+
console,
|
|
1560
|
+
[
|
|
1561
|
+
SavedFile(
|
|
1562
|
+
"CSC5010", "lectures", "lectures/Lec 5.pdf", "C:/courses/CSC5010/lectures/Lec 5.pdf"
|
|
1563
|
+
),
|
|
1564
|
+
SavedFile(
|
|
1565
|
+
"MDS5020",
|
|
1566
|
+
"assignments",
|
|
1567
|
+
"assignments/hw2/report.pdf",
|
|
1568
|
+
"C:/courses/MDS5020/assignments/hw2/report.pdf",
|
|
1569
|
+
),
|
|
1570
|
+
],
|
|
1571
|
+
)
|
|
1572
|
+
|
|
1573
|
+
assert fake.rules == ["新下载明细"]
|
|
1574
|
+
assert fake.messages == [
|
|
1575
|
+
"[new] CSC5010 | lectures/Lec 5.pdf",
|
|
1576
|
+
"[new] MDS5020 | assignments/hw2/report.pdf",
|
|
1577
|
+
]
|
|
1578
|
+
|
|
1579
|
+
|
|
1580
|
+
def test_print_saved_files_skips_empty_section() -> None:
|
|
1472
1581
|
from typing import cast
|
|
1473
1582
|
|
|
1474
1583
|
from bb_sync.core import service
|
|
1475
1584
|
from bb_sync.core.output import Console
|
|
1476
1585
|
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
self.rules: list[str] = []
|
|
1586
|
+
fake = _RecordingConsole()
|
|
1587
|
+
console = cast(Console, fake)
|
|
1588
|
+
service._print_saved_files(console, [])
|
|
1481
1589
|
|
|
1482
|
-
|
|
1483
|
-
|
|
1590
|
+
# 没有新文件时不打标题,避免空区块
|
|
1591
|
+
assert fake.rules == []
|
|
1592
|
+
assert fake.messages == []
|
|
1484
1593
|
|
|
1485
|
-
def rule(self, title: str = "") -> None:
|
|
1486
|
-
self.rules.append(title)
|
|
1487
1594
|
|
|
1488
|
-
|
|
1595
|
+
def test_sync_stats_as_dict_carries_new_files() -> None:
|
|
1596
|
+
from bb_sync.blackboard.models import SavedFile, SyncStats
|
|
1597
|
+
|
|
1598
|
+
stats = SyncStats(downloaded=1)
|
|
1599
|
+
stats.files.append(
|
|
1600
|
+
SavedFile(
|
|
1601
|
+
"CSC5010", "lectures", "lectures/Lec 1.pdf", "C:/courses/CSC5010/lectures/Lec 1.pdf"
|
|
1602
|
+
)
|
|
1603
|
+
)
|
|
1604
|
+
|
|
1605
|
+
payload = stats.as_dict()
|
|
1606
|
+
|
|
1607
|
+
assert payload["downloaded"] == 1
|
|
1608
|
+
assert payload["files"] == [
|
|
1609
|
+
{
|
|
1610
|
+
"course": "CSC5010",
|
|
1611
|
+
"category": "lectures",
|
|
1612
|
+
"name": "lectures/Lec 1.pdf",
|
|
1613
|
+
"path": "C:/courses/CSC5010/lectures/Lec 1.pdf",
|
|
1614
|
+
}
|
|
1615
|
+
]
|
|
1616
|
+
|
|
1617
|
+
|
|
1618
|
+
def test_console_line_writes_to_stderr_without_ansi_when_not_tty(capsys) -> None:
|
|
1619
|
+
"""带样式的诊断行只走 stderr;非 TTY(测试 / 管道)下不发转义序列。"""
|
|
1620
|
+
from bb_sync.core.output import Console
|
|
1621
|
+
|
|
1622
|
+
Console().line(("课程 ", ""), ("CSC5010", "cyan"))
|
|
1623
|
+
out, err = capsys.readouterr()
|
|
1624
|
+
assert out == ""
|
|
1625
|
+
assert "课程 CSC5010" in err
|
|
1626
|
+
assert "" not in err
|
|
1627
|
+
|
|
1628
|
+
Console(quiet=True).line(("课程 ", ""), ("CSC5010", "cyan"))
|
|
1629
|
+
out, err = capsys.readouterr()
|
|
1630
|
+
assert (out, err) == ("", "")
|
|
1631
|
+
|
|
1632
|
+
|
|
1633
|
+
def test_print_due_details_empty_writes_congratulations() -> None:
|
|
1634
|
+
from typing import cast
|
|
1635
|
+
|
|
1636
|
+
from bb_sync.core import service
|
|
1637
|
+
from bb_sync.core.output import Console
|
|
1638
|
+
|
|
1639
|
+
fake = _RecordingConsole()
|
|
1489
1640
|
console = cast(Console, fake)
|
|
1490
1641
|
service._print_due_details(console, [])
|
|
1491
1642
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
version = 1
|
|
2
|
-
revision =
|
|
2
|
+
revision = 5
|
|
3
3
|
requires-python = ">=3.11"
|
|
4
4
|
|
|
5
5
|
[[package]]
|
|
@@ -31,7 +31,7 @@ wheels = [
|
|
|
31
31
|
|
|
32
32
|
[[package]]
|
|
33
33
|
name = "bb-sync"
|
|
34
|
-
version = "2.
|
|
34
|
+
version = "2.1.1"
|
|
35
35
|
source = { editable = "." }
|
|
36
36
|
dependencies = [
|
|
37
37
|
{ name = "click" },
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|