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.
Files changed (53) hide show
  1. {bb_sync-2.0.0 → bb_sync-2.1.1}/AGENTS.md +3 -0
  2. {bb_sync-2.0.0 → bb_sync-2.1.1}/CHANGELOG.md +25 -0
  3. {bb_sync-2.0.0 → bb_sync-2.1.1}/PKG-INFO +1 -1
  4. {bb_sync-2.0.0 → bb_sync-2.1.1}/docs/scripting.md +5 -1
  5. {bb_sync-2.0.0 → bb_sync-2.1.1}/pyproject.toml +1 -1
  6. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/blackboard/downloader.py +10 -5
  7. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/blackboard/models.py +22 -0
  8. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/core/output.py +56 -9
  9. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/core/service.py +149 -28
  10. {bb_sync-2.0.0 → bb_sync-2.1.1}/tests/test_cli_e2e.py +26 -0
  11. {bb_sync-2.0.0 → bb_sync-2.1.1}/tests/test_units.py +173 -22
  12. {bb_sync-2.0.0 → bb_sync-2.1.1}/uv.lock +2 -2
  13. {bb_sync-2.0.0 → bb_sync-2.1.1}/.github/workflows/ci.yml +0 -0
  14. {bb_sync-2.0.0 → bb_sync-2.1.1}/.github/workflows/release.yml +0 -0
  15. {bb_sync-2.0.0 → bb_sync-2.1.1}/.gitignore +0 -0
  16. {bb_sync-2.0.0 → bb_sync-2.1.1}/.pre-commit-config.yaml +0 -0
  17. {bb_sync-2.0.0 → bb_sync-2.1.1}/CONTRIBUTING.md +0 -0
  18. {bb_sync-2.0.0 → bb_sync-2.1.1}/LICENSE +0 -0
  19. {bb_sync-2.0.0 → bb_sync-2.1.1}/README.md +0 -0
  20. {bb_sync-2.0.0 → bb_sync-2.1.1}/config.yaml +0 -0
  21. {bb_sync-2.0.0 → bb_sync-2.1.1}/docs/config.md +0 -0
  22. {bb_sync-2.0.0 → bb_sync-2.1.1}/scripts/debug_course.py +0 -0
  23. {bb_sync-2.0.0 → bb_sync-2.1.1}/scripts/debug_courses.py +0 -0
  24. {bb_sync-2.0.0 → bb_sync-2.1.1}/scripts/debug_login.py +0 -0
  25. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/__init__.py +0 -0
  26. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/__main__.py +0 -0
  27. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/blackboard/__init__.py +0 -0
  28. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/blackboard/login.py +0 -0
  29. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/blackboard/scraper.py +0 -0
  30. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/browser/__init__.py +0 -0
  31. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/browser/launcher.py +0 -0
  32. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/cli.py +0 -0
  33. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/commands/__init__.py +0 -0
  34. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/commands/auth.py +0 -0
  35. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/commands/browser.py +0 -0
  36. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/commands/config.py +0 -0
  37. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/commands/course.py +0 -0
  38. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/commands/doctor.py +0 -0
  39. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/commands/due.py +0 -0
  40. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/commands/run.py +0 -0
  41. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/context.py +0 -0
  42. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/core/__init__.py +0 -0
  43. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/core/config.py +0 -0
  44. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/core/config_store.py +0 -0
  45. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/core/doctor.py +0 -0
  46. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/core/due.py +0 -0
  47. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/core/errors.py +0 -0
  48. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/core/help.py +0 -0
  49. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/core/update.py +0 -0
  50. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/creds.py +0 -0
  51. {bb_sync-2.0.0 → bb_sync-2.1.1}/src/bb_sync/paths.py +0 -0
  52. {bb_sync-2.0.0 → bb_sync-2.1.1}/tests/conftest.py +0 -0
  53. {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.0.0
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", ...]}` |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bb-sync"
7
- version = "2.0.0"
7
+ version = "2.1.1"
8
8
  description = "港中深(CUHK-Shenzhen)Blackboard 课件下载与课程资源自动同步:登录 ADFS SSO,增量下载课件/作业/指导、公告与待办"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -192,8 +192,11 @@ def _content_length(headers) -> int | None:
192
192
  return None
193
193
 
194
194
 
195
- def _category_dir(course_dir: Path, item: FileItem) -> Path:
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 _relpath(path: Path, course_dir: Path) -> str:
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 = _category_dir(course_dir, item)
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", _relpath(already, course_dir))
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``)—— 一律走 stderr,
18
- 且随 ``--quiet`` 降级。
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
- _NO_COLOR_ENV = "NO_COLOR"
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": "dim", "step": "cyan"}.get(level, "dim")
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="dim")
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__ = ["Console"]
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 Course, DueItem, DueStats, FileItem, SyncStats
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 Console
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.step(f"=== {course.code or '?'} | {course.title} → {course_dir} ===")
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
- console.log(" 内容区: " + (", ".join(n for _, n in menu) or "(无)"))
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.log(f" 发现 {len(uniq)} 个文件")
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.log(f" ↓ [{category}] {name}")
246
+ console.line((" ↓ ", STYLE_SUCCESS), badge, (name, STYLE_TEXT))
190
247
  elif status == "would-download":
191
- console.log(f" ? [{category}] {name} (dry-run)")
248
+ console.line((" ? ", STYLE_WARN), badge, (f"{name} (dry-run)", STYLE_TEXT))
192
249
  elif status == "exists":
193
- console.log(f" = [{category}] {name} (已存在,跳过)")
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.log(f"根目录: {root}")
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.log(f" {label:<10} → {root / course.slug}")
209
- console.log(f"合计 {len(courses)} 门课程" + ("(dry-run,不会写入)" if dry_run else ""))
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.log(f"[due] {message}(dry-run,未写入 due.md)")
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.log(f"[due] {len(due_items)} 项 → due.md")
369
+ console.line(
370
+ ("[due] ", STYLE_ACCENT), (f"{len(due_items)} 项 → due.md", STYLE_TEXT)
371
+ )
306
372
  elif scan.incomplete:
307
- console.log("[due] 未读取到待办(部分课程失败)→ due.md")
373
+ console.line(
374
+ ("[due] ", STYLE_ACCENT), ("未读取到待办(部分课程失败)→ due.md", STYLE_MUTED)
375
+ )
308
376
  else:
309
- console.log("[due] Congratulations! 没有待办 → due.md")
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.log(f"待下载: {stats.would_download} 个文件(未实际下载)")
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.log(f"新下载 {stats.downloaded} 个,已存在跳过 {stats.exists} 个")
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.warn(f"失败 {stats.failed} 个")
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.log(f"[due] {due_date} | {item.course_label} | {badge}{item.title}")
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.log(f"发现 {stats.total} 项待办,已写入 {stats.path}")
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
- class FakeConsole:
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 test_print_due_details_empty_writes_congratulations() -> None:
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
- class FakeConsole:
1478
- def __init__(self) -> None:
1479
- self.messages: list[str] = []
1480
- self.rules: list[str] = []
1586
+ fake = _RecordingConsole()
1587
+ console = cast(Console, fake)
1588
+ service._print_saved_files(console, [])
1481
1589
 
1482
- def log(self, message: str) -> None:
1483
- self.messages.append(message)
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
- fake = FakeConsole()
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 = 3
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.0.0"
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