From a613e930453b26fdf242e5a5d9066986d6955c0d Mon Sep 17 00:00:00 2001 From: Apryle Wu Date: Mon, 7 Sep 2026 14:39:34 +0800 Subject: [PATCH 1/2] feat: expand Blackboard, NCES and campus handbook workflows --- CHANGELOG.md | 19 + README.md | 63 +- docs/ARCHITECTURE.md | 14 +- docs/AUTHENTICATION.md | 8 + docs/MCP.md | 48 +- docs/MIGRATION.md | 2 +- docs/ONLINE.md | 38 +- docs/OUTPUT.md | 6 + docs/SERVICES.md | 24 +- skills/sustech-cli/SKILL.md | 24 +- src/cli.ts | 4897 +++++++++++++----- src/context/service.ts | 23 +- src/context/types.ts | 10 + src/core/argv.ts | 4 +- src/core/auth-check.ts | 118 + src/core/capabilities.ts | 45 +- src/core/command-metadata.ts | 87 +- src/core/consequences.ts | 4 + src/mcp/public-tool-names.ts | 16 + src/mcp/public-tools.ts | 146 +- src/mcp/registry.ts | 7 - src/mcp/server.ts | 88 +- src/online/index.ts | 2 + src/online/manual-text.ts | 52 + src/online/manual.ts | 800 +++ src/online/search.ts | 133 +- src/online/types.ts | 2 +- src/services/base.ts | 30 +- src/services/blackboard-browser.ts | 349 ++ src/services/blackboard.ts | 3440 +++++++++++- src/services/index.ts | 1 + src/services/nces.ts | 1200 ++++- src/services/sustech-online.ts | 6 +- src/services/text.ts | 828 ++- src/sso/cas.ts | 8 + src/test/argv.test.ts | 11 + src/test/auth.test.ts | 142 + src/test/blackboard_announcements.test.ts | 400 ++ src/test/blackboard_browser.test.ts | 91 + src/test/blackboard_discussion_write.test.ts | 290 ++ src/test/blackboard_discussions.test.ts | 939 ++++ src/test/blackboard_message_write.test.ts | 105 + src/test/blackboard_messages.test.ts | 255 + src/test/blackboard_readflows.test.ts | 891 ++++ src/test/blackboard_roster.test.ts | 116 + src/test/blackboard_submission.test.ts | 280 +- src/test/cli.test.ts | 511 ++ src/test/consequences.test.ts | 7 + src/test/context.test.ts | 16 +- src/test/mcp.test.ts | 27 +- src/test/nces-resolution.test.ts | 494 +- src/test/nces_extended.test.ts | 915 ++++ src/test/online-manual.test.ts | 481 ++ src/test/profile.test.ts | 3 + src/test/services_auth.test.ts | 22 +- src/test/services_public.test.ts | 4 +- src/tis/course-decision.ts | 8 + 57 files changed, 17093 insertions(+), 1457 deletions(-) create mode 100644 src/core/auth-check.ts create mode 100644 src/online/manual-text.ts create mode 100644 src/online/manual.ts create mode 100644 src/services/blackboard-browser.ts create mode 100644 src/test/blackboard_announcements.test.ts create mode 100644 src/test/blackboard_browser.test.ts create mode 100644 src/test/blackboard_discussion_write.test.ts create mode 100644 src/test/blackboard_discussions.test.ts create mode 100644 src/test/blackboard_message_write.test.ts create mode 100644 src/test/blackboard_messages.test.ts create mode 100644 src/test/blackboard_roster.test.ts create mode 100644 src/test/nces_extended.test.ts create mode 100644 src/test/online-manual.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 46dd40a..c169a96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to `sustech-cli` are documented in this file. ## [Unreleased] +### Added + +- Expanded the public NCES integration with exact course-code resolution, + teacher profiles, course rating statistics, filtered review pagination, and + typed MCP tools. NCES-generated summaries are explicitly labelled as + community AI summaries rather than official course information. +- Added `bb announcements` to aggregate visible system and course + announcements while preserving successful results and reporting inaccessible + courses as partial failures. +- Extended `online search` with a fixed-allowlist `manual` section covering + selected SUSTech Online service, study, transport, life, facility, and + calendar guidance with source, freshness, and CC BY-SA metadata. + +### Changed + +- NCES browse sorting now uses the upstream server-side sort, and NCES search + supports typed course, teacher, and review result pages. +- The local MCP surface now exposes `42` typed public/local read-only tools. + ## [0.10.0] - 2026-08-29 ### Added diff --git a/README.md b/README.md index 89698a6..40a69d6 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,17 @@ Public data does not require an account: ```bash sustech calendar day 2026-09-01 sustech faculty search "computer vision" +sustech online search "校园卡" --section manual +sustech online manual list --source service --limit 10 +sustech online manual get ID_OR_TITLE sustech online talks list --limit 10 sustech online contact search "教学" +sustech nces filter-options +sustech nces browse --offering-unit "计算机科学与工程系" --page-size 5 +sustech nces global-stats +sustech nces rankings top-teachers --limit 5 +sustech nces by-code CS302 --term 20252 +sustech nces reviews 244 --sort newest --page-size 5 sustech transit lines sustech library search "graph neural networks" --limit 5 ``` @@ -59,6 +68,18 @@ sustech tis plan explain CS330 --round bxxk sustech tis degree missing sustech tis degree progress sustech bb calendar --type GradebookColumn +sustech bb announcements --days 14 +sustech bb deadlines --days 14 --submission-state not_attempted --json +sustech bb tree _8537_1 --max 50 +sustech bb types --course MSE306 +sustech bb roster _8537_1 --role Student --page-size 10 +sustech bb discussions _5325_1 --page-size 10 +sustech bb grades --course MSE306 --submission-state completed --limit 10 --json +sustech bb assignments --course MSE306 --with-attempts --json +sustech bb assignments _8537_1 --with-attempts --json +sustech bb assignments _8537_1 --submission-state not_attempted --json +sustech bb messages _8537_1 --folder-type Inbox --page-size 10 +sustech bb attempt-files _8537_1 _2201_1 sustech tis schedule sustech bb courses ``` @@ -114,8 +135,9 @@ For an agent without Skill support, provide this short instruction: A Skill is the onboarding layer; the CLI remains the executable source of truth. The package also ships a local `stdio` MCP entrypoint, `sustech-mcp`, for -clients that support native tools. It needs no hosted server and exposes `33` -typed public/local read-only tools plus JSON resources, resource templates, and +clients that support native tools. It needs no hosted server and exposes `42` +typed read-only tools in total (`39` public allowlisted tools plus `3` metadata +tools), plus JSON resources, resource templates, and prompts for discovery, public campus data, library, faculty, transit, NCES, papers, and selected SUSTech Online reads. Authenticated data, browser flows, local writes, and remote mutations remain unavailable through MCP. See @@ -133,9 +155,9 @@ version's exact command, authentication, network, and confirmation metadata. | Diagnostics | version, capabilities, consequences, doctor | Local; optional live auth checks | | Academic context | calendar, Context v2 live summaries, profile reports, academic snapshots, `academic changes`, one-shot `academic watch` | Public and authenticated reads; guarded local exports | | TIS | catalog, schedule, grades, exams, TIS-reported degree progress, conservative missing-course report, persistent planning, `tis plan solve/explain/recommend`, local degree audit, live classrooms, iCalendar | CAS login; selection/enrollment writes are confirm-gated | -| Blackboard | courses, deadlines, calendar reads, native calendar-link workflow, search, attachment download/sync, attempts, submission | CAS login for REST reads; the native calendar link is a separate stored secret and local writes are guarded | +| Blackboard | courses, roster, course messages, message send preview/apply, discussions, recursive content trees, content type summaries, announcements, deadlines, calendar reads, cross-course grades, per-course and cross-course assignment/attempt overviews, native calendar-link workflow, search, attachment download/sync, attempts, submission | CAS login for REST reads; `bb roster`, `bb messages` / `bb message-participants`, `bb message-send preview/apply`, `bb discussion-groups`, announcement aggregation, `bb tree`, cross-course `bb grades`, cross-course `bb assignments --course ...`, `bb assignments --with-attempts` / `--submission-state`, `bb deadlines --submission-state`, and `bb types` preserve partial failures. Blackboard discussions use the official Learn REST discussion API when the target course exposes it; Original-course forum lists, `bb discussion` thread reads, and `bb discussion-replies` thread-detail reads fall back to the Blackboard HTML discussion board, while group reads and discussion writes that still require the REST surface fail closed with `BLACKBOARD_DISCUSSIONS_UNSUPPORTED`. The native calendar link is a separate stored secret, and local writes are guarded | | Library and campus services | Primo catalog search/detail, WS programs, eHall booking, library booking, PMS jobs and usage | Public catalog reads plus authenticated reads; booking and queue writes are confirm-gated | -| Research and courses | Crossref/OA papers, NCES browse and search, SUSTech Online talks | Public; OA downloads use guarded local paths; NCES and SUSTech Online remain community references only | +| Research and courses | Crossref/OA papers, NCES browse/filter-options/global-stats/rankings/search/by-code/course/reviews/teacher/stats, SUSTech Online talks and selected handbook search | Public; OA downloads use guarded local paths; NCES and SUSTech Online remain community references only | | Campus and device context | faculty, resources, transit, Wi-Fi status/events | Public or local | | Community directory | Selected institutional SUSTech Online contacts with provenance and freshness advisories | Public community source; emergency, financial, personal, dining/chat, and professor-list sections are excluded | @@ -215,6 +237,8 @@ falling back to plaintext. ```bash sustech auth login --profile main sustech auth check --profile main --service bb --json +sustech auth check --service bb --browser --interactive --json +sustech doctor --service bb --live --browser --interactive --json sustech auth logout --profile main ``` @@ -223,6 +247,11 @@ the documented environment variables or credentials file. Service sessions and cookies remain in memory. See [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md) for precedence, backend requirements, and non-interactive use. +For Blackboard only, `auth check` and `doctor --live` also support a read-only +browser-backed verification path with `--browser`, plus `--interactive` when +the user needs to finish CAS manually. That path never accepts browser +credentials in the CLI and never persists browser cookies. + Blackboard also exposes a private native calendar subscription link. Treat that link like a bearer token or password: store it only through stdin, let `show` mask it by default, and reveal it only with an explicit `--reveal`: @@ -261,8 +290,14 @@ Blackboard attachment and submission example: ```bash sustech bb attachments _8537_1 _629896_1 --json +sustech bb grades --course CS208 --submission-state completed --limit 10 --json +sustech bb assignments --course CS208 --with-attempts --json +sustech bb assignments _8537_1 --with-attempts --json sustech bb download _8537_1 _629896_1 ATTACHMENT_ID \ --destination ./homework.pdf +sustech bb attempt-files _8537_1 _2201_1 --json +sustech bb attempt-download _8537_1 _2201_1 FILE_ID \ + --destination ./submitted-homework.pdf sustech bb submit preview \ --course-id _8537_1 --content-id _629896_1 --file homework.pdf @@ -308,7 +343,7 @@ sustech context --live --level verbose `context` now has three explicit detail levels: - `terse`: compact calendar and near-term summary -- `normal`: adds the next deadline, next evaluation, and next exam when known +- `normal`: adds the next deadline, the most recent Blackboard announcement from the last 14 days, the next evaluation, and the next exam when known - `verbose`: adds public weather, AQI, and library-status observations when `--live` is enabled @@ -351,6 +386,12 @@ review instead of being promoted to a definite requirement match. - Blackboard submission follows official Learn REST attempt/upload endpoints and is fixture-tested, but it has not yet performed a real Blackboard write. +- Student-submitted attempt files are separate from teacher-provided content + attachments. `bb attempt-files` lists one attempt's files, and + `bb attempt-download` downloads one of them to an explicit local path when + Blackboard exposes a working attempt-file download endpoint for that record; + otherwise the CLI now fails closed with + `BLACKBOARD_ATTEMPT_FILE_UNAVAILABLE`. - Primo catalog access has both direct and browser-backed paths, but direct public HTTP access can still depend on the local runtime's TLS behavior. When in doubt, use `--browser` and complete any CAS step manually. @@ -359,9 +400,15 @@ review instead of being promoted to a definite requirement match. an interactive slide CAPTCHA. The CLI will not bypass that challenge. A previously stored Blackboard native calendar link can still be fetched without CAS. -- The supported submission surface is Classic/Original assignment attempts; - the CLI does not scrape or silently fall back to the legacy - `uploadAssignment` HTML form. +- Blackboard submission stays on the official Learn REST path: file attachments + remain limited to Classic/Original assignment attempts, and supported + assignment targets can also submit text through the attempt payload. The CLI + does not scrape or silently fall back to the legacy `uploadAssignment` HTML + form. +- Blackboard `bb message-send preview/apply` stays on the official course + message create endpoint, binds apply to the previewed SHA-256 plus exact + recipient IDs, and verifies the created message by Sent-folder read-back. It + is still protocol/fixture-tested only. - Newly added TIS selection, booking, library-booking, and PMS write paths are protocol/fixture-tested only. No real account mutation was performed while building this expansion. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 53b2e3c..e9bbd60 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -108,14 +108,22 @@ commands for them. and one-shot `academic watch` do not mutate remote campus state. - `bb submit preview` authenticates for live read-only preflight checks but never calls a mutation endpoint. +- `bb discussions`, `bb discussion`, and `bb discussion-replies` prefer the + official Learn REST discussion endpoints with explicit `offset`/`limit` + paging and server-side title, gradable, author, status, and read-state + filters; for Blackboard Original courses that reject REST, forum, thread, + and thread-detail reply reads fall back to the HTML discussion board, while + group reads and discussion writes remain REST-only and fail closed. - `bb calendar` is an authenticated read with optional date, type, and course filters. `bb calendar-link set` validates a native Learn ICS feed and stores it as a separate operating-system secret; `show` masks it by default, and `fetch` can refresh the feed without a fresh CAS login. - `bb attachments` keeps teacher-provided content files separate from student - attempt files. `bb download` is a local mutation with an explicit destination, - same-origin URL checks, exclusive no-overwrite placement, and a portable - filesystem fallback when hard links are unavailable. + attempt files. `bb download` and `bb attempt-download` are local mutations + with explicit destinations, same-origin URL checks, exclusive no-overwrite + placement, and a portable filesystem fallback when hard links are + unavailable. `bb attempt-files` exposes the student's submitted filenames + without mixing them into the teacher-attachment surface. - Booking, library-booking, and PMS sessions keep credentials and session material in memory only, reject requests outside their allowlists, and never expose a generic authenticated write primitive. diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index c8a94fa..022bc9e 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -12,6 +12,8 @@ submission state remain in memory. sustech auth login sustech auth status sustech auth check --service bb +sustech auth check --service bb --browser --interactive +sustech doctor --service bb --live --browser --interactive sustech auth logout ``` @@ -34,6 +36,12 @@ CAPTCHA, the CLI stops before password submission and returns `CAS_INTERACTIVE_CHALLENGE_REQUIRED`. It does not attempt to bypass that challenge. +For Blackboard only, `auth check` and `doctor --live` also support a separate +read-only browser-backed verification path. Use `--browser` to request that +path, and add `--interactive` when you need to complete the CAS page manually +in the opened browser window. This path does not accept browser credentials in +the CLI and does not persist browser cookies. + ## Primo browser mode The library catalog browser flow is separate from `auth login`: diff --git a/docs/MCP.md b/docs/MCP.md index 3c104a9..0108c7e 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -47,7 +47,8 @@ The entrypoint behavior is intentionally narrow: ## Tool surface -The server exposes `33` typed public/local read-only tools. It does not expose +The server exposes `42` typed read-only tools in total (`39` public allowlisted +tools plus `3` metadata tools). It does not expose a generic string runner such as `sustech_run`. Core metadata: @@ -66,12 +67,38 @@ Public research and catalog data: - `sustech_papers_search` - `sustech_nces_browse` +- `sustech_nces_filter_options` +- `sustech_nces_global_stats` +- `sustech_nces_rankings` - `sustech_nces_search` +- `sustech_nces_by_code` - `sustech_nces_course` +- `sustech_nces_reviews` +- `sustech_nces_teacher` +- `sustech_nces_stats` - `sustech_library_search` - `sustech_library_detail` - `sustech_library_search_url` +`sustech_nces_browse` accepts an optional `offeringUnit` string that maps to +NCES `offering_unit`, and `sustech_nces_filter_options` returns the live +accepted values from NCES. + +`sustech_nces_search` accepts `type=all|course|teacher|review`. Its top-level +`items`, `total`, `pages`, `page`, and `perPage` always describe the selected +bucket (`course` when `type=all`). For `type=all`, the response also exposes +`aggregateTotal` as the sum of the current course, teacher, and review bucket +totals, plus `aggregateItems` and `aggregateShown` for the currently returned +mixed page. It keeps explicit per-bucket totals and page counts, plus +`courseItems`, `selectedBucket`, and `selectedItems` so MCP callers can render +the compatibility bucket directly while still inspecting the mixed counts +without pretending they share one real combined pagination stream. + +`sustech_nces_course` and `sustech_nces_by_code` return the current review +window by default. `sustech_nces_by_code` also accepts repeated `teacher` +filters for section disambiguation. Pass `allReviews: true` only when you +explicitly want the tool to fetch every currently exposed review page. + Public faculty and campus datasets: - `sustech_faculty_departments` @@ -92,9 +119,28 @@ Public SUSTech Online layer: - `sustech_online_talks_list` - `sustech_online_talks_search` - `sustech_online_talks_get` +- `sustech_online_manual_list` +- `sustech_online_manual_get` - `sustech_online_contact_search` - `sustech_online_contact_get` +`sustech_online_search` accepts `section: "manual"` for the selected handbook +corpus. Each returned hit preserves community authority, source path, license, +fetch/update metadata, and freshness advisories. The `since` and `until` fields +are available only when the section is omitted or set to `"talks"`; the typed +schema excludes them for `"contact"` and `"manual"`. The manual branch also +accepts optional allowlisted `source` filters from +`service|study|transport|life|facility|calendar`, and returns +`manualMatchedTotal` when the section is `"manual"` so callers can distinguish +returned hits from the full pre-limit manual match count. + +`sustech_online_manual_list` accepts repeated allowlisted `source` filters from +`service|study|transport|life|facility|calendar`, and +`sustech_online_manual_get` accepts the deterministic handbook id returned by +list or search, or an exact handbook title. `sustech_online_manual_list` also +returns `matchedTotal` so callers can distinguish the allowlisted corpus size +from the current limited result count. + All tools return the same versioned JSON envelope that the direct CLI already uses, both as `structuredContent` and as a text fallback. This keeps the CLI as the installed source of truth while giving MCP clients typed input schemas. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index fef115d..828cc31 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -25,7 +25,7 @@ no real account mutation was attempted while completing this expansion. | Academic snapshots | normalized TIS state with optional Blackboard deadlines; `academic changes`; one-shot `academic watch` | guarded versioned snapshot files | Implemented with digest verification, no-overwrite defaults, and no remote write behavior | P1 | | Resources | built-in campus resource registry and search | none | Implemented | P1 | | Wi-Fi | current association and recent macOS SUSTC Wi-Fi events | none | Implemented on macOS only | P1 | -| Blackboard | courses, content, teacher-provided attachment listing/download, assignments, deadlines, calendar REST reads, search, attempts, native calendar-link storage/fetch | guarded local sync, optional ICS write, Classic assignment submission | CLI CAS login and courses read live-smoked; calendar reads, native feed-link storage/fetch, local download/sync, and the hash-bound submission workflow use official Learn REST/BBML paths or keyring fixtures and remain conservatively documented | P2 | +| Blackboard | courses, content, roster, course messages, course-message send preview/apply, discussions, teacher-provided attachment listing/download, per-course and cross-course assignments, cross-course grades, deadlines, calendar REST reads, search, attempts, native calendar-link storage/fetch | guarded local sync, optional ICS write, Classic/Original file submission plus supported text submission | CLI CAS login and courses read live-smoked; roster, course-message, discussion-group discovery, and course-message send now follow official current Learn REST endpoints. Message send validates exact roster recipients, binds apply to the previewed text SHA-256, and verifies the created Sent-folder record. Calendar reads, native feed-link storage/fetch, local download/sync, and the hash-bound submission workflow use official Learn REST/BBML paths or keyring fixtures and remain conservatively documented | P2 | | Library catalog | Primo search/detail reads, browser fallback transport | none | Implemented with direct public HTTP plus manual `--browser [--interactive]` fallback; browser auth stays user-completed and cookies are not persisted by the CLI | P2 | | Library booking | account state, idle summary, labs, rooms, reservation counts, reservations | guarded create/cancel | Login, account, summary, labs, and count live-smoked; previews now use conservative exact-availability checks and fail closed when the slot cannot be proved safe | P2 | | E-Hall booking | redacted user profile, rooms, meetings | guarded create/cancel | Login and rooms read live-smoked; previews now use conservative exact day/time availability checks and fail closed on unreadable overlap state | P2 | diff --git a/docs/ONLINE.md b/docs/ONLINE.md index 30221b6..e14ac1b 100644 --- a/docs/ONLINE.md +++ b/docs/ONLINE.md @@ -8,9 +8,14 @@ not require a campus account: sustech online talks list --since 2026-09-01 --limit 20 sustech online talks search "artificial intelligence" --limit 10 sustech online talks get 2026-07-30T10-00-00_François_Forget +sustech online manual list --source service --limit 10 +sustech online manual get ID_OR_TITLE sustech online contact search "教学" --limit 10 sustech online contact get teaching:教学工作部 sustech online search "library" --section contact +sustech online search "校园卡" --section manual --source service +sustech online search "校园卡" --section manual +sustech online search "宿舍" --section manual ``` ## Authority and freshness @@ -36,6 +41,35 @@ Use these records for discovery and convenience. Recheck time-sensitive talk details and important institutional contacts against the linked official page before acting. +## Selected handbook scope + +The `manual` search section indexes selected stable guidance from six exact +public pages: service, study, transport, life, facilities, and calendar. For +service, study, transport, life, and facilities, the parser keeps an explicit +heading-title allowlist instead of mirroring the complete site. The calendar +source is narrower than a full mirror but broader than a title allowlist: it +keeps level-2 sections from the dedicated calendar page. Manual search is +opt-in with `--section manual`, so existing unscoped talk/contact search +results keep their established record kinds. Useful +examples include campus-card/student-ID basics, campus network and +teaching systems, accommodation, transport, maps/buildings, study references, +and calendar entries. + +When you want predictable handbook enumeration instead of free-text ranking, +use `sustech online manual list` and `sustech online manual get`. They expose +the same allowlisted corpus and derive deterministic handbook ids from each +section path for follow-up reads and MCP callers. `online manual list` returns +the limited record set plus `matchedTotal`, and `online search --section manual` +returns `manualMatchedTotal`, so callers can distinguish returned hits from the +full pre-limit match count. `online search --section manual` can also use the +same `--source service|study|transport|life|facility|calendar` filter when you +want free-text search within one handbook area. + +The manual corpus excludes medical/emergency guidance, tax and financial +instructions, dining/chat and QQ-group lists, professor lists, and unofficial +software activation. A manual hit is still community-maintained guidance; use +its links to verify consequential details with the responsible official unit. + ## Selected contact scope The contact parser is an allowlist, not a full mirror of the source page. It @@ -57,7 +91,9 @@ The client fetches only the exact allowlisted Markdown files from the public `SUSTech-CRA/sustech-online-ng` repository and the matching rendered `sustech.online` page used for update metadata. Redirects are rejected, final origins and exact paths are checked, document size and timeout are bounded, and -talk identifiers can resolve to only one file in the talks directory. +talk identifiers can resolve to only one file in the talks directory. Manual +reads are restricted to six exact repository files and their six matching site +pages; final fetched URLs must match the allowlist target exactly. Returned institutional links are limited to `sustech.edu.cn` subdomains and the community site. Poster links are limited to those hosts plus the exact diff --git a/docs/OUTPUT.md b/docs/OUTPUT.md index dfade5c..4b47772 100644 --- a/docs/OUTPUT.md +++ b/docs/OUTPUT.md @@ -75,3 +75,9 @@ part of text, JSON, JSONL, error details, or capability output. Blackboard attachment listings likewise omit signed `bbcswebdav` URLs. A successful `bb download` result contains only stable attachment metadata, the absolute destination path, byte count, content type, and SHA-256. + +Blackboard attempt-file listings likewise return stable file identifiers and +names rather than raw download URLs. A successful `bb attempt-download` result +contains the selected file identity, the absolute destination path, byte count, +content type, and SHA-256. The `files` read-back inside a successful +`bb submit apply` result uses the same URL-free file identity shape. diff --git a/docs/SERVICES.md b/docs/SERVICES.md index bd7d12c..6bdef62 100644 --- a/docs/SERVICES.md +++ b/docs/SERVICES.md @@ -21,15 +21,15 @@ while the CLI already supplies that transport for a specific command family. | Service | Availability | Auth | CLI surface today | Notes | | --- | --- | --- | --- | --- | -| `blackboard` | `adapter_required` | CAS cookie session | `bb user`, `bb courses`, `bb content`, `bb attachments`, `bb download`, `bb assignments`, `bb deadlines`, `bb calendar`, `bb search`, `bb sync`, `bb attempts`, `bb submit preview`, `bb submit apply`, `bb calendar-link set/show/fetch/delete` | CLI CAS login and courses read passed an opt-in live smoke test on 2026-08-26. Content download/sync supports the official Original endpoint and embedded BBML links. Calendar-item reads and native calendar-link handling are implemented with fixtures and keyring tests. Assignment submission is fixture-tested against the official Learn REST attempt/upload/file flow and currently targets Classic/Original assignments. | +| `blackboard` | `adapter_required` | CAS cookie session | `bb user`, `bb courses`, `bb content`, `bb tree`, `bb types`, `bb attachments`, `bb download`, `bb roster`, `bb message-folders`, `bb messages`, `bb message-participants`, `bb message-send preview/apply`, `bb discussions`, `bb discussion-groups`, `bb discussion`, `bb discussion-replies`, `bb assignments`, `bb grades`, `bb attempt-files`, `bb attempt-download`, `bb announcements`, `bb deadlines`, `bb calendar`, `bb search`, `bb sync`, `bb attempts`, `bb submit preview`, `bb submit apply`, `bb calendar-link set/show/fetch/delete` | CLI CAS login and announcement aggregation passed opt-in read-only live smoke tests. Recursive content-tree reads via `bb tree`; course roster reads via `bb roster`; course-message reads via `bb message-folders` / `bb messages` / `bb message-participants`; and discussion/forum reads via `bb discussions` / `bb discussion-groups` / `bb discussion` / `bb discussion-replies` when Blackboard exposes a compatible Learn REST discussion surface. For Blackboard Original courses that reject the REST discussion API, `bb discussions` falls back to the HTML discussion-board forum list, `bb discussion` falls back to the HTML thread list, and `bb discussion-replies` falls back to the HTML thread-detail reply list, while group reads and discussion writes that still require the REST surface fail closed with `BLACKBOARD_DISCUSSIONS_UNSUPPORTED`. `bb message-send preview/apply` uses the official Learn REST course-message create endpoint, validates exact recipient IDs against the live course roster, binds apply to the reviewed text SHA-256, and verifies the created message by Sent-folder read-back. Cross-course `bb grades`, cross-course `bb assignments --course ...`, `bb assignments --with-attempts`, `bb assignments --submission-state ...`, `bb deadlines --submission-state ...`, and `bb types` preserve accessible results and record per-course, per-folder, or per-assignment failures as partial output where applicable. Content download/sync supports the official Original endpoint and embedded BBML links. Attempt-file reads expose the authenticated student's submitted files separately from teacher attachments; attempt-file downloads work when Blackboard exposes a usable download endpoint for that record and otherwise fail closed as unavailable. Blackboard message-send and assignment submission remain fixture-tested only: file attachments still follow the Classic/Original attempt-file path, while supported Blackboard assignment targets can also submit text through the official attempt payload. | | `booking` | `implemented` | CAS cookie session plus booking bearer token, campus reachability | `booking whoami`, `booking rooms`, `booking my-meetings`, `booking create preview/apply`, `booking cancel preview/apply` | CLI login and room-list read passed an opt-in live smoke test on 2026-08-26. Create preview now checks the live room calendar for the exact day/time and fails closed when overlaps or unreadable calendar state prevent a safe decision. Remote writes still require preview, `--confirm`, and exact read-back. | | `library-catalog` | `implemented` | public HTTP or manual browser session | `library search`, `library detail` | Primo public search/detail normalization is implemented, and `--browser [--interactive]` provides a manual browser-backed fallback. The CLI never fabricates records, never accepts browser credentials, never solves CAPTCHAs, and never persists browser cookies. Some runtimes may still need the browser path because upstream TLS behavior can differ by host. | | `library-booking` | `implemented` | IC booking cookie session, campus reachability | `lib-booking whoami`, `lib-booking home-summary`, `lib-booking labs`, `lib-booking rooms`, `lib-booking reservation-count`, `lib-booking reservations`, `lib-booking create preview/apply`, `lib-booking cancel preview/apply` | Login plus identity, summary, labs, and count reads passed an opt-in live smoke test on 2026-08-26. Create preview now combines room open-times with reservation metadata and fails closed when exact availability cannot be proved safely. Membership and capacity rules remain conservative. | | `ws` | `adapter_required` | CAS cookie session | `ws programs`, `ws detail` | CLI CAS login and program-list read passed an opt-in live smoke test on 2026-08-26. | | `pms` | `implemented` | PMS auth token, RSA login, OSESSIONID cookie, campus reachability | `pms check`, `pms server-groups`, `pms stations`, `pms jobs`, `pms scan-jobs`, `pms usage`, `pms upload preview/apply`, `pms delete preview/apply` | CLI performs the PMS auth flow directly, keeps OSESSIONID in memory, and uses transient RSA login material. Queue mutations are fixture-tested only. A first browser-side account link may still be needed on some accounts. | -| `nces` | `implemented` | none | `nces browse`, `nces search`, `nces course` | Public HTTP API backed by `ncesnext.com`; callers should avoid aggressive polling. | +| `nces` | `implemented` | none | `nces browse`, `nces filter-options`, `nces global-stats`, `nces rankings`, `nces search`, `nces by-code`, `nces course`, `nces reviews`, `nces teacher`, `nces stats` | Public HTTP API backed by `ncesnext.com`; browse now supports live offering-unit filtering from the upstream `course/filter-options` endpoint. Exact code lookup can align the default review window to a requested NCES term, while `--all-reviews` explicitly expands a detail read across every currently exposed review page. Ratings, reviews, teacher associations, and AI summaries are community references rather than official academic records. Callers should avoid aggressive polling. | | `papers` | `implemented` | none | `papers search`, `papers fetch-oa` | Uses CrossRef bibliographic relevance plus optional Unpaywall resolution. OA downloads require an explicit guarded destination and validate redirects, PDF bytes, size, and SHA-256. | -| `sustech-online` | `implemented` | none | `online search`, `online talks list/search/get`, `online contact search/get` | Reads exact allowlisted public Markdown and optional rendered-page freshness metadata. Results remain community-labelled and CC BY-SA attributed; high-stakes, financial, personal, dining/chat, and professor-list contact sections are excluded. | +| `sustech-online` | `implemented` | none | `online search`, `online talks list/search/get`, `online contact search/get`, `online manual list/get` | `online search` covers talks and contacts by default, and exposes an opt-in fixed-allowlist `manual` section through `--section manual`. Reads retain community/freshness/CC BY-SA metadata; high-stakes, financial, personal, dining/chat, and professor-list content is excluded. | ## Authenticated transport guards @@ -117,11 +117,21 @@ requires one attachment ID and an explicit `--destination`; signed URLs are not returned in text or machine output. The downloader streams to a temporary file, computes SHA-256, and refuses overwrite unless `--overwrite` is present. +`bb attempt-files COURSE_ID ATTEMPT_ID` lists the files Blackboard associates +with one authenticated student's assignment attempt. `bb attempt-download` +downloads one of those attempt files to an explicit destination when Blackboard +exposes a usable download endpoint for that record, using the same same-origin +checks, temporary-file streaming, SHA-256 verification, and no-overwrite +defaults as `bb download`. If Blackboard exposes only metadata and the +official download endpoint still returns `404`, the CLI reports +`BLACKBOARD_ATTEMPT_FILE_UNAVAILABLE` instead of a generic transport error. + Blackboard assignment submission uses the official Learn REST APIs: v2 grade -columns and attempts, v1 temporary uploads, and v1 attempt files. The attempt -file endpoint is limited by Blackboard to Classic/Original assignments, which -the read-only preflight verifies from both the content handler and grade-column -metadata. +columns and attempts, v1 temporary uploads, and v1 attempt files. Text +submissions can use the official attempt payload on supported assignment +targets. The attempt-file endpoint is still limited by Blackboard to +Classic/Original assignments, which the read-only preflight verifies from both +the content handler and grade-column metadata. `bb submit preview` authenticates but only reads. `bb submit apply` requires `--confirm`, the previewed `--expected-sha256`, a fresh preflight, and a diff --git a/skills/sustech-cli/SKILL.md b/skills/sustech-cli/SKILL.md index aa78dc2..d7f5da4 100644 --- a/skills/sustech-cli/SKILL.md +++ b/skills/sustech-cli/SKILL.md @@ -36,14 +36,23 @@ includes these high-value areas: `faculty departments`, `faculty list`, `faculty get`, `faculty search`, `faculty render`, `transit facilities`, `transit find`, `transit lines`, `transit schedule`, `transit stops`, `transit live`, `online search`, - `online talks list/search/get`, `online contact search/get`. + `online talks list/search/get`, `online manual list/get`, + `online contact search/get`. Use `online search QUERY --section manual` for + the selected public handbook corpus, and `online manual list/get` when you + need deterministic handbook ids or an exact handbook record. - Academic profile and audits: `profile show`, `profile export`, `academic snapshot save`, `academic changes`, `academic watch`, `doctor`. - Research helpers: `papers search`, `papers fetch-oa`, `nces browse`, - `nces search`, `nces course`. -- Blackboard: `bb user`, `bb courses`, `bb content`, `bb attachments`, - `bb assignments`, `bb deadlines`, `bb calendar`, `bb search`, - `bb attempts`, `bb download`, `bb sync`, `bb submit preview`, + `nces filter-options`, `nces global-stats`, `nces rankings`, + `nces search`, `nces by-code`, `nces course`, `nces reviews`, + `nces teacher`, `nces stats`. +- Blackboard: `bb user`, `bb courses`, `bb content`, `bb tree`, `bb types`, + `bb attachments`, `bb download`, `bb roster`, `bb message-folders`, + `bb messages`, `bb message-participants`, `bb message-send preview/apply`, + `bb discussions`, `bb discussion-groups`, `bb discussion`, + `bb discussion-replies`, `bb assignments`, `bb grades`, `bb attempt-files`, + `bb attempt-download`, `bb announcements`, `bb deadlines`, `bb calendar`, + `bb search`, `bb sync`, `bb attempts`, `bb submit preview`, `bb submit apply`, `bb calendar-link set/show/fetch/delete`. - TIS reads and planning: `tis courses search`, `tis courses available`, `tis enrolled`, `tis schedule`, `tis grades`, `tis exams`, @@ -69,6 +78,8 @@ Some useful routing hints: - For “what is due soon”, prefer `bb deadlines` and optionally `context --live --level normal` or `context --live --level verbose`. +- For recent Blackboard notices, prefer `bb announcements --days N`; preserve + `partial` and per-course failures instead of claiming the result is complete. - For Blackboard timeline questions, prefer `bb calendar` when you need typed `--since`/`--until`/`--type`/`--course-id` filtering. - For “find a Blackboard file/course item”, prefer `bb search` before scraping. @@ -103,7 +114,8 @@ Some useful routing hints: cannot complete on the current host. Browser auth stays manual. - Treat every `online` result as community-maintained. Preserve its source URL, repository path, fetch/update times, CC BY-SA license, and advisories. Talk - records may be model-processed. The selected contact surface deliberately + records may be model-processed. Manual search is also restricted to a fixed + heading/page allowlist and is not official policy. The selected surface deliberately excludes emergency, medical/crisis, financial/bank, personal, dining/chat, QQ-group, and professor-email-list sections; do not use it as an emergency directory or invent excluded records. Recheck consequential contact or event diff --git a/src/cli.ts b/src/cli.ts index a1f7c44..484fd98 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,7 @@ #!/usr/bin/env node +import { realpathSync } from "node:fs"; import { resolve as resolvePath } from "node:path"; +import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; import { comparableAcademicSnapshotSourceCount, @@ -27,6 +29,12 @@ import { buildLibraryBookingCreateApplyConfirmation, shellQuote, } from "./cli-confirmations.js"; +import { + authenticateBlackboardBrowserSession, + authenticateCredentials as authenticateServiceCredentials, + casServiceConfig, + type AuthService, +} from "./core/auth-check.js"; import { inferCommandName } from "./core/argv.js"; import { formatBrandArt, shouldUseBrandColor } from "./core/branding.js"; import { CAPABILITIES, formatCapabilities } from "./core/capabilities.js"; @@ -85,16 +93,21 @@ import { formatDepartments, formatFaculty } from "./faculty/text.js"; import { formatOnlineContact, formatOnlineContactSearch, + formatOnlineManualRecord, + formatOnlineManualRecords, formatOnlineSearchHits, formatOnlineTalk, formatOnlineTalkSearch, formatOnlineTalks, getOnlineContact, + getOnlineManualRecordWithStatus, getOnlineTalk, + listOnlineManualRecordsWithStatus, listOnlineTalks, - searchOnline, + searchOnlineWithStatus, searchOnlineContacts, searchOnlineTalks, + type OnlineManualSourceKey, } from "./online/index.js"; import { searchResources, type ResourceCategory } from "./resources/catalog.js"; import { formatResources } from "./resources/text.js"; @@ -196,15 +209,28 @@ import { buildLibraryBookingCancelPreview, applyLibraryBookingCreate, applyLibraryBookingCancel, + cleanText, createBlackboardAttempt, + createBlackboardBrowserAdapter, + createBlackboardCourseMessage, + createBlackboardDiscussionMessage, + createBlackboardDiscussionReply, + downloadBlackboardAttemptFile, downloadOpenAccessPdf, downloadBlackboardContentAttachment, evaluateBlackboardSubmissionPreflight, + filterBlackboardAssignmentsBySubmissionState, formatBrowserPrimoCatalogDetail, formatBrowserPrimoCatalogSearch, formatServiceStatuses, getBlackboardAttempt, getBlackboardContentItem, + listBlackboardCourseMessageFolders, + listBlackboardCourseMessageParticipants, + listBlackboardCourseMessages, + listBlackboardCourseRoster, + listBlackboardDiscussionGroups, + getBlackboardDiscussionMessages, getBlackboardUser, getBlackboardUploadSettings, getLibraryBookingUser, @@ -213,23 +239,35 @@ import { getLibraryIdleSummary, getLibraryReservationCount, getNcesCourseDetail, + getNcesCourseStats, + getNcesTeacherDetail, getWsProgramDetail, getWsToken, listBlackboardAssignments, + listBlackboardAssignmentsWithAttempts, + nextBlackboardAnnouncement, listBlackboardCalendarItems, listBlackboardDeadlines, + listBlackboardDiscussionReplies, + listBlackboardDiscussions, listBlackboardContentAttachments, listBlackboardAttemptFiles, listBlackboardAttempts, listBlackboardContent, + listBlackboardContentTree, listBlackboardCourses, nextBlackboardDeadline, searchBlackboardContentTree, + summarizeBlackboardContentTypes, listBookingRooms, listLibraryLabs, listLibraryReservationsPage, listLibraryRooms, listMyBookingMeetings, + listNcesCourseReviews, + getNcesCourseFilterOptions, + getNcesGlobalStats, + getNcesRankings, createPrimoPublicAdapter, buildPmsPrintDeletePreview, buildPmsPrintUploadPreview, @@ -241,18 +279,26 @@ import { listPmsStations, listPmsUsageHistory, listWsPrograms, - inspectBlackboardSubmissionFile, pmsDuplexLabel, pmsPaperName, + publicBlackboardAttemptFile, readBlackboardSubmissionPayload, + readBlackboardSubmissionTextPayload, readPmsUploadPayload, + getNcesCourseByCode, resolveNcesCourseLookups, searchLibraryCatalog, searchPrimoCatalogByBrowser, selectBlackboardAssignment, searchCrossref, searchNces, + listBlackboardAssignmentsAcrossCourses, + listBlackboardGrades, + tisToNcesTerm, + type NcesSearchResult, serviceStatus, + sampleText, + listBlackboardAnnouncements, syncBlackboardAttachments, updateBlackboardAttempt, uploadBlackboardTemporaryFile, @@ -264,10 +310,20 @@ import { loadBlackboardCalendarLink, saveBlackboardCalendarLink, type BlackboardAttempt, + type BlackboardAnnouncement, type BlackboardAttemptFile, type BlackboardCalendarItemType, + type BlackboardCourseMembership, + type BlackboardCourseMessage, + type BlackboardDiscussion, + type BlackboardDiscussionGroup, + type BlackboardDiscussionMessage, + type BlackboardCourseMessageFolderType, + type BlackboardCourseMessageParticipationType, type BlackboardDeadline, + type BlackboardDiscussionMessageStatus, type BlackboardSubmissionFile, + type BlackboardSubmissionText, type BlackboardSubmissionPreflight as BlackboardSubmissionAssessment, type PmsPrintUploadOptions, type ServiceAdapter, @@ -280,10 +336,28 @@ import { formatBookingCancelSuccess, formatBookingProfile, formatBookingRooms, + formatBlackboardAnnouncements, + formatBlackboardAssignmentsAcrossCourses, formatBlackboardAssignments, + formatBlackboardAssignmentsWithAttempts, + formatBlackboardDiscussion, + formatBlackboardDiscussionWritePreview, + formatBlackboardDiscussionWriteSuccess, + formatBlackboardDiscussionGroups, + formatBlackboardDiscussionReplies, + formatBlackboardDiscussions, + formatBlackboardGrades, formatBlackboardCalendar, formatBlackboardAttachmentDownload, + formatBlackboardAttemptFileDownload, + formatBlackboardAttemptFiles, formatBlackboardAttachments, + formatBlackboardMessageWritePreview, + formatBlackboardMessageWriteSuccess, + formatBlackboardRoster, + formatBlackboardMessageFolders, + formatBlackboardMessageParticipants, + formatBlackboardMessages, formatBlackboardAttempts, formatBlackboardContent, formatBlackboardCourses, @@ -292,9 +366,19 @@ import { formatBlackboardSubmissionSuccess, formatBlackboardSubmitPreview, formatBlackboardSync, + formatBlackboardTree, + formatBlackboardTypes, formatBlackboardUser, + formatNcesCourseByCode, formatNcesCourses, formatNcesDetail, + formatNcesFilterOptions, + formatNcesGlobalStats, + formatNcesRankings, + formatNcesReviews, + formatNcesSearch, + formatNcesStats, + formatNcesTeacher, formatPaperDownload, formatPapers, formatLibraryBookingUser, @@ -331,11 +415,11 @@ Usage: sustech capabilities [--json|--jsonl] sustech describe COMMAND... [--json|--jsonl] sustech consequences [OPERATION] [--json|--jsonl] - sustech doctor [--profile NAME] [--credentials-file PATH] [--service all|tis,bb,ws,booking,lib-booking,pms] [--live] + sustech doctor [--profile NAME] [--credentials-file PATH] [--service all|tis,bb,ws,booking,lib-booking,pms] [--live] [--browser [--interactive]] sustech auth login [--profile NAME] [--sid SID] [--service bb|tis|ws|booking|lib-booking|pms] [--password-stdin] sustech auth status [--profile NAME] sustech auth logout [--profile NAME] - sustech auth check [--profile NAME] [--service tis|bb|ws|booking|lib-booking|library-booking|pms] [--credentials-file PATH] [--json|--jsonl] + sustech auth check [--profile NAME] [--service tis|bb|ws|booking|lib-booking|library-booking|pms] [--credentials-file PATH] [--browser [--interactive]] [--json|--jsonl] sustech calendar terms [--year YYYY] [--calendar-level undergraduate|graduate] sustech calendar day [YYYY-MM-DD|--date YYYY-MM-DD] [--calendar-level undergraduate|graduate] sustech academic snapshot save --destination PATH [--semester YYYY-YYYY-N] [--include-blackboard] [--overwrite] @@ -347,10 +431,12 @@ Usage: sustech faculty get SLUG sustech faculty search QUERY [--department DEPARTMENT] [--limit N] sustech faculty render SLUG - sustech online search QUERY [--section talks|contact] [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--limit N] + sustech online search QUERY [--section talks|contact|manual] [--source SOURCE]... [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--limit N] sustech online talks list [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--limit N] sustech online talks search QUERY [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--limit N] sustech online talks get ID + sustech online manual list [--source SOURCE]... [--limit N] + sustech online manual get ID_OR_TITLE [--source SOURCE]... sustech online contact search QUERY [--limit N] sustech online contact get ID sustech context [--date YYYY-MM-DD] [--calendar-level undergraduate|graduate] [--level terse|normal|verbose] [--live] [--credentials-file PATH] @@ -363,26 +449,53 @@ Usage: sustech services status [SERVICE] sustech papers search QUERY [--max N] [--min-year YYYY] [--open-access|--resolve-oa] sustech papers fetch-oa DOI --destination PATH [--overwrite] - sustech nces browse [--page N] [--page-size N] [--sort rating|reviews|name] - sustech nces search QUERY - sustech nces course ID - sustech bb user - sustech bb courses [QUERY] - sustech bb content COURSE_ID [--parent-id CONTENT_ID] - sustech bb attachments COURSE_ID CONTENT_ID - sustech bb download COURSE_ID CONTENT_ID ATTACHMENT_ID --destination PATH [--overwrite] - sustech bb assignments COURSE_ID - sustech bb deadlines [--days N] [--course QUERY] - sustech bb calendar [--since ISO-DATETIME] [--until ISO-DATETIME] [--type Course|GradebookColumn|Institution|OfficeHours|Personal] [--course-id COURSE_ID] + sustech nces browse [--page N] [--page-size N] [--sort rating|reviews|name] [--offering-unit NAME] + sustech nces filter-options + sustech nces global-stats + sustech nces rankings CATEGORY [--limit N] + sustech nces search QUERY [--page N] [--page-size N] [--type all|course|teacher|review] + sustech nces by-code CODE [--term TERM_ID] [--teacher NAME]... [--all-reviews] + sustech nces course ID [--all-reviews] + sustech nces reviews ID [--page N] [--page-size N] [--sort helpful|newest|oldest|rating-high|rating-low] [--term TERM_ID] [--rating 1-10] + sustech nces teacher ID + sustech nces stats ID + sustech bb user [--browser [--interactive]] + sustech bb courses [QUERY] [--browser [--interactive]] + sustech bb content COURSE_ID [--parent-id CONTENT_ID] [--browser [--interactive]] + sustech bb tree COURSE_ID [--content-id CONTENT_ID] [--max N] [--browser [--interactive]] + sustech bb types [--course QUERY] [--browser [--interactive]] + sustech bb attachments COURSE_ID CONTENT_ID [--browser [--interactive]] + sustech bb download COURSE_ID CONTENT_ID ATTACHMENT_ID --destination PATH [--overwrite] [--browser [--interactive]] + sustech bb roster COURSE_ID [--role ROLE_ID] [--availability Yes|No|Disabled] [--page N] [--page-size N] [--sort FIELD[(desc)]] [--browser [--interactive]] + sustech bb message-folders COURSE_ID [--page N] [--page-size N] [--browser [--interactive]] + sustech bb messages COURSE_ID [--folder-type Inbox|Sent|Delete|Custom] [--folder-name NAME] [--page N] [--page-size N] [--sort FIELD[(desc)]] [--browser [--interactive]] + sustech bb message-participants COURSE_ID MESSAGE_ID [--participation-type From|To|Cc|Bcc] [--page N] [--page-size N] [--sort FIELD[(desc)]] [--browser [--interactive]] + sustech bb message-send preview COURSE_ID [--subject TEXT] [--to-user USER_ID]... [--cc-user USER_ID]... [--bcc-user USER_ID]... --text-file PATH [--browser [--interactive]] + sustech bb message-send apply COURSE_ID [--subject TEXT] [--to-user USER_ID]... [--cc-user USER_ID]... [--bcc-user USER_ID]... --text-file PATH --expected-sha256 HEX --confirm + sustech bb discussions COURSE_ID [--title QUERY] [--gradable true|false] [--page N] [--page-size N] [--sort FIELD[(desc)]] [--browser [--interactive]] + sustech bb discussion-groups COURSE_ID DISCUSSION_ID [--page N] [--page-size N] [--sort FIELD[(desc)]] [--browser [--interactive]] + sustech bb discussion COURSE_ID DISCUSSION_ID [--group-id GROUP_ID] [--user-id USER_ID] [--status Published|Deleted|Draft] [--is-read true|false] [--page N] [--page-size N] [--sort FIELD[(desc)]] [--browser [--interactive]] + sustech bb discussion-replies COURSE_ID DISCUSSION_ID MESSAGE_ID [--group-id GROUP_ID] [--user-id USER_ID] [--status Published|Deleted|Draft] [--is-read true|false] [--page N] [--page-size N] [--sort FIELD[(desc)]] [--browser [--interactive]] + sustech bb discussion-post preview COURSE_ID DISCUSSION_ID --text-file PATH [--group-id GROUP_ID] [--status Published|Deleted|Draft] [--browser [--interactive]] + sustech bb discussion-post apply COURSE_ID DISCUSSION_ID --text-file PATH --expected-sha256 HEX [--group-id GROUP_ID] [--status Published|Deleted|Draft] --confirm + sustech bb discussion-reply preview COURSE_ID DISCUSSION_ID MESSAGE_ID --text-file PATH [--group-id GROUP_ID] [--status Published|Deleted|Draft] [--browser [--interactive]] + sustech bb discussion-reply apply COURSE_ID DISCUSSION_ID MESSAGE_ID --text-file PATH --expected-sha256 HEX [--group-id GROUP_ID] [--status Published|Deleted|Draft] --confirm + sustech bb assignments [COURSE_ID] [--course QUERY] [--with-attempts] [--submission-state not_attempted|in_progress|submitted|completed|mixed|other] [--browser [--interactive]] + sustech bb grades [--course QUERY] [--submission-state in_progress|submitted|completed|mixed|other] [--limit N] [--browser [--interactive]] + sustech bb attempt-files COURSE_ID ATTEMPT_ID [--browser [--interactive]] + sustech bb attempt-download COURSE_ID ATTEMPT_ID FILE_ID --destination PATH [--overwrite] [--browser [--interactive]] + sustech bb announcements [--days N] [--course QUERY] [--browser [--interactive]] + sustech bb deadlines [--days N] [--course QUERY] [--submission-state not_attempted|in_progress|submitted|completed|mixed|other] [--browser [--interactive]] + sustech bb calendar [--since ISO-DATETIME] [--until ISO-DATETIME] [--type Course|GradebookColumn|Institution|OfficeHours|Personal] [--course-id COURSE_ID] [--browser [--interactive]] sustech bb calendar-link set --url-stdin [--profile NAME] sustech bb calendar-link show [--reveal] [--profile NAME] sustech bb calendar-link fetch [--destination PATH [--overwrite]] [--profile NAME] sustech bb calendar-link delete [--profile NAME] - sustech bb search QUERY [--course QUERY] [--kind file|folder|assignment|document|unknown] [--attachments include|only|none] [--page N] [--page-size N] - sustech bb sync COURSE_ID --destination DIR [--content-id CONTENT_ID] [--overwrite] - sustech bb attempts COURSE_ID [--content-id CONTENT_ID|--column-id COLUMN_ID] [--status InProgress|NeedsGrading|Completed] - sustech bb submit preview --course-id COURSE_ID [--content-id CONTENT_ID|--column-id COLUMN_ID] --file PATH [--comment TEXT] - sustech bb submit apply --course-id COURSE_ID [--content-id CONTENT_ID|--column-id COLUMN_ID] --file PATH --expected-sha256 HEX [--comment TEXT] [--allow-late] --confirm + sustech bb search QUERY [--course QUERY] [--kind file|folder|assignment|document|unknown] [--attachments include|only|none] [--page N] [--page-size N] [--browser [--interactive]] + sustech bb sync COURSE_ID --destination DIR [--content-id CONTENT_ID] [--overwrite] [--browser [--interactive]] + sustech bb attempts COURSE_ID [--content-id CONTENT_ID|--column-id COLUMN_ID] [--status InProgress|NeedsGrading|Completed] [--browser [--interactive]] + sustech bb submit preview --course-id COURSE_ID [--content-id CONTENT_ID|--column-id COLUMN_ID] (--file PATH|--text-file PATH) [--comment TEXT] [--browser [--interactive]] + sustech bb submit apply --course-id COURSE_ID [--content-id CONTENT_ID|--column-id COLUMN_ID] (--file PATH|--text-file PATH) --expected-sha256 HEX [--comment TEXT] [--allow-late] --confirm sustech ws programs [KEYWORD] [--page N] [--page-size N] sustech ws detail ID [--program-code CODE] [--program-token TOKEN] sustech library search QUERY [--limit N] [--browser [--interactive]] @@ -490,6 +603,10 @@ type Values = OutputFlags & { "content-id"?: string; "column-id"?: string; file?: string; + subject?: string; + "to-user"?: string[]; + "cc-user"?: string[]; + "bcc-user"?: string[]; comment?: string; "expected-sha256"?: string; destination?: string; @@ -497,8 +614,11 @@ type Values = OutputFlags & { overwrite?: boolean; days?: string; course?: string; + role?: string; + availability?: string; kind?: string; attachments?: string; + gradable?: string; live?: boolean; "allow-late"?: boolean; "period-start"?: string; @@ -520,9 +640,14 @@ type Values = OutputFlags & { minutes?: string; category?: string; section?: string; + source?: string[]; page?: string; "page-size"?: string; + "folder-type"?: string; + "folder-name"?: string; + "participation-type"?: string; sort?: string; + rating?: string; "min-year"?: string; "open-access"?: boolean; "resolve-oa"?: boolean; @@ -559,16 +684,26 @@ type Values = OutputFlags & { profile?: string; sid?: string; "password-stdin"?: boolean; + "text-file"?: string; path?: string; requirements?: string; details?: boolean; since?: string; until?: string; + teacher?: string[]; + "group-id"?: string; + "user-id"?: string; + "is-read"?: string; "url-stdin"?: boolean; reveal?: boolean; "include-blackboard"?: boolean; browser?: boolean; interactive?: boolean; + "with-attempts"?: boolean; + "all-reviews"?: boolean; + "submission-state"?: string; + term?: string; + "offering-unit"?: string; "early-period-threshold"?: string; "weight-early-session"?: string; "weight-gap-segment"?: string; @@ -578,8 +713,6 @@ type Values = OutputFlags & { help?: boolean; }; -type AuthService = "tis" | "bb" | "ws" | "booking" | "lib-booking" | "pms"; - function brandArt(): string { return formatBrandArt(shouldUseBrandColor(process.stdout.isTTY)); } @@ -1908,28 +2041,43 @@ async function runAuth(positionals: readonly string[], values: Values, output: O async function runDoctor(values: Values, output: OutputOptions): Promise { const services = doctorServices(values.service); + if (values.browser && !values.live) { + throw usageError("--browser requires --live for doctor."); + } + if (values.interactive && !values.browser) { + throw usageError("--interactive requires --browser for Blackboard live diagnostics."); + } + if (values.browser && !services.includes("bb")) { + throw usageError("--browser is currently supported only when doctor includes Blackboard."); + } const backend = await getCredentialBackendStatus(); const profile = await getCredentialStatus(values.profile); const liveResults: DoctorLiveResult[] = []; - let credentialSource: string | undefined; + let credentialSource: string | undefined = values.browser && services.length === 1 && services[0] === "bb" + ? "browser-session" + : undefined; if (values.live) { let credentials: Credentials | undefined; let credentialError: unknown; - try { - credentials = await resolvedCredentials(values); - credentialSource = credentials.source; - } catch (error) { - credentialError = error; + async function liveCredentials(): Promise { + if (credentials) return credentials; + if (credentialError !== undefined) throw credentialError; + try { + credentials = await resolvedCredentials(values); + credentialSource = credentials.source; + return credentials; + } catch (error) { + credentialError = error; + throw error; + } } for (const service of services) { - if (!credentials) { - liveResults.push({ service, status: "fail", ...doctorFailure(credentialError) }); - continue; - } try { - const result = await authenticateCredentials(credentials, service); + const result = service === "bb" && values.browser + ? await authenticateBlackboardBrowserSession({ interactive: values.interactive }) + : await authenticateCredentials(await liveCredentials(), service); liveResults.push({ service, status: "pass", @@ -1974,6 +2122,15 @@ async function checkAuthentication( profile?: string; credentialBackend?: string; }> { + if (values.interactive && !values.browser) { + throw usageError("--interactive requires --browser for Blackboard auth checks."); + } + if (values.browser) { + if (service !== "bb") { + throw usageError("--browser is currently supported only for Blackboard auth checks."); + } + return authenticateBlackboardBrowserSession({ interactive: values.interactive }); + } const credentials = await resolvedCredentials(values); const result = await authenticateCredentials(credentials, service); return { @@ -1987,49 +2144,7 @@ async function authenticateCredentials( credentials: Credentials, service: AuthService, ): Promise<{ authenticated: true; credentialSource: string; identity?: string }> { - if (service === "tis") { - await new TisSession(credentials).login(); - return { authenticated: true, credentialSource: credentials.source }; - } - if (service === "bb" || service === "ws") { - await new CasSession(credentials, casServiceConfig(service)).login(); - return { authenticated: true, credentialSource: credentials.source }; - } - if (service === "booking") { - const session = new BookingSession(credentials); - await session.login(); - return { - authenticated: true, - credentialSource: credentials.source, - ...(session.userProfile?.name ? { identity: session.userProfile.name } : {}), - }; - } - if (service === "lib-booking") { - const session = new LibraryBookingSession(credentials); - await session.login(); - const user = await getLibraryBookingUser(session); - return { - authenticated: true, - credentialSource: credentials.source, - ...((user.trueName || user.logonName) ? { identity: user.trueName || user.logonName } : {}), - }; - } - if (service === "pms") { - const session = new PmsSession({ username: credentials.sid, password: credentials.password }); - await session.login(); - const check = await session.check(); - if (!check.authenticated) { - throw new CliError("PMS login completed but the session check failed.", "AUTHENTICATION_FAILED", 2, { - service: "pms", - }); - } - return { - authenticated: true, - credentialSource: credentials.source, - ...(check.displayName ? { identity: check.displayName } : {}), - }; - } - throw usageError("Unsupported authentication service."); + return authenticateServiceCredentials(credentials, service); } async function bookingService(values: Values): Promise { @@ -2063,6 +2178,9 @@ async function resolvedCredentials(values: Values): Promise { } async function casServiceAdapter(values: Values, service: "bb" | "ws"): Promise { + if (service === "bb" && values.browser) { + return createBlackboardBrowserAdapter({ interactive: values.interactive }); + } const { session } = await authenticatedCasService(values, casServiceConfig(service)); return { name: service, @@ -2072,24 +2190,6 @@ async function casServiceAdapter(values: Values, service: "bb" | "ws"): Promise< }; } -function casServiceConfig(service: string): CasServiceConfig { - if (service === "bb") { - return { - name: "Blackboard", - baseUrl: "https://bb.sustech.edu.cn", - serviceUrl: "https://bb.sustech.edu.cn/webapps/bb-sso-BBLEARN/index.jsp", - }; - } - if (service === "ws") { - return { - name: "SUSTech Global", - baseUrl: "https://ws.sustech.edu.cn", - serviceUrl: "https://ws.sustech.edu.cn/SUSTechHome.aspx", - }; - } - throw usageError("--service must be tis, bb, or ws."); -} - async function tisClient(values: Values): Promise { const { session } = await authenticatedSession(values); return new TisClient(session); @@ -2289,6 +2389,7 @@ async function runOnline( const section = positionals[1]; const operation = positionals[2]; const limit = parsePositiveInteger(values.limit, 20, "--limit"); + const sources = onlineManualSources(values.source); if (limit > 200) throw usageError("--limit cannot exceed 200 for SUSTech Online queries."); const since = values.since === undefined ? undefined : isoDate(values.since, "--since"); const until = values.until === undefined ? undefined : isoDate(values.until, "--until"); @@ -2304,22 +2405,48 @@ async function runOnline( const query = positionals.slice(2).join(" ").trim(); if (!query) throw usageError("A SUSTech Online search query is required."); const selectedSection = onlineSection(values.section); - if (selectedSection === "contact" && (since || until)) { + if (selectedSection !== undefined && selectedSection !== "talks" && (since || until)) { throw usageError("--since and --until apply only to talk searches."); } - const hits = await searchOnline(query, { + if (sources && sources.length > 0 && selectedSection !== "manual") { + throw usageError("--source applies only to `online search --section manual` and `online manual` commands."); + } + const report = await searchOnlineWithStatus(query, { section: selectedSection, + ...(selectedSection === "manual" && sources && sources.length > 0 ? { source: sources } : {}), since, until, limit, }); + const hits = report.hits; writeSuccess({ command: "online search", - data: { query, section: selectedSection ?? "all", hits, total: hits.length }, - text: formatOnlineSearchHits(hits, query), + data: { + query, + section: selectedSection ?? "all", + hits, + total: hits.length, + partial: report.partial, + manualSourceStatuses: report.manualSourceStatuses, + ...(selectedSection === "manual" && report.manualMatchedTotal !== undefined + ? { manualMatchedTotal: report.manualMatchedTotal, returned: hits.length } + : {}), + }, + text: formatOnlineSearchHits(hits, query, { + partial: report.partial, + manualSourceStatuses: report.manualSourceStatuses, + }), items: hits, - summary: { query, section: selectedSection ?? "all", total: hits.length }, - meta, + summary: { + query, + section: selectedSection ?? "all", + total: hits.length, + ...(selectedSection === "manual" && report.manualMatchedTotal !== undefined + ? { manualMatchedTotal: report.manualMatchedTotal, returned: hits.length } + : {}), + partial: report.partial, + }, + meta: { ...meta, partial: report.partial }, }, output); return; } @@ -2356,6 +2483,66 @@ async function runOnline( return; } + if (section === "manual" && operation === "list" && positionals.length === 3) { + if (since || until) throw usageError("--since and --until apply only to talk searches."); + const report = await listOnlineManualRecordsWithStatus({ + limit, + ...(sources && sources.length > 0 ? { source: sources } : {}), + }); + writeSuccess({ + command: "online manual list", + data: { + source: sources && sources.length > 0 ? sources : "all", + records: report.records, + total: report.matchedTotal, + returned: report.records.length, + matchedTotal: report.matchedTotal, + partial: report.partial, + manualSourceStatuses: report.sourceStatuses, + }, + text: formatOnlineManualRecords( + report.records, + sources && sources.length > 0 + ? `SUSTech Online manual · ${sources.join(", ")}` + : "SUSTech Online manual", + { partial: report.partial, sourceStatuses: report.sourceStatuses }, + ), + items: report.records, + summary: { + source: sources && sources.length > 0 ? sources : "all", + total: report.matchedTotal, + returned: report.records.length, + matchedTotal: report.matchedTotal, + partial: report.partial, + }, + meta: { ...meta, partial: report.partial }, + }, output); + return; + } + if (section === "manual" && operation === "get" && positionals.length >= 4) { + if (since || until) throw usageError("--since and --until apply only to talk searches."); + const identifier = positionals.slice(3).join(" ").trim(); + const report = await getOnlineManualRecordWithStatus(required(identifier, "manual record id or exact title"), { + ...(sources && sources.length > 0 ? { source: sources } : {}), + }); + writeSuccess({ + command: "online manual get", + data: { + identifier, + record: report.record, + partial: report.partial, + manualSourceStatuses: report.sourceStatuses, + }, + text: formatOnlineManualRecord(report.record, { + partial: report.partial, + sourceStatuses: report.sourceStatuses, + }), + meta: { ...meta, partial: report.partial }, + summary: { id: report.record.id, sourceKey: report.record.sourceKey, partial: report.partial }, + }, output); + return; + } + if (section === "contact" && operation === "search") { const query = positionals.slice(3).join(" ").trim(); if (!query) throw usageError("A contact search query is required."); @@ -2380,10 +2567,27 @@ async function runOnline( throw usageError(`Unknown command: ${positionals.join(" ")}`); } -function onlineSection(value?: string): "talks" | "contact" | undefined { +function onlineSection(value?: string): "talks" | "contact" | "manual" | undefined { if (value === undefined) return undefined; - if (value === "talks" || value === "contact") return value; - throw usageError("--section must be talks or contact."); + if (value === "talks" || value === "contact" || value === "manual") return value; + throw usageError("--section must be talks, contact, or manual."); +} + +function onlineManualSources(values?: string[]): OnlineManualSourceKey[] | undefined { + if (values === undefined) return undefined; + const trimmed = values.map((value) => value.trim()).filter(Boolean); + if (trimmed.length === 0) return undefined; + const allowed = new Set(["calendar", "facility", "life", "service", "study", "transport"]); + const unique: OnlineManualSourceKey[] = []; + for (const value of trimmed) { + if (!allowed.has(value as OnlineManualSourceKey)) { + throw usageError("--source must be service, study, transport, life, facility, or calendar."); + } + if (!unique.includes(value as OnlineManualSourceKey)) { + unique.push(value as OnlineManualSourceKey); + } + } + return unique; } async function runProfile( @@ -2731,8 +2935,13 @@ async function runTisPlanDecision( degreeFailure = errorMessage(error); } + const ncesTermId = tisToNcesTerm(semester.xn, semester.xq); const ncesBatch = await resolveNcesCourseLookups( buildCourseDecisionNcesLookupRequests(selection.matched), + { + termId: ncesTermId, + ...(operation === "explain" ? { includeDetail: true } : {}), + }, ); const baseReport = recommendCourseSections({ selectableCourses: selectable.courses, @@ -2777,6 +2986,7 @@ async function runTisPlanDecision( mutation: false, path: view.path, semester, + ncesTermId, round, selectors, missingSelectors: selection.missingSelectors, @@ -2856,6 +3066,7 @@ async function runContext( calendar, ...(live?.schedule ? { schedule: live.schedule } : {}), ...(live?.nextDeadline ? { nextDeadline: live.nextDeadline } : {}), + ...(live?.recentAnnouncement ? { recentAnnouncement: live.recentAnnouncement } : {}), ...(live?.nextEvaluation ? { nextEvaluation: live.nextEvaluation } : {}), ...(live?.nextExam ? { nextExam: live.nextExam } : {}), ...(live?.weather ? { weather: live.weather } : {}), @@ -2884,6 +3095,8 @@ interface ContextLiveSourceStatus { message?: string; } +const CONTEXT_BLACKBOARD_ANNOUNCEMENT_DAYS = 14; + async function loadLiveContext( date: string, now: Date, @@ -2893,12 +3106,14 @@ async function loadLiveContext( ): Promise<{ schedule?: { now?: string; next?: string; nextDetail?: string; tomorrowMorning?: string }; nextDeadline?: DeadlineSummary; + recentAnnouncement?: { title: string; source: "system" | "course"; course?: string; activityAt?: string }; nextEvaluation?: { course: string; name: string; daysLeft?: number; dueAt?: string }; nextExam?: { name: string; code: string; date: string; time?: string; building?: string; room?: string; campus?: string }; liveSources: { tisSchedule: ContextLiveSourceStatus; tisExams: ContextLiveSourceStatus; blackboardDeadlines: ContextLiveSourceStatus; + blackboardAnnouncements?: ContextLiveSourceStatus; tisEvaluations?: ContextLiveSourceStatus; weather?: ContextLiveSourceStatus; airQuality?: ContextLiveSourceStatus; @@ -2912,6 +3127,7 @@ async function loadLiveContext( tisSchedule: ContextLiveSourceStatus; tisExams: ContextLiveSourceStatus; blackboardDeadlines: ContextLiveSourceStatus; + blackboardAnnouncements?: ContextLiveSourceStatus; tisEvaluations?: ContextLiveSourceStatus; weather?: ContextLiveSourceStatus; airQuality?: ContextLiveSourceStatus; @@ -2920,6 +3136,7 @@ async function loadLiveContext( tisSchedule: { state: "missing" }, tisExams: { state: "missing" }, blackboardDeadlines: { state: "missing" }, + ...(contextLoadsNormalFields(level) ? { blackboardAnnouncements: { state: "missing" as const } } : {}), ...(contextLoadsNormalFields(level) ? { tisEvaluations: { state: "missing" as const } } : {}), ...(contextLoadsVerboseFields(level) ? { @@ -2933,12 +3150,14 @@ async function loadLiveContext( const result: { schedule?: { now?: string; next?: string; nextDetail?: string; tomorrowMorning?: string }; nextDeadline?: DeadlineSummary; + recentAnnouncement?: { title: string; source: "system" | "course"; course?: string; activityAt?: string }; nextEvaluation?: { course: string; name: string; daysLeft?: number; dueAt?: string }; nextExam?: { name: string; code: string; date: string; time?: string; building?: string; room?: string; campus?: string }; liveSources: { tisSchedule: ContextLiveSourceStatus; tisExams: ContextLiveSourceStatus; blackboardDeadlines: ContextLiveSourceStatus; + blackboardAnnouncements?: ContextLiveSourceStatus; tisEvaluations?: ContextLiveSourceStatus; weather?: ContextLiveSourceStatus; airQuality?: ContextLiveSourceStatus; @@ -3043,21 +3262,59 @@ async function loadLiveContext( try { const adapter = await casServiceAdapter(values, "bb"); - const report = await listBlackboardDeadlines(adapter, { now }); - const deadline = nextBlackboardDeadline(report); - if (deadline) result.nextDeadline = contextDeadlineSummary(deadline); - liveSources.blackboardDeadlines = { - state: report.failures.length > 0 ? "partial" : deadline ? "provided" : "missing", - generatedAt: report.generatedAt, - failureCount: report.failures.length, - ...(report.failures[0]?.message ? { message: report.failures[0].message } : {}), - }; + const [deadlinesResult, announcementsResult] = await Promise.allSettled([ + listBlackboardDeadlines(adapter, { now }), + liveSources.blackboardAnnouncements + ? listBlackboardAnnouncements(adapter, { now, days: CONTEXT_BLACKBOARD_ANNOUNCEMENT_DAYS }) + : Promise.resolve(undefined), + ]); + + if (deadlinesResult.status === "fulfilled") { + const deadline = nextBlackboardDeadline(deadlinesResult.value); + if (deadline) result.nextDeadline = contextDeadlineSummary(deadline); + liveSources.blackboardDeadlines = { + state: deadlinesResult.value.failures.length > 0 ? "partial" : deadline ? "provided" : "missing", + generatedAt: deadlinesResult.value.generatedAt, + failureCount: deadlinesResult.value.failures.length, + ...(deadlinesResult.value.failures[0]?.message ? { message: deadlinesResult.value.failures[0].message } : {}), + }; + } else { + liveSources.blackboardDeadlines = { + state: "error", + message: errorMessage(deadlinesResult.reason), + }; + } + + if (liveSources.blackboardAnnouncements) { + if (announcementsResult.status === "fulfilled") { + const announcement = announcementsResult.value ? nextBlackboardAnnouncement(announcementsResult.value) : null; + if (announcement) result.recentAnnouncement = contextAnnouncementSummary(announcement); + liveSources.blackboardAnnouncements = { + state: announcementsResult.value?.failures.length + ? "partial" + : announcement + ? "provided" + : "missing", + ...(announcementsResult.value + ? { + generatedAt: announcementsResult.value.generatedAt, + failureCount: announcementsResult.value.failures.length, + } + : {}), + ...(announcementsResult.value?.failures[0]?.message ? { message: announcementsResult.value.failures[0].message } : {}), + }; + } else { + liveSources.blackboardAnnouncements = { + state: "error", + message: errorMessage(announcementsResult.reason), + }; + } + } } catch (error) { const message = errorMessage(error); - liveSources.blackboardDeadlines = { - state: error instanceof CliError && error.code === "CREDENTIALS_REQUIRED" ? "credentials-missing" : "error", - message, - }; + const state = error instanceof CliError && error.code === "CREDENTIALS_REQUIRED" ? "credentials-missing" : "error"; + liveSources.blackboardDeadlines = { state, message }; + if (liveSources.blackboardAnnouncements) liveSources.blackboardAnnouncements = { state, message }; } if (contextLoadsVerboseFields(level)) { @@ -3095,10 +3352,27 @@ function contextDeadlineSummary(input: { }; } +function contextAnnouncementSummary(input: BlackboardAnnouncement): { + title: string; + source: "system" | "course"; + course?: string; + activityAt?: string; +} { + return { + title: input.title, + source: input.source, + ...(input.source === "course" + ? { course: [input.courseCode, input.courseName].filter(Boolean).join(" ").trim() || input.courseName || input.courseCode } + : {}), + ...(input.modified || input.created ? { activityAt: input.modified || input.created } : {}), + }; +} + function formatContextLiveSources(sources: { tisSchedule: ContextLiveSourceStatus; tisExams: ContextLiveSourceStatus; blackboardDeadlines: ContextLiveSourceStatus; + blackboardAnnouncements?: ContextLiveSourceStatus; tisEvaluations?: ContextLiveSourceStatus; weather?: ContextLiveSourceStatus; airQuality?: ContextLiveSourceStatus; @@ -3108,6 +3382,7 @@ function formatContextLiveSources(sources: { formatContextLiveSource("TIS schedule", sources.tisSchedule), formatContextLiveSource("TIS exams", sources.tisExams), formatContextLiveSource("Blackboard deadlines", sources.blackboardDeadlines), + ...(sources.blackboardAnnouncements ? [formatContextLiveSource("Blackboard announcements", sources.blackboardAnnouncements)] : []), ...(sources.tisEvaluations ? [formatContextLiveSource("TIS evaluations", sources.tisEvaluations)] : []), ...(sources.weather ? [formatContextLiveSource("Weather", sources.weather)] : []), ...(sources.airQuality ? [formatContextLiveSource("Air quality", sources.airQuality)] : []), @@ -3605,33 +3880,240 @@ async function runNces( const perPage = parsePositiveInteger(values["page-size"], 30, "--page-size"); if (perPage > 50) throw usageError("--page-size cannot exceed 50 for NCES."); const sort = ncesSort(values.sort); - const result = await browseNces({ page, perPage, sort }); + const offeringUnit = optionalNonEmptyString(values["offering-unit"], "--offering-unit"); + const result = await browseNces({ page, perPage, sort, ...(offeringUnit ? { offeringUnit } : {}) }); writeSuccess({ command: "nces browse", data: result, - text: formatNcesCourses(result.items, "NCES course evaluations"), + text: formatNcesCourses(result.items, `NCES course evaluations${result.offeringUnit ? ` · ${result.offeringUnit}` : ""}`), items: result.items, - summary: { page: result.page, perPage: result.perPage, pages: result.pages, total: result.total, shown: result.items.length }, + summary: { + page: result.page, + perPage: result.perPage, + pages: result.pages, + total: result.total, + shown: result.items.length, + ...(result.offeringUnit ? { offeringUnit: result.offeringUnit } : {}), + }, + }, output); + return; + } + if (command === "filter-options" && positionals.length === 2) { + const result = await getNcesCourseFilterOptions(); + writeSuccess({ + command: "nces filter-options", + data: result, + text: formatNcesFilterOptions(result), + items: result.offeringUnits.map((offeringUnit) => ({ offeringUnit })), + summary: { offeringUnits: result.offeringUnits.length }, + }, output); + return; + } + if (command === "global-stats" && positionals.length === 2) { + const stats = await getNcesGlobalStats(); + writeSuccess({ + command: "nces global-stats", + data: stats, + text: formatNcesGlobalStats(stats), + summary: { + users: stats.userCount, + courses: stats.courseCount, + reviews: stats.reviewCount, + teachers: stats.teacherCount, + registeredTeachers: stats.registeredTeacherCount, + }, + }, output); + return; + } + if (command === "rankings" && positionals.length === 3) { + const category = ncesRankingCategory(positionals[2]); + const limit = parsePositiveInteger(values.limit, 10, "--limit"); + if (limit > 50) throw usageError("--limit cannot exceed 50 for NCES rankings."); + const rankings = await getNcesRankings(); + const items = ncesRankingItems(rankings, category).slice(0, limit); + writeSuccess({ + command: "nces rankings", + data: { + category, + limit, + total: ncesRankingItems(rankings, category).length, + items, + stats: rankings.stats, + }, + text: formatNcesRankings(rankings, category, items), + items, + summary: { + category, + limit, + total: ncesRankingItems(rankings, category).length, + shown: items.length, + }, }, output); return; } if (command === "search") { const query = positionals.slice(2).join(" ").trim(); if (!query) throw usageError("An NCES search query is required."); - const result = await searchNces(query); + const page = parsePositiveInteger(values.page, 1, "--page"); + const perPage = parsePositiveInteger(values["page-size"], 20, "--page-size"); + if (perPage > 50) throw usageError("--page-size cannot exceed 50 for NCES."); + const type = ncesSearchType(values.type); + let result: NcesSearchResult; + if (type === "teacher") result = await searchNces(query, { page, perPage, type }); + else if (type === "review") result = await searchNces(query, { page, perPage, type }); + else result = await searchNces(query, { page, perPage, type }); + const renderedItems = type === "all" + ? [...result.courseItems, ...result.teachers, ...result.sampleReviews] + : [...result.items]; writeSuccess({ command: "nces search", data: { query, ...result }, - text: formatNcesCourses(result.items, `NCES search · ${query}`), - items: result.items, - summary: { query, total: result.total, shown: result.items.length, sampleReviews: result.sampleReviews.length }, + text: formatNcesSearch(query, result.courseItems, result.teachers, result.sampleReviews, { + type, + courseTotal: result.courseTotal, + teacherTotal: result.teacherTotal, + reviewTotal: result.reviewTotal, + page, + perPage, + }), + items: renderedItems, + summary: { + query, + type, + page: result.page, + perPage: result.perPage, + total: result.total, + pages: result.pages, + courses: result.courseTotal, + teachers: result.teacherTotal, + reviews: result.reviewTotal, + shown: renderedItems.length, + ...(type === "all" + ? { + bucketMode: "mixed", + aggregateTotal: result.aggregateTotal, + aggregateShown: result.aggregateShown, + selectedBucket: result.selectedBucket, + selectedShown: result.selectedItems.length, + courseShown: result.courseItems.length, + teacherShown: result.teachers.length, + reviewShown: result.sampleReviews.length, + } + : { selectedBucket: result.selectedBucket, selectedShown: result.selectedItems.length }), + }, + }, output); + return; + } + if (command === "by-code" && positionals.length === 3) { + const code = required(positionals[2], "NCES course code").trim().toUpperCase(); + const term = ncesTerm(values.term); + const teachers = repeatedNonEmptyStrings(values.teacher, "--teacher"); + const allReviews = Boolean(values["all-reviews"]); + const resolution = teachers.length > 0 + ? (await resolveNcesCourseLookups( + [{ key: "by-code", code, teachers }], + { ...(term ? { termId: term } : {}), includeDetail: true }, + )).items["by-code"] + : undefined; + const course = resolution + ? (allReviews && resolution.picked + ? await getNcesCourseDetail(resolution.picked.ncesId, { + ...(term ? { reviewTerm: term, preferredTerm: term } : {}), + allReviews: true, + }) + : resolution.detail ?? null) + : await getNcesCourseByCode(code, { ...(term ? { term } : {}), ...(allReviews ? { allReviews: true } : {}) }); + const availableTerms = course + ? [...new Set([ + ...course.terms.map((item) => item.termId).filter(Boolean), + ...course.reviewTerms.filter(Boolean), + ])] + : []; + const termMatched = term === undefined + ? undefined + : resolution + ? resolution.signals.termMatched + : course === null + ? undefined + : availableTerms.includes(term) || course.semesters.includes(term.slice(0, 4) + ({ "1": "秋", "2": "春", "3": "夏" }[term[4]] ?? "")); + const text = resolution + ? [ + `Teacher filter · ${teachers.join(", ")}`, + `Resolution · ${resolution.status} · confidence ${resolution.confidence}`, + ...(resolution.notes.length > 0 ? [`Notes · ${resolution.notes.join(" | ")}`] : []), + "", + formatNcesCourseByCode(code, term, course), + ].join("\n") + : formatNcesCourseByCode(code, term, course); + writeSuccess({ + command: "nces by-code", + data: { + code, + term, + ...(termMatched === undefined ? {} : { termMatched }), + ...(teachers.length > 0 ? { teachers, resolution } : {}), + allReviews, + ...(course ? { availableTerms } : {}), + found: course !== null, + course, + }, + text, + summary: { + code, + found: course !== null, + allReviews, + ...(teachers.length > 0 ? { teachers, resolutionStatus: resolution?.status, confidence: resolution?.confidence } : {}), + ...(term ? { term, termMatched: termMatched === true } : {}), + }, }, output); return; } if (command === "course" && positionals.length === 3) { const id = parsePositiveInteger(positionals[2], 1, "NCES course ID"); - const course = await getNcesCourseDetail(id); - writeSuccess({ command: "nces course", data: { id, found: course !== null, course }, text: formatNcesDetail(course) }, output); + const allReviews = Boolean(values["all-reviews"]); + const course = await getNcesCourseDetail(id, allReviews ? { allReviews: true } : {}); + writeSuccess({ + command: "nces course", + data: { id, allReviews, found: course !== null, course }, + text: formatNcesDetail(course), + summary: { id, allReviews, found: course !== null }, + }, output); + return; + } + if (command === "reviews" && positionals.length === 3) { + const id = parsePositiveInteger(positionals[2], 1, "NCES course ID"); + const page = parsePositiveInteger(values.page, 1, "--page"); + const perPage = parsePositiveInteger(values["page-size"], 20, "--page-size"); + if (perPage > 50) throw usageError("--page-size cannot exceed 50 for NCES."); + const sort = ncesReviewSort(values.sort); + const term = ncesTerm(values.term); + const rating = parseIntegerInRange(values.rating, "--rating", 1, 10); + const result = await listNcesCourseReviews(id, { + page, + perPage, + sort, + ...(term ? { term } : {}), + ...(rating !== undefined ? { rating } : {}), + }); + writeSuccess({ + command: "nces reviews", + data: result, + text: formatNcesReviews(result), + items: result.items, + summary: { id, page: result.page, perPage: result.perPage, pages: result.pages, total: result.total, shown: result.items.length }, + }, output); + return; + } + if (command === "teacher" && positionals.length === 3) { + const id = parsePositiveInteger(positionals[2], 1, "NCES teacher ID"); + const teacher = await getNcesTeacherDetail(id); + writeSuccess({ command: "nces teacher", data: { id, found: teacher !== null, teacher }, text: formatNcesTeacher(teacher) }, output); + return; + } + if (command === "stats" && positionals.length === 3) { + const id = parsePositiveInteger(positionals[2], 1, "NCES course ID"); + const stats = await getNcesCourseStats(id); + writeSuccess({ command: "nces stats", data: { id, found: stats !== null, stats }, text: formatNcesStats(id, stats) }, output); return; } throw usageError(`Unknown command: ${positionals.join(" ")}`); @@ -3642,6 +4124,9 @@ async function runBlackboard( values: Values, output: ReturnType, ): Promise { + if (values.interactive && !values.browser) { + throw usageError("--interactive requires --browser for Blackboard commands."); + } const command = positionals[1]; if (command === "calendar-link" && positionals.length === 3) { const operation = positionals[2]; @@ -3738,10 +4223,10 @@ async function runBlackboard( } if (command === "submit" && positionals[2] === "preview" && positionals.length === 3) { const target = blackboardSubmissionTarget(values); - const file = await inspectBlackboardSubmissionFile(required(values.file, "--file")); + const submission = await readBlackboardSubmissionInput(values); const comment = submissionComment(values.comment); const adapter = await casServiceAdapter(values, "bb"); - const preflight = await buildBlackboardSubmissionPreflight(adapter, values, target, file, comment); + const preflight = await buildBlackboardSubmissionPreflight(adapter, values, target, submission, comment); writeSuccess({ command: "bb submit preview", data: { mode: "preview", mutation: false, ...preflight }, @@ -3784,6 +4269,58 @@ async function runBlackboard( }, output); return; } + if (command === "tree" && positionals.length === 3) { + const courseId = opaqueToken(required(positionals[2], "Blackboard course ID"), "Blackboard course ID"); + const rootContentId = values["content-id"] + ? opaqueToken(values["content-id"], "--content-id") + : undefined; + const maxItems = parsePositiveInteger(values.max, 500, "--max"); + if (maxItems > 5_000) throw usageError("--max cannot exceed 5000 for Blackboard tree."); + const adapter = await casServiceAdapter(values, "bb"); + const report = await listBlackboardContentTree(adapter, { + courseId, + ...(rootContentId ? { rootContentId } : {}), + maxItems, + }); + writeSuccess({ + command: "bb tree", + data: report, + text: formatBlackboardTree(report), + items: report.entries, + summary: { + courseId: report.courseId, + ...(report.rootContentId ? { rootContentId: report.rootContentId } : {}), + maxItems: report.maxItems, + returnedItems: report.returnedItems, + truncated: report.truncated, + partial: report.partial, + failures: report.failures.length, + }, + ...(report.failures.length > 0 ? { meta: { failures: report.failures } } : {}), + }, output); + return; + } + if (command === "types" && positionals.length === 2) { + const courseQuery = values.course?.trim() || undefined; + const adapter = await casServiceAdapter(values, "bb"); + const report = await summarizeBlackboardContentTypes(adapter, { ...(courseQuery ? { courseQuery } : {}) }); + writeSuccess({ + command: "bb types", + data: report, + text: formatBlackboardTypes(report), + items: report.courses, + summary: { + ...(courseQuery ? { courseQuery } : {}), + coursesMatched: report.coursesMatched, + coursesScanned: report.coursesScanned, + totalItems: report.totalItems, + partial: report.partial, + failures: report.failures.length, + }, + ...(report.failures.length > 0 ? { meta: { failures: report.failures } } : {}), + }, output); + return; + } if (command === "attachments" && positionals.length === 4) { const courseId = opaqueToken(required(positionals[2], "Blackboard course ID"), "Blackboard course ID"); const contentId = opaqueToken(required(positionals[3], "Blackboard content ID"), "Blackboard content ID"); @@ -3819,274 +4356,285 @@ async function runBlackboard( }, output); return; } - if (command === "assignments" && positionals.length === 3) { - const adapter = await casServiceAdapter(values, "bb"); + if (command === "roster" && positionals.length === 3) { const courseId = opaqueToken(required(positionals[2], "Blackboard course ID"), "Blackboard course ID"); - const assignments = await listBlackboardAssignments(adapter, courseId); + const role = optionalInlineText(values.role, "--role", 200); + const availability = blackboardMembershipAvailabilityValue(values.availability); + const page = parsePositiveInteger(values.page, 1, "--page"); + const pageSize = parsePositiveInteger(values["page-size"], 25, "--page-size"); + if (pageSize > 100) throw usageError("--page-size cannot exceed 100 for Blackboard roster."); + const sort = optionalInlineText(values.sort, "--sort", 200); + const adapter = await casServiceAdapter(values, "bb"); + const report = await listBlackboardCourseRoster(adapter, { + courseId, + ...(role ? { role } : {}), + ...(availability ? { availability } : {}), + page, + pageSize, + ...(sort ? { sort } : {}), + }); writeSuccess({ - command: "bb assignments", - data: { courseId, assignments, total: assignments.length }, - text: formatBlackboardAssignments(assignments), - items: assignments, - summary: { courseId, total: assignments.length }, + command: "bb roster", + data: report, + text: formatBlackboardRoster(report), + items: report.memberships, + summary: { + courseId: report.courseId, + ...(report.role ? { role: report.role } : {}), + ...(report.availability ? { availability: report.availability } : {}), + page: report.page, + pageSize: report.pageSize, + returned: report.returned, + hasMore: report.hasMore, + ...(report.nextPage ? { nextPage: report.nextPage } : {}), + }, }, output); return; } - if (command === "deadlines" && positionals.length === 2) { - const days = values.days === undefined ? undefined : parsePositiveInteger(values.days, 1, "--days"); - const courseQuery = values.course?.trim() || undefined; + if (command === "message-folders" && positionals.length === 3) { + const courseId = opaqueToken(required(positionals[2], "Blackboard course ID"), "Blackboard course ID"); + const page = parsePositiveInteger(values.page, 1, "--page"); + const pageSize = parsePositiveInteger(values["page-size"], 25, "--page-size"); + if (pageSize > 100) throw usageError("--page-size cannot exceed 100 for Blackboard message folders."); const adapter = await casServiceAdapter(values, "bb"); - const report = await listBlackboardDeadlines(adapter, { - now: new Date(), - ...(days !== undefined ? { days } : {}), - ...(courseQuery ? { courseQuery } : {}), - }); + const report = await listBlackboardCourseMessageFolders(adapter, { courseId, page, pageSize }); writeSuccess({ - command: "bb deadlines", + command: "bb message-folders", data: report, - text: formatBlackboardDeadlines(report), - items: report.deadlines, + text: formatBlackboardMessageFolders(report), + items: report.folders, summary: { - ...(days !== undefined ? { days } : {}), - ...(courseQuery ? { courseQuery } : {}), - coursesMatched: report.coursesMatched, - coursesScanned: report.coursesScanned, - total: report.deadlines.length, - failures: report.failures.length, + courseId: report.courseId, + page: report.page, + pageSize: report.pageSize, + returned: report.returned, + hasMore: report.hasMore, + ...(report.nextPage ? { nextPage: report.nextPage } : {}), }, - ...(report.failures.length > 0 ? { meta: { failures: report.failures } } : {}), }, output); return; } - if (command === "calendar" && positionals.length === 2) { - const type = blackboardCalendarItemType(values.type); - const courseId = values["course-id"] - ? opaqueToken(values["course-id"], "--course-id") - : undefined; - const adapter = await casServiceAdapter(values, "bb"); - const report = await listBlackboardCalendarItems(adapter, { - ...(values.since ? { since: values.since } : {}), - ...(values.until ? { until: values.until } : {}), - ...(type ? { type } : {}), - ...(courseId ? { courseId } : {}), - }); - writeSuccess({ - command: "bb calendar", - data: report, - text: formatBlackboardCalendar(report), - items: report.items, - summary: { - since: report.since, - until: report.until, - ...(report.type ? { type: report.type } : {}), - ...(report.courseId ? { courseId: report.courseId } : {}), - total: report.totalItems, - partial: report.partial, - failures: report.failures.length, - }, - ...(report.failures.length > 0 ? { meta: { failures: report.failures } } : {}), - }, output); - return; - } - if (command === "search") { - const query = positionals.slice(2).join(" ").trim(); - if (!query) throw usageError("A Blackboard search query is required."); + if (command === "messages" && positionals.length === 3) { + const courseId = opaqueToken(required(positionals[2], "Blackboard course ID"), "Blackboard course ID"); const page = parsePositiveInteger(values.page, 1, "--page"); const pageSize = parsePositiveInteger(values["page-size"], 25, "--page-size"); - if (pageSize > 100) throw usageError("--page-size cannot exceed 100 for Blackboard search."); - const courseQuery = values.course?.trim() || undefined; - const kind = values.kind ? blackboardContentKind(values.kind) : undefined; - const attachments = blackboardSearchAttachmentMode(values.attachments); + if (pageSize > 100) throw usageError("--page-size cannot exceed 100 for Blackboard course messages."); + const folderType = blackboardMessageFolderTypeValue(values["folder-type"]); + const folderName = optionalInlineText(values["folder-name"], "--folder-name", 200); + if (folderName && folderType !== "Custom") { + throw usageError("--folder-name requires --folder-type Custom for Blackboard course messages."); + } + if (folderType === "Custom" && !folderName) { + throw usageError("--folder-type Custom requires --folder-name for Blackboard course messages."); + } + const sort = optionalInlineText(values.sort, "--sort", 200); const adapter = await casServiceAdapter(values, "bb"); - const report = await searchBlackboardContentTree(adapter, { - query, - ...(courseQuery ? { courseQuery } : {}), - ...(kind ? { kind } : {}), - attachments, + const report = await listBlackboardCourseMessages(adapter, { + courseId, + ...(folderType ? { folderType } : {}), + ...(folderName ? { folderName } : {}), page, pageSize, + ...(sort ? { sort } : {}), }); writeSuccess({ - command: "bb search", + command: "bb messages", data: report, - text: formatBlackboardSearch(report), - items: report.results, + text: formatBlackboardMessages(report), + items: report.messages, summary: { - query, + courseId: report.courseId, + ...(report.folderType ? { folderType: report.folderType } : {}), + ...(report.folderName ? { folderName: report.folderName } : {}), page: report.page, pageSize: report.pageSize, - totalMatches: report.totalMatches, returned: report.returned, hasMore: report.hasMore, - failures: report.failures.length, + ...(report.nextPage ? { nextPage: report.nextPage } : {}), }, - ...(report.failures.length > 0 ? { meta: { failures: report.failures } } : {}), }, output); return; } - if (command === "sync" && positionals.length === 3) { + if (command === "message-participants" && positionals.length === 4) { const courseId = opaqueToken(required(positionals[2], "Blackboard course ID"), "Blackboard course ID"); - const destination = required(values.destination, "--destination"); + const messageId = opaqueToken(required(positionals[3], "Blackboard message ID"), "Blackboard message ID"); + const page = parsePositiveInteger(values.page, 1, "--page"); + const pageSize = parsePositiveInteger(values["page-size"], 25, "--page-size"); + if (pageSize > 100) throw usageError("--page-size cannot exceed 100 for Blackboard message participants."); + const participationType = blackboardMessageParticipationTypeValue(values["participation-type"]); + const sort = optionalInlineText(values.sort, "--sort", 200); const adapter = await casServiceAdapter(values, "bb"); - const report = await syncBlackboardAttachments(adapter, { + const report = await listBlackboardCourseMessageParticipants(adapter, { courseId, - destination, - ...(values["content-id"] ? { contentId: opaqueToken(values["content-id"], "--content-id") } : {}), - overwrite: values.overwrite === true, + messageId, + ...(participationType ? { participationType } : {}), + page, + pageSize, + ...(sort ? { sort } : {}), }); writeSuccess({ - command: "bb sync", + command: "bb message-participants", data: report, - text: formatBlackboardSync(report), - items: report.files, + text: formatBlackboardMessageParticipants(report), + items: report.participants, summary: { courseId: report.courseId, - destination: report.destination, - plannedFiles: report.plannedFiles, - downloadedFiles: report.downloadedFiles, - partial: report.partial, - failures: report.failures.length, + messageId: report.messageId, + ...(report.participationType ? { participationType: report.participationType } : {}), + page: report.page, + pageSize: report.pageSize, + returned: report.returned, + hasMore: report.hasMore, + ...(report.nextPage ? { nextPage: report.nextPage } : {}), }, - ...(report.failures.length > 0 ? { meta: { failures: report.failures } } : {}), }, output); return; } - if (command === "attempts" && positionals.length === 3) { - const courseId = opaqueToken(required(positionals[2], "Blackboard course ID"), "Blackboard course ID"); - const selector = blackboardAssignmentSelector(values); - const status = blackboardAttemptStatus(values.status); + if (command === "message-send" && positionals[2] === "preview" && positionals.length === 4) { + const target = blackboardCourseMessageWriteTarget(positionals, values); + const input = await readBlackboardCourseMessageWriteInput(values); const adapter = await casServiceAdapter(values, "bb"); - const assignments = await listBlackboardAssignments(adapter, courseId); - const assignment = resolveBlackboardAssignmentSelector(assignments, selector, courseId); - const attempts = await listBlackboardAttempts(adapter, courseId, assignment.id, { ...(status ? { status } : {}) }); + const preflight = await buildBlackboardCourseMessageWritePreflight(adapter, values, target, input); writeSuccess({ - command: "bb attempts", - data: { courseId, assignment, ...(status ? { status } : {}), attempts, total: attempts.length }, - text: formatBlackboardAttempts(assignment, attempts), - items: attempts, - summary: { courseId, contentId: assignment.contentId, columnId: assignment.id, ...(status ? { status } : {}), total: attempts.length }, + command: "bb message-send preview", + data: { mode: "preview", mutation: false, ...preflight }, + text: formatBlackboardMessageWritePreview(preflight), }, output); return; } - if (command === "submit" && positionals[2] === "apply" && positionals.length === 3) { - const target = blackboardSubmissionTarget(values); - const filePath = required(values.file, "--file"); + if (command === "message-send" && positionals[2] === "apply" && positionals.length === 4) { + const target = blackboardCourseMessageWriteTarget(positionals, values); if (!values.confirm) { throw new ConfirmationRequiredError( - "Blackboard submission", - "Blackboard submission uploads and submits an assignment attempt. Re-run the exact previewed command with --confirm.", + "Blackboard course message", + "Blackboard course messages notify selected course participants. Re-run the exact previewed command with --confirm.", ); } const expectedSha256 = blackboardExpectedSha256(required(values["expected-sha256"], "--expected-sha256")); - const payload = await readBlackboardSubmissionPayload(filePath); - const file = payload.file; - const comment = submissionComment(values.comment); - if (file.sha256 !== expectedSha256) { + const input = await readBlackboardCourseMessageWriteInput(values); + if (input.textFile.sha256 !== expectedSha256) { throw new CliError( - "The selected file no longer matches the reviewed preview hash. Re-run preview before submitting.", - "BLACKBOARD_FILE_HASH_MISMATCH", + "The selected Blackboard message text no longer matches the reviewed preview hash. Re-run preview before sending.", + "BLACKBOARD_MESSAGE_HASH_MISMATCH", 4, { - file: file.absolutePath, + file: input.textFile.absolutePath, expectedSha256, - actualSha256: file.sha256, + actualSha256: input.textFile.sha256, }, ); } const adapter = await casServiceAdapter(values, "bb"); - const preflight = await buildBlackboardSubmissionPreflight(adapter, values, target, file, comment); - ensureBlackboardSubmissionAllowed(preflight, values["allow-late"] === true); - - const assignment = preflight.assignment; - let createdAttemptId = ""; - let uploadedId = ""; - let stage: "upload" | "create_attempt" | "attach_file" | "submit_attempt" | "verify" = "upload"; + const preflight = await buildBlackboardCourseMessageWritePreflight(adapter, values, target, input); + ensureBlackboardCourseMessageWriteAllowed(preflight); + let createdMessageId = ""; + let stage: "create" | "verify" = "create"; try { - stage = "upload"; - const uploaded = await uploadBlackboardTemporaryFile(adapter, file, payload.bytes); - uploadedId = uploaded.id; - stage = "create_attempt"; - const attempt = await createBlackboardAttempt(adapter, target.courseId, assignment.id, { - ...(comment ? { studentComments: comment } : {}), - }); - createdAttemptId = attempt.id; - stage = "attach_file"; - await attachBlackboardAttemptFile(adapter, target.courseId, createdAttemptId, { - name: file.name, - uploadId: uploadedId, - }); - stage = "submit_attempt"; - const submitted = await updateBlackboardAttempt(adapter, target.courseId, assignment.id, createdAttemptId, { - status: "NeedsGrading", + const created = await createBlackboardCourseMessage(adapter, target.courseId, { + ...(preflight.target.subject ? { subject: preflight.target.subject } : {}), + body: input.body, + toUsers: preflight.target.toUsers, + ccUsers: preflight.target.ccUsers, + bccUsers: preflight.target.bccUsers, }); + createdMessageId = created.id; stage = "verify"; - const snapshot = await observeBlackboardAttemptSnapshot(adapter, target.courseId, assignment.id, createdAttemptId); - const observedAttempt = snapshot.attempt ?? submitted; - const verification = snapshot.attempt - ? verifyBlackboardSubmission(snapshot.attempt.status, snapshot.files, file.name) - : { + const snapshot = await observeBlackboardCourseMessageWrite(adapter, preflight, createdMessageId); + const verification = snapshot.error + ? { status: "unavailable" as const, - message: "The submitted attempt status could not be read back from Blackboard.", - }; + message: "The created Blackboard course message could not be read back after the create request.", + } + : verifyBlackboardCourseMessageWrite(preflight, input, snapshot.message ?? created); if (verification.status !== "confirmed") { throw new CliError( - "Blackboard accepted the submission request, but the read-back verification was inconclusive.", - "BLACKBOARD_SUBMISSION_NOT_CONFIRMED", + "Blackboard accepted the message-send request, but the read-back verification was inconclusive.", + "BLACKBOARD_MESSAGE_SEND_NOT_CONFIRMED", 1, { courseId: target.courseId, - contentId: assignment.contentId, - columnId: assignment.id, - attemptId: createdAttemptId, + messageId: createdMessageId, + recipients: { + toUsers: preflight.target.toUsers, + ccUsers: preflight.target.ccUsers, + bccUsers: preflight.target.bccUsers, + }, verification, warning: "DO_NOT_RETRY_AUTOMATICALLY", }, ); } - writeBlackboardSubmissionResult(output, preflight, file, comment, observedAttempt, snapshot.files, verification); + writeSuccess({ + command: "bb message-send apply", + data: { + mode: "apply", + mutation: true, + target: preflight.target, + courseId: preflight.courseId, + courseCode: preflight.courseCode, + courseName: preflight.courseName, + recipients: preflight.recipients, + body: preflight.body, + message: snapshot.message ?? created, + verification, + }, + text: formatBlackboardMessageWriteSuccess({ + target: preflight.target, + courseCode: preflight.courseCode, + courseName: preflight.courseName, + body: preflight.body, + message: snapshot.message ?? created, + verification, + }), + }, output); return; } catch (error) { - if (error instanceof CliError && error.code === "BLACKBOARD_FILE_CHANGED") throw error; - let candidateAttemptIds: string[] = []; - if (!createdAttemptId) { - const drift = stage === "create_attempt" - ? await observeBlackboardAttemptCreation(adapter, target.courseId, assignment.id, preflight.attempts) - : undefined; - candidateAttemptIds = drift?.candidateAttemptIds ?? []; - if (drift?.attempt) createdAttemptId = drift.attempt.id; - } - const snapshot = createdAttemptId - ? await observeBlackboardAttemptSnapshot(adapter, target.courseId, assignment.id, createdAttemptId) - : { files: [] as BlackboardAttemptFile[] }; - const verification = verifyBlackboardSubmission(snapshot.attempt?.status ?? "", snapshot.files, file.name); - if (snapshot.attempt && verification.status === "confirmed") { - writeBlackboardSubmissionResult( - output, - preflight, - file, - comment, - snapshot.attempt, - snapshot.files, - verification, - true, - ); + const snapshot = createdMessageId + ? await observeBlackboardCourseMessageWrite(adapter, preflight, createdMessageId) + : {}; + const verification = snapshot.error + ? { + status: "unavailable" as const, + message: "The created Blackboard course message could not be read back after the request failed.", + } + : verifyBlackboardCourseMessageWrite(preflight, input, snapshot.message); + if (createdMessageId && verification.status === "confirmed" && snapshot.message) { + writeSuccess({ + command: "bb message-send apply", + data: { + mode: "apply", + mutation: true, + target: preflight.target, + courseId: preflight.courseId, + courseCode: preflight.courseCode, + courseName: preflight.courseName, + recipients: preflight.recipients, + body: preflight.body, + message: snapshot.message, + verification, + recoveredAfterError: true, + }, + text: formatBlackboardMessageWriteSuccess({ + target: preflight.target, + courseCode: preflight.courseCode, + courseName: preflight.courseName, + body: preflight.body, + message: snapshot.message, + verification, + }), + meta: { recoveredAfterError: true }, + }, output); return; } throw new CliError( - "Blackboard submission outcome is uncertain. Do not retry automatically.", - "BLACKBOARD_SUBMISSION_OUTCOME_UNKNOWN", + "Blackboard message-send outcome is uncertain. Do not retry automatically.", + "BLACKBOARD_MESSAGE_SEND_OUTCOME_UNKNOWN", 5, { stage, courseId: target.courseId, - contentId: assignment.contentId, - columnId: assignment.id, - ...(createdAttemptId ? { attemptId: createdAttemptId } : {}), - candidateAttemptIds, - ...(uploadedId ? { uploadId: uploadedId } : {}), - fileName: file.name, - ...(snapshot.attempt?.status ? { attemptStatus: snapshot.attempt.status } : {}), - observedFiles: snapshot.files.map((entry) => entry.name), + ...(createdMessageId ? { messageId: createdMessageId } : {}), + textFile: input.textFile.absolutePath, verification, cause: error instanceof Error ? error.message : String(error), warning: "DO_NOT_RETRY_AUTOMATICALLY", @@ -4094,1035 +4642,2930 @@ async function runBlackboard( ); } } - throw usageError(`Unknown command: ${positionals.join(" ")}`); -} - -async function runWs( - positionals: string[], - values: Values, - output: ReturnType, -): Promise { - const command = positionals[1]; - const adapter = await casServiceAdapter(values, "ws"); - const token = await getWsToken(adapter); - if (!token.userToken) throw new CliError("WS session did not expose a user token.", "WS_PROTOCOL_ERROR", 1); - if (command === "programs") { - const keywords = positionals.slice(2).join(" ").trim() || undefined; + if (command === "discussions" && positionals.length === 3) { + const courseId = opaqueToken(required(positionals[2], "Blackboard course ID"), "Blackboard course ID"); const page = parsePositiveInteger(values.page, 1, "--page"); - const pageSize = parsePositiveInteger(values["page-size"], 20, "--page-size"); - if (pageSize > 100) throw usageError("--page-size cannot exceed 100 for WS."); - const result = await listWsPrograms(adapter, token, { page, pageSize, keywords }); + const pageSize = parsePositiveInteger(values["page-size"], 25, "--page-size"); + if (pageSize > 100) throw usageError("--page-size cannot exceed 100 for Blackboard discussions."); + const title = optionalInlineText(values.title, "--title", 200); + const gradable = blackboardDiscussionGradableValue(values.gradable); + const sort = optionalInlineText(values.sort, "--sort", 200); + const adapter = await casServiceAdapter(values, "bb"); + const report = await listBlackboardDiscussions(adapter, { + courseId, + ...(title ? { title } : {}), + ...(gradable !== undefined ? { gradable } : {}), + page, + pageSize, + ...(sort ? { sort } : {}), + }); writeSuccess({ - command: "ws programs", - data: result, - text: formatWsPrograms(result.programs), - items: result.programs, - summary: { page: result.page, pageSize: result.pageSize, total: result.total, shown: result.programs.length }, + command: "bb discussions", + data: report, + text: formatBlackboardDiscussions(report), + items: report.discussions, + summary: { + courseId: report.courseId, + page: report.page, + pageSize: report.pageSize, + returned: report.returned, + hasMore: report.hasMore, + ...(report.nextPage ? { nextPage: report.nextPage } : {}), + }, }, output); return; } - if (command === "detail" && positionals.length === 3) { - const id = opaqueToken(required(positionals[2], "WS program ID"), "WS program ID"); - const detail = await getWsProgramDetail(adapter, token, { - id, - code: values["program-code"], - programToken: values["program-token"], + if (command === "discussion-groups" && positionals.length === 4) { + const courseId = opaqueToken(required(positionals[2], "Blackboard course ID"), "Blackboard course ID"); + const discussionId = opaqueToken(required(positionals[3], "Blackboard discussion ID"), "Blackboard discussion ID"); + const page = parsePositiveInteger(values.page, 1, "--page"); + const pageSize = parsePositiveInteger(values["page-size"], 25, "--page-size"); + if (pageSize > 100) throw usageError("--page-size cannot exceed 100 for Blackboard discussion groups."); + const sort = optionalInlineText(values.sort, "--sort", 200); + const adapter = await casServiceAdapter(values, "bb"); + const report = await listBlackboardDiscussionGroups(adapter, { + courseId, + discussionId, + page, + pageSize, + ...(sort ? { sort } : {}), }); - writeSuccess({ command: "ws detail", data: { id, detail }, text: formatWsDetail(detail) }, output); + writeSuccess({ + command: "bb discussion-groups", + data: report, + text: formatBlackboardDiscussionGroups(report), + items: report.groups, + summary: { + courseId: report.courseId, + discussionId: report.discussion.id, + page: report.page, + pageSize: report.pageSize, + returned: report.returned, + hasMore: report.hasMore, + ...(report.nextPage ? { nextPage: report.nextPage } : {}), + }, + }, output); return; } - throw usageError(`Unknown command: ${positionals.join(" ")}`); -} - -async function runLibrary( - positionals: string[], - values: Values, - output: ReturnType, -): Promise { - const command = positionals[1]; - if (command === "search") { - const query = positionals.slice(2).join(" ").trim(); - if (!query) throw usageError("A library search query is required."); - if (values.interactive && !values.browser) throw usageError("--interactive requires --browser for library catalog commands."); - const limit = parsePositiveInteger(values.limit, 10, "--limit"); - if (limit > 50) throw usageError("--limit cannot exceed 50 for library catalog search."); - if (values.browser) { - const page = await searchPrimoCatalogByBrowser( - { query, limit, scope: "default" }, - { interactive: values.interactive }, + if (command === "discussion" && positionals.length === 4) { + const courseId = opaqueToken(required(positionals[2], "Blackboard course ID"), "Blackboard course ID"); + const discussionId = opaqueToken(required(positionals[3], "Blackboard discussion ID"), "Blackboard discussion ID"); + const page = parsePositiveInteger(values.page, 1, "--page"); + const pageSize = parsePositiveInteger(values["page-size"], 25, "--page-size"); + if (pageSize > 100) throw usageError("--page-size cannot exceed 100 for Blackboard discussion messages."); + const groupId = values["group-id"] ? opaqueToken(values["group-id"], "--group-id") : undefined; + const userId = values["user-id"] ? opaqueToken(values["user-id"], "--user-id") : undefined; + const status = blackboardDiscussionMessageStatusValue(values.status); + const isRead = blackboardDiscussionReadValue(values["is-read"]); + const sort = optionalInlineText(values.sort, "--sort", 200); + const adapter = await casServiceAdapter(values, "bb"); + const report = await getBlackboardDiscussionMessages(adapter, { + courseId, + discussionId, + ...(groupId ? { groupId } : {}), + ...(userId ? { userId } : {}), + ...(status ? { status } : {}), + ...(isRead !== undefined ? { isRead } : {}), + page, + pageSize, + ...(sort ? { sort } : {}), + }); + writeSuccess({ + command: "bb discussion", + data: report, + text: formatBlackboardDiscussion(report), + items: report.messages, + summary: { + courseId: report.courseId, + discussionId: report.discussion.id, + page: report.page, + pageSize: report.pageSize, + returned: report.returned, + hasMore: report.hasMore, + ...(report.nextPage ? { nextPage: report.nextPage } : {}), + }, + }, output); + return; + } + if (command === "discussion-replies" && positionals.length === 5) { + const courseId = opaqueToken(required(positionals[2], "Blackboard course ID"), "Blackboard course ID"); + const discussionId = opaqueToken(required(positionals[3], "Blackboard discussion ID"), "Blackboard discussion ID"); + const messageId = opaqueToken(required(positionals[4], "Blackboard message ID"), "Blackboard message ID"); + const page = parsePositiveInteger(values.page, 1, "--page"); + const pageSize = parsePositiveInteger(values["page-size"], 25, "--page-size"); + if (pageSize > 100) throw usageError("--page-size cannot exceed 100 for Blackboard discussion replies."); + const groupId = values["group-id"] ? opaqueToken(values["group-id"], "--group-id") : undefined; + const userId = values["user-id"] ? opaqueToken(values["user-id"], "--user-id") : undefined; + const status = blackboardDiscussionMessageStatusValue(values.status); + const isRead = blackboardDiscussionReadValue(values["is-read"]); + const sort = optionalInlineText(values.sort, "--sort", 200); + const adapter = await casServiceAdapter(values, "bb"); + const report = await listBlackboardDiscussionReplies(adapter, { + courseId, + discussionId, + messageId, + ...(groupId ? { groupId } : {}), + ...(userId ? { userId } : {}), + ...(status ? { status } : {}), + ...(isRead !== undefined ? { isRead } : {}), + page, + pageSize, + ...(sort ? { sort } : {}), + }); + writeSuccess({ + command: "bb discussion-replies", + data: report, + text: formatBlackboardDiscussionReplies(report), + items: report.replies, + summary: { + courseId: report.courseId, + discussionId: report.discussionId, + messageId: report.messageId, + page: report.page, + pageSize: report.pageSize, + returned: report.returned, + hasMore: report.hasMore, + ...(report.nextPage ? { nextPage: report.nextPage } : {}), + }, + }, output); + return; + } + if (command === "discussion-post" && positionals[2] === "preview" && positionals.length === 5) { + const target = blackboardDiscussionWriteTarget("post", positionals, values); + const input = await readBlackboardDiscussionWriteInput(values); + const adapter = await casServiceAdapter(values, "bb"); + const preflight = await buildBlackboardDiscussionWritePreflight(adapter, { + credentialsFile: values["credentials-file"], + profile: values.profile, + }, target, input); + writeSuccess({ + command: "bb discussion-post preview", + data: { mode: "preview", mutation: false, ...preflight }, + text: formatBlackboardDiscussionWritePreview(preflight), + }, output); + return; + } + if (command === "discussion-post" && positionals[2] === "apply" && positionals.length === 5) { + const target = blackboardDiscussionWriteTarget("post", positionals, values); + if (!values.confirm) { + throw new ConfirmationRequiredError( + "Blackboard discussion post", + "Blackboard discussion posting creates a visible course message. Re-run the exact previewed command with --confirm.", + ); + } + const expectedSha256 = blackboardExpectedSha256(required(values["expected-sha256"], "--expected-sha256")); + const input = await readBlackboardDiscussionWriteInput(values); + if (input.textFile.sha256 !== expectedSha256) { + throw new CliError( + "The selected Blackboard discussion text no longer matches the reviewed preview hash. Re-run preview before posting.", + "BLACKBOARD_DISCUSSION_HASH_MISMATCH", + 4, + { + file: input.textFile.absolutePath, + expectedSha256, + actualSha256: input.textFile.sha256, + }, ); + } + const adapter = await casServiceAdapter(values, "bb"); + const preflight = await buildBlackboardDiscussionWritePreflight(adapter, { + credentialsFile: values["credentials-file"], + profile: values.profile, + }, target, input); + ensureBlackboardDiscussionWriteAllowed(preflight); + let createdMessageId = ""; + let stage: "create" | "verify" = "create"; + try { + const created = await createBlackboardDiscussionMessage(adapter, target.courseId, target.discussionId, { + body: input.body, + ...(preflight.target.groupId ? { groupId: preflight.target.groupId } : {}), + status: preflight.target.status, + }); + createdMessageId = created.id; + stage = "verify"; + const snapshot = await observeBlackboardDiscussionWrite(adapter, preflight, createdMessageId); + const verification = snapshot.error + ? { + status: "unavailable" as const, + message: "The created Blackboard discussion message could not be read back after the create request.", + } + : verifyBlackboardDiscussionWrite(preflight, input, snapshot.message ?? created); + if (verification.status !== "confirmed") { + throw new CliError( + "Blackboard accepted the discussion-post request, but the read-back verification was inconclusive.", + "BLACKBOARD_DISCUSSION_POST_NOT_CONFIRMED", + 1, + { + courseId: target.courseId, + discussionId: target.discussionId, + ...(preflight.target.groupId ? { groupId: preflight.target.groupId } : {}), + messageId: createdMessageId, + verification, + warning: "DO_NOT_RETRY_AUTOMATICALLY", + }, + ); + } writeSuccess({ - command: "library search", - data: { mutation: false, transport: "browser", page }, - text: formatBrowserPrimoCatalogSearch(page), - items: page.results, - summary: { query, shown: page.totalReturned, transport: "browser", authentication: page.authentication }, + command: "bb discussion-post apply", + data: { + mode: "apply", + mutation: true, + target: preflight.target, + courseId: preflight.courseId, + courseCode: preflight.courseCode, + courseName: preflight.courseName, + discussion: preflight.discussion, + ...(preflight.group ? { group: preflight.group } : {}), + body: preflight.body, + message: snapshot.message ?? created, + verification, + }, + text: formatBlackboardDiscussionWriteSuccess({ + target: preflight.target, + courseCode: preflight.courseCode, + discussion: preflight.discussion, + ...(preflight.group ? { group: preflight.group } : {}), + body: preflight.body, + message: snapshot.message ?? created, + verification, + }), }, output); return; + } catch (error) { + const snapshot = createdMessageId + ? await observeBlackboardDiscussionWrite(adapter, preflight, createdMessageId) + : {}; + const verification = snapshot.error + ? { + status: "unavailable" as const, + message: "The created Blackboard discussion message could not be read back after the request failed.", + } + : verifyBlackboardDiscussionWrite(preflight, input, snapshot.message); + if (createdMessageId && verification.status === "confirmed" && snapshot.message) { + writeSuccess({ + command: "bb discussion-post apply", + data: { + mode: "apply", + mutation: true, + target: preflight.target, + courseId: preflight.courseId, + courseCode: preflight.courseCode, + courseName: preflight.courseName, + discussion: preflight.discussion, + ...(preflight.group ? { group: preflight.group } : {}), + body: preflight.body, + message: snapshot.message, + verification, + recoveredAfterError: true, + }, + text: formatBlackboardDiscussionWriteSuccess({ + target: preflight.target, + courseCode: preflight.courseCode, + discussion: preflight.discussion, + ...(preflight.group ? { group: preflight.group } : {}), + body: preflight.body, + message: snapshot.message, + verification, + }), + meta: { recoveredAfterError: true }, + }, output); + return; + } + throw new CliError( + "Blackboard discussion-post outcome is uncertain. Do not retry automatically.", + "BLACKBOARD_DISCUSSION_POST_OUTCOME_UNKNOWN", + 5, + { + stage, + courseId: target.courseId, + discussionId: target.discussionId, + ...(preflight.target.groupId ? { groupId: preflight.target.groupId } : {}), + ...(createdMessageId ? { messageId: createdMessageId } : {}), + textFile: input.textFile.absolutePath, + verification, + cause: error instanceof Error ? error.message : String(error), + warning: "DO_NOT_RETRY_AUTOMATICALLY", + }, + ); } - const page = await searchLibraryCatalog(createPrimoPublicAdapter(), { query, limit }); + } + if (command === "discussion-reply" && positionals[2] === "preview" && positionals.length === 6) { + const target = blackboardDiscussionWriteTarget("reply", positionals, values); + const input = await readBlackboardDiscussionWriteInput(values); + const adapter = await casServiceAdapter(values, "bb"); + const preflight = await buildBlackboardDiscussionWritePreflight(adapter, { + credentialsFile: values["credentials-file"], + profile: values.profile, + }, target, input); writeSuccess({ - command: "library search", - data: { mutation: false, transport: "public-json", page }, - text: formatLibraryCatalogSearch(page), - items: page.items, - summary: { query, total: page.total, shown: page.items.length, first: page.first, last: page.last, transport: "public-json" }, + command: "bb discussion-reply preview", + data: { mode: "preview", mutation: false, ...preflight }, + text: formatBlackboardDiscussionWritePreview(preflight), }, output); return; } - if (command === "detail" && positionals.length === 3) { - const reference = inlineText(required(positionals[2], "Primo record reference"), "Primo record reference", 2048); - if (values.interactive && !values.browser) throw usageError("--interactive requires --browser for library catalog commands."); - if (values.browser) { - const detail = await getPrimoCatalogDetailByBrowser(reference, { interactive: values.interactive }); + if (command === "discussion-reply" && positionals[2] === "apply" && positionals.length === 6) { + const target = blackboardDiscussionWriteTarget("reply", positionals, values); + if (!values.confirm) { + throw new ConfirmationRequiredError( + "Blackboard discussion reply", + "Blackboard discussion replies create a visible course message. Re-run the exact previewed command with --confirm.", + ); + } + const expectedSha256 = blackboardExpectedSha256(required(values["expected-sha256"], "--expected-sha256")); + const input = await readBlackboardDiscussionWriteInput(values); + if (input.textFile.sha256 !== expectedSha256) { + throw new CliError( + "The selected Blackboard discussion reply text no longer matches the reviewed preview hash. Re-run preview before replying.", + "BLACKBOARD_DISCUSSION_HASH_MISMATCH", + 4, + { + file: input.textFile.absolutePath, + expectedSha256, + actualSha256: input.textFile.sha256, + }, + ); + } + const adapter = await casServiceAdapter(values, "bb"); + const preflight = await buildBlackboardDiscussionWritePreflight(adapter, { + credentialsFile: values["credentials-file"], + profile: values.profile, + }, target, input); + ensureBlackboardDiscussionWriteAllowed(preflight); + let createdReplyId = ""; + let stage: "create" | "verify" = "create"; + try { + const created = await createBlackboardDiscussionReply( + adapter, + target.courseId, + target.discussionId, + target.messageId!, + { + body: input.body, + ...(preflight.target.groupId ? { groupId: preflight.target.groupId } : {}), + status: preflight.target.status, + }, + ); + createdReplyId = created.id; + stage = "verify"; + const snapshot = await observeBlackboardDiscussionWrite(adapter, preflight, createdReplyId); + const verification = snapshot.error + ? { + status: "unavailable" as const, + message: "The created Blackboard reply could not be read back after the create request.", + } + : verifyBlackboardDiscussionWrite(preflight, input, snapshot.message ?? created); + if (verification.status !== "confirmed") { + throw new CliError( + "Blackboard accepted the discussion-reply request, but the read-back verification was inconclusive.", + "BLACKBOARD_DISCUSSION_REPLY_NOT_CONFIRMED", + 1, + { + courseId: target.courseId, + discussionId: target.discussionId, + messageId: target.messageId, + ...(preflight.target.groupId ? { groupId: preflight.target.groupId } : {}), + replyId: createdReplyId, + verification, + warning: "DO_NOT_RETRY_AUTOMATICALLY", + }, + ); + } writeSuccess({ - command: "library detail", - data: { mutation: false, transport: "browser", detail }, - text: formatBrowserPrimoCatalogDetail(detail), - summary: { reference: detail.reference, transport: "browser", authentication: detail.authentication }, + command: "bb discussion-reply apply", + data: { + mode: "apply", + mutation: true, + target: preflight.target, + courseId: preflight.courseId, + courseCode: preflight.courseCode, + courseName: preflight.courseName, + discussion: preflight.discussion, + ...(preflight.group ? { group: preflight.group } : {}), + ...(preflight.parentMessage ? { parentMessage: preflight.parentMessage } : {}), + body: preflight.body, + message: snapshot.message ?? created, + verification, + }, + text: formatBlackboardDiscussionWriteSuccess({ + target: preflight.target, + courseCode: preflight.courseCode, + discussion: preflight.discussion, + ...(preflight.group ? { group: preflight.group } : {}), + ...(preflight.parentMessage ? { parentMessage: preflight.parentMessage } : {}), + body: preflight.body, + message: snapshot.message ?? created, + verification, + }), }, output); return; + } catch (error) { + const snapshot = createdReplyId + ? await observeBlackboardDiscussionWrite(adapter, preflight, createdReplyId) + : {}; + const verification = snapshot.error + ? { + status: "unavailable" as const, + message: "The created Blackboard reply could not be read back after the request failed.", + } + : verifyBlackboardDiscussionWrite(preflight, input, snapshot.message); + if (createdReplyId && verification.status === "confirmed" && snapshot.message) { + writeSuccess({ + command: "bb discussion-reply apply", + data: { + mode: "apply", + mutation: true, + target: preflight.target, + courseId: preflight.courseId, + courseCode: preflight.courseCode, + courseName: preflight.courseName, + discussion: preflight.discussion, + ...(preflight.group ? { group: preflight.group } : {}), + ...(preflight.parentMessage ? { parentMessage: preflight.parentMessage } : {}), + body: preflight.body, + message: snapshot.message, + verification, + recoveredAfterError: true, + }, + text: formatBlackboardDiscussionWriteSuccess({ + target: preflight.target, + courseCode: preflight.courseCode, + discussion: preflight.discussion, + ...(preflight.group ? { group: preflight.group } : {}), + ...(preflight.parentMessage ? { parentMessage: preflight.parentMessage } : {}), + body: preflight.body, + message: snapshot.message, + verification, + }), + meta: { recoveredAfterError: true }, + }, output); + return; + } + throw new CliError( + "Blackboard discussion-reply outcome is uncertain. Do not retry automatically.", + "BLACKBOARD_DISCUSSION_REPLY_OUTCOME_UNKNOWN", + 5, + { + stage, + courseId: target.courseId, + discussionId: target.discussionId, + messageId: target.messageId, + ...(preflight.target.groupId ? { groupId: preflight.target.groupId } : {}), + ...(createdReplyId ? { replyId: createdReplyId } : {}), + textFile: input.textFile.absolutePath, + verification, + cause: error instanceof Error ? error.message : String(error), + warning: "DO_NOT_RETRY_AUTOMATICALLY", + }, + ); } - const detail = await getLibraryCatalogDetail(createPrimoPublicAdapter(), reference); + } + if (command === "assignments" && (positionals.length === 2 || positionals.length === 3)) { + const courseId = positionals.length === 3 + ? opaqueToken(required(positionals[2], "Blackboard course ID"), "Blackboard course ID") + : undefined; + const courseQuery = values.course?.trim() || undefined; + if (courseId && courseQuery) { + throw usageError("Use either a Blackboard course ID positional or --course QUERY, not both."); + } + const submissionState = blackboardAssignmentSubmissionStateValue(values["submission-state"]); + const adapter = await casServiceAdapter(values, "bb"); + if (!courseId) { + const report = await listBlackboardAssignmentsAcrossCourses(adapter, { + ...(courseQuery ? { courseQuery } : {}), + withAttempts: values["with-attempts"] === true, + ...(submissionState ? { submissionState } : {}), + }); + writeSuccess({ + command: "bb assignments", + data: report, + text: formatBlackboardAssignmentsAcrossCourses(report), + items: report.assignments, + summary: { + ...(courseQuery ? { courseQuery } : {}), + withAttempts: report.withAttempts, + ...(submissionState ? { submissionState } : {}), + coursesMatched: report.coursesMatched, + coursesScanned: report.coursesScanned, + total: report.totalAssignments, + returned: report.assignments.length, + attemptedAssignments: report.attemptedAssignments, + partial: report.partial, + failures: report.failures.length, + }, + ...(report.failures.length > 0 ? { meta: { failures: report.failures } } : {}), + }, output); + return; + } + if (values["with-attempts"] === true || submissionState !== undefined) { + const report = await listBlackboardAssignmentsWithAttempts(adapter, courseId); + const assignments = submissionState + ? filterBlackboardAssignmentsBySubmissionState(report.assignments, submissionState) + : report.assignments; + const attemptedAssignments = assignments.filter((item) => (item.attemptSummary?.totalAttempts ?? 0) > 0).length; + writeSuccess({ + command: "bb assignments", + data: { + withAttempts: true, + ...(submissionState ? { submissionState } : {}), + ...report, + assignments, + returnedAssignments: assignments.length, + }, + text: formatBlackboardAssignmentsWithAttempts(report, { + assignments, + ...(submissionState ? { submissionState } : {}), + }), + items: assignments, + summary: { + courseId, + withAttempts: true, + ...(submissionState ? { submissionState } : {}), + total: report.totalAssignments, + returned: assignments.length, + attemptedAssignments, + partial: report.partial, + failures: report.failures.length, + }, + ...(report.failures.length > 0 ? { meta: { failures: report.failures } } : {}), + }, output); + return; + } + const assignments = await listBlackboardAssignments(adapter, courseId); writeSuccess({ - command: "library detail", - data: { mutation: false, transport: "public-json", detail }, - text: formatLibraryCatalogDetail(detail), - summary: { reference: detail.reference, transport: "public-json" }, + command: "bb assignments", + data: { courseId, assignments, total: assignments.length }, + text: formatBlackboardAssignments(assignments), + items: assignments, + summary: { courseId, total: assignments.length }, }, output); return; } - if (command === "search-url") { - const query = positionals.slice(2).join(" ").trim(); - if (!query) throw usageError("A library search query is required."); - const limit = parsePositiveInteger(values.limit, 10, "--limit"); - const url = buildPrimoSearchUrl({ query, limit }); - const status = serviceStatus("library-catalog"); - const data = { - query, - url, - availability: "browser-required", - mutation: false, - ...(status ? { service: status } : {}), - }; + if (command === "grades" && positionals.length === 2) { + const submissionState = blackboardGradesSubmissionStateValue(values["submission-state"]); + const courseQuery = values.course?.trim() || undefined; + const limit = parsePositiveInteger(values.limit, 50, "--limit"); + if (limit > 200) throw usageError("--limit cannot exceed 200 for Blackboard grades."); + const adapter = await casServiceAdapter(values, "bb"); + const report = await listBlackboardGrades(adapter, { + ...(courseQuery ? { courseQuery } : {}), + ...(submissionState ? { submissionState } : {}), + limit, + }); writeSuccess({ - command: "library search-url", - data, - text: `Library search URL · browser required\n${url}\nNo catalog result was fabricated by the CLI.`, + command: "bb grades", + data: report, + text: formatBlackboardGrades(report), + items: report.grades, + summary: { + ...(courseQuery ? { courseQuery } : {}), + ...(submissionState ? { submissionState } : {}), + limit, + coursesMatched: report.coursesMatched, + coursesScanned: report.coursesScanned, + total: report.attemptedAssignments, + returned: report.grades.length, + partial: report.partial, + failures: report.failures.length, + }, + ...(report.failures.length > 0 ? { meta: { failures: report.failures } } : {}), }, output); return; } - throw usageError(`Unknown command: ${positionals.join(" ")}`); -} - -async function runBooking( - positionals: string[], - values: Values, - output: ReturnType, -): Promise { - const command = positionals[1]; - const operation = positionals[2]; - if (command === "create" && operation === "preview" && positionals.length === 3) { - const target = bookingCreateTarget(values); - const session = await bookingService(values); - const preview = await buildBookingCreatePreview(session, target); - const confirmation = preview.applyAllowed - ? buildBookingCreateApplyConfirmation(target, { - credentialsFile: values["credentials-file"], - profile: values.profile, - }) - : undefined; + if (command === "attempt-files" && positionals.length === 4) { + const courseId = opaqueToken(required(positionals[2], "Blackboard course ID"), "Blackboard course ID"); + const attemptId = opaqueToken(required(positionals[3], "Blackboard attempt ID"), "Blackboard attempt ID"); + const adapter = await casServiceAdapter(values, "bb"); + const files = await listBlackboardAttemptFiles(adapter, courseId, attemptId); + const publicFiles = files.map(publicBlackboardAttemptFile); writeSuccess({ - command: "booking create preview", - data: { - mode: "preview", - mutation: false, - target, - preview, - confirmation: { - required: true, - available: Boolean(confirmation), - ...(confirmation ? { argv: confirmation.argv, command: confirmation.command } : {}), - }, - }, - text: formatBookingCreatePreview(preview, confirmation?.command), + command: "bb attempt-files", + data: { courseId, attemptId, files: publicFiles, total: files.length }, + text: formatBlackboardAttemptFiles(attemptId, files), + items: publicFiles, + summary: { courseId, attemptId, total: files.length }, }, output); return; } - if (command === "create" && operation === "apply" && positionals.length === 3) { - if (!values.confirm) throw new ConfirmationRequiredError("E-Hall booking create", "E-Hall booking create changes campus room state. Re-run the exact previewed command with --confirm."); - const target = bookingCreateTarget(values); - const session = await bookingService(values); - const result = await applyBookingCreate(session, target); + if (command === "attempt-download" && positionals.length === 5) { + const courseId = opaqueToken(required(positionals[2], "Blackboard course ID"), "Blackboard course ID"); + const attemptId = opaqueToken(required(positionals[3], "Blackboard attempt ID"), "Blackboard attempt ID"); + const fileId = opaqueToken(required(positionals[4], "Blackboard attempt file ID"), "Blackboard attempt file ID"); + const destination = required(values.destination, "--destination"); + const adapter = await casServiceAdapter(values, "bb"); + const result = await downloadBlackboardAttemptFile( + adapter, + courseId, + attemptId, + fileId, + destination, + { overwrite: values.overwrite === true }, + ); writeSuccess({ - command: "booking create apply", + command: "bb attempt-download", data: { - mode: "apply", - mutation: true, - ...result, + courseId, + attemptId, + file: publicBlackboardAttemptFile(result.file), + destination: result.destination, + size: result.size, + sha256: result.sha256, + contentType: result.contentType, + overwritten: result.overwritten, }, - text: formatBookingCreateSuccess(result), + text: formatBlackboardAttemptFileDownload(result, attemptId), + summary: { courseId, attemptId, fileId: result.file.id, destination: result.destination, size: result.size, overwritten: result.overwritten }, }, output); return; } - if (command === "cancel" && operation === "preview" && positionals.length === 3) { - const target = bookingCancelTarget(values); - const session = await bookingService(values); - const preview = await buildBookingCancelPreview(session, target); - const confirmation = preview.applyAllowed - ? buildBookingCancelApplyConfirmation(target, { - credentialsFile: values["credentials-file"], - profile: values.profile, - }) - : undefined; + if (command === "announcements" && positionals.length === 2) { + const days = values.days === undefined ? undefined : parsePositiveInteger(values.days, 1, "--days"); + const courseQuery = values.course?.trim() || undefined; + const adapter = await casServiceAdapter(values, "bb"); + const report = await listBlackboardAnnouncements(adapter, { + now: new Date(), + ...(days !== undefined ? { days } : {}), + ...(courseQuery ? { courseQuery } : {}), + }); writeSuccess({ - command: "booking cancel preview", - data: { - mode: "preview", - mutation: false, - target, - preview, - confirmation: { - required: true, - available: Boolean(confirmation), - ...(confirmation ? { argv: confirmation.argv, command: confirmation.command } : {}), - }, + command: "bb announcements", + data: report, + text: formatBlackboardAnnouncements(report), + items: report.announcements, + summary: { + ...(days !== undefined ? { days } : {}), + ...(courseQuery ? { courseQuery } : {}), + coursesMatched: report.coursesMatched, + coursesScanned: report.coursesScanned, + systemAnnouncements: report.systemAnnouncements, + courseAnnouncements: report.courseAnnouncements, + total: report.announcements.length, + partial: report.partial, + failures: report.failures.length, }, - text: formatBookingCancelPreview(preview, confirmation?.command), + ...(report.failures.length > 0 ? { meta: { failures: report.failures } } : {}), }, output); return; } - if (command === "cancel" && operation === "apply" && positionals.length === 3) { - if (!values.confirm) throw new ConfirmationRequiredError("E-Hall booking cancel", "E-Hall booking cancel releases a live room slot. Re-run the exact previewed command with --confirm."); - const target = bookingCancelTarget(values); - const session = await bookingService(values); - const result = await applyBookingCancel(session, target); + if (command === "deadlines" && positionals.length === 2) { + const days = values.days === undefined ? undefined : parsePositiveInteger(values.days, 1, "--days"); + const courseQuery = values.course?.trim() || undefined; + const submissionState = blackboardAssignmentSubmissionStateValue(values["submission-state"]); + const adapter = await casServiceAdapter(values, "bb"); + const report = await listBlackboardDeadlines(adapter, { + now: new Date(), + ...(days !== undefined ? { days } : {}), + ...(courseQuery ? { courseQuery } : {}), + ...(submissionState ? { submissionState } : {}), + }); writeSuccess({ - command: "booking cancel apply", - data: { - mode: "apply", - mutation: true, - ...result, + command: "bb deadlines", + data: report, + text: formatBlackboardDeadlines(report), + items: report.deadlines, + summary: { + ...(days !== undefined ? { days } : {}), + ...(courseQuery ? { courseQuery } : {}), + ...(submissionState ? { submissionState } : {}), + coursesMatched: report.coursesMatched, + coursesScanned: report.coursesScanned, + total: report.deadlines.length, + partial: report.partial, + failures: report.failures.length, }, - text: formatBookingCancelSuccess(result), + ...(report.failures.length > 0 ? { meta: { failures: report.failures } } : {}), }, output); return; } - if (command === "whoami" && positionals.length === 2) { - const session = await bookingService(values); - await session.login(); - const profile = session.userProfile; - if (!profile) throw new CliError("Booking login did not expose a user profile.", "SERVICE_PROTOCOL_ERROR", 1); - writeSuccess({ command: "booking whoami", data: profile, text: formatBookingProfile(profile) }, output); + if (command === "calendar" && positionals.length === 2) { + const type = blackboardCalendarItemType(values.type); + const courseId = values["course-id"] + ? opaqueToken(values["course-id"], "--course-id") + : undefined; + const adapter = await casServiceAdapter(values, "bb"); + const report = await listBlackboardCalendarItems(adapter, { + ...(values.since ? { since: values.since } : {}), + ...(values.until ? { until: values.until } : {}), + ...(type ? { type } : {}), + ...(courseId ? { courseId } : {}), + }); + writeSuccess({ + command: "bb calendar", + data: report, + text: formatBlackboardCalendar(report), + items: report.items, + summary: { + since: report.since, + until: report.until, + ...(report.type ? { type: report.type } : {}), + ...(report.courseId ? { courseId: report.courseId } : {}), + total: report.totalItems, + partial: report.partial, + failures: report.failures.length, + }, + ...(report.failures.length > 0 ? { meta: { failures: report.failures } } : {}), + }, output); return; } - if (command === "rooms") { + if (command === "search") { + const query = positionals.slice(2).join(" ").trim(); + if (!query) throw usageError("A Blackboard search query is required."); const page = parsePositiveInteger(values.page, 1, "--page"); - const pageSize = parsePositiveInteger(values["page-size"], 100, "--page-size"); - if (pageSize > 500) throw usageError("--page-size cannot exceed 500 for booking rooms."); - const query = positionals.slice(2).join(" ").trim() || undefined; - const session = await bookingService(values); - let rooms = await listBookingRooms(session, { page, pageSize, keyword: query }); - if (values.available) rooms = rooms.filter((room) => room.available); + const pageSize = parsePositiveInteger(values["page-size"], 25, "--page-size"); + if (pageSize > 100) throw usageError("--page-size cannot exceed 100 for Blackboard search."); + const courseQuery = values.course?.trim() || undefined; + const kind = values.kind ? blackboardContentKind(values.kind) : undefined; + const attachments = blackboardSearchAttachmentMode(values.attachments); + const adapter = await casServiceAdapter(values, "bb"); + const report = await searchBlackboardContentTree(adapter, { + query, + ...(courseQuery ? { courseQuery } : {}), + ...(kind ? { kind } : {}), + attachments, + page, + pageSize, + }); writeSuccess({ - command: "booking rooms", - data: { query, availableOnly: Boolean(values.available), page, pageSize, rooms, total: rooms.length }, - text: formatBookingRooms(rooms), - items: rooms, - summary: { query, availableOnly: Boolean(values.available), page, pageSize, total: rooms.length }, + command: "bb search", + data: report, + text: formatBlackboardSearch(report), + items: report.results, + summary: { + query, + page: report.page, + pageSize: report.pageSize, + totalMatches: report.totalMatches, + returned: report.returned, + hasMore: report.hasMore, + failures: report.failures.length, + }, + ...(report.failures.length > 0 ? { meta: { failures: report.failures } } : {}), }, output); return; } - if (command === "my-meetings" && positionals.length === 2) { - const page = parsePositiveInteger(values.page, 1, "--page"); - const pageSize = parsePositiveInteger(values["page-size"], 50, "--page-size"); - if (pageSize > 500) throw usageError("--page-size cannot exceed 500 for booking meetings."); - const session = await bookingService(values); - const meetings = await listMyBookingMeetings(session, { page, pageSize }); + if (command === "sync" && positionals.length === 3) { + const courseId = opaqueToken(required(positionals[2], "Blackboard course ID"), "Blackboard course ID"); + const destination = required(values.destination, "--destination"); + const adapter = await casServiceAdapter(values, "bb"); + const report = await syncBlackboardAttachments(adapter, { + courseId, + destination, + ...(values["content-id"] ? { contentId: opaqueToken(values["content-id"], "--content-id") } : {}), + overwrite: values.overwrite === true, + }); writeSuccess({ - command: "booking my-meetings", - data: { page, pageSize, meetings, total: meetings.length }, - text: formatBookingMeetings(meetings), - items: meetings, - summary: { page, pageSize, total: meetings.length }, + command: "bb sync", + data: report, + text: formatBlackboardSync(report), + items: report.files, + summary: { + courseId: report.courseId, + destination: report.destination, + plannedFiles: report.plannedFiles, + downloadedFiles: report.downloadedFiles, + partial: report.partial, + failures: report.failures.length, + }, + ...(report.failures.length > 0 ? { meta: { failures: report.failures } } : {}), }, output); return; } - throw usageError(`Unknown command: ${positionals.join(" ")}`); -} - -async function runLibraryBooking( - positionals: string[], - values: Values, - output: ReturnType, -): Promise { - const command = positionals[1]; - const operation = positionals[2]; - if (command === "create" && operation === "preview" && positionals.length === 3) { - const target = libraryBookingCreateTarget(values); - const session = await libraryBookingService(values); - const preview = await buildLibraryBookingCreatePreview(session, target); - const confirmation = preview.applyAllowed - ? buildLibraryBookingCreateApplyConfirmation(target, { - credentialsFile: values["credentials-file"], - profile: values.profile, - }) - : undefined; - writeSuccess({ - command: "lib-booking create preview", - data: { - mode: "preview", - mutation: false, - target, - preview, - confirmation: { - required: true, - available: Boolean(confirmation), - ...(confirmation ? { argv: confirmation.argv, command: confirmation.command } : {}), - }, - }, - text: formatLibraryBookingCreatePreview(preview, confirmation?.command), - }, output); - return; - } - if (command === "create" && operation === "apply" && positionals.length === 3) { - if (!values.confirm) throw new ConfirmationRequiredError("Library booking create", "Library booking create changes a live reservation slot. Re-run the exact previewed command with --confirm."); - const target = libraryBookingCreateTarget(values); - const session = await libraryBookingService(values); - const result = await applyLibraryBookingCreate(session, target); + if (command === "attempts" && positionals.length === 3) { + const courseId = opaqueToken(required(positionals[2], "Blackboard course ID"), "Blackboard course ID"); + const selector = blackboardAssignmentSelector(values); + const status = blackboardAttemptStatus(values.status); + const adapter = await casServiceAdapter(values, "bb"); + const assignments = await listBlackboardAssignments(adapter, courseId); + const assignment = resolveBlackboardAssignmentSelector(assignments, selector, courseId); + const attempts = await listBlackboardAttempts(adapter, courseId, assignment.id, { ...(status ? { status } : {}) }); writeSuccess({ - command: "lib-booking create apply", - data: { - mode: "apply", - mutation: true, - ...result, - }, - text: formatLibraryBookingCreateSuccess(result), + command: "bb attempts", + data: { courseId, assignment, ...(status ? { status } : {}), attempts, total: attempts.length }, + text: formatBlackboardAttempts(assignment, attempts), + items: attempts, + summary: { courseId, contentId: assignment.contentId, columnId: assignment.id, ...(status ? { status } : {}), total: attempts.length }, }, output); return; } - if (command === "cancel" && operation === "preview" && positionals.length === 3) { - const target = libraryBookingCancelTarget(values); - const session = await libraryBookingService(values); - const preview = await buildLibraryBookingCancelPreview(session, target); - const confirmation = preview.applyAllowed - ? buildLibraryBookingCancelApplyConfirmation(target, { - credentialsFile: values["credentials-file"], - profile: values.profile, - }) - : undefined; - writeSuccess({ - command: "lib-booking cancel preview", - data: { - mode: "preview", - mutation: false, - target, - preview, - confirmation: { - required: true, - available: Boolean(confirmation), - ...(confirmation ? { argv: confirmation.argv, command: confirmation.command } : {}), + if (command === "submit" && positionals[2] === "apply" && positionals.length === 3) { + const target = blackboardSubmissionTarget(values); + if (!values.confirm) { + throw new ConfirmationRequiredError( + "Blackboard submission", + "Blackboard submission uploads and submits an assignment attempt. Re-run the exact previewed command with --confirm.", + ); + } + const expectedSha256 = blackboardExpectedSha256(required(values["expected-sha256"], "--expected-sha256")); + const submission = await readBlackboardSubmissionInput(values); + const comment = submissionComment(values.comment); + const actualSha256 = submission.kind === "file" ? submission.file.sha256 : submission.textFile.sha256; + const sourcePath = submission.kind === "file" ? submission.file.absolutePath : submission.textFile.absolutePath; + if (actualSha256 !== expectedSha256) { + throw new CliError( + "The selected Blackboard submission input no longer matches the reviewed preview hash. Re-run preview before submitting.", + "BLACKBOARD_FILE_HASH_MISMATCH", + 4, + { + file: sourcePath, + expectedSha256, + actualSha256, }, - }, - text: formatLibraryBookingCancelPreview(preview, confirmation?.command), - }, output); - return; + ); + } + const adapter = await casServiceAdapter(values, "bb"); + const preflight = await buildBlackboardSubmissionPreflight(adapter, values, target, submission, comment); + ensureBlackboardSubmissionAllowed(preflight, values["allow-late"] === true); + + const assignment = preflight.assignment; + let createdAttemptId = ""; + let uploadedId = ""; + let stage: "upload" | "create_attempt" | "attach_file" | "submit_attempt" | "verify" = submission.kind === "file" ? "upload" : "create_attempt"; + try { + if (submission.kind === "file") { + stage = "upload"; + const uploaded = await uploadBlackboardTemporaryFile(adapter, submission.file, submission.bytes); + uploadedId = uploaded.id; + } + stage = "create_attempt"; + const attempt = await createBlackboardAttempt(adapter, target.courseId, assignment.id, { + ...(submission.kind === "text" ? { studentSubmission: submission.text } : {}), + ...(comment ? { studentComments: comment } : {}), + }); + createdAttemptId = attempt.id; + if (submission.kind === "file") { + stage = "attach_file"; + await attachBlackboardAttemptFile(adapter, target.courseId, createdAttemptId, { + name: submission.file.name, + uploadId: uploadedId, + }); + } + stage = "submit_attempt"; + const submitted = await updateBlackboardAttempt(adapter, target.courseId, assignment.id, createdAttemptId, { + status: "NeedsGrading", + }); + stage = "verify"; + const snapshot = await observeBlackboardAttemptSnapshot(adapter, target.courseId, assignment.id, createdAttemptId); + const observedAttempt = snapshot.attempt ?? submitted; + const verification = snapshot.attempt + ? verifyBlackboardSubmission(snapshot.attempt, snapshot.files, submission) + : { + status: "unavailable" as const, + message: "The submitted attempt status could not be read back from Blackboard.", + }; + if (verification.status !== "confirmed") { + throw new CliError( + "Blackboard accepted the submission request, but the read-back verification was inconclusive.", + "BLACKBOARD_SUBMISSION_NOT_CONFIRMED", + 1, + { + courseId: target.courseId, + contentId: assignment.contentId, + columnId: assignment.id, + attemptId: createdAttemptId, + verification, + warning: "DO_NOT_RETRY_AUTOMATICALLY", + }, + ); + } + writeBlackboardSubmissionResult(output, preflight, submission, comment, observedAttempt, snapshot.files, verification); + return; + } catch (error) { + if (error instanceof CliError && error.code === "BLACKBOARD_FILE_CHANGED") throw error; + let candidateAttemptIds: string[] = []; + if (!createdAttemptId) { + const drift = stage === "create_attempt" + ? await observeBlackboardAttemptCreation(adapter, target.courseId, assignment.id, preflight.attempts) + : undefined; + candidateAttemptIds = drift?.candidateAttemptIds ?? []; + if (drift?.attempt) createdAttemptId = drift.attempt.id; + } + const snapshot = createdAttemptId + ? await observeBlackboardAttemptSnapshot(adapter, target.courseId, assignment.id, createdAttemptId) + : { files: [] as BlackboardAttemptFile[] }; + const verification = snapshot.attempt + ? verifyBlackboardSubmission(snapshot.attempt, snapshot.files, submission) + : { status: "unavailable" as const, message: "Blackboard did not expose enough read-back state to confirm the submission." }; + if (snapshot.attempt && verification.status === "confirmed") { + writeBlackboardSubmissionResult( + output, + preflight, + submission, + comment, + snapshot.attempt, + snapshot.files, + verification, + true, + ); + return; + } + throw new CliError( + "Blackboard submission outcome is uncertain. Do not retry automatically.", + "BLACKBOARD_SUBMISSION_OUTCOME_UNKNOWN", + 5, + { + stage, + courseId: target.courseId, + contentId: assignment.contentId, + columnId: assignment.id, + ...(createdAttemptId ? { attemptId: createdAttemptId } : {}), + candidateAttemptIds, + ...(uploadedId ? { uploadId: uploadedId } : {}), + ...(submission.kind === "file" + ? { fileName: submission.file.name } + : { textFile: submission.textFile.absolutePath }), + ...(snapshot.attempt?.status ? { attemptStatus: snapshot.attempt.status } : {}), + observedFiles: snapshot.files.map((entry) => entry.name), + verification, + cause: error instanceof Error ? error.message : String(error), + warning: "DO_NOT_RETRY_AUTOMATICALLY", + }, + ); + } } - if (command === "cancel" && operation === "apply" && positionals.length === 3) { - if (!values.confirm) throw new ConfirmationRequiredError("Library booking cancel", "Library booking cancel releases a live reservation slot. Re-run the exact previewed command with --confirm."); - const target = libraryBookingCancelTarget(values); - const session = await libraryBookingService(values); - const result = await applyLibraryBookingCancel(session, target); + throw usageError(`Unknown command: ${positionals.join(" ")}`); +} + +async function runWs( + positionals: string[], + values: Values, + output: ReturnType, +): Promise { + const command = positionals[1]; + const adapter = await casServiceAdapter(values, "ws"); + const token = await getWsToken(adapter); + if (!token.userToken) throw new CliError("WS session did not expose a user token.", "WS_PROTOCOL_ERROR", 1); + if (command === "programs") { + const keywords = positionals.slice(2).join(" ").trim() || undefined; + const page = parsePositiveInteger(values.page, 1, "--page"); + const pageSize = parsePositiveInteger(values["page-size"], 20, "--page-size"); + if (pageSize > 100) throw usageError("--page-size cannot exceed 100 for WS."); + const result = await listWsPrograms(adapter, token, { page, pageSize, keywords }); writeSuccess({ - command: "lib-booking cancel apply", - data: { - mode: "apply", - mutation: true, - ...result, - }, - text: formatLibraryBookingCancelSuccess(result), + command: "ws programs", + data: result, + text: formatWsPrograms(result.programs), + items: result.programs, + summary: { page: result.page, pageSize: result.pageSize, total: result.total, shown: result.programs.length }, }, output); return; } - if (command === "whoami" && positionals.length === 2) { - const session = await libraryBookingService(values); - const user = await getLibraryBookingUser(session); - writeSuccess({ command: "lib-booking whoami", data: user, text: formatLibraryBookingUser(user) }, output); + if (command === "detail" && positionals.length === 3) { + const id = opaqueToken(required(positionals[2], "WS program ID"), "WS program ID"); + const detail = await getWsProgramDetail(adapter, token, { + id, + code: values["program-code"], + programToken: values["program-token"], + }); + writeSuccess({ command: "ws detail", data: { id, detail }, text: formatWsDetail(detail) }, output); return; } - if (command === "home-summary" && positionals.length === 2) { - const session = await libraryBookingService(values); - const categories = await getLibraryIdleSummary(session); + throw usageError(`Unknown command: ${positionals.join(" ")}`); +} + +async function runLibrary( + positionals: string[], + values: Values, + output: ReturnType, +): Promise { + const command = positionals[1]; + if (command === "search") { + const query = positionals.slice(2).join(" ").trim(); + if (!query) throw usageError("A library search query is required."); + if (values.interactive && !values.browser) throw usageError("--interactive requires --browser for library catalog commands."); + const limit = parsePositiveInteger(values.limit, 10, "--limit"); + if (limit > 50) throw usageError("--limit cannot exceed 50 for library catalog search."); + if (values.browser) { + const page = await searchPrimoCatalogByBrowser( + { query, limit, scope: "default" }, + { interactive: values.interactive }, + ); + writeSuccess({ + command: "library search", + data: { mutation: false, transport: "browser", page }, + text: formatBrowserPrimoCatalogSearch(page), + items: page.results, + summary: { query, shown: page.totalReturned, transport: "browser", authentication: page.authentication }, + }, output); + return; + } + const page = await searchLibraryCatalog(createPrimoPublicAdapter(), { query, limit }); writeSuccess({ - command: "lib-booking home-summary", - data: { categories, total: categories.length }, - text: formatLibraryIdleSummary(categories), - items: categories, - summary: { total: categories.length }, - }, output); - return; - } - if (command === "labs" && positionals.length === 2) { - const classKind = parsePositiveInteger(values["class-kind"], 1, "--class-kind"); - const session = await libraryBookingService(values); - const labs = await listLibraryLabs(session, classKind); - writeSuccess({ - command: "lib-booking labs", - data: { classKind, labs, total: labs.length }, - text: formatLibraryLabs(labs), - items: labs, - summary: { classKind, total: labs.length }, + command: "library search", + data: { mutation: false, transport: "public-json", page }, + text: formatLibraryCatalogSearch(page), + items: page.items, + summary: { query, total: page.total, shown: page.items.length, first: page.first, last: page.last, transport: "public-json" }, }, output); return; } - if (command === "rooms" && positionals.length === 2) { - const kindId = parsePositiveInteger(required(values["kind-id"], "--kind-id"), 1, "--kind-id"); - const labId = parsePositiveInteger(required(values["lab-id"], "--lab-id"), 1, "--lab-id"); - const classKind = parsePositiveInteger(values["class-kind"], 1, "--class-kind"); - const session = await libraryBookingService(values); - const groups = await listLibraryRooms(session, { kindId, labId, classKind }); - const total = groups.reduce((sum, group) => sum + group.labs.reduce((labSum, lab) => labSum + lab.rooms.length, 0), 0); + if (command === "detail" && positionals.length === 3) { + const reference = inlineText(required(positionals[2], "Primo record reference"), "Primo record reference", 2048); + if (values.interactive && !values.browser) throw usageError("--interactive requires --browser for library catalog commands."); + if (values.browser) { + const detail = await getPrimoCatalogDetailByBrowser(reference, { interactive: values.interactive }); + writeSuccess({ + command: "library detail", + data: { mutation: false, transport: "browser", detail }, + text: formatBrowserPrimoCatalogDetail(detail), + summary: { reference: detail.reference, transport: "browser", authentication: detail.authentication }, + }, output); + return; + } + const detail = await getLibraryCatalogDetail(createPrimoPublicAdapter(), reference); writeSuccess({ - command: "lib-booking rooms", - data: { kindId, labId, classKind, groups, total }, - text: formatLibraryRooms(groups), - items: groups, - summary: { kindId, labId, classKind, groups: groups.length, total }, + command: "library detail", + data: { mutation: false, transport: "public-json", detail }, + text: formatLibraryCatalogDetail(detail), + summary: { reference: detail.reference, transport: "public-json" }, }, output); return; } - if (command === "reservation-count" && positionals.length === 2) { - const session = await libraryBookingService(values); - const count = await getLibraryReservationCount(session); - writeSuccess({ command: "lib-booking reservation-count", data: { count }, text: `Library reservation count\n${count}` }, output); - return; - } - if (command === "reservations" && positionals.length === 2) { - const start = isoDate(values.start ?? todayInShenzhen(), "--start"); - const end = isoDate(values.end ?? addIsoDays(start, 30), "--end"); - if (end < start) throw usageError("--end must be on or after --start."); - const page = parsePositiveInteger(values.page, 1, "--page"); - const pageSize = parsePositiveInteger(values["page-size"], 20, "--page-size"); - if (pageSize > 100) throw usageError("--page-size cannot exceed 100 for library reservations."); - const needStatus = values["need-status"] === undefined - ? undefined - : parseNonNegativeInteger(values["need-status"], 0, "--need-status"); - const session = await libraryBookingService(values); - const result = await listLibraryReservationsPage(session, { start, end, page, pageSize, needStatus }); - const reservations = result.reservations; + if (command === "search-url") { + const query = positionals.slice(2).join(" ").trim(); + if (!query) throw usageError("A library search query is required."); + const limit = parsePositiveInteger(values.limit, 10, "--limit"); + const url = buildPrimoSearchUrl({ query, limit }); + const status = serviceStatus("library-catalog"); + const data = { + query, + url, + availability: "browser-required", + mutation: false, + ...(status ? { service: status } : {}), + }; writeSuccess({ - command: "lib-booking reservations", - data: { start, end, page, pageSize, needStatus, reservations, total: result.total, shown: reservations.length }, - text: formatLibraryReservations(reservations), - items: reservations, - summary: { start, end, page, pageSize, needStatus, total: result.total, shown: reservations.length }, + command: "library search-url", + data, + text: `Library search URL · browser required\n${url}\nNo catalog result was fabricated by the CLI.`, }, output); return; } throw usageError(`Unknown command: ${positionals.join(" ")}`); } -async function runPms( +async function runBooking( positionals: string[], values: Values, output: ReturnType, ): Promise { const command = positionals[1]; - if (command === "check" && positionals.length === 2) { - const session = await pmsService(values); - await session.login(); - const result = await session.check(); - if (!result.authenticated) throw new CliError("PMS session check failed.", "AUTHENTICATION_FAILED", 2, { service: "pms" }); - writeSuccess({ command: "pms check", data: result, text: `PMS authentication\n${result.message}` }, output); + const operation = positionals[2]; + if (command === "create" && operation === "preview" && positionals.length === 3) { + const target = bookingCreateTarget(values); + const session = await bookingService(values); + const preview = await buildBookingCreatePreview(session, target); + const confirmation = preview.applyAllowed + ? buildBookingCreateApplyConfirmation(target, { + credentialsFile: values["credentials-file"], + profile: values.profile, + }) + : undefined; + writeSuccess({ + command: "booking create preview", + data: { + mode: "preview", + mutation: false, + target, + preview, + confirmation: { + required: true, + available: Boolean(confirmation), + ...(confirmation ? { argv: confirmation.argv, command: confirmation.command } : {}), + }, + }, + text: formatBookingCreatePreview(preview, confirmation?.command), + }, output); return; } - if (command === "server-groups" && positionals.length === 2) { - const session = await pmsService(values); - const groups = await listPmsServerGroups(session); + if (command === "create" && operation === "apply" && positionals.length === 3) { + if (!values.confirm) throw new ConfirmationRequiredError("E-Hall booking create", "E-Hall booking create changes campus room state. Re-run the exact previewed command with --confirm."); + const target = bookingCreateTarget(values); + const session = await bookingService(values); + const result = await applyBookingCreate(session, target); writeSuccess({ - command: "pms server-groups", - data: { groups, total: groups.length }, - text: formatPmsServerGroups(groups), - items: groups, - summary: { total: groups.length }, + command: "booking create apply", + data: { + mode: "apply", + mutation: true, + ...result, + }, + text: formatBookingCreateSuccess(result), }, output); return; } - if (command === "stations" && positionals.length === 2) { - const serverGroup = values["server-group"] === undefined - ? undefined - : parsePositiveInteger(values["server-group"], 1, "--server-group"); - const session = await pmsService(values); - const stations = await listPmsStations(session, serverGroup); + if (command === "cancel" && operation === "preview" && positionals.length === 3) { + const target = bookingCancelTarget(values); + const session = await bookingService(values); + const preview = await buildBookingCancelPreview(session, target); + const confirmation = preview.applyAllowed + ? buildBookingCancelApplyConfirmation(target, { + credentialsFile: values["credentials-file"], + profile: values.profile, + }) + : undefined; writeSuccess({ - command: "pms stations", - data: { serverGroup, stations, total: stations.length }, - text: formatPmsStations(stations), - items: stations, - summary: { serverGroup, total: stations.length }, + command: "booking cancel preview", + data: { + mode: "preview", + mutation: false, + target, + preview, + confirmation: { + required: true, + available: Boolean(confirmation), + ...(confirmation ? { argv: confirmation.argv, command: confirmation.command } : {}), + }, + }, + text: formatBookingCancelPreview(preview, confirmation?.command), }, output); return; } - if (command === "jobs" && positionals.length === 2) { - const session = await pmsService(values); - const jobs = await listPmsPrintJobs(session); + if (command === "cancel" && operation === "apply" && positionals.length === 3) { + if (!values.confirm) throw new ConfirmationRequiredError("E-Hall booking cancel", "E-Hall booking cancel releases a live room slot. Re-run the exact previewed command with --confirm."); + const target = bookingCancelTarget(values); + const session = await bookingService(values); + const result = await applyBookingCancel(session, target); writeSuccess({ - command: "pms jobs", - data: { jobs, total: jobs.length }, - text: formatPmsPrintJobs(jobs), - items: jobs, - summary: { total: jobs.length }, + command: "booking cancel apply", + data: { + mode: "apply", + mutation: true, + ...result, + }, + text: formatBookingCancelSuccess(result), }, output); return; } - if (command === "scan-jobs" && positionals.length === 2) { - const session = await pmsService(values); - const jobs = await listPmsScanJobs(session); + if (command === "whoami" && positionals.length === 2) { + const session = await bookingService(values); + await session.login(); + const profile = session.userProfile; + if (!profile) throw new CliError("Booking login did not expose a user profile.", "SERVICE_PROTOCOL_ERROR", 1); + writeSuccess({ command: "booking whoami", data: profile, text: formatBookingProfile(profile) }, output); + return; + } + if (command === "rooms") { + const page = parsePositiveInteger(values.page, 1, "--page"); + const pageSize = parsePositiveInteger(values["page-size"], 100, "--page-size"); + if (pageSize > 500) throw usageError("--page-size cannot exceed 500 for booking rooms."); + const query = positionals.slice(2).join(" ").trim() || undefined; + const session = await bookingService(values); + let rooms = await listBookingRooms(session, { page, pageSize, keyword: query }); + if (values.available) rooms = rooms.filter((room) => room.available); writeSuccess({ - command: "pms scan-jobs", - data: { jobs, total: jobs.length }, - text: formatPmsScanJobs(jobs), - items: jobs, - summary: { total: jobs.length }, + command: "booking rooms", + data: { query, availableOnly: Boolean(values.available), page, pageSize, rooms, total: rooms.length }, + text: formatBookingRooms(rooms), + items: rooms, + summary: { query, availableOnly: Boolean(values.available), page, pageSize, total: rooms.length }, }, output); return; } - if (command === "usage" && positionals.length === 2) { - const begin = isoDate(required(values.begin, "--begin"), "--begin"); - const end = isoDate(required(values.end, "--end"), "--end"); - if (end < begin) throw usageError("--end must be on or after --begin."); - const type = parsePositiveInteger(values.type, 1, "--type"); + if (command === "my-meetings" && positionals.length === 2) { const page = parsePositiveInteger(values.page, 1, "--page"); - const pageSize = parsePositiveInteger(values["page-size"], 20, "--page-size"); - if (pageSize > 100) throw usageError("--page-size cannot exceed 100 for PMS usage."); - const session = await pmsService(values); - const result = await listPmsUsageHistory(session, { begin, end, type, page, pageSize }); + const pageSize = parsePositiveInteger(values["page-size"], 50, "--page-size"); + if (pageSize > 500) throw usageError("--page-size cannot exceed 500 for booking meetings."); + const session = await bookingService(values); + const meetings = await listMyBookingMeetings(session, { page, pageSize }); writeSuccess({ - command: "pms usage", - data: { begin, end, type, page, pageSize, ...result, total: result.records.length }, - text: formatPmsUsage(result.records), - items: result.records, - summary: { begin, end, type, page, pageSize, totalPages: result.totalPages, total: result.records.length }, + command: "booking my-meetings", + data: { page, pageSize, meetings, total: meetings.length }, + text: formatBookingMeetings(meetings), + items: meetings, + summary: { page, pageSize, total: meetings.length }, }, output); return; } - if (command === "upload" && positionals[2] === "preview" && positionals.length === 3) { - const file = await inspectPmsUploadFile(required(values.file, "--file")); - const options = pmsUploadOptions(values); - const session = await pmsService(values); - const jobs = await listPmsPrintJobs(session); - const preview = buildPmsPrintUploadPreview( - jobs, - file, - options, - buildPmsUploadApplyConfirmation(file.absolutePath, file.sha256, options, { + throw usageError(`Unknown command: ${positionals.join(" ")}`); +} + +async function runLibraryBooking( + positionals: string[], + values: Values, + output: ReturnType, +): Promise { + const command = positionals[1]; + const operation = positionals[2]; + if (command === "create" && operation === "preview" && positionals.length === 3) { + const target = libraryBookingCreateTarget(values); + const session = await libraryBookingService(values); + const preview = await buildLibraryBookingCreatePreview(session, target); + const confirmation = preview.applyAllowed + ? buildLibraryBookingCreateApplyConfirmation(target, { credentialsFile: values["credentials-file"], profile: values.profile, - }), - ); + }) + : undefined; writeSuccess({ - command: "pms upload preview", - data: { mode: "preview", mutation: false, ...preview }, - text: formatPmsUploadPreview(preview), + command: "lib-booking create preview", + data: { + mode: "preview", + mutation: false, + target, + preview, + confirmation: { + required: true, + available: Boolean(confirmation), + ...(confirmation ? { argv: confirmation.argv, command: confirmation.command } : {}), + }, + }, + text: formatLibraryBookingCreatePreview(preview, confirmation?.command), }, output); return; } - if (command === "upload" && positionals[2] === "apply" && positionals.length === 3) { - const filePath = required(values.file, "--file"); - if (!values.confirm) { - throw new ConfirmationRequiredError( - "PMS print upload", - "PMS print upload adds a remote queue entry. Re-run the exact previewed command with --confirm.", - ); - } - const expectedSha256 = blackboardExpectedSha256(required(values["expected-sha256"], "--expected-sha256")); - const payload = await readPmsUploadPayload(filePath); - const file = payload.file; - if (file.sha256 !== expectedSha256) { - throw new CliError( - "The selected file no longer matches the reviewed preview hash. Re-run preview before uploading.", - "PMS_UPLOAD_FILE_HASH_MISMATCH", - 4, - { - file: file.absolutePath, - expectedSha256, - actualSha256: file.sha256, - }, - ); - } - const options = pmsUploadOptions(values); - const session = await pmsService(values); - const previousJobs = await listPmsPrintJobs(session); - const preflight = buildPmsPrintUploadPreview( - previousJobs, - file, - options, - buildPmsUploadApplyConfirmation(file.absolutePath, file.sha256, options, { + if (command === "create" && operation === "apply" && positionals.length === 3) { + if (!values.confirm) throw new ConfirmationRequiredError("Library booking create", "Library booking create changes a live reservation slot. Re-run the exact previewed command with --confirm."); + const target = libraryBookingCreateTarget(values); + const session = await libraryBookingService(values); + const result = await applyLibraryBookingCreate(session, target); + writeSuccess({ + command: "lib-booking create apply", + data: { + mode: "apply", + mutation: true, + ...result, + }, + text: formatLibraryBookingCreateSuccess(result), + }, output); + return; + } + if (command === "cancel" && operation === "preview" && positionals.length === 3) { + const target = libraryBookingCancelTarget(values); + const session = await libraryBookingService(values); + const preview = await buildLibraryBookingCancelPreview(session, target); + const confirmation = preview.applyAllowed + ? buildLibraryBookingCancelApplyConfirmation(target, { credentialsFile: values["credentials-file"], profile: values.profile, - }), - ); - try { - const mutation = await session.uploadPrintJob({ name: file.name, bytes: payload.bytes }, options); - const readBackJobs = await bestEffortPmsPrintJobs(session); - const verification = readBackJobs - ? verifyPmsPrintUpload(previousJobs, readBackJobs, file, options) - : { status: "unavailable" as const, message: "The print queue could not be read back after the upload request.", observedJobIds: [] }; - const observedJob = readBackJobs?.find((job) => verification.observedJobIds[0] === job.jobId); - if (verification.status !== "confirmed") { - throw new CliError( - "PMS accepted the upload request, but the read-back verification was inconclusive.", - "PMS_UPLOAD_NOT_CONFIRMED", - 5, - { - file: file.absolutePath, - options, - uploadMessage: mutation.message, - verification, - warning: "DO_NOT_RETRY_AUTOMATICALLY", - }, - ); - } - writeSuccess({ - command: "pms upload apply", - data: { - mode: "apply", - mutation: true, - file, - options, - preflight: { - checkedAt: preflight.checkedAt, - queueSize: previousJobs.length, - }, - ...(observedJob ? { job: observedJob } : {}), - verification, - uploadMessage: mutation.message, + }) + : undefined; + writeSuccess({ + command: "lib-booking cancel preview", + data: { + mode: "preview", + mutation: false, + target, + preview, + confirmation: { + required: true, + available: Boolean(confirmation), + ...(confirmation ? { argv: confirmation.argv, command: confirmation.command } : {}), }, - text: formatPmsUploadSuccess({ job: observedJob, verification }), - }, output); - return; - } catch (error) { - const readBackJobs = await bestEffortPmsPrintJobs(session); - const verification = readBackJobs - ? verifyPmsPrintUpload(previousJobs, readBackJobs, file, options) - : { status: "unavailable" as const, message: "The print queue could not be read back after the upload request failed.", observedJobIds: [] }; - const observedJob = readBackJobs?.find((job) => verification.observedJobIds[0] === job.jobId); - if (verification.status === "confirmed") { - writeSuccess({ - command: "pms upload apply", - data: { - mode: "apply", - mutation: true, - file, - options, - ...(observedJob ? { job: observedJob } : {}), - verification, - recoveredAfterError: true, - }, - text: formatPmsUploadSuccess({ job: observedJob, verification }), - meta: { recoveredAfterError: true }, - }, output); - return; - } - if (isPmsMutationOutcomeUncertain(error)) { - throw new CliError( - "PMS print upload outcome is uncertain. Do not retry automatically.", - "PMS_UPLOAD_OUTCOME_UNKNOWN", - 5, - { - file: file.absolutePath, - options, - verification, - cause: error instanceof Error ? error.message : String(error), - warning: "DO_NOT_RETRY_AUTOMATICALLY", - }, - ); - } - throw error; - } + }, + text: formatLibraryBookingCancelPreview(preview, confirmation?.command), + }, output); + return; } - if (command === "delete" && positionals[2] === "preview" && positionals.length === 4) { - const jobId = pmsJobId(positionals[3]); - const session = await pmsService(values); - const jobs = await listPmsPrintJobs(session); - const job = requirePmsPrintJob(jobs, jobId); - const preview = buildPmsPrintDeletePreview( - jobs, - job, - buildPmsDeleteApplyConfirmation(jobId, { - credentialsFile: values["credentials-file"], - profile: values.profile, - }), - ); + if (command === "cancel" && operation === "apply" && positionals.length === 3) { + if (!values.confirm) throw new ConfirmationRequiredError("Library booking cancel", "Library booking cancel releases a live reservation slot. Re-run the exact previewed command with --confirm."); + const target = libraryBookingCancelTarget(values); + const session = await libraryBookingService(values); + const result = await applyLibraryBookingCancel(session, target); writeSuccess({ - command: "pms delete preview", - data: { mode: "preview", mutation: false, ...preview }, - text: formatPmsDeletePreview(preview), + command: "lib-booking cancel apply", + data: { + mode: "apply", + mutation: true, + ...result, + }, + text: formatLibraryBookingCancelSuccess(result), }, output); return; } - if (command === "delete" && positionals[2] === "apply" && positionals.length === 4) { - const jobId = pmsJobId(positionals[3]); - if (!values.confirm) { - throw new ConfirmationRequiredError( - "PMS print-job deletion", - "PMS print-job deletion removes a queued remote document. Re-run the exact previewed command with --confirm.", - ); + if (command === "whoami" && positionals.length === 2) { + const session = await libraryBookingService(values); + const user = await getLibraryBookingUser(session); + writeSuccess({ command: "lib-booking whoami", data: user, text: formatLibraryBookingUser(user) }, output); + return; + } + if (command === "home-summary" && positionals.length === 2) { + const session = await libraryBookingService(values); + const categories = await getLibraryIdleSummary(session); + writeSuccess({ + command: "lib-booking home-summary", + data: { categories, total: categories.length }, + text: formatLibraryIdleSummary(categories), + items: categories, + summary: { total: categories.length }, + }, output); + return; + } + if (command === "labs" && positionals.length === 2) { + const classKind = parsePositiveInteger(values["class-kind"], 1, "--class-kind"); + const session = await libraryBookingService(values); + const labs = await listLibraryLabs(session, classKind); + writeSuccess({ + command: "lib-booking labs", + data: { classKind, labs, total: labs.length }, + text: formatLibraryLabs(labs), + items: labs, + summary: { classKind, total: labs.length }, + }, output); + return; + } + if (command === "rooms" && positionals.length === 2) { + const kindId = parsePositiveInteger(required(values["kind-id"], "--kind-id"), 1, "--kind-id"); + const labId = parsePositiveInteger(required(values["lab-id"], "--lab-id"), 1, "--lab-id"); + const classKind = parsePositiveInteger(values["class-kind"], 1, "--class-kind"); + const session = await libraryBookingService(values); + const groups = await listLibraryRooms(session, { kindId, labId, classKind }); + const total = groups.reduce((sum, group) => sum + group.labs.reduce((labSum, lab) => labSum + lab.rooms.length, 0), 0); + writeSuccess({ + command: "lib-booking rooms", + data: { kindId, labId, classKind, groups, total }, + text: formatLibraryRooms(groups), + items: groups, + summary: { kindId, labId, classKind, groups: groups.length, total }, + }, output); + return; + } + if (command === "reservation-count" && positionals.length === 2) { + const session = await libraryBookingService(values); + const count = await getLibraryReservationCount(session); + writeSuccess({ command: "lib-booking reservation-count", data: { count }, text: `Library reservation count\n${count}` }, output); + return; + } + if (command === "reservations" && positionals.length === 2) { + const start = isoDate(values.start ?? todayInShenzhen(), "--start"); + const end = isoDate(values.end ?? addIsoDays(start, 30), "--end"); + if (end < start) throw usageError("--end must be on or after --start."); + const page = parsePositiveInteger(values.page, 1, "--page"); + const pageSize = parsePositiveInteger(values["page-size"], 20, "--page-size"); + if (pageSize > 100) throw usageError("--page-size cannot exceed 100 for library reservations."); + const needStatus = values["need-status"] === undefined + ? undefined + : parseNonNegativeInteger(values["need-status"], 0, "--need-status"); + const session = await libraryBookingService(values); + const result = await listLibraryReservationsPage(session, { start, end, page, pageSize, needStatus }); + const reservations = result.reservations; + writeSuccess({ + command: "lib-booking reservations", + data: { start, end, page, pageSize, needStatus, reservations, total: result.total, shown: reservations.length }, + text: formatLibraryReservations(reservations), + items: reservations, + summary: { start, end, page, pageSize, needStatus, total: result.total, shown: reservations.length }, + }, output); + return; + } + throw usageError(`Unknown command: ${positionals.join(" ")}`); +} + +async function runPms( + positionals: string[], + values: Values, + output: ReturnType, +): Promise { + const command = positionals[1]; + if (command === "check" && positionals.length === 2) { + const session = await pmsService(values); + await session.login(); + const result = await session.check(); + if (!result.authenticated) throw new CliError("PMS session check failed.", "AUTHENTICATION_FAILED", 2, { service: "pms" }); + writeSuccess({ command: "pms check", data: result, text: `PMS authentication\n${result.message}` }, output); + return; + } + if (command === "server-groups" && positionals.length === 2) { + const session = await pmsService(values); + const groups = await listPmsServerGroups(session); + writeSuccess({ + command: "pms server-groups", + data: { groups, total: groups.length }, + text: formatPmsServerGroups(groups), + items: groups, + summary: { total: groups.length }, + }, output); + return; + } + if (command === "stations" && positionals.length === 2) { + const serverGroup = values["server-group"] === undefined + ? undefined + : parsePositiveInteger(values["server-group"], 1, "--server-group"); + const session = await pmsService(values); + const stations = await listPmsStations(session, serverGroup); + writeSuccess({ + command: "pms stations", + data: { serverGroup, stations, total: stations.length }, + text: formatPmsStations(stations), + items: stations, + summary: { serverGroup, total: stations.length }, + }, output); + return; + } + if (command === "jobs" && positionals.length === 2) { + const session = await pmsService(values); + const jobs = await listPmsPrintJobs(session); + writeSuccess({ + command: "pms jobs", + data: { jobs, total: jobs.length }, + text: formatPmsPrintJobs(jobs), + items: jobs, + summary: { total: jobs.length }, + }, output); + return; + } + if (command === "scan-jobs" && positionals.length === 2) { + const session = await pmsService(values); + const jobs = await listPmsScanJobs(session); + writeSuccess({ + command: "pms scan-jobs", + data: { jobs, total: jobs.length }, + text: formatPmsScanJobs(jobs), + items: jobs, + summary: { total: jobs.length }, + }, output); + return; + } + if (command === "usage" && positionals.length === 2) { + const begin = isoDate(required(values.begin, "--begin"), "--begin"); + const end = isoDate(required(values.end, "--end"), "--end"); + if (end < begin) throw usageError("--end must be on or after --begin."); + const type = parsePositiveInteger(values.type, 1, "--type"); + const page = parsePositiveInteger(values.page, 1, "--page"); + const pageSize = parsePositiveInteger(values["page-size"], 20, "--page-size"); + if (pageSize > 100) throw usageError("--page-size cannot exceed 100 for PMS usage."); + const session = await pmsService(values); + const result = await listPmsUsageHistory(session, { begin, end, type, page, pageSize }); + writeSuccess({ + command: "pms usage", + data: { begin, end, type, page, pageSize, ...result, total: result.records.length }, + text: formatPmsUsage(result.records), + items: result.records, + summary: { begin, end, type, page, pageSize, totalPages: result.totalPages, total: result.records.length }, + }, output); + return; + } + if (command === "upload" && positionals[2] === "preview" && positionals.length === 3) { + const file = await inspectPmsUploadFile(required(values.file, "--file")); + const options = pmsUploadOptions(values); + const session = await pmsService(values); + const jobs = await listPmsPrintJobs(session); + const preview = buildPmsPrintUploadPreview( + jobs, + file, + options, + buildPmsUploadApplyConfirmation(file.absolutePath, file.sha256, options, { + credentialsFile: values["credentials-file"], + profile: values.profile, + }), + ); + writeSuccess({ + command: "pms upload preview", + data: { mode: "preview", mutation: false, ...preview }, + text: formatPmsUploadPreview(preview), + }, output); + return; + } + if (command === "upload" && positionals[2] === "apply" && positionals.length === 3) { + const filePath = required(values.file, "--file"); + if (!values.confirm) { + throw new ConfirmationRequiredError( + "PMS print upload", + "PMS print upload adds a remote queue entry. Re-run the exact previewed command with --confirm.", + ); + } + const expectedSha256 = blackboardExpectedSha256(required(values["expected-sha256"], "--expected-sha256")); + const payload = await readPmsUploadPayload(filePath); + const file = payload.file; + if (file.sha256 !== expectedSha256) { + throw new CliError( + "The selected file no longer matches the reviewed preview hash. Re-run preview before uploading.", + "PMS_UPLOAD_FILE_HASH_MISMATCH", + 4, + { + file: file.absolutePath, + expectedSha256, + actualSha256: file.sha256, + }, + ); + } + const options = pmsUploadOptions(values); + const session = await pmsService(values); + const previousJobs = await listPmsPrintJobs(session); + const preflight = buildPmsPrintUploadPreview( + previousJobs, + file, + options, + buildPmsUploadApplyConfirmation(file.absolutePath, file.sha256, options, { + credentialsFile: values["credentials-file"], + profile: values.profile, + }), + ); + try { + const mutation = await session.uploadPrintJob({ name: file.name, bytes: payload.bytes }, options); + const readBackJobs = await bestEffortPmsPrintJobs(session); + const verification = readBackJobs + ? verifyPmsPrintUpload(previousJobs, readBackJobs, file, options) + : { status: "unavailable" as const, message: "The print queue could not be read back after the upload request.", observedJobIds: [] }; + const observedJob = readBackJobs?.find((job) => verification.observedJobIds[0] === job.jobId); + if (verification.status !== "confirmed") { + throw new CliError( + "PMS accepted the upload request, but the read-back verification was inconclusive.", + "PMS_UPLOAD_NOT_CONFIRMED", + 5, + { + file: file.absolutePath, + options, + uploadMessage: mutation.message, + verification, + warning: "DO_NOT_RETRY_AUTOMATICALLY", + }, + ); + } + writeSuccess({ + command: "pms upload apply", + data: { + mode: "apply", + mutation: true, + file, + options, + preflight: { + checkedAt: preflight.checkedAt, + queueSize: previousJobs.length, + }, + ...(observedJob ? { job: observedJob } : {}), + verification, + uploadMessage: mutation.message, + }, + text: formatPmsUploadSuccess({ job: observedJob, verification }), + }, output); + return; + } catch (error) { + const readBackJobs = await bestEffortPmsPrintJobs(session); + const verification = readBackJobs + ? verifyPmsPrintUpload(previousJobs, readBackJobs, file, options) + : { status: "unavailable" as const, message: "The print queue could not be read back after the upload request failed.", observedJobIds: [] }; + const observedJob = readBackJobs?.find((job) => verification.observedJobIds[0] === job.jobId); + if (verification.status === "confirmed") { + writeSuccess({ + command: "pms upload apply", + data: { + mode: "apply", + mutation: true, + file, + options, + ...(observedJob ? { job: observedJob } : {}), + verification, + recoveredAfterError: true, + }, + text: formatPmsUploadSuccess({ job: observedJob, verification }), + meta: { recoveredAfterError: true }, + }, output); + return; + } + if (isPmsMutationOutcomeUncertain(error)) { + throw new CliError( + "PMS print upload outcome is uncertain. Do not retry automatically.", + "PMS_UPLOAD_OUTCOME_UNKNOWN", + 5, + { + file: file.absolutePath, + options, + verification, + cause: error instanceof Error ? error.message : String(error), + warning: "DO_NOT_RETRY_AUTOMATICALLY", + }, + ); + } + throw error; + } + } + if (command === "delete" && positionals[2] === "preview" && positionals.length === 4) { + const jobId = pmsJobId(positionals[3]); + const session = await pmsService(values); + const jobs = await listPmsPrintJobs(session); + const job = requirePmsPrintJob(jobs, jobId); + const preview = buildPmsPrintDeletePreview( + jobs, + job, + buildPmsDeleteApplyConfirmation(jobId, { + credentialsFile: values["credentials-file"], + profile: values.profile, + }), + ); + writeSuccess({ + command: "pms delete preview", + data: { mode: "preview", mutation: false, ...preview }, + text: formatPmsDeletePreview(preview), + }, output); + return; + } + if (command === "delete" && positionals[2] === "apply" && positionals.length === 4) { + const jobId = pmsJobId(positionals[3]); + if (!values.confirm) { + throw new ConfirmationRequiredError( + "PMS print-job deletion", + "PMS print-job deletion removes a queued remote document. Re-run the exact previewed command with --confirm.", + ); + } + const session = await pmsService(values); + const previousJobs = await listPmsPrintJobs(session); + const job = requirePmsPrintJob(previousJobs, jobId); + try { + const mutation = await session.deletePrintJob(jobId); + const readBackJobs = await bestEffortPmsPrintJobs(session); + const verification = readBackJobs + ? verifyPmsPrintDeletion(readBackJobs, jobId) + : { status: "unavailable" as const, message: "The print queue could not be read back after the delete request.", observedJobIds: [] }; + if (verification.status !== "confirmed") { + throw new CliError( + "PMS accepted the delete request, but the read-back verification was inconclusive.", + "PMS_DELETE_NOT_CONFIRMED", + 5, + { + jobId, + deleteMessage: mutation.message, + verification, + warning: "DO_NOT_RETRY_AUTOMATICALLY", + }, + ); + } + writeSuccess({ + command: "pms delete apply", + data: { + mode: "apply", + mutation: true, + job, + verification, + deleteMessage: mutation.message, + }, + text: formatPmsDeleteSuccess({ job, verification }), + }, output); + return; + } catch (error) { + const readBackJobs = await bestEffortPmsPrintJobs(session); + const verification = readBackJobs + ? verifyPmsPrintDeletion(readBackJobs, jobId) + : { status: "unavailable" as const, message: "The print queue could not be read back after the delete request failed.", observedJobIds: [] }; + if (verification.status === "confirmed") { + writeSuccess({ + command: "pms delete apply", + data: { + mode: "apply", + mutation: true, + job, + verification, + recoveredAfterError: true, + }, + text: formatPmsDeleteSuccess({ job, verification }), + meta: { recoveredAfterError: true }, + }, output); + return; + } + if (isPmsMutationOutcomeUncertain(error)) { + throw new CliError( + "PMS print-job deletion outcome is uncertain. Do not retry automatically.", + "PMS_DELETE_OUTCOME_UNKNOWN", + 5, + { + jobId, + verification, + cause: error instanceof Error ? error.message : String(error), + warning: "DO_NOT_RETRY_AUTOMATICALLY", + }, + ); + } + throw error; + } + } + throw usageError(`Unknown command: ${positionals.join(" ")}`); +} + +function pmsUploadOptions(values: Values): PmsPrintUploadOptions { + const color = pmsColorValue(values.color); + const paper = pmsPaperValue(values.paper); + const duplex = pmsDuplexValue(values.duplex); + const copies = parsePositiveInteger(values.copies, 1, "--copies"); + const pageFrom = parseNonNegativeInteger(values["page-from"], 0, "--page-from"); + if (pageFrom === 0 && values["page-to"] !== undefined) { + throw usageError("--page-to requires --page-from."); + } + const pageTo = pageFrom === 0 ? 0 : parsePositiveInteger(values["page-to"], pageFrom, "--page-to"); + if (pageFrom > 0 && pageTo < pageFrom) throw usageError("--page-to must be greater than or equal to --page-from."); + return { + ...color, + ...paper, + ...duplex, + copies, + pageFrom, + pageTo, + }; +} + +function pmsColorValue(value: string | undefined): Pick { + const normalized = (value ?? "bw").trim().toLowerCase(); + if (normalized === "bw" || normalized === "blackwhite" || normalized === "black-white" || normalized === "1" || normalized === "黑白") { + return { color: "bw", colorCode: 1 }; + } + if (normalized === "color" || normalized === "2" || normalized === "彩色") { + return { color: "color", colorCode: 2 }; + } + throw usageError("--color must be bw or color."); +} + +function pmsPaperValue(value: string | undefined): Pick { + const normalized = (value ?? "unspecified").trim().toLowerCase(); + if (normalized === "unspecified" || normalized === "" || normalized === "-1" || normalized === "不指定") { + return { paper: "unspecified", paperCode: -1 }; + } + if (normalized === "a4" || normalized === "9") return { paper: "A4", paperCode: 9 }; + if (normalized === "a3" || normalized === "8") return { paper: "A3", paperCode: 8 }; + throw usageError("--paper must be unspecified, A4, or A3."); +} + +function pmsDuplexValue(value: string | undefined): Pick { + const normalized = (value ?? "single").trim().toLowerCase(); + if (normalized === "single" || normalized === "1" || normalized === "单面") return { duplex: "single", duplexCode: 1 }; + if (normalized === "short" || normalized === "short-edge" || normalized === "2" || normalized === "双面短边") { + return { duplex: "short", duplexCode: 2 }; + } + if (normalized === "long" || normalized === "long-edge" || normalized === "3" || normalized === "双面长边") { + return { duplex: "long", duplexCode: 3 }; + } + throw usageError("--duplex must be single, short, or long."); +} + +function buildPmsUploadApplyConfirmation( + absolutePath: string, + expectedSha256: string, + options: PmsPrintUploadOptions, + metadata: { credentialsFile?: string; profile?: string } = {}, +): { required: true; available: true; expectedSha256: string; argv: string[]; command: string } { + const argv = [ + "sustech", + "pms", + "upload", + "apply", + ...(metadata.credentialsFile ? ["--credentials-file", metadata.credentialsFile] : []), + ...(metadata.profile ? ["--profile", metadata.profile] : []), + "--file", + absolutePath, + "--expected-sha256", + expectedSha256, + "--color", + options.color, + "--paper", + options.paper, + "--duplex", + options.duplex, + ...(options.pageFrom > 0 ? ["--page-from", String(options.pageFrom), "--page-to", String(options.pageTo)] : []), + "--copies", + String(options.copies), + "--confirm", + ]; + return { + required: true, + available: true, + expectedSha256, + argv, + command: argv.map(shellQuote).join(" "), + }; +} + +function buildPmsDeleteApplyConfirmation( + jobId: number, + metadata: { credentialsFile?: string; profile?: string } = {}, +): { required: true; available: true; argv: string[]; command: string } { + const argv = [ + "sustech", + "pms", + "delete", + "apply", + ...(metadata.credentialsFile ? ["--credentials-file", metadata.credentialsFile] : []), + ...(metadata.profile ? ["--profile", metadata.profile] : []), + String(jobId), + "--confirm", + ]; + return { + required: true, + available: true, + argv, + command: argv.map(shellQuote).join(" "), + }; +} + +async function bestEffortPmsPrintJobs(session: PmsSession): Promise> | undefined> { + try { + return await listPmsPrintJobs(session); + } catch { + return undefined; + } +} + +function pmsJobId(value: string | undefined): number { + return parsePositiveInteger(value, 1, "PMS job ID"); +} + +function requirePmsPrintJob(jobs: readonly Awaited>[number][], jobId: number) { + const job = findPmsPrintJob(jobs, jobId); + if (job) return job; + throw new CliError( + "The requested PMS print job was not found in the current queue.", + "PMS_PRINT_JOB_NOT_FOUND", + 4, + { + jobId, + warning: "NO_MUTATION_PERFORMED", + availableJobIds: jobs.map((entry) => entry.jobId), + }, + ); +} + +function isPmsMutationOutcomeUncertain(error: unknown): boolean { + if (!(error instanceof CliError)) return false; + return error.code === "NETWORK_ERROR" + || error.code === "NETWORK_TIMEOUT" + || error.code === "TOO_MANY_REDIRECTS" + || (error.code === "SERVICE_HTTP_ERROR" && Number(error.details?.status) >= 500); +} + +function enrollTarget(values: Values, semester: ReturnType) { + const courseId = opaqueToken(required(values["course-id"], "--course-id"), "--course-id"); + const rwh = opaqueToken(required(values.rwh, "--rwh"), "--rwh"); + const bid = parsePositiveInteger(values.bid, 1, "--bid"); + const round = opaqueToken(values.round ?? "yixuan", "--round"); + return { semester, courseId, rwh, bid, round, cultivation: "1" as const }; +} + +function required(value: string | undefined, option: string): string { + if (!value?.trim()) throw usageError(`${option} is required.`); + return value.trim(); +} + +type BlackboardSubmissionTarget = { + courseId: string; + contentId?: string; + columnId?: string; +}; + +type BlackboardSubmissionVerification = { + status: "confirmed" | "not_observed" | "unavailable"; + message: string; +}; + +type BlackboardCliSubmissionInput = + | { kind: "file"; file: BlackboardSubmissionFile; bytes: Uint8Array } + | { kind: "text"; textFile: BlackboardSubmissionText; text: string }; + +type BlackboardCliSubmissionSummary = + | { kind: "file"; file: BlackboardSubmissionFile } + | { kind: "text"; textFile: BlackboardSubmissionText }; + +type BlackboardSubmissionPreviewData = { + checkedAt: string; + target: BlackboardSubmissionTarget; + assignment: Awaited>[number]; + content: Awaited>; + attempts: Array<{ + id: string; + status: BlackboardAttempt["status"]; + created: string; + attemptDate: string; + submissionDate?: string; + }>; + attemptsUsed: number; + remainingAttempts?: number; + inProgressAttempts: number; + submission: BlackboardCliSubmissionSummary; + commentSummary: { present: boolean; length: number }; + uploadSettings?: Awaited>; + blockers: BlackboardSubmissionAssessment["blockers"]; + warnings: BlackboardSubmissionAssessment["warnings"]; + late: boolean; + applyAllowed: boolean; + confirmation: { + required: true; + available: boolean; + expectedSha256: string; + argv?: string[]; + command?: string; + }; +}; + +function blackboardAssignmentSelector(values: Values): { contentId?: string; columnId?: string } { + const contentId = values["content-id"] ? opaqueToken(values["content-id"], "--content-id") : undefined; + const columnId = values["column-id"] ? opaqueToken(values["column-id"], "--column-id") : undefined; + if (!contentId && !columnId) { + throw usageError("One of --content-id or --column-id is required."); + } + return { ...(contentId ? { contentId } : {}), ...(columnId ? { columnId } : {}) }; +} + +function blackboardSubmissionTarget(values: Values): BlackboardSubmissionTarget { + return { + courseId: opaqueToken(required(values["course-id"], "--course-id"), "--course-id"), + ...blackboardAssignmentSelector(values), + }; +} + +function resolveBlackboardAssignmentSelector( + assignments: Awaited>, + selector: { contentId?: string; columnId?: string }, + courseId: string, +) { + const assignment = selectBlackboardAssignment(assignments, selector); + if (assignment) return assignment; + + const contentMatch = selector.contentId + ? selectBlackboardAssignment(assignments, { contentId: selector.contentId }) + : undefined; + const columnMatch = selector.columnId + ? selectBlackboardAssignment(assignments, { columnId: selector.columnId }) + : undefined; + if (contentMatch && columnMatch && contentMatch.id !== columnMatch.id) { + throw new CliError( + "The provided --content-id and --column-id do not refer to the same Blackboard assignment.", + "BLACKBOARD_ASSIGNMENT_MISMATCH", + 1, + { courseId, contentId: selector.contentId, columnId: selector.columnId }, + ); + } + throw new CliError( + "The provided Blackboard assignment selector did not match any assignment in this course.", + "BLACKBOARD_ASSIGNMENT_NOT_FOUND", + 1, + { courseId, contentId: selector.contentId, columnId: selector.columnId }, + ); +} + +function blackboardAttemptStatus(value: string | undefined): + | "InProgress" + | "NeedsGrading" + | "Completed" + | undefined { + if (value === undefined) return undefined; + if (value === "InProgress" || value === "NeedsGrading" || value === "Completed") return value; + throw usageError("--status must be InProgress, NeedsGrading, or Completed for Blackboard attempts."); +} + +function blackboardDiscussionGradableValue(value: string | undefined): boolean | undefined { + return blackboardBooleanFilterValue(value, "--gradable"); +} + +function blackboardDiscussionReadValue(value: string | undefined): boolean | undefined { + return blackboardBooleanFilterValue(value, "--is-read"); +} + +function blackboardMembershipAvailabilityValue(value: string | undefined): + "Yes" | "No" | "Disabled" | undefined { + if (value === undefined) return undefined; + if (value === "Yes" || value === "No" || value === "Disabled") return value; + throw usageError("--availability must be Yes, No, or Disabled for Blackboard roster."); +} + +function blackboardMessageFolderTypeValue(value: string | undefined): + Exclude + | undefined { + if (value === undefined) return undefined; + if (value === "Inbox" || value === "Sent" || value === "Delete" || value === "Custom") return value; + throw usageError("--folder-type must be Inbox, Sent, Delete, or Custom for Blackboard course messages."); +} + +function blackboardMessageParticipationTypeValue(value: string | undefined): + Exclude + | undefined { + if (value === undefined) return undefined; + if (value === "From" || value === "To" || value === "Cc" || value === "Bcc") return value; + throw usageError("--participation-type must be From, To, Cc, or Bcc for Blackboard course-message participants."); +} + +function blackboardBooleanFilterValue(value: string | undefined, option: string): boolean | undefined { + if (value === undefined) return undefined; + if (value === "true" || value === "1") return true; + if (value === "false" || value === "0") return false; + throw usageError(`${option} must be true or false.`); +} + +function blackboardDiscussionMessageStatusValue(value: string | undefined): + Exclude + | undefined { + if (value === undefined) return undefined; + if (value === "Published" || value === "Deleted" || value === "Draft") return value; + throw usageError("--status must be Published, Deleted, or Draft for Blackboard discussions."); +} + +function blackboardAssignmentSubmissionStateValue(value: string | undefined): + | "not_attempted" + | "in_progress" + | "submitted" + | "completed" + | "mixed" + | "other" + | undefined { + if (value === undefined) return undefined; + if ( + value === "not_attempted" + || value === "in_progress" + || value === "submitted" + || value === "completed" + || value === "mixed" + || value === "other" + ) return value; + throw usageError("--submission-state must be not_attempted, in_progress, submitted, completed, mixed, or other."); +} + +function blackboardGradesSubmissionStateValue(value: string | undefined): + | "in_progress" + | "submitted" + | "completed" + | "mixed" + | "other" + | undefined { + if (value === undefined) return undefined; + if ( + value === "in_progress" + || value === "submitted" + || value === "completed" + || value === "mixed" + || value === "other" + ) return value; + throw usageError("--submission-state must be in_progress, submitted, completed, mixed, or other for Blackboard grades."); +} + +function submissionComment(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +async function readBlackboardSubmissionInput(values: Values): Promise { + const filePath = values.file?.trim(); + const textFilePath = values["text-file"]?.trim(); + if (filePath && textFilePath) { + throw usageError("Choose exactly one of --file or --text-file for Blackboard submission."); + } + if (filePath) { + const payload = await readBlackboardSubmissionPayload(filePath); + return { kind: "file", file: payload.file, bytes: payload.bytes }; + } + if (textFilePath) { + const payload = await readBlackboardSubmissionTextPayload(textFilePath); + return { kind: "text", textFile: payload.textFile, text: payload.text }; + } + throw usageError("One of --file or --text-file is required."); +} + +function blackboardSubmissionSummary(input: BlackboardCliSubmissionInput): BlackboardCliSubmissionSummary { + return input.kind === "file" + ? { kind: "file", file: input.file } + : { kind: "text", textFile: input.textFile }; +} + +function summariseSubmissionComment(comment: string | undefined): { present: boolean; length: number } { + return { present: Boolean(comment), length: comment?.length ?? 0 }; +} + +function blackboardExpectedSha256(value: string): string { + const normalised = value.trim().toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(normalised)) { + throw usageError("--expected-sha256 must be a 64-character lowercase or uppercase hexadecimal digest."); + } + return normalised; +} + +interface BlackboardCourseMessageWriteTarget { + courseId: string; + subject?: string; + toUsers: string[]; + ccUsers: string[]; + bccUsers: string[]; +} + +interface BlackboardCourseMessageWriteInput { + textFile: BlackboardSubmissionText; + body: string; +} + +interface BlackboardCourseMessageWriteResolvedRecipient { + userId: string; + displayName: string; + courseRoleId: string; +} + +interface BlackboardCourseMessageWritePreflight { + checkedAt: string; + target: BlackboardCourseMessageWriteTarget; + courseId: string; + courseCode: string; + courseName: string; + recipients: { + toUsers: BlackboardCourseMessageWriteResolvedRecipient[]; + ccUsers: BlackboardCourseMessageWriteResolvedRecipient[]; + bccUsers: BlackboardCourseMessageWriteResolvedRecipient[]; + }; + body: { + textFile: BlackboardSubmissionText; + preview: string; + }; + blockers: Array<{ code: string; message: string }>; + warnings: Array<{ code: string; message: string }>; + applyAllowed: boolean; + confirmation: { + required: true; + available: boolean; + expectedSha256: string; + argv?: string[]; + command?: string; + }; +} + +interface BlackboardCourseMessageWriteVerification { + status: "confirmed" | "not_observed" | "unavailable"; + message: string; +} + +export type BlackboardDiscussionWriteMode = "post" | "reply"; + +export interface BlackboardDiscussionWriteTarget { + mode: BlackboardDiscussionWriteMode; + courseId: string; + discussionId: string; + messageId?: string; + groupId?: string; + status: Exclude; +} + +export interface BlackboardDiscussionWriteInput { + textFile: BlackboardSubmissionText; + body: string; +} + +export interface BlackboardDiscussionWritePreflight { + checkedAt: string; + target: BlackboardDiscussionWriteTarget; + courseId: string; + courseCode: string; + courseName: string; + discussion: BlackboardDiscussion; + group?: BlackboardDiscussionGroup; + parentMessage?: BlackboardDiscussionMessage; + body: { + textFile: BlackboardSubmissionText; + preview: string; + }; + blockers: Array<{ code: string; message: string }>; + warnings: Array<{ code: string; message: string }>; + applyAllowed: boolean; + confirmation: { + required: true; + available: boolean; + expectedSha256: string; + argv?: string[]; + command?: string; + }; +} + +export interface BlackboardDiscussionWriteConfirmationOptions { + credentialsFile?: string; + profile?: string; +} + +interface BlackboardDiscussionWriteVerification { + status: "confirmed" | "not_observed" | "unavailable"; + message: string; +} + +function blackboardCourseMessageWriteTarget( + positionals: string[], + values: Values, +): BlackboardCourseMessageWriteTarget { + const subject = optionalInlineText(values.subject, "--subject", 200); + const toUsers = blackboardMessageRecipientOption(values["to-user"], "--to-user"); + const ccUsers = blackboardMessageRecipientOption(values["cc-user"], "--cc-user"); + const bccUsers = blackboardMessageRecipientOption(values["bcc-user"], "--bcc-user"); + if (toUsers.length + ccUsers.length + bccUsers.length === 0) { + throw usageError("At least one of --to-user, --cc-user, or --bcc-user is required for Blackboard course messages."); + } + assertDistinctBlackboardMessageRecipientOptions({ toUsers, ccUsers, bccUsers }); + return { + courseId: opaqueToken(required(positionals[3], "Blackboard course ID"), "Blackboard course ID"), + ...(subject ? { subject } : {}), + toUsers, + ccUsers, + bccUsers, + }; +} + +async function readBlackboardCourseMessageWriteInput(values: Values): Promise { + const textFilePath = values["text-file"]?.trim(); + if (!textFilePath) throw usageError("--text-file is required for Blackboard course messages."); + const payload = await readBlackboardSubmissionTextPayload(textFilePath); + if (!cleanText(payload.text)) { + throw new CliError( + "The Blackboard course-message text file cannot be blank after trimming whitespace.", + "BLACKBOARD_MESSAGE_TEXT_EMPTY", + 2, + { file: payload.textFile.absolutePath }, + ); + } + return { + textFile: payload.textFile, + body: payload.text, + }; +} + +async function buildBlackboardCourseMessageWritePreflight( + adapter: ServiceAdapter, + values: Values, + target: BlackboardCourseMessageWriteTarget, + input: BlackboardCourseMessageWriteInput, +): Promise { + const blockers: Array<{ code: string; message: string }> = []; + const warnings: Array<{ code: string; message: string }> = []; + const sentReport = await listBlackboardCourseMessages(adapter, { + courseId: target.courseId, + folderType: "Sent", + page: 1, + pageSize: 1, + sort: "postedDate(desc)", + }); + const courseId = sentReport.courseId; + const courseCode = sentReport.courseCode; + const courseName = sentReport.courseName; + const resolvedRecipients = await resolveBlackboardCourseMessageRecipients(adapter, courseId, { + toUsers: target.toUsers, + ccUsers: target.ccUsers, + bccUsers: target.bccUsers, + }); + const missingRecipients = resolvedRecipients.missing; + if (missingRecipients.length > 0) { + blockers.push({ + code: "RECIPIENT_NOT_FOUND", + message: `The selected Blackboard recipients could not be found by exact ID in this course roster: ${missingRecipients.join(", ")}.`, + }); + } + if (!target.subject) { + warnings.push({ + code: "SUBJECT_EMPTY", + message: "The Blackboard course message will be created without a subject line.", + }); + } + if (target.toUsers.length === 0) { + warnings.push({ + code: "TO_RECIPIENTS_EMPTY", + message: "This Blackboard course message has no direct To recipients; only Cc and/or Bcc recipients will be used.", + }); + } + const confirmation = blockers.length === 0 + ? buildBlackboardCourseMessageWriteApplyConfirmation(target, input.textFile, { + credentialsFile: values["credentials-file"], + profile: values.profile, + }) + : undefined; + return { + checkedAt: new Date().toISOString(), + target, + courseId, + courseCode, + courseName, + recipients: { + toUsers: resolvedRecipients.toUsers, + ccUsers: resolvedRecipients.ccUsers, + bccUsers: resolvedRecipients.bccUsers, + }, + body: { + textFile: input.textFile, + preview: sampleText(cleanText(input.body), 240), + }, + blockers, + warnings, + applyAllowed: blockers.length === 0, + confirmation: { + required: true, + available: Boolean(confirmation), + expectedSha256: input.textFile.sha256, + ...(confirmation ? { argv: confirmation.argv, command: confirmation.command } : {}), + }, + }; +} + +function ensureBlackboardCourseMessageWriteAllowed(preflight: BlackboardCourseMessageWritePreflight): void { + if (preflight.blockers.length === 0) return; + throw new CliError( + "Blackboard course-message write is blocked by the current live target state.", + "BLACKBOARD_MESSAGE_SEND_BLOCKED", + 4, + { + courseId: preflight.target.courseId, + blockers: preflight.blockers, + warning: "NO_MUTATION_PERFORMED", + }, + ); +} + +function buildBlackboardCourseMessageWriteApplyConfirmation( + target: BlackboardCourseMessageWriteTarget, + textFile: BlackboardSubmissionText, + options: { + credentialsFile?: string; + profile?: string; + } = {}, +): { required: true; argv: string[]; command: string } { + const argv = [ + "sustech", + "bb", + "message-send", + "apply", + target.courseId, + ...(options.credentialsFile ? ["--credentials-file", options.credentialsFile] : []), + ...(options.profile ? ["--profile", options.profile] : []), + ...(target.subject ? ["--subject", target.subject] : []), + ...target.toUsers.flatMap((userId) => ["--to-user", userId]), + ...target.ccUsers.flatMap((userId) => ["--cc-user", userId]), + ...target.bccUsers.flatMap((userId) => ["--bcc-user", userId]), + "--text-file", + textFile.absolutePath, + "--expected-sha256", + textFile.sha256, + "--confirm", + ]; + return { required: true, argv, command: argv.map(shellQuote).join(" ") }; +} + +function blackboardMessageRecipientOption(value: string[] | undefined, option: "--to-user" | "--cc-user" | "--bcc-user"): string[] { + const items: string[] = []; + const seen = new Set(); + for (const entry of value ?? []) { + const token = opaqueToken(entry, option); + const comparable = blackboardComparableId(token); + if (seen.has(comparable)) { + throw usageError(`${option} must not repeat the same Blackboard user ID.`); } - const session = await pmsService(values); - const previousJobs = await listPmsPrintJobs(session); - const job = requirePmsPrintJob(previousJobs, jobId); - try { - const mutation = await session.deletePrintJob(jobId); - const readBackJobs = await bestEffortPmsPrintJobs(session); - const verification = readBackJobs - ? verifyPmsPrintDeletion(readBackJobs, jobId) - : { status: "unavailable" as const, message: "The print queue could not be read back after the delete request.", observedJobIds: [] }; - if (verification.status !== "confirmed") { - throw new CliError( - "PMS accepted the delete request, but the read-back verification was inconclusive.", - "PMS_DELETE_NOT_CONFIRMED", - 5, - { - jobId, - deleteMessage: mutation.message, - verification, - warning: "DO_NOT_RETRY_AUTOMATICALLY", - }, - ); - } - writeSuccess({ - command: "pms delete apply", - data: { - mode: "apply", - mutation: true, - job, - verification, - deleteMessage: mutation.message, - }, - text: formatPmsDeleteSuccess({ job, verification }), - }, output); - return; - } catch (error) { - const readBackJobs = await bestEffortPmsPrintJobs(session); - const verification = readBackJobs - ? verifyPmsPrintDeletion(readBackJobs, jobId) - : { status: "unavailable" as const, message: "The print queue could not be read back after the delete request failed.", observedJobIds: [] }; - if (verification.status === "confirmed") { - writeSuccess({ - command: "pms delete apply", - data: { - mode: "apply", - mutation: true, - job, - verification, - recoveredAfterError: true, - }, - text: formatPmsDeleteSuccess({ job, verification }), - meta: { recoveredAfterError: true }, - }, output); - return; - } - if (isPmsMutationOutcomeUncertain(error)) { - throw new CliError( - "PMS print-job deletion outcome is uncertain. Do not retry automatically.", - "PMS_DELETE_OUTCOME_UNKNOWN", - 5, - { - jobId, - verification, - cause: error instanceof Error ? error.message : String(error), - warning: "DO_NOT_RETRY_AUTOMATICALLY", - }, - ); + seen.add(comparable); + items.push(token); + } + return items; +} + +function assertDistinctBlackboardMessageRecipientOptions(input: { + toUsers: readonly string[]; + ccUsers: readonly string[]; + bccUsers: readonly string[]; +}): void { + const seen = new Map(); + for (const [option, values] of [ + ["--to-user", input.toUsers], + ["--cc-user", input.ccUsers], + ["--bcc-user", input.bccUsers], + ] as const) { + for (const value of values) { + const comparable = blackboardComparableId(value); + const existing = seen.get(comparable); + if (existing) { + throw usageError(`${option} duplicates a Blackboard recipient already selected with ${existing}.`); } - throw error; + seen.set(comparable, option); } } - throw usageError(`Unknown command: ${positionals.join(" ")}`); } -function pmsUploadOptions(values: Values): PmsPrintUploadOptions { - const color = pmsColorValue(values.color); - const paper = pmsPaperValue(values.paper); - const duplex = pmsDuplexValue(values.duplex); - const copies = parsePositiveInteger(values.copies, 1, "--copies"); - const pageFrom = parseNonNegativeInteger(values["page-from"], 0, "--page-from"); - if (pageFrom === 0 && values["page-to"] !== undefined) { - throw usageError("--page-to requires --page-from."); +async function resolveBlackboardCourseMessageRecipients( + adapter: ServiceAdapter, + courseId: string, + requested: { + toUsers: readonly string[]; + ccUsers: readonly string[]; + bccUsers: readonly string[]; + }, +): Promise<{ + toUsers: BlackboardCourseMessageWriteResolvedRecipient[]; + ccUsers: BlackboardCourseMessageWriteResolvedRecipient[]; + bccUsers: BlackboardCourseMessageWriteResolvedRecipient[]; + missing: string[]; +}> { + const needed = new Set([ + ...requested.toUsers.map((entry) => blackboardComparableId(entry)), + ...requested.ccUsers.map((entry) => blackboardComparableId(entry)), + ...requested.bccUsers.map((entry) => blackboardComparableId(entry)), + ]); + const resolved = new Map(); + for (let page = 1; page <= 100 && resolved.size < needed.size; page += 1) { + const report = await listBlackboardCourseRoster(adapter, { + courseId, + page, + pageSize: 100, + }); + for (const membership of report.memberships) { + if (!membership.userId) continue; + const comparable = blackboardComparableId(membership.userId); + if (!needed.has(comparable) || resolved.has(comparable)) continue; + resolved.set(comparable, blackboardCourseMessageResolvedRecipient(membership)); + } + if (!report.hasMore) break; } - const pageTo = pageFrom === 0 ? 0 : parsePositiveInteger(values["page-to"], pageFrom, "--page-to"); - if (pageFrom > 0 && pageTo < pageFrom) throw usageError("--page-to must be greater than or equal to --page-from."); + const pick = (items: readonly string[]) => items + .map((entry) => resolved.get(blackboardComparableId(entry))) + .filter((entry): entry is BlackboardCourseMessageWriteResolvedRecipient => entry !== undefined); + const missing = [...needed].filter((entry) => !resolved.has(entry)).sort((left, right) => left.localeCompare(right, "en-US")); return { - ...color, - ...paper, - ...duplex, - copies, - pageFrom, - pageTo, + toUsers: pick(requested.toUsers), + ccUsers: pick(requested.ccUsers), + bccUsers: pick(requested.bccUsers), + missing, }; } -function pmsColorValue(value: string | undefined): Pick { - const normalized = (value ?? "bw").trim().toLowerCase(); - if (normalized === "bw" || normalized === "blackwhite" || normalized === "black-white" || normalized === "1" || normalized === "黑白") { - return { color: "bw", colorCode: 1 }; +function blackboardCourseMessageResolvedRecipient( + membership: BlackboardCourseMembership, +): BlackboardCourseMessageWriteResolvedRecipient { + return { + userId: membership.userId, + displayName: membership.user?.displayName || membership.userId, + courseRoleId: membership.courseRoleId, + }; +} + +async function findBlackboardCourseMessageById( + adapter: ServiceAdapter, + options: { courseId: string; messageId: string }, +): Promise<{ courseCode: string; courseName: string; message?: BlackboardCourseMessage }> { + let latest: Awaited> | undefined; + for (let page = 1; page <= 100; page += 1) { + const report = await listBlackboardCourseMessages(adapter, { + courseId: options.courseId, + folderType: "Sent", + page, + pageSize: 100, + sort: "postedDate(desc)", + }); + latest = report; + const matched = report.messages.find((entry) => entry.id === options.messageId); + if (matched) { + return { + courseCode: report.courseCode, + courseName: report.courseName, + message: matched, + }; + } + if (!report.hasMore) break; } - if (normalized === "color" || normalized === "2" || normalized === "彩色") { - return { color: "color", colorCode: 2 }; + if (!latest) { + throw new CliError("The Blackboard course messages could not be read.", "BLACKBOARD_MESSAGE_READ_FAILED", 1, { + courseId: options.courseId, + folderType: "Sent", + }); } - throw usageError("--color must be bw or color."); + return { + courseCode: latest.courseCode, + courseName: latest.courseName, + }; } -function pmsPaperValue(value: string | undefined): Pick { - const normalized = (value ?? "unspecified").trim().toLowerCase(); - if (normalized === "unspecified" || normalized === "" || normalized === "-1" || normalized === "不指定") { - return { paper: "unspecified", paperCode: -1 }; +async function observeBlackboardCourseMessageWrite( + adapter: ServiceAdapter, + preflight: BlackboardCourseMessageWritePreflight, + createdId: string, +): Promise<{ message?: BlackboardCourseMessage; error?: unknown }> { + try { + const observed = await findBlackboardCourseMessageById(adapter, { + courseId: preflight.target.courseId, + messageId: createdId, + }); + return { ...(observed.message ? { message: observed.message } : {}) }; + } catch (error) { + return { error }; } - if (normalized === "a4" || normalized === "9") return { paper: "A4", paperCode: 9 }; - if (normalized === "a3" || normalized === "8") return { paper: "A3", paperCode: 8 }; - throw usageError("--paper must be unspecified, A4, or A3."); } -function pmsDuplexValue(value: string | undefined): Pick { - const normalized = (value ?? "single").trim().toLowerCase(); - if (normalized === "single" || normalized === "1" || normalized === "单面") return { duplex: "single", duplexCode: 1 }; - if (normalized === "short" || normalized === "short-edge" || normalized === "2" || normalized === "双面短边") { - return { duplex: "short", duplexCode: 2 }; +function verifyBlackboardCourseMessageWrite( + preflight: BlackboardCourseMessageWritePreflight, + input: BlackboardCourseMessageWriteInput, + message: BlackboardCourseMessage | undefined, +): BlackboardCourseMessageWriteVerification { + if (!message) { + return { + status: "not_observed", + message: "The created Blackboard course message could not be found by exact ID in the Sent-folder read-back.", + }; } - if (normalized === "long" || normalized === "long-edge" || normalized === "3" || normalized === "双面长边") { - return { duplex: "long", duplexCode: 3 }; + const mismatches: string[] = []; + const expectedSubject = preflight.target.subject ?? ""; + const expectedBody = cleanText(input.body); + if ((message.subject || "") !== expectedSubject) mismatches.push("subject"); + if (message.body !== expectedBody) mismatches.push("body"); + if (message.isReply) mismatches.push("isReply"); + if (!sameBlackboardComparableIds(message.toUsers, preflight.target.toUsers)) mismatches.push("toUsers"); + if (!sameBlackboardComparableIds(message.ccUsers, preflight.target.ccUsers)) mismatches.push("ccUsers"); + if (!sameBlackboardComparableIds(message.bccUsers, preflight.target.bccUsers)) mismatches.push("bccUsers"); + if (mismatches.length === 0) { + return { + status: "confirmed", + message: "The created Blackboard course message was read back with the expected ID, recipients, subject, and body.", + }; } - throw usageError("--duplex must be single, short, or long."); + return { + status: "not_observed", + message: `Blackboard read-back mismatched: ${mismatches.join(", ")}.`, + }; } -function buildPmsUploadApplyConfirmation( - absolutePath: string, - expectedSha256: string, - options: PmsPrintUploadOptions, - metadata: { credentialsFile?: string; profile?: string } = {}, -): { required: true; available: true; expectedSha256: string; argv: string[]; command: string } { - const argv = [ - "sustech", - "pms", - "upload", - "apply", - ...(metadata.credentialsFile ? ["--credentials-file", metadata.credentialsFile] : []), - ...(metadata.profile ? ["--profile", metadata.profile] : []), - "--file", - absolutePath, - "--expected-sha256", - expectedSha256, - "--color", - options.color, - "--paper", - options.paper, - "--duplex", - options.duplex, - ...(options.pageFrom > 0 ? ["--page-from", String(options.pageFrom), "--page-to", String(options.pageTo)] : []), - "--copies", - String(options.copies), - "--confirm", - ]; +function blackboardComparableId(value: string): string { + return value.startsWith("_") && value.endsWith("_1") ? value.slice(1, -2) : value; +} + +function sameBlackboardComparableIds(left: readonly string[], right: readonly string[]): boolean { + const leftIds = [...new Set(left.map((entry) => blackboardComparableId(entry)))].sort((a, b) => a.localeCompare(b, "en-US")); + const rightIds = [...new Set(right.map((entry) => blackboardComparableId(entry)))].sort((a, b) => a.localeCompare(b, "en-US")); + return leftIds.length === rightIds.length && leftIds.every((entry, index) => entry === rightIds[index]); +} + +function blackboardDiscussionWriteTarget( + mode: BlackboardDiscussionWriteMode, + positionals: string[], + values: Values, +): BlackboardDiscussionWriteTarget { return { - required: true, - available: true, - expectedSha256, - argv, - command: argv.map(shellQuote).join(" "), + mode, + courseId: opaqueToken(required(positionals[3], "Blackboard course ID"), "Blackboard course ID"), + discussionId: opaqueToken(required(positionals[4], "Blackboard discussion ID"), "Blackboard discussion ID"), + ...(mode === "reply" ? { messageId: opaqueToken(required(positionals[5], "Blackboard message ID"), "Blackboard message ID") } : {}), + ...(values["group-id"] ? { groupId: opaqueToken(values["group-id"], "--group-id") } : {}), + status: blackboardDiscussionMessageStatusValue(values.status) ?? "Published", }; } -function buildPmsDeleteApplyConfirmation( - jobId: number, - metadata: { credentialsFile?: string; profile?: string } = {}, -): { required: true; available: true; argv: string[]; command: string } { - const argv = [ - "sustech", - "pms", - "delete", - "apply", - ...(metadata.credentialsFile ? ["--credentials-file", metadata.credentialsFile] : []), - ...(metadata.profile ? ["--profile", metadata.profile] : []), - String(jobId), - "--confirm", - ]; +async function readBlackboardDiscussionWriteInput(values: Values): Promise { + const textFilePath = values["text-file"]?.trim(); + if (!textFilePath) throw usageError("--text-file is required for Blackboard discussion writes."); + const payload = await readBlackboardSubmissionTextPayload(textFilePath); + if (!cleanText(payload.text)) { + throw new CliError( + "The Blackboard discussion text file cannot be blank after trimming whitespace.", + "BLACKBOARD_DISCUSSION_TEXT_EMPTY", + 2, + { file: payload.textFile.absolutePath }, + ); + } return { - required: true, - available: true, - argv, - command: argv.map(shellQuote).join(" "), + textFile: payload.textFile, + body: payload.text, }; } -async function bestEffortPmsPrintJobs(session: PmsSession): Promise> | undefined> { - try { - return await listPmsPrintJobs(session); - } catch { - return undefined; +export async function buildBlackboardDiscussionWritePreflight( + adapter: ServiceAdapter, + options: BlackboardDiscussionWriteConfirmationOptions, + target: BlackboardDiscussionWriteTarget, + input: BlackboardDiscussionWriteInput, +): Promise { + const blockers: Array<{ code: string; message: string }> = []; + const warnings: Array<{ code: string; message: string }> = []; + let courseCode = target.courseId; + let courseName = target.courseId; + let discussion: BlackboardDiscussion; + let group: BlackboardDiscussionGroup | undefined; + let parentMessage: BlackboardDiscussionMessage | undefined; + let effectiveGroupId = target.groupId; + + if (target.mode === "reply") { + const parent = await findBlackboardDiscussionMessageById(adapter, { + courseId: target.courseId, + discussionId: target.discussionId, + messageId: target.messageId!, + }); + courseCode = parent.courseCode; + courseName = parent.courseName; + discussion = parent.discussion; + parentMessage = parent.message; + if (!parentMessage) { + blockers.push({ + code: "PARENT_MESSAGE_NOT_FOUND", + message: "The selected Blackboard discussion message could not be found by exact ID.", + }); + } else { + if (target.groupId && parentMessage.groupId && target.groupId !== parentMessage.groupId) { + blockers.push({ + code: "GROUP_MISMATCH", + message: "The selected --group-id does not match the parent discussion message's group.", + }); + } else if (target.groupId && !parentMessage.groupId) { + blockers.push({ + code: "GROUP_NOT_ALLOWED", + message: "The selected parent discussion message is not group-scoped, so --group-id should be omitted.", + }); + } + effectiveGroupId = target.groupId ?? (parentMessage.groupId || undefined); + } + } else { + const report = await getBlackboardDiscussionMessages(adapter, { + courseId: target.courseId, + discussionId: target.discussionId, + ...(target.groupId ? { groupId: target.groupId } : {}), + page: 1, + pageSize: 1, + }); + courseCode = report.courseCode; + courseName = report.courseName; + discussion = report.discussion; + } + + if (discussion.source === "original-html" || parentMessage?.source === "original-html") { + blockers.push({ + code: "REST_SURFACE_REQUIRED", + message: "This Blackboard discussion is only available through the Original HTML fallback. Discussion writes remain unavailable until the course exposes a compatible Learn REST discussion API.", + }); + } + + if (discussion.groupDiscussion) { + if (!effectiveGroupId) { + blockers.push({ + code: "GROUP_REQUIRED", + message: "This Blackboard discussion is group-scoped. Pass the exact --group-id before applying a new post.", + }); + } else { + group = await findBlackboardDiscussionGroupById(adapter, target.courseId, target.discussionId, effectiveGroupId); + if (!group) { + blockers.push({ + code: "GROUP_NOT_FOUND", + message: "The selected Blackboard discussion group could not be found by exact ID.", + }); + } + } + } else if (effectiveGroupId) { + blockers.push({ + code: "GROUP_NOT_ALLOWED", + message: "This Blackboard discussion is not group-scoped, so --group-id should be omitted.", + }); } -} -function pmsJobId(value: string | undefined): number { - return parsePositiveInteger(value, 1, "PMS job ID"); + if (target.status === "Draft") { + warnings.push({ + code: "STATUS_DRAFT", + message: "Blackboard will create this discussion message as a Draft rather than a published post.", + }); + } else if (target.status === "Deleted") { + warnings.push({ + code: "STATUS_DELETED", + message: "Blackboard will create this discussion message with Deleted status if the server accepts it.", + }); + } + + const resolvedTarget: BlackboardDiscussionWriteTarget = { + ...target, + ...(effectiveGroupId ? { groupId: effectiveGroupId } : {}), + }; + const confirmation = blockers.length === 0 + ? buildBlackboardDiscussionWriteApplyConfirmation( + resolvedTarget, + input.textFile, + { + credentialsFile: options.credentialsFile, + profile: options.profile, + }, + ) + : undefined; + return { + checkedAt: new Date().toISOString(), + target: resolvedTarget, + courseId: target.courseId, + courseCode, + courseName, + discussion: discussion!, + ...(group ? { group } : {}), + ...(parentMessage ? { parentMessage } : {}), + body: { + textFile: input.textFile, + preview: sampleText(cleanText(input.body), 240), + }, + blockers, + warnings, + applyAllowed: blockers.length === 0, + confirmation: { + required: true, + available: Boolean(confirmation), + expectedSha256: input.textFile.sha256, + ...(confirmation ? { argv: confirmation.argv, command: confirmation.command } : {}), + }, + }; } -function requirePmsPrintJob(jobs: readonly Awaited>[number][], jobId: number) { - const job = findPmsPrintJob(jobs, jobId); - if (job) return job; +function ensureBlackboardDiscussionWriteAllowed(preflight: BlackboardDiscussionWritePreflight): void { + if (preflight.blockers.length === 0) return; throw new CliError( - "The requested PMS print job was not found in the current queue.", - "PMS_PRINT_JOB_NOT_FOUND", + "Blackboard discussion write is blocked by the current live target state.", + "BLACKBOARD_DISCUSSION_WRITE_BLOCKED", 4, { - jobId, + courseId: preflight.target.courseId, + discussionId: preflight.target.discussionId, + ...(preflight.target.messageId ? { messageId: preflight.target.messageId } : {}), + ...(preflight.target.groupId ? { groupId: preflight.target.groupId } : {}), + blockers: preflight.blockers, warning: "NO_MUTATION_PERFORMED", - availableJobIds: jobs.map((entry) => entry.jobId), }, ); } -function isPmsMutationOutcomeUncertain(error: unknown): boolean { - if (!(error instanceof CliError)) return false; - return error.code === "NETWORK_ERROR" - || error.code === "NETWORK_TIMEOUT" - || error.code === "TOO_MANY_REDIRECTS" - || (error.code === "SERVICE_HTTP_ERROR" && Number(error.details?.status) >= 500); -} - -function enrollTarget(values: Values, semester: ReturnType) { - const courseId = opaqueToken(required(values["course-id"], "--course-id"), "--course-id"); - const rwh = opaqueToken(required(values.rwh, "--rwh"), "--rwh"); - const bid = parsePositiveInteger(values.bid, 1, "--bid"); - const round = opaqueToken(values.round ?? "yixuan", "--round"); - return { semester, courseId, rwh, bid, round, cultivation: "1" as const }; -} - -function required(value: string | undefined, option: string): string { - if (!value?.trim()) throw usageError(`${option} is required.`); - return value.trim(); +function buildBlackboardDiscussionWriteApplyConfirmation( + target: BlackboardDiscussionWriteTarget, + textFile: BlackboardSubmissionText, + options: { + credentialsFile?: string; + profile?: string; + } = {}, +): { required: true; argv: string[]; command: string } { + const argv = [ + "sustech", + "bb", + target.mode === "post" ? "discussion-post" : "discussion-reply", + "apply", + target.courseId, + target.discussionId, + ...(target.messageId ? [target.messageId] : []), + ...(options.credentialsFile ? ["--credentials-file", options.credentialsFile] : []), + ...(options.profile ? ["--profile", options.profile] : []), + "--text-file", + textFile.absolutePath, + "--expected-sha256", + textFile.sha256, + ...(target.groupId ? ["--group-id", target.groupId] : []), + "--status", + target.status, + "--confirm", + ]; + return { required: true, argv, command: argv.map(shellQuote).join(" ") }; } -type BlackboardSubmissionTarget = { - courseId: string; - contentId?: string; - columnId?: string; -}; - -type BlackboardSubmissionVerification = { - status: "confirmed" | "not_observed" | "unavailable"; - message: string; -}; - -type BlackboardSubmissionPreviewData = { - checkedAt: string; - target: BlackboardSubmissionTarget; - assignment: Awaited>[number]; - content: Awaited>; - attempts: Array<{ - id: string; - status: BlackboardAttempt["status"]; - created: string; - attemptDate: string; - submissionDate?: string; - }>; - attemptsUsed: number; - remainingAttempts?: number; - inProgressAttempts: number; - file: Awaited>; - commentSummary: { present: boolean; length: number }; - uploadSettings?: Awaited>; - blockers: BlackboardSubmissionAssessment["blockers"]; - warnings: BlackboardSubmissionAssessment["warnings"]; - late: boolean; - applyAllowed: boolean; - confirmation: { - required: true; - available: boolean; - expectedSha256: string; - argv?: string[]; - command?: string; - }; -}; - -function blackboardAssignmentSelector(values: Values): { contentId?: string; columnId?: string } { - const contentId = values["content-id"] ? opaqueToken(values["content-id"], "--content-id") : undefined; - const columnId = values["column-id"] ? opaqueToken(values["column-id"], "--column-id") : undefined; - if (!contentId && !columnId) { - throw usageError("One of --content-id or --column-id is required."); +async function findBlackboardDiscussionGroupById( + adapter: ServiceAdapter, + courseId: string, + discussionId: string, + groupId: string, +): Promise { + for (let page = 1; page <= 100; page += 1) { + const report = await listBlackboardDiscussionGroups(adapter, { + courseId, + discussionId, + page, + pageSize: 100, + }); + const matched = report.groups.find((entry) => entry.groupId === groupId); + if (matched) return matched; + if (!report.hasMore) return undefined; } - return { ...(contentId ? { contentId } : {}), ...(columnId ? { columnId } : {}) }; + return undefined; } -function blackboardSubmissionTarget(values: Values): BlackboardSubmissionTarget { +async function findBlackboardDiscussionMessageById( + adapter: ServiceAdapter, + options: { + courseId: string; + discussionId: string; + messageId: string; + groupId?: string; + }, +): Promise<{ + courseCode: string; + courseName: string; + discussion: BlackboardDiscussion; + message?: BlackboardDiscussionMessage; +}> { + let latest: Awaited> | undefined; + for (let page = 1; page <= 100; page += 1) { + const report = await getBlackboardDiscussionMessages(adapter, { + courseId: options.courseId, + discussionId: options.discussionId, + ...(options.groupId ? { groupId: options.groupId } : {}), + page, + pageSize: 100, + }); + latest = report; + const matched = report.messages.find((entry) => entry.id === options.messageId); + if (matched) { + return { + courseCode: report.courseCode, + courseName: report.courseName, + discussion: report.discussion, + message: matched, + }; + } + if (!report.hasMore) break; + } + if (!latest) { + throw new CliError("The Blackboard discussion messages could not be read.", "BLACKBOARD_DISCUSSION_NOT_FOUND", 1, { + courseId: options.courseId, + discussionId: options.discussionId, + }); + } return { - courseId: opaqueToken(required(values["course-id"], "--course-id"), "--course-id"), - ...blackboardAssignmentSelector(values), + courseCode: latest.courseCode, + courseName: latest.courseName, + discussion: latest.discussion, }; } -function resolveBlackboardAssignmentSelector( - assignments: Awaited>, - selector: { contentId?: string; columnId?: string }, - courseId: string, -) { - const assignment = selectBlackboardAssignment(assignments, selector); - if (assignment) return assignment; - - const contentMatch = selector.contentId - ? selectBlackboardAssignment(assignments, { contentId: selector.contentId }) - : undefined; - const columnMatch = selector.columnId - ? selectBlackboardAssignment(assignments, { columnId: selector.columnId }) - : undefined; - if (contentMatch && columnMatch && contentMatch.id !== columnMatch.id) { - throw new CliError( - "The provided --content-id and --column-id do not refer to the same Blackboard assignment.", - "BLACKBOARD_ASSIGNMENT_MISMATCH", - 1, - { courseId, contentId: selector.contentId, columnId: selector.columnId }, - ); +async function findBlackboardDiscussionReplyById( + adapter: ServiceAdapter, + options: { + courseId: string; + discussionId: string; + messageId: string; + replyId: string; + groupId?: string; + }, +): Promise<{ + courseCode: string; + courseName: string; + reply?: BlackboardDiscussionMessage; +}> { + let latest: Awaited> | undefined; + for (let page = 1; page <= 100; page += 1) { + const report = await listBlackboardDiscussionReplies(adapter, { + courseId: options.courseId, + discussionId: options.discussionId, + messageId: options.messageId, + ...(options.groupId ? { groupId: options.groupId } : {}), + page, + pageSize: 100, + }); + latest = report; + const matched = report.replies.find((entry) => entry.id === options.replyId); + if (matched) { + return { + courseCode: report.courseCode, + courseName: report.courseName, + reply: matched, + }; + } + if (!report.hasMore) break; } - throw new CliError( - "The provided Blackboard assignment selector did not match any assignment in this course.", - "BLACKBOARD_ASSIGNMENT_NOT_FOUND", - 1, - { courseId, contentId: selector.contentId, columnId: selector.columnId }, - ); -} - -function blackboardAttemptStatus(value: string | undefined): - | "InProgress" - | "NeedsGrading" - | "Completed" - | undefined { - if (value === undefined) return undefined; - if (value === "InProgress" || value === "NeedsGrading" || value === "Completed") return value; - throw usageError("--status must be InProgress, NeedsGrading, or Completed for Blackboard attempts."); -} - -function submissionComment(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed ? trimmed : undefined; + if (!latest) { + throw new CliError("The Blackboard discussion replies could not be read.", "BLACKBOARD_DISCUSSION_NOT_FOUND", 1, { + courseId: options.courseId, + discussionId: options.discussionId, + messageId: options.messageId, + }); + } + return { + courseCode: latest.courseCode, + courseName: latest.courseName, + }; } -function summariseSubmissionComment(comment: string | undefined): { present: boolean; length: number } { - return { present: Boolean(comment), length: comment?.length ?? 0 }; +async function observeBlackboardDiscussionWrite( + adapter: ServiceAdapter, + preflight: BlackboardDiscussionWritePreflight, + createdId: string, +): Promise<{ message?: BlackboardDiscussionMessage; error?: unknown }> { + try { + if (preflight.target.mode === "reply") { + const observed = await findBlackboardDiscussionReplyById(adapter, { + courseId: preflight.target.courseId, + discussionId: preflight.target.discussionId, + messageId: preflight.target.messageId!, + replyId: createdId, + ...(preflight.target.groupId ? { groupId: preflight.target.groupId } : {}), + }); + return { ...(observed.reply ? { message: observed.reply } : {}) }; + } + const observed = await findBlackboardDiscussionMessageById(adapter, { + courseId: preflight.target.courseId, + discussionId: preflight.target.discussionId, + messageId: createdId, + ...(preflight.target.groupId ? { groupId: preflight.target.groupId } : {}), + }); + return { ...(observed.message ? { message: observed.message } : {}) }; + } catch (error) { + return { error }; + } } -function blackboardExpectedSha256(value: string): string { - const normalised = value.trim().toLowerCase(); - if (!/^[0-9a-f]{64}$/.test(normalised)) { - throw usageError("--expected-sha256 must be a 64-character lowercase or uppercase hexadecimal digest."); +function verifyBlackboardDiscussionWrite( + preflight: BlackboardDiscussionWritePreflight, + input: BlackboardDiscussionWriteInput, + message: BlackboardDiscussionMessage | undefined, +): BlackboardDiscussionWriteVerification { + if (!message) { + return { + status: "not_observed", + message: preflight.target.mode === "reply" + ? "The created Blackboard reply could not be found by exact ID in the reply read-back." + : "The created Blackboard discussion message could not be found by exact ID in the discussion read-back.", + }; } - return normalised; + const mismatches: string[] = []; + const expectedBody = cleanText(input.body); + if (message.body !== expectedBody) mismatches.push("body"); + if (message.status !== preflight.target.status) mismatches.push("status"); + if (preflight.target.groupId && message.groupId !== preflight.target.groupId) mismatches.push("groupId"); + if (preflight.target.mode === "reply" && message.parentId !== preflight.target.messageId) mismatches.push("parentId"); + if (mismatches.length === 0) { + return { + status: "confirmed", + message: preflight.target.mode === "reply" + ? "The created Blackboard reply was read back with the expected ID, parent message, body, and status." + : "The created Blackboard discussion message was read back with the expected ID, body, and status.", + }; + } + return { + status: "not_observed", + message: `Blackboard read-back mismatched: ${mismatches.join(", ")}.`, + }; } async function buildBlackboardSubmissionPreflight( adapter: ServiceAdapter, values: Values, target: BlackboardSubmissionTarget, - file: Awaited>, + submission: BlackboardCliSubmissionInput, comment: string | undefined, ): Promise { const assignments = await listBlackboardAssignments(adapter, target.courseId); @@ -5141,7 +7584,9 @@ async function buildBlackboardSubmissionPreflight( assignment, content, attempts, - file, + submission: submission.kind === "file" + ? { kind: "file", file: submission.file } + : { kind: "text", text: submission.textFile }, ...(uploadSettings ? { uploadSettings } : {}), }); const attemptsAllowed = assessed.attemptsAllowed; @@ -5163,7 +7608,7 @@ async function buildBlackboardSubmissionPreflight( columnId: assignment.id, }; const handoff = assessed.ready - ? buildBlackboardSubmitApplyConfirmation(resolvedTarget, file.absolutePath, file.sha256, { + ? buildBlackboardSubmitApplyConfirmation(resolvedTarget, blackboardSubmissionSummary(submission), { credentialsFile: values["credentials-file"], profile: values.profile, comment, @@ -5185,9 +7630,9 @@ async function buildBlackboardSubmissionPreflight( attemptsUsed, ...(remainingAttempts !== undefined ? { remainingAttempts } : {}), inProgressAttempts: assessed.inProgressAttemptIds.length, - file, + submission: blackboardSubmissionSummary(submission), commentSummary: summariseSubmissionComment(comment), - ...(uploadSettings ? { uploadSettings } : {}), + ...(submission.kind === "file" && uploadSettings ? { uploadSettings } : {}), blockers: assessed.blockers, warnings, late: assessed.late, @@ -5195,7 +7640,7 @@ async function buildBlackboardSubmissionPreflight( confirmation: { required: true, available: Boolean(handoff), - expectedSha256: file.sha256, + expectedSha256: submission.kind === "file" ? submission.file.sha256 : submission.textFile.sha256, ...(handoff ? { argv: handoff.argv, command: handoff.command } : {}), }, }; @@ -5288,18 +7733,30 @@ async function observeBlackboardAttemptCreation( } function verifyBlackboardSubmission( - status: string, + attempt: BlackboardAttempt, files: Awaited>, - expectedFileName: string, + submission: BlackboardCliSubmissionInput, ): BlackboardSubmissionVerification { - const observedFile = files.some((entry) => entry.name === expectedFileName); - if ((status === "NeedsGrading" || status === "Completed") && observedFile) { - return { status: "confirmed", message: "NeedsGrading/Completed and the uploaded filename were read back from Blackboard." }; + if (submission.kind === "file") { + const observedFile = files.some((entry) => entry.name === submission.file.name); + if ((attempt.status === "NeedsGrading" || attempt.status === "Completed") && observedFile) { + return { status: "confirmed", message: "NeedsGrading/Completed and the uploaded filename were read back from Blackboard." }; + } + if (attempt.status) { + return { + status: "not_observed", + message: `Attempt status was ${attempt.status}, but the expected uploaded filename was not fully observed in the read-back state.`, + }; + } + return { status: "unavailable", message: "Blackboard did not expose enough read-back state to confirm the submission." }; } - if (status) { + if ((attempt.status === "NeedsGrading" || attempt.status === "Completed") && attempt.studentSubmission === submission.text) { + return { status: "confirmed", message: "NeedsGrading/Completed and the submitted text were read back from Blackboard." }; + } + if (attempt.status) { return { status: "not_observed", - message: `Attempt status was ${status}, but the expected uploaded filename was not fully observed in the read-back state.`, + message: `Attempt status was ${attempt.status}, but the expected submission text was not fully observed in the read-back state.`, }; } return { status: "unavailable", message: "Blackboard did not expose enough read-back state to confirm the submission." }; @@ -5308,13 +7765,14 @@ function verifyBlackboardSubmission( function writeBlackboardSubmissionResult( output: ReturnType, preflight: BlackboardSubmissionPreviewData, - file: BlackboardSubmissionFile, + submission: BlackboardCliSubmissionInput, comment: string | undefined, attempt: BlackboardAttempt, files: readonly BlackboardAttemptFile[], verification: BlackboardSubmissionVerification, recoveredAfterError = false, ): void { + const publicFiles = files.map(publicBlackboardAttemptFile); writeSuccess({ command: "bb submit apply", data: { @@ -5322,7 +7780,8 @@ function writeBlackboardSubmissionResult( mutation: true, target: preflight.target, assignment: preflight.assignment, - file, + submission: blackboardSubmissionSummary(submission), + ...(submission.kind === "file" ? { file: submission.file } : { textFile: submission.textFile }), commentSummary: summariseSubmissionComment(comment), preflight: { checkedAt: preflight.checkedAt, @@ -5332,12 +7791,13 @@ function writeBlackboardSubmissionResult( ...(preflight.assignment.grading.due ? { due: preflight.assignment.grading.due } : {}), }, attempt, - files, + files: publicFiles, verification, - ...(preflight.uploadSettings ? { uploadSettings: preflight.uploadSettings } : {}), + ...(submission.kind === "file" && preflight.uploadSettings ? { uploadSettings: preflight.uploadSettings } : {}), }, text: formatBlackboardSubmissionSuccess({ assignment: preflight.assignment, + submission: blackboardSubmissionSummary(submission), attempt, files, verification, @@ -5348,8 +7808,7 @@ function writeBlackboardSubmissionResult( function buildBlackboardSubmitApplyConfirmation( target: BlackboardSubmissionTarget, - absolutePath: string, - expectedSha256: string, + submission: BlackboardCliSubmissionSummary, options: { credentialsFile?: string; profile?: string; @@ -5368,10 +7827,11 @@ function buildBlackboardSubmitApplyConfirmation( target.courseId, ...(target.contentId ? ["--content-id", target.contentId] : []), ...(target.columnId ? ["--column-id", target.columnId] : []), - "--file", - absolutePath, + ...(submission.kind === "file" + ? ["--file", submission.file.absolutePath] + : ["--text-file", submission.textFile.absolutePath]), "--expected-sha256", - expectedSha256, + submission.kind === "file" ? submission.file.sha256 : submission.textFile.sha256, ...(options.comment ? ["--comment", options.comment] : []), ...(options.allowLate ? ["--allow-late"] : []), "--confirm", @@ -5446,7 +7906,7 @@ function runDescribe( function commandUsageLines(command: string): string[] { const usageLines = HELP.split("\n").slice(3); const prefix = ` sustech ${command}`; - const start = usageLines.findIndex((line) => line.startsWith(prefix)); + const start = usageLines.findIndex((line) => line === prefix || line.startsWith(`${prefix} `)); if (start < 0) return [`sustech ${command}`]; const collected: string[] = []; for (let index = start; index < usageLines.length; index += 1) { @@ -5500,11 +7960,15 @@ function commandConsequenceOperations(command: string): string[] { "tis enroll apply": ["tis.enroll"], "tis bid apply": ["tis.bid"], "bb download": ["blackboard.download"], + "bb attempt-download": ["blackboard.attempt-download"], "bb sync": ["blackboard.sync"], "bb calendar-link set": ["blackboard.calendar-link.store"], "bb calendar-link fetch": ["blackboard.calendar-link.fetch"], "bb calendar-link delete": ["blackboard.calendar-link.delete"], "bb submit apply": ["blackboard.submit"], + "bb message-send apply": ["blackboard.message-send"], + "bb discussion-post apply": ["blackboard.discussion-post"], + "bb discussion-reply apply": ["blackboard.discussion-reply"], "booking create apply": ["booking.create"], "booking cancel apply": ["booking.cancel"], "lib-booking create apply": ["library-booking.create"], @@ -5739,6 +8203,74 @@ function ncesSort(value: string | undefined): "rating" | "reviews" | "name" { throw usageError("--sort must be rating, reviews, or name for NCES."); } +function ncesSearchType(value: string | undefined): "all" | "course" | "teacher" | "review" { + if (value === undefined || value === "all") return "all"; + if (value === "course" || value === "teacher" || value === "review") return value; + throw usageError("--type must be all, course, teacher, or review for NCES search."); +} + +function ncesReviewSort(value: string | undefined): "helpful" | "newest" | "oldest" | "rating-high" | "rating-low" { + if (value === undefined || value === "helpful") return "helpful"; + if (value === "newest" || value === "oldest" || value === "rating-high" || value === "rating-low") return value; + throw usageError("--sort must be helpful, newest, oldest, rating-high, or rating-low for NCES reviews."); +} + +function ncesRankingCategory( + value: string | undefined, +): "top-teachers" | "top-rated-courses" | "popular-courses" | "top-reviews" | "long-reviews" | "top-users" { + if ( + value === "top-teachers" + || value === "top-rated-courses" + || value === "popular-courses" + || value === "top-reviews" + || value === "long-reviews" + || value === "top-users" + ) return value; + throw usageError("NCES rankings CATEGORY must be top-teachers, top-rated-courses, popular-courses, top-reviews, long-reviews, or top-users."); +} + +function ncesTerm(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const term = value.trim(); + if (!/^\d{5}$/u.test(term)) throw usageError("--term must be a five-digit NCES term ID such as 20252."); + return term; +} + +function optionalNonEmptyString(value: string | boolean | string[] | undefined, flag: string): string | undefined { + if (value === undefined || value === false) return undefined; + if (Array.isArray(value)) throw usageError(`${flag} accepts exactly one value.`); + const normalized = String(value).trim(); + if (!normalized) throw usageError(`${flag} cannot be empty.`); + return normalized; +} + +function repeatedNonEmptyStrings(value: readonly string[] | undefined, flag: string): string[] { + if (!value) return []; + const items: string[] = []; + const seen = new Set(); + for (const entry of value) { + const normalized = entry.trim(); + if (!normalized) throw usageError(`${flag} cannot be empty.`); + const key = normalized.toLocaleLowerCase("zh-Hans-CN"); + if (seen.has(key)) continue; + seen.add(key); + items.push(normalized); + } + return items; +} + +function ncesRankingItems( + rankings: Awaited>, + category: ReturnType, +) { + if (category === "top-teachers") return rankings.topTeachers; + if (category === "top-rated-courses") return rankings.topRatedCourses; + if (category === "popular-courses") return rankings.popularCourses; + if (category === "top-reviews") return rankings.topReviews; + if (category === "long-reviews") return rankings.longReviews; + return rankings.topUsers; +} + function blackboardContentKind(value: string): "file" | "folder" | "assignment" | "document" | "unknown" { if (value === "file" || value === "folder" || value === "assignment" || value === "document" || value === "unknown") { return value; @@ -6038,8 +8570,19 @@ function usageError(message: string): CliError { return new CliError(message, "USAGE", 2, { help: "Run `sustech --help` for usage." }); } -main(process.argv.slice(2)).catch((error: unknown) => { - const argv = process.argv.slice(2); - const command = inferCommandName(argv); - process.exitCode = writeError(error, command, inferOutputOptions(argv)); -}); +function isDirectExecution(): boolean { + if (!process.argv[1]) return false; + try { + return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(resolvePath(process.argv[1])); + } catch { + return false; + } +} + +if (isDirectExecution()) { + main(process.argv.slice(2)).catch((error: unknown) => { + const argv = process.argv.slice(2); + const command = inferCommandName(argv); + process.exitCode = writeError(error, command, inferOutputOptions(argv)); + }); +} diff --git a/src/context/service.ts b/src/context/service.ts index 68cfa58..6b9e483 100644 --- a/src/context/service.ts +++ b/src/context/service.ts @@ -1,6 +1,7 @@ import type { AcademicCalendar } from "../calendar/client.js"; import type { CalendarDayInfo } from "../calendar/types.js"; import type { + AnnouncementSummary, AirQualitySummary, ContextInput, ContextLevel, @@ -24,6 +25,7 @@ export class ContextService { academicDay: academic.state, schedule: input.schedule ? "provided" : "missing", nextDeadline: input.nextDeadline === undefined ? "missing" : "provided", + recentAnnouncement: input.recentAnnouncement === undefined ? "missing" : "provided", nextEvaluation: input.nextEvaluation === undefined ? "missing" : "provided", nextExam: input.nextExam === undefined ? "missing" : "provided", weather: input.weather === undefined ? "missing" : "provided", @@ -44,6 +46,7 @@ export class ContextService { schedule: input.schedule ?? {}, ...(LEVEL_ORDER[level] >= LEVEL_ORDER.normal ? { nextDeadline: input.nextDeadline ?? null, + recentAnnouncement: input.recentAnnouncement ?? null, nextEvaluation: input.nextEvaluation ?? null, nextExam: input.nextExam ?? null, } : {}), @@ -73,6 +76,7 @@ export class ContextService { if (snapshot.holiday) record.holiday = snapshot.holiday; if (LEVEL_ORDER[snapshot.level] >= LEVEL_ORDER.normal) { record.nextDeadline = snapshot.nextDeadline ?? null; + record.recentAnnouncement = snapshot.recentAnnouncement ?? null; record.nextEvaluation = snapshot.nextEvaluation ?? null; record.nextExam = snapshot.nextExam ?? null; } @@ -97,7 +101,15 @@ function renderLines(snapshot: ContextSnapshot): string[] { ...(snapshot.holiday ? [`Today is [${snapshot.holiday}]`] : []), ]; appendSchedule(lines, snapshot.schedule); - if (LEVEL_ORDER[snapshot.level] >= LEVEL_ORDER.normal) appendNormal(lines, snapshot.nextDeadline ?? null, snapshot.nextEvaluation ?? null, snapshot.nextExam ?? null); + if (LEVEL_ORDER[snapshot.level] >= LEVEL_ORDER.normal) { + appendNormal( + lines, + snapshot.nextDeadline ?? null, + snapshot.recentAnnouncement ?? null, + snapshot.nextEvaluation ?? null, + snapshot.nextExam ?? null, + ); + } if (LEVEL_ORDER[snapshot.level] >= LEVEL_ORDER.verbose) appendVerbose(lines, snapshot.weather ?? null, snapshot.airQuality ?? null, snapshot.libraryStatus ?? null); return lines; } @@ -120,10 +132,12 @@ function appendSchedule(lines: string[], schedule: ScheduleReminder): void { function appendNormal( lines: string[], nextDeadline: DeadlineSummary | null, + recentAnnouncement: AnnouncementSummary | null, nextEvaluation: EvaluationSummary | null, nextExam: ExamSummary | null, ): void { if (nextDeadline) lines.push(`Next deadline: [${nextDeadline.name}] — ${deadlineStatus(nextDeadline.daysLeft, nextDeadline.dueAt)}`); + if (recentAnnouncement) lines.push(`Recent Blackboard announcement: [${recentAnnouncement.title}] — ${announcementStatus(recentAnnouncement)}`); if (nextEvaluation) lines.push(`Next evaluation: [${nextEvaluation.course} — ${nextEvaluation.name}] — ${deadlineStatus(nextEvaluation.daysLeft, nextEvaluation.dueAt, "Evaluation")}`); if (nextExam) { const location = [nextExam.building, nextExam.room].filter(Boolean).join(" ").trim() || nextExam.campus || ""; @@ -131,6 +145,13 @@ function appendNormal( } } +function announcementStatus(announcement: AnnouncementSummary): string { + const owner = announcement.source === "system" + ? "System" + : announcement.course || "Course"; + return announcement.activityAt ? `${owner} · ${announcement.activityAt}` : owner; +} + function appendVerbose( lines: string[], weather: WeatherSummary | null, diff --git a/src/context/types.ts b/src/context/types.ts index 55bba7d..7ce39e3 100644 --- a/src/context/types.ts +++ b/src/context/types.ts @@ -18,6 +18,13 @@ export interface DeadlineSummary { course?: string; } +export interface AnnouncementSummary { + title: string; + source: "system" | "course"; + course?: string; + activityAt?: string; +} + export interface EvaluationSummary { course: string; name: string; @@ -59,6 +66,7 @@ export interface ContextInput { academicDay?: CalendarDayInfo; schedule?: ScheduleReminder; nextDeadline?: DeadlineSummary | null; + recentAnnouncement?: AnnouncementSummary | null; nextEvaluation?: EvaluationSummary | null; nextExam?: ExamSummary | null; weather?: WeatherSummary | null; @@ -70,6 +78,7 @@ export interface ContextSourceStatus { academicDay: SourceState; schedule: SourceState; nextDeadline: SourceState; + recentAnnouncement: SourceState; nextEvaluation: SourceState; nextExam: SourceState; weather: SourceState; @@ -89,6 +98,7 @@ export interface ContextSnapshot { holiday?: string; schedule: ScheduleReminder; nextDeadline?: DeadlineSummary | null; + recentAnnouncement?: AnnouncementSummary | null; nextEvaluation?: EvaluationSummary | null; nextExam?: ExamSummary | null; weather?: WeatherSummary | null; diff --git a/src/core/argv.ts b/src/core/argv.ts index 7754ec8..6e57538 100644 --- a/src/core/argv.ts +++ b/src/core/argv.ts @@ -24,9 +24,9 @@ export function inferCommandName(argv: string[]): string { if (!command) return group; if ( (group === "tis" && ["courses", "enroll", "classroom", "selection", "bid", "plan", "degree"].includes(command)) - || (group === "online" && ["talks", "contact"].includes(command)) + || (group === "online" && ["talks", "contact", "manual"].includes(command)) || (group === "academic" && command === "snapshot") - || (group === "bb" && ["submit", "calendar-link"].includes(command)) + || (group === "bb" && ["submit", "calendar-link", "discussion-post", "discussion-reply", "message-send"].includes(command)) || (group === "pms" && (command === "upload" || command === "delete")) || (group === "booking" && ["create", "cancel"].includes(command)) || (group === "lib-booking" && ["create", "cancel"].includes(command)) diff --git a/src/core/auth-check.ts b/src/core/auth-check.ts new file mode 100644 index 0000000..9e7a6d9 --- /dev/null +++ b/src/core/auth-check.ts @@ -0,0 +1,118 @@ +import { type Credentials } from "./credentials.js"; +import { CliError } from "./errors.js"; +import { BookingSession } from "../services/booking-auth.js"; +import { + createBlackboardBrowserAdapter, + type BlackboardBrowserOptions, + type BlackboardBrowserRuntime, +} from "../services/blackboard-browser.js"; +import { getBlackboardUser } from "../services/blackboard.js"; +import { type ServiceAdapter } from "../services/base.js"; +import { LibraryBookingSession } from "../services/library-booking-auth.js"; +import { getLibraryBookingUser } from "../services/library.js"; +import { PmsSession } from "../services/pms-auth.js"; +import { type CasServiceConfig, CasSession } from "../sso/cas.js"; +import { TisSession } from "../tis/auth.js"; + +export type AuthService = "tis" | "bb" | "ws" | "booking" | "lib-booking" | "pms"; + +export interface BlackboardBrowserAuthOptions extends BlackboardBrowserOptions { + runtime?: BlackboardBrowserRuntime; + fetchImpl?: typeof fetch; +} + +export async function authenticateCredentials( + credentials: Credentials, + service: AuthService, +): Promise<{ authenticated: true; credentialSource: string; identity?: string }> { + if (service === "tis") { + await new TisSession(credentials).login(); + return { authenticated: true, credentialSource: credentials.source }; + } + if (service === "bb") { + const session = new CasSession(credentials, casServiceConfig("bb")); + await session.login(); + const user = await getBlackboardUser(casSessionAdapter("bb", session)); + return { + authenticated: true, + credentialSource: credentials.source, + ...((user.displayName || user.userName) ? { identity: user.displayName || user.userName } : {}), + }; + } + if (service === "ws") { + await new CasSession(credentials, casServiceConfig("ws")).login(); + return { authenticated: true, credentialSource: credentials.source }; + } + if (service === "booking") { + const session = new BookingSession(credentials); + await session.login(); + return { + authenticated: true, + credentialSource: credentials.source, + ...(session.userProfile?.name ? { identity: session.userProfile.name } : {}), + }; + } + if (service === "lib-booking") { + const session = new LibraryBookingSession(credentials); + await session.login(); + const user = await getLibraryBookingUser(session); + return { + authenticated: true, + credentialSource: credentials.source, + ...((user.trueName || user.logonName) ? { identity: user.trueName || user.logonName } : {}), + }; + } + const session = new PmsSession({ username: credentials.sid, password: credentials.password }); + await session.login(); + const check = await session.check(); + if (!check.authenticated) { + throw new CliError("PMS login completed but the session check failed.", "AUTHENTICATION_FAILED", 2, { + service: "pms", + }); + } + return { + authenticated: true, + credentialSource: credentials.source, + ...(check.displayName ? { identity: check.displayName } : {}), + }; +} + +export async function authenticateBlackboardBrowserSession( + options: BlackboardBrowserAuthOptions = {}, +): Promise<{ authenticated: true; credentialSource: string; identity?: string }> { + const adapter = await createBlackboardBrowserAdapter( + options, + options.runtime, + options.fetchImpl, + ); + const user = await getBlackboardUser(adapter); + return { + authenticated: true, + credentialSource: "browser-session", + ...((user.displayName || user.userName) ? { identity: user.displayName || user.userName } : {}), + }; +} + +export function casServiceConfig(service: "bb" | "ws"): CasServiceConfig { + if (service === "bb") { + return { + name: "Blackboard", + baseUrl: "https://bb.sustech.edu.cn", + serviceUrl: "https://bb.sustech.edu.cn/webapps/bb-sso-BBLEARN/index.jsp", + }; + } + return { + name: "SUSTech Global", + baseUrl: "https://ws.sustech.edu.cn", + serviceUrl: "https://ws.sustech.edu.cn/SUSTechHome.aspx", + }; +} + +function casSessionAdapter(name: "bb" | "ws", session: CasSession): ServiceAdapter { + return { + name, + fetch(input: string, init?: RequestInit): Promise { + return session.fetch(input, init); + }, + }; +} diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index deb89d9..ae45097 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -23,10 +23,12 @@ export const CAPABILITIES: readonly Capability[] = [ capability("faculty get", "Read one public faculty profile.", "read", { status: "preview" }), capability("faculty search", "Search public faculty profile fields.", "read", { status: "preview" }), capability("faculty render", "Render a public faculty profile as Agent-readable Markdown.", "read", { status: "preview" }), - capability("online search", "Search selected public community-maintained SUSTech Online content with source and freshness metadata.", "read", { status: "preview" }), + capability("online search", "Search public SUSTech Online talks, contacts, and the opt-in allowlisted handbook section with source and freshness metadata.", "read", { status: "preview" }), capability("online talks list", "List public SUSTech talks indexed by the community-maintained SUSTech Online repository.", "read", { status: "preview" }), capability("online talks search", "Search public SUSTech talks indexed by the community-maintained SUSTech Online repository.", "read", { status: "preview" }), capability("online talks get", "Read one exact public SUSTech talk with community-source provenance.", "read", { status: "preview" }), + capability("online manual list", "List selected public SUSTech Online handbook records with source provenance and freshness metadata.", "read", { status: "preview" }), + capability("online manual get", "Read one exact public SUSTech Online handbook record by deterministic handbook id or exact title.", "read", { status: "preview" }), capability("online contact search", "Search institutional public contacts selected from the community-maintained SUSTech Online repository.", "read", { status: "preview" }), capability("online contact get", "Read one exact institutional public contact with community-source provenance.", "read", { status: "preview" }), capability("context", "Compose a truthful current-date snapshot and optional live academic context with per-source availability.", "read", { status: "preview" }), @@ -43,16 +45,43 @@ export const CAPABILITIES: readonly Capability[] = [ capability("services status", "Report implemented, adapter-required, and unavailable service layers.", "local"), capability("papers search", "Search public CrossRef metadata with optional Unpaywall resolution.", "read"), capability("papers fetch-oa", "Download one Unpaywall-resolved OA PDF to an explicit guarded destination.", "mutation", { status: "preview" }), - capability("nces browse", "Browse public NCES community course evaluations.", "read", { status: "preview" }), - capability("nces search", "Search public NCES courses and review samples.", "read", { status: "preview" }), - capability("nces course", "Read one public NCES course and its reviews.", "read", { status: "preview" }), + capability("nces browse", "Browse public NCES community course evaluations with optional live offering-unit filtering.", "read", { status: "preview" }), + capability("nces filter-options", "Read the live NCES browse filter options.", "read", { status: "preview" }), + capability("nces global-stats", "Read public NCES-wide community counts, averages, and distributions.", "read", { status: "preview" }), + capability("nces rankings", "Read public NCES community ranking lists for teachers, courses, reviews, and users.", "read", { status: "preview" }), + capability("nces search", "Search public NCES courses, teachers, and review samples.", "read", { status: "preview" }), + capability("nces by-code", "Resolve one NCES course directly from its course code with optional term, teacher filters, and an explicit full-review option.", "read", { status: "preview" }), + capability("nces course", "Read one public NCES course plus its current review window, with an explicit full-review option.", "read", { status: "preview" }), + capability("nces reviews", "Read a filtered, paginated page of public community reviews for one NCES course.", "read", { status: "preview" }), + capability("nces teacher", "Read one public NCES teacher profile and its associated courses.", "read", { status: "preview" }), + capability("nces stats", "Read public community rating distributions and per-term statistics for one NCES course.", "read", { status: "preview" }), capability("bb user", "Read the authenticated Blackboard user profile.", "read", { authentication: "bb", status: "preview" }), capability("bb courses", "List the authenticated user's Blackboard courses.", "read", { authentication: "bb", status: "preview" }), capability("bb content", "List Blackboard content items for one course or folder.", "read", { authentication: "bb", status: "preview" }), + capability("bb tree", "Traverse one Blackboard course or folder recursively with partial-failure reporting.", "read", { authentication: "bb", status: "preview" }), + capability("bb types", "Summarize Blackboard content kinds across accessible course trees.", "read", { authentication: "bb", status: "preview" }), capability("bb attachments", "List teacher-provided files attached to one Blackboard content item.", "read", { authentication: "bb", status: "preview" }), capability("bb download", "Download one explicitly selected Blackboard content attachment to a local path.", "mutation", { authentication: "bb", status: "preview" }), - capability("bb assignments", "List Blackboard gradebook assignment columns.", "read", { authentication: "bb", status: "preview" }), - capability("bb deadlines", "Aggregate upcoming Blackboard assignment deadlines across accessible courses.", "read", { authentication: "bb", status: "preview" }), + capability("bb roster", "List Blackboard course memberships with optional role, availability, and paging filters.", "read", { authentication: "bb", status: "preview" }), + capability("bb message-folders", "List Blackboard course-message folders for one course with paging metadata.", "read", { authentication: "bb", status: "preview" }), + capability("bb messages", "List Blackboard course messages for one course with official folder filters and paging.", "read", { authentication: "bb", status: "preview" }), + capability("bb message-participants", "Read one paginated page of Blackboard course-message participants.", "read", { authentication: "bb", status: "preview" }), + capability("bb message-send preview", "Resolve one Blackboard course-message target, validate a text payload, and bind an exact message-send apply command.", "plan", { authentication: "bb", status: "preview" }), + capability("bb message-send apply", "Create one Blackboard course message from an exact reviewed text payload and recipient set.", "mutation", { authentication: "bb", confirmation: "required", status: "preview" }), + capability("bb discussions", "List Blackboard discussion forums for one course through the Learn REST discussion API, with an Original-course HTML forum-list fallback plus optional title, gradable, and paging filters.", "read", { authentication: "bb", status: "preview" }), + capability("bb discussion-groups", "List discussion-group associations for one Blackboard discussion when Blackboard exposes that discussion through the Learn REST API.", "read", { authentication: "bb", status: "preview" }), + capability("bb discussion", "Read one Blackboard discussion forum together with one paginated page of discussion messages through the Learn REST API, with an Original-course HTML thread-list fallback when REST is unsupported.", "read", { authentication: "bb", status: "preview" }), + capability("bb discussion-replies", "Read one paginated page of replies for a Blackboard discussion message through the Learn REST API, with an Original-course HTML thread-detail fallback when REST is unsupported.", "read", { authentication: "bb", status: "preview" }), + capability("bb discussion-post preview", "Resolve one Blackboard discussion target, validate a text payload, and bind an exact discussion-post apply command when the course's discussion REST API is available.", "plan", { authentication: "bb", status: "preview" }), + capability("bb discussion-post apply", "Create one Blackboard discussion message from an exact reviewed text payload when the course's discussion REST API is available.", "mutation", { authentication: "bb", confirmation: "required", status: "preview" }), + capability("bb discussion-reply preview", "Resolve one Blackboard discussion reply target, validate a text payload, and bind an exact discussion-reply apply command when the course's discussion REST API is available.", "plan", { authentication: "bb", status: "preview" }), + capability("bb discussion-reply apply", "Reply to one Blackboard discussion message from an exact reviewed text payload when the course's discussion REST API is available.", "mutation", { authentication: "bb", confirmation: "required", status: "preview" }), + capability("bb assignments", "List Blackboard gradebook assignment columns for one course or across accessible courses, optionally with per-assignment attempt summaries.", "read", { authentication: "bb", status: "preview" }), + capability("bb grades", "Summarize attempted Blackboard assignment results across accessible courses, with optional course and submission-state filters.", "read", { authentication: "bb", status: "preview" }), + capability("bb attempt-files", "List files already attached to one authenticated Blackboard assignment attempt.", "read", { authentication: "bb", status: "preview" }), + capability("bb attempt-download", "Download one file already attached to a Blackboard assignment attempt to an explicit local path.", "mutation", { authentication: "bb", status: "preview" }), + capability("bb announcements", "Aggregate recent Blackboard course announcements and visible system announcements.", "read", { authentication: "bb", status: "preview" }), + capability("bb deadlines", "Aggregate upcoming Blackboard assignment deadlines across accessible courses, with optional submission-state filtering.", "read", { authentication: "bb", status: "preview" }), capability("bb calendar", "Read authenticated Blackboard calendar items with optional date, type, and course filters.", "read", { authentication: "bb", status: "preview" }), capability("bb calendar-link set", "Validate and save a native Blackboard calendar subscription link in the operating-system credential store using stdin.", "mutation", { status: "preview" }), capability("bb calendar-link show", "Inspect a stored Blackboard calendar subscription link, masked unless explicitly revealed.", "local", { network: false, status: "preview" }), @@ -61,8 +90,8 @@ export const CAPABILITIES: readonly Capability[] = [ capability("bb search", "Search Blackboard course content and matching attachment names across accessible courses.", "read", { authentication: "bb", status: "preview" }), capability("bb sync", "Sync teacher-provided Blackboard attachments into an explicit local directory.", "mutation", { authentication: "bb", status: "preview" }), capability("bb attempts", "List the authenticated student's Blackboard attempts for one assignment.", "read", { authentication: "bb", status: "preview" }), - capability("bb submit preview", "Run authenticated read-only checks and bind a Blackboard submission plan to a file hash.", "plan", { authentication: "bb", status: "preview" }), - capability("bb submit apply", "Upload and submit a Blackboard assignment attempt.", "mutation", { authentication: "bb", confirmation: "required", status: "preview" }), + capability("bb submit preview", "Run authenticated read-only checks and bind a Blackboard submission plan to an exact file or text hash.", "plan", { authentication: "bb", status: "preview" }), + capability("bb submit apply", "Submit a Blackboard assignment attempt from an exact reviewed file or text source.", "mutation", { authentication: "bb", confirmation: "required", status: "preview" }), capability("ws programs", "List or search authenticated SUSTech Global programs.", "read", { authentication: "ws", status: "preview" }), capability("ws detail", "Read one authenticated SUSTech Global program detail.", "read", { authentication: "ws", status: "preview" }), capability("library search", "Search public Primo metadata through the normalized JSON path or an explicitly selected ephemeral browser session.", "read", { status: "preview" }), diff --git a/src/core/command-metadata.ts b/src/core/command-metadata.ts index 1f7f931..9e88b4f 100644 --- a/src/core/command-metadata.ts +++ b/src/core/command-metadata.ts @@ -16,9 +16,15 @@ export const CLI_PARSE_OPTIONS = { direction: { type: "string" }, "route-index": { type: "string" }, status: { type: "string" }, + "submission-state": { type: "string" }, "content-id": { type: "string" }, "column-id": { type: "string" }, file: { type: "string" }, + "text-file": { type: "string" }, + subject: { type: "string" }, + "to-user": { type: "string", multiple: true }, + "cc-user": { type: "string", multiple: true }, + "bcc-user": { type: "string", multiple: true }, comment: { type: "string" }, "expected-sha256": { type: "string" }, destination: { type: "string" }, @@ -26,8 +32,11 @@ export const CLI_PARSE_OPTIONS = { overwrite: { type: "boolean", default: false }, days: { type: "string" }, course: { type: "string" }, + role: { type: "string" }, + availability: { type: "string" }, kind: { type: "string" }, attachments: { type: "string" }, + gradable: { type: "string" }, live: { type: "boolean", default: false }, "allow-late": { type: "boolean", default: false }, "period-start": { type: "string" }, @@ -49,9 +58,11 @@ export const CLI_PARSE_OPTIONS = { minutes: { type: "string" }, category: { type: "string" }, section: { type: "string" }, + source: { type: "string", multiple: true }, page: { type: "string" }, "page-size": { type: "string" }, sort: { type: "string" }, + rating: { type: "string" }, "min-year": { type: "string" }, "open-access": { type: "boolean", default: false }, "resolve-oa": { type: "boolean", default: false }, @@ -93,11 +104,22 @@ export const CLI_PARSE_OPTIONS = { details: { type: "boolean", default: false }, since: { type: "string" }, until: { type: "string" }, + "group-id": { type: "string" }, + "user-id": { type: "string" }, + "is-read": { type: "string" }, "url-stdin": { type: "boolean", default: false }, reveal: { type: "boolean", default: false }, "include-blackboard": { type: "boolean", default: false }, browser: { type: "boolean", default: false }, interactive: { type: "boolean", default: false }, + "with-attempts": { type: "boolean", default: false }, + "all-reviews": { type: "boolean", default: false }, + teacher: { type: "string", multiple: true }, + term: { type: "string" }, + "offering-unit": { type: "string" }, + "folder-type": { type: "string" }, + "folder-name": { type: "string" }, + "participation-type": { type: "string" }, "early-period-threshold": { type: "string" }, "weight-early-session": { type: "string" }, "weight-gap-segment": { type: "string" }, @@ -120,8 +142,8 @@ export const COMMAND_OPTIONS: Readonly> "auth login": ["profile", "sid", "service", "password-stdin"], "auth status": ["profile"], "auth logout": ["profile"], - "auth check": ["service", "credentials-file", "profile"], - doctor: ["profile", "credentials-file", "service", "live"], + "auth check": ["service", "credentials-file", "profile", "browser", "interactive"], + doctor: ["profile", "credentials-file", "service", "live", "browser", "interactive"], "calendar terms": ["year", "calendar-level"], "calendar day": ["date", "calendar-level"], "academic snapshot save": ["credentials-file", "semester", "destination", "include-blackboard", "overwrite"], @@ -130,10 +152,12 @@ export const COMMAND_OPTIONS: Readonly> "academic watch": ["credentials-file", "semester", "state", "include-blackboard", "overwrite"], "faculty list": ["full", "limit"], "faculty search": ["department", "limit"], - "online search": ["section", "since", "until", "limit"], + "online search": ["section", "source", "since", "until", "limit"], "online talks list": ["since", "until", "limit"], "online talks search": ["since", "until", "limit"], "online talks get": [], + "online manual list": ["source", "limit"], + "online manual get": ["source"], "online contact search": ["limit"], "online contact get": [], context: ["date", "calendar-level", "level", "live", "credentials-file"], @@ -144,24 +168,53 @@ export const COMMAND_OPTIONS: Readonly> "wifi events": ["minutes"], "papers search": ["max", "min-year", "open-access", "resolve-oa"], "papers fetch-oa": ["destination", "overwrite"], - "nces browse": ["page", "page-size", "sort"], - "bb user": ["credentials-file"], - "bb courses": ["credentials-file"], - "bb content": ["credentials-file", "parent-id"], - "bb attachments": ["credentials-file"], - "bb download": ["credentials-file", "destination", "overwrite"], - "bb assignments": ["credentials-file"], - "bb deadlines": ["credentials-file", "days", "course"], - "bb calendar": ["credentials-file", "course-id", "type", "since", "until"], + "nces browse": ["page", "page-size", "sort", "offering-unit"], + "nces filter-options": [], + "nces global-stats": [], + "nces rankings": ["limit"], + "nces search": ["page", "page-size", "type"], + "nces by-code": ["term", "teacher", "all-reviews"], + "nces course": ["all-reviews"], + "nces reviews": ["page", "page-size", "sort", "term", "rating"], + "nces teacher": [], + "nces stats": [], + "bb user": ["credentials-file", "browser", "interactive"], + "bb courses": ["credentials-file", "browser", "interactive"], + "bb content": ["credentials-file", "parent-id", "browser", "interactive"], + "bb tree": ["credentials-file", "content-id", "max", "browser", "interactive"], + "bb types": ["credentials-file", "course", "browser", "interactive"], + "bb attachments": ["credentials-file", "browser", "interactive"], + "bb download": ["credentials-file", "destination", "overwrite", "browser", "interactive"], + "bb roster": ["credentials-file", "role", "availability", "page", "page-size", "sort", "browser", "interactive"], + "bb message-folders": ["credentials-file", "page", "page-size", "browser", "interactive"], + "bb messages": ["credentials-file", "folder-type", "folder-name", "page", "page-size", "sort", "browser", "interactive"], + "bb message-participants": ["credentials-file", "participation-type", "page", "page-size", "sort", "browser", "interactive"], + "bb message-send preview": ["credentials-file", "subject", "to-user", "cc-user", "bcc-user", "text-file", "browser", "interactive"], + "bb message-send apply": ["credentials-file", "subject", "to-user", "cc-user", "bcc-user", "text-file", "expected-sha256", "confirm"], + "bb discussions": ["credentials-file", "title", "gradable", "page", "page-size", "sort", "browser", "interactive"], + "bb discussion-groups": ["credentials-file", "page", "page-size", "sort", "browser", "interactive"], + "bb discussion": ["credentials-file", "group-id", "user-id", "status", "is-read", "page", "page-size", "sort", "browser", "interactive"], + "bb discussion-replies": ["credentials-file", "group-id", "user-id", "status", "is-read", "page", "page-size", "sort", "browser", "interactive"], + "bb discussion-post preview": ["credentials-file", "group-id", "status", "text-file", "browser", "interactive"], + "bb discussion-post apply": ["credentials-file", "group-id", "status", "text-file", "expected-sha256", "confirm"], + "bb discussion-reply preview": ["credentials-file", "group-id", "status", "text-file", "browser", "interactive"], + "bb discussion-reply apply": ["credentials-file", "group-id", "status", "text-file", "expected-sha256", "confirm"], + "bb assignments": ["credentials-file", "course", "with-attempts", "submission-state", "browser", "interactive"], + "bb grades": ["credentials-file", "course", "submission-state", "limit", "browser", "interactive"], + "bb attempt-files": ["credentials-file", "browser", "interactive"], + "bb attempt-download": ["credentials-file", "destination", "overwrite", "browser", "interactive"], + "bb announcements": ["credentials-file", "days", "course", "browser", "interactive"], + "bb deadlines": ["credentials-file", "days", "course", "submission-state", "browser", "interactive"], + "bb calendar": ["credentials-file", "course-id", "type", "since", "until", "browser", "interactive"], "bb calendar-link set": ["profile", "url-stdin"], "bb calendar-link show": ["profile", "reveal"], "bb calendar-link fetch": ["profile", "destination", "overwrite"], "bb calendar-link delete": ["profile"], - "bb search": ["credentials-file", "course", "kind", "attachments", "page", "page-size"], - "bb sync": ["credentials-file", "content-id", "destination", "overwrite"], - "bb attempts": ["credentials-file", "content-id", "column-id", "status"], - "bb submit preview": ["credentials-file", "course-id", "content-id", "column-id", "file", "comment"], - "bb submit apply": ["credentials-file", "course-id", "content-id", "column-id", "file", "expected-sha256", "comment", "allow-late", "confirm"], + "bb search": ["credentials-file", "course", "kind", "attachments", "page", "page-size", "browser", "interactive"], + "bb sync": ["credentials-file", "content-id", "destination", "overwrite", "browser", "interactive"], + "bb attempts": ["credentials-file", "content-id", "column-id", "status", "browser", "interactive"], + "bb submit preview": ["credentials-file", "course-id", "content-id", "column-id", "file", "text-file", "comment", "browser", "interactive"], + "bb submit apply": ["credentials-file", "course-id", "content-id", "column-id", "file", "text-file", "expected-sha256", "comment", "allow-late", "confirm"], "ws programs": ["credentials-file", "page", "page-size"], "ws detail": ["credentials-file", "program-code", "program-token"], "library search": ["limit", "browser", "interactive"], diff --git a/src/core/consequences.ts b/src/core/consequences.ts index f511b00..db650c8 100644 --- a/src/core/consequences.ts +++ b/src/core/consequences.ts @@ -22,11 +22,15 @@ export const CONSEQUENCES: readonly Consequence[] = [ consequence("tis.bid", "high", false, "Changes the bid assigned to one or more selectable course sections.", "A wrong bid can reduce the chance of obtaining a course or oversubscribe the round budget.", "Read cart/enrolled plus round state back and match the exact RWH and bid values.", "implemented"), consequence("tis.evaluation.submit", "high", true, "Submits a teaching evaluation.", "Submitted answers may be final and apply to the wrong teacher or course.", "Read evaluation status back and confirm only the intended task changed.", "unavailable"), consequence("blackboard.download", "low", true, "Writes one selected teacher-provided attachment to an explicit local path.", "A wrong destination, combined with --overwrite, can replace an existing local file.", "Use the returned absolute path, byte count, and SHA-256 to verify the saved file.", "implemented"), + consequence("blackboard.attempt-download", "low", true, "Writes one selected file from a Blackboard assignment attempt to an explicit local path.", "A wrong destination, combined with --overwrite, can replace an existing local file.", "Use the returned absolute path, byte count, and SHA-256 to verify the saved file.", "implemented"), consequence("blackboard.sync", "medium", true, "Writes multiple selected Blackboard attachments under an explicit local directory.", "A wrong destination, or reuse with --overwrite, can replace local files while syncing several items.", "Review the returned file list, absolute paths, byte counts, and SHA-256 hashes before using the synced copy.", "implemented"), consequence("blackboard.calendar-link.store", "medium", false, "Stores or replaces one private Blackboard calendar subscription link in the operating-system credential store.", "Selecting the wrong profile or link can make later calendar refreshes expose another account's events.", "Run bb calendar-link show without --reveal for the exact profile and confirm the masked link plus persistent backend.", "implemented"), consequence("blackboard.calendar-link.fetch", "medium", true, "Optionally writes the personal Blackboard calendar feed to an explicit local iCalendar path.", "The calendar contains personal course events, and --overwrite can replace an existing local file.", "Verify the returned absolute path, byte count, SHA-256 digest, masked source, and profile.", "implemented"), consequence("blackboard.calendar-link.delete", "medium", false, "Deletes one private Blackboard calendar subscription link from the operating-system credential store.", "Local refreshes using that profile will stop until the link is stored again.", "Run bb calendar-link show for the exact profile and confirm it reports that no link is configured.", "implemented"), consequence("blackboard.submit", "critical", true, "Uploads and submits an assignment attempt.", "The wrong file or assignment can affect grading and may consume an attempt.", "Read attempt status and submitted filename back; retain the receipt when Blackboard exposes one.", "implemented"), + consequence("blackboard.message-send", "high", false, "Creates one Blackboard course message for the selected recipients in a specific course.", "A wrong course, recipient set, subject, or body can notify unintended classmates or staff.", "Read the created message back and match the exact message ID, recipients, subject, and body.", "implemented"), + consequence("blackboard.discussion-post", "high", false, "Creates one Blackboard discussion message in a specific course forum.", "A wrong discussion, group, or message body can become visible to classmates or staff.", "Read the created discussion message back and match the exact message ID, body, and status.", "implemented"), + consequence("blackboard.discussion-reply", "high", false, "Creates one Blackboard reply under a specific discussion message.", "A wrong reply target or body can notify or mislead other course participants.", "Read the created reply back and match the exact reply ID, parent message ID, body, and status.", "implemented"), consequence("booking.create", "medium", false, "Creates a campus room reservation.", "It can occupy a limited room slot and affect other users.", "Read the user's meetings back and match the exact room, title, and time.", "implemented"), consequence("booking.cancel", "high", true, "Cancels an existing room reservation.", "The released slot may be taken and cannot be guaranteed to return.", "Read the user's meetings back and confirm the exact meeting ID is absent.", "implemented"), consequence("library-booking.create", "medium", false, "Creates a library room reservation.", "It uses reservation quota and may block a scarce room.", "Read reservations back and match the exact devId, title, and time window.", "implemented"), diff --git a/src/mcp/public-tool-names.ts b/src/mcp/public-tool-names.ts index dd8e6c5..4a846ee 100644 --- a/src/mcp/public-tool-names.ts +++ b/src/mcp/public-tool-names.ts @@ -1,13 +1,29 @@ export const PUBLIC_MCP_TOOL_BY_COMMAND = { consequences: "sustech_consequences", + "calendar day": "sustech_calendar_day", "calendar terms": "sustech_calendar_terms", + "online search": "sustech_online_search", + "online talks list": "sustech_online_talks_list", + "online talks search": "sustech_online_talks_search", + "online talks get": "sustech_online_talks_get", + "online manual list": "sustech_online_manual_list", + "online manual get": "sustech_online_manual_get", + "online contact search": "sustech_online_contact_search", + "online contact get": "sustech_online_contact_get", "resources list": "sustech_resources_list", "resources search": "sustech_resources_search", "services status": "sustech_services_status", "papers search": "sustech_papers_search", "nces browse": "sustech_nces_browse", + "nces filter-options": "sustech_nces_filter_options", + "nces global-stats": "sustech_nces_global_stats", + "nces rankings": "sustech_nces_rankings", "nces search": "sustech_nces_search", + "nces by-code": "sustech_nces_by_code", "nces course": "sustech_nces_course", + "nces reviews": "sustech_nces_reviews", + "nces teacher": "sustech_nces_teacher", + "nces stats": "sustech_nces_stats", "library search": "sustech_library_search", "library detail": "sustech_library_detail", "library search-url": "sustech_library_search_url", diff --git a/src/mcp/public-tools.ts b/src/mcp/public-tools.ts index 8aa701d..3d43e07 100644 --- a/src/mcp/public-tools.ts +++ b/src/mcp/public-tools.ts @@ -15,6 +15,13 @@ const PRIMO_SEGMENT = z.string().trim().min(1).max(500) const RESOURCE_CATEGORIES = ["official", "academic", "maps", "papers", "community"] as const satisfies readonly ResourceCategory[]; const TRANSIT_DAY_TYPES = ["workday", "holiday"] as const; const NCES_SORTS = ["rating", "reviews", "name"] as const; +const NCES_SEARCH_TYPES = ["all", "course", "teacher", "review"] as const; +const NCES_REVIEW_SORTS = ["helpful", "newest", "oldest", "rating-high", "rating-low"] as const; +const NCES_RANKING_CATEGORIES = ["top-teachers", "top-rated-courses", "popular-courses", "top-reviews", "long-reviews", "top-users"] as const; +const NCES_COURSE_CODE = z.string().trim().regex(/^[A-Za-z0-9._-]{1,40}$/u, "Unsupported NCES course-code format."); +const NCES_TERM = z.string().regex(/^\d{5}$/u, "Expected a five-digit NCES term ID."); +const NCES_OFFERING_UNIT = z.string().trim().min(1).max(200); +const NCES_TEACHER = z.string().trim().min(1).max(200); const FACULTY_LIMIT = z.number().int().min(1).max(200); const TRANSIT_LIMIT = z.number().int().min(1).max(100); const PAPERS_MAX = z.number().int().min(1).max(100); @@ -134,45 +141,166 @@ export function registerPublicMcpTools(server: McpServer): void { PUBLIC_MCP_TOOL_BY_COMMAND["nces browse"], { title: "Browse NCES community course evaluations", - description: "Browse public NCES courses by page, page size, and supported sort order.", + description: "Browse public NCES courses by page, page size, supported sort order, and optional live offering-unit filter.", inputSchema: z.object({ page: NCES_PAGE.optional(), pageSize: NCES_PAGE_SIZE.optional(), sort: z.enum(NCES_SORTS).optional(), + offeringUnit: NCES_OFFERING_UNIT.optional(), }), annotations: readOnlyAnnotations(true), }, - async ({ page, pageSize, sort }, ctx) => runTypedCommand("nces browse", [ + async ({ page, pageSize, sort, offeringUnit }, ctx) => runTypedCommand("nces browse", [ ...numberOption("--page", page), ...numberOption("--page-size", pageSize), ...option("--sort", sort), + ...option("--offering-unit", offeringUnit), + ], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["nces filter-options"], + { + title: "Read NCES browse filter options", + description: "Read the live NCES offering-unit values accepted by course browse.", + inputSchema: z.object({}), + annotations: readOnlyAnnotations(true), + }, + async (_input, ctx) => runTypedCommand("nces filter-options", [], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["nces global-stats"], + { + title: "Read NCES global stats", + description: "Read NCES-wide public counts, averages, and distributions.", + inputSchema: z.object({}), + annotations: readOnlyAnnotations(true), + }, + async (_input, ctx) => runTypedCommand("nces global-stats", [], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["nces rankings"], + { + title: "Read NCES rankings", + description: "Read one public NCES ranking list for teachers, courses, reviews, or users.", + inputSchema: z.object({ + category: z.enum(NCES_RANKING_CATEGORIES), + limit: z.number().int().min(1).max(50).optional(), + }), + annotations: readOnlyAnnotations(true), + }, + async ({ category, limit }, ctx) => runTypedCommand("nces rankings", [ + category, + ...numberOption("--limit", limit), ], ctx.mcpReq.signal), ); server.registerTool( PUBLIC_MCP_TOOL_BY_COMMAND["nces search"], { - title: "Search NCES courses", - description: "Search public NCES courses and review samples by keyword.", + title: "Search NCES courses, teachers, and reviews", + description: "Search public NCES course, teacher, or review buckets by keyword.", inputSchema: z.object({ query: QUERY, + page: NCES_PAGE.optional(), + pageSize: NCES_PAGE_SIZE.optional(), + type: z.enum(NCES_SEARCH_TYPES).optional(), }), annotations: readOnlyAnnotations(true), }, - async ({ query }, ctx) => runTypedCommand("nces search", [query], ctx.mcpReq.signal), + async ({ query, page, pageSize, type }, ctx) => runTypedCommand("nces search", [ + query, + ...numberOption("--page", page), + ...numberOption("--page-size", pageSize), + ...option("--type", type), + ], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["nces by-code"], + { + title: "Resolve one NCES course by code", + description: "Resolve a public NCES course by exact course code with optional term and teacher disambiguation, then return its current community detail with an optional full-review expansion.", + inputSchema: z.object({ + code: NCES_COURSE_CODE, + term: NCES_TERM.optional(), + teacher: z.array(NCES_TEACHER).min(1).max(20).optional(), + allReviews: z.boolean().optional(), + }), + annotations: readOnlyAnnotations(true), + }, + async ({ code, term, teacher, allReviews }, ctx) => runTypedCommand("nces by-code", [ + code, + ...option("--term", term), + ...repeatedOptions("--teacher", teacher), + ...(allReviews ? ["--all-reviews"] : []), + ], ctx.mcpReq.signal), ); server.registerTool( PUBLIC_MCP_TOOL_BY_COMMAND["nces course"], { title: "Read one NCES course", - description: "Read one public NCES course and its reviews by numeric course identifier.", + description: "Read one public NCES course by numeric identifier, with an optional full-review expansion.", inputSchema: z.object({ id: z.number().int().min(1), + allReviews: z.boolean().optional(), }), annotations: readOnlyAnnotations(true), }, - async ({ id }, ctx) => runTypedCommand("nces course", [String(id)], ctx.mcpReq.signal), + async ({ id, allReviews }, ctx) => runTypedCommand("nces course", [ + String(id), + ...(allReviews ? ["--all-reviews"] : []), + ], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["nces reviews"], + { + title: "Read NCES course reviews", + description: "Read one filtered page of public community reviews for an NCES course.", + inputSchema: z.object({ + id: z.number().int().min(1), + page: NCES_PAGE.optional(), + pageSize: NCES_PAGE_SIZE.optional(), + sort: z.enum(NCES_REVIEW_SORTS).optional(), + term: NCES_TERM.optional(), + rating: z.number().int().min(1).max(10).optional(), + }), + annotations: readOnlyAnnotations(true), + }, + async ({ id, page, pageSize, sort, term, rating }, ctx) => runTypedCommand("nces reviews", [ + String(id), + ...numberOption("--page", page), + ...numberOption("--page-size", pageSize), + ...option("--sort", sort), + ...option("--term", term), + ...numberOption("--rating", rating), + ], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["nces teacher"], + { + title: "Read one NCES teacher", + description: "Read one public NCES teacher profile and its associated courses by numeric identifier.", + inputSchema: z.object({ id: z.number().int().min(1) }), + annotations: readOnlyAnnotations(true), + }, + async ({ id }, ctx) => runTypedCommand("nces teacher", [String(id)], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["nces stats"], + { + title: "Read NCES course statistics", + description: "Read public community rating distributions and per-term statistics for one NCES course.", + inputSchema: z.object({ id: z.number().int().min(1) }), + annotations: readOnlyAnnotations(true), + }, + async ({ id }, ctx) => runTypedCommand("nces stats", [String(id)], ctx.mcpReq.signal), ); server.registerTool( @@ -418,6 +546,10 @@ function numberOption(name: string, value: number | undefined): string[] { return value === undefined ? [] : [name, String(value)]; } +function repeatedOptions(name: string, values: readonly string[] | undefined): string[] { + return values?.flatMap((value) => [name, value]) ?? []; +} + function readOnlyAnnotations(openWorldHint: boolean) { return { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint }; } diff --git a/src/mcp/registry.ts b/src/mcp/registry.ts index 428e3bc..ab518c2 100644 --- a/src/mcp/registry.ts +++ b/src/mcp/registry.ts @@ -5,13 +5,6 @@ export const MCP_TOOL_BY_COMMAND = { capabilities: "sustech_discover", describe: "sustech_describe", version: "sustech_version", - "calendar day": "sustech_calendar_day", - "online search": "sustech_online_search", - "online talks list": "sustech_online_talks_list", - "online talks search": "sustech_online_talks_search", - "online talks get": "sustech_online_talks_get", - "online contact search": "sustech_online_contact_search", - "online contact get": "sustech_online_contact_get", ...PUBLIC_MCP_TOOL_BY_COMMAND, } as const; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 0dbeb4d..6b4a8e6 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -6,6 +6,7 @@ import { CAPABILITIES } from "../core/capabilities.js"; import { CLI_VERSION } from "../core/version.js"; import { describeCliForMcp, runCliForMcp, type McpRunnerOptions } from "./runner.js"; import { registerSustechMcpPrompts } from "./prompts.js"; +import { PUBLIC_MCP_TOOL_BY_COMMAND } from "./public-tool-names.js"; import { isMcpExecutableCapability, mcpToolForCommand } from "./registry.js"; import { registerPublicMcpTools } from "./public-tools.js"; import { registerSustechMcpResources } from "./resources.js"; @@ -18,6 +19,7 @@ const ISO_DATE = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD.") const QUERY = z.string().trim().min(1).max(500); const IDENTIFIER = z.string().trim().min(1).max(500); const LIMIT = z.number().int().min(1).max(200); +const ONLINE_MANUAL_SOURCE = z.enum(["service", "study", "transport", "life", "facility", "calendar"]); export interface SustechMcpServerOptions { runner?: Pick; @@ -137,22 +139,36 @@ export function createSustechMcpServer(options: SustechMcpServerOptions = {}): M "sustech_online_search", { title: "Search selected SUSTech Online public information", - description: "Search talks and institutional contacts from selected community-maintained SUSTech Online pages, with provenance and freshness metadata.", - inputSchema: z.object({ - query: QUERY, - section: z.enum(["talks", "contact"]).optional(), - since: ISO_DATE.optional(), - until: ISO_DATE.optional(), - limit: LIMIT.optional(), - }), + description: "Search selected handbook sections, talks, and institutional contacts from community-maintained SUSTech Online pages, with provenance and freshness metadata.", + inputSchema: z.union([ + z.object({ + query: QUERY, + section: z.literal("talks").optional(), + since: ISO_DATE.optional(), + until: ISO_DATE.optional(), + limit: LIMIT.optional(), + }).strict(), + z.object({ + query: QUERY, + section: z.literal("contact"), + limit: LIMIT.optional(), + }).strict(), + z.object({ + query: QUERY, + section: z.literal("manual"), + source: z.array(ONLINE_MANUAL_SOURCE).min(1).max(6).optional(), + limit: LIMIT.optional(), + }).strict(), + ]), annotations: readOnlyAnnotations(true), }, - async ({ query, section, since, until, limit }, ctx) => execute("online search", [ - query, - ...option("--section", section), - ...option("--since", since), - ...option("--until", until), - ...numberOption("--limit", limit), + async (input, ctx) => execute("online search", [ + input.query, + ...option("--section", input.section), + ...repeatedOptions("--source", "source" in input ? input.source : undefined), + ...option("--since", "since" in input ? input.since : undefined), + ...option("--until", "until" in input ? input.until : undefined), + ...numberOption("--limit", input.limit), ], ctx.mcpReq.signal), ); @@ -188,7 +204,7 @@ export function createSustechMcpServer(options: SustechMcpServerOptions = {}): M ); server.registerTool( - "sustech_online_talks_get", + PUBLIC_MCP_TOOL_BY_COMMAND["online talks get"], { title: "Read one public SUSTech talk", description: "Read one exact talk by the stable identifier returned by a talks list or search.", @@ -199,7 +215,41 @@ export function createSustechMcpServer(options: SustechMcpServerOptions = {}): M ); server.registerTool( - "sustech_online_contact_search", + PUBLIC_MCP_TOOL_BY_COMMAND["online manual list"], + { + title: "List selected SUSTech Online handbook records", + description: "List public handbook records from the selected SUSTech Online allowlist, with community-source provenance and freshness metadata.", + inputSchema: z.object({ + source: z.array(ONLINE_MANUAL_SOURCE).min(1).max(6).optional(), + limit: LIMIT.optional(), + }), + annotations: readOnlyAnnotations(true), + }, + async ({ source, limit }, ctx) => execute("online manual list", [ + ...repeatedOptions("--source", source), + ...numberOption("--limit", limit), + ], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["online manual get"], + { + title: "Read one SUSTech Online handbook record", + description: "Read one exact public handbook record by the deterministic id returned by list/search, or by exact title, with community-source provenance.", + inputSchema: z.object({ + identifier: QUERY, + source: z.array(ONLINE_MANUAL_SOURCE).min(1).max(6).optional(), + }), + annotations: readOnlyAnnotations(true), + }, + async ({ identifier, source }, ctx) => execute("online manual get", [ + identifier, + ...repeatedOptions("--source", source), + ], ctx.mcpReq.signal), + ); + + server.registerTool( + PUBLIC_MCP_TOOL_BY_COMMAND["online contact search"], { title: "Search institutional SUSTech contacts", description: "Search selected public institutional contacts from SUSTech Online. Personal, social, finance, and emergency content is excluded.", @@ -210,7 +260,7 @@ export function createSustechMcpServer(options: SustechMcpServerOptions = {}): M ); server.registerTool( - "sustech_online_contact_get", + PUBLIC_MCP_TOOL_BY_COMMAND["online contact get"], { title: "Read one institutional SUSTech contact", description: "Read one exact institutional public contact by the stable identifier returned by contact search.", @@ -263,6 +313,10 @@ function numberOption(name: string, value: number | undefined): string[] { return value === undefined ? [] : [name, String(value)]; } +function repeatedOptions(name: string, values: readonly string[] | undefined): string[] { + return values === undefined ? [] : values.flatMap((value) => [name, value]); +} + function readOnlyAnnotations(openWorldHint: boolean) { return { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint }; } diff --git a/src/online/index.ts b/src/online/index.ts index 62190f6..f3bffff 100644 --- a/src/online/index.ts +++ b/src/online/index.ts @@ -1,5 +1,7 @@ export * from "./contact.js"; export * from "./contact-text.js"; +export * from "./manual.js"; +export * from "./manual-text.js"; export * from "./search.js"; export * from "./shared.js"; export * from "./talks.js"; diff --git a/src/online/manual-text.ts b/src/online/manual-text.ts new file mode 100644 index 0000000..3a4c81c --- /dev/null +++ b/src/online/manual-text.ts @@ -0,0 +1,52 @@ +import { formatOnlineAdvisories } from "./shared.js"; +import type { OnlineManualRecord, OnlineManualSourceStatus } from "./manual.js"; + +export function formatOnlineManualRecords( + records: readonly OnlineManualRecord[], + title = "SUSTech Online manual", + options: { partial?: boolean; sourceStatuses?: readonly OnlineManualSourceStatus[] } = {}, +): string { + const statusLine = formatPartialStatus(options.partial, options.sourceStatuses); + if (records.length === 0) return `${title}\n\nNo public handbook records matched.${statusLine}`; + const blocks = records.map((record, index) => [ + `${index + 1}. ${record.title}`, + ` Id: ${record.id}`, + ` Source: ${record.sourceTitle} [${record.sourceKey}]`, + ` Section: ${record.sectionPath}`, + ` Summary: ${record.summary}`, + record.links[0]?.url ? ` Link: ${record.links[0].url}` : ` Page: ${record.pageUrl}`, + ` Provenance: ${record.pageRepoPath}${record.provenance.sourceUpdatedAt ? ` · updated ${record.provenance.sourceUpdatedAt}` : ""} · ${record.provenance.license}`, + ` Advisories: ${formatOnlineAdvisories(record.provenance.advisories)}`, + ].filter(Boolean).join("\n")); + return `${title}\n\n${blocks.join("\n\n")}\n\n${records.length} record(s).${statusLine}`; +} + +export function formatOnlineManualRecord( + record: OnlineManualRecord, + options: { partial?: boolean; sourceStatuses?: readonly OnlineManualSourceStatus[] } = {}, +): string { + const lines = [ + `SUSTech Online manual · ${record.title}`, + "", + `Id: ${record.id}`, + `Source: ${record.sourceTitle} [${record.sourceKey}]`, + `Section: ${record.sectionPath}`, + `Summary: ${record.summary}`, + `Page: ${record.pageUrl}`, + record.links.length > 0 ? `Links: ${record.links.map((link) => `${link.text} → ${link.url}`).join(" | ")}` : "", + `Content: ${record.content}`, + `Provenance: ${record.pageRepoPath}${record.provenance.sourceUpdatedAt ? ` · updated ${record.provenance.sourceUpdatedAt}` : ""} · ${record.provenance.license}`, + `Advisories: ${formatOnlineAdvisories(record.provenance.advisories)}`, + ].filter(Boolean); + const statusLine = formatPartialStatus(options.partial, options.sourceStatuses); + return lines.join("\n") + statusLine; +} + +function formatPartialStatus( + partial: boolean | undefined, + sourceStatuses: readonly OnlineManualSourceStatus[] | undefined, +): string { + if (!partial) return ""; + const incomplete = sourceStatuses?.filter((status) => status.status !== "ok") ?? []; + return `\n\nPartial result: ${incomplete.length} manual source(s) were unavailable or no longer matched the allowlist.`; +} diff --git a/src/online/manual.ts b/src/online/manual.ts new file mode 100644 index 0000000..7b21374 --- /dev/null +++ b/src/online/manual.ts @@ -0,0 +1,800 @@ +import { CliError } from "../core/errors.js"; +import { USER_AGENT } from "../core/version.js"; +import { collapseWhitespace, createFetchAdapter, sampleText, ServiceError, type ServiceAdapter } from "../services/base.js"; +import { + buildOnlineProvenance, + makeOnlineId, + ONLINE_DEFAULT_STALE_AFTER_DAYS, + ONLINE_DEFAULT_TIMEOUT_MS, + ONLINE_MAX_DOCUMENT_BYTES, + ONLINE_RAW_ORIGIN, + ONLINE_REPO_BRANCH, + ONLINE_REPO_NAME, + ONLINE_REPO_OWNER, + ONLINE_SITE_ORIGIN, + scoreSearchMatch, + type OnlineFetchOptions, +} from "./shared.js"; +import type { OnlineProvenance } from "./types.js"; + +export type OnlineManualSourceKey = "calendar" | "facility" | "life" | "service" | "study" | "transport"; +export type OnlineManualSourceState = "error" | "invalid" | "ok"; + +export interface OnlineManualLink { + text: string; + url: string; +} + +export interface OnlineManualRecord { + kind: "manual"; + id: string; + sourceKey: OnlineManualSourceKey; + sourceTitle: string; + title: string; + headingPath: string[]; + sectionPath: string; + sectionLevel: number; + pageUrl: string; + pageRepoPath: string; + summary: string; + content: string; + links: OnlineManualLink[]; + provenance: OnlineProvenance; +} + +export interface OnlineManualSourceStatus { + sourceKey: OnlineManualSourceKey; + sourceTitle: string; + sourceRepoPath: string; + sourceUrl: string; + status: OnlineManualSourceState; + fetchedAt: string; + sourceUpdatedAt?: string; + pageMetadataAvailable: boolean; + recordCount: number; + message?: string; +} + +export interface OnlineManualCorpus { + records: OnlineManualRecord[]; + sourceStatuses: OnlineManualSourceStatus[]; +} + +export interface OnlineManualSearchReport { + records: OnlineManualRecord[]; + matchedTotal: number; + sourceStatuses: OnlineManualSourceStatus[]; + partial: boolean; +} + +export interface OnlineManualListReport { + records: OnlineManualRecord[]; + matchedTotal: number; + sourceStatuses: OnlineManualSourceStatus[]; + partial: boolean; +} + +export interface OnlineManualGetReport { + record: OnlineManualRecord; + sourceStatuses: OnlineManualSourceStatus[]; + partial: boolean; +} + +export interface OnlineManualQueryOptions extends OnlineFetchOptions { + limit?: number; + source?: OnlineManualSourceKey | readonly OnlineManualSourceKey[]; +} + +interface ManualSourceDefinition { + key: OnlineManualSourceKey; + title: string; + repoPath: string; + sitePath: string; + include(section: ParsedManualSection): boolean; +} + +interface ParsedManualSection { + level: number; + title: string; + path: string[]; + bodyMarkdown: string; +} + +interface OnlineManualMarkdownDocument { + markdown: string; + pageUpdatedAt?: string; + pageMetadataAvailable: boolean; + fetchedAt: string; +} + +interface LoadedSource { + definition: ManualSourceDefinition; + document?: OnlineManualMarkdownDocument; + error?: Error; +} + +const ONLINE_MANUAL_SOURCES: readonly ManualSourceDefinition[] = [ + { + key: "service", + title: "服务与技巧", + repoPath: "docs/service/README.md", + sitePath: "/service/", + include(section): boolean { + return matchesSectionTitle(section.title, [ + "学号", + "校园卡&学生证", + "校园网络", + "Ehall", + "计算机研究协会(CRA)", + "牛娃小镇", + "Sakai", + "联创打印系统", + "BlackBoard", + "电子邮件服务", + "企业微信", + "📦邮件与快递收发", + "学校已购买的软件与服务", + "教育邮箱福利", + "讨论间", + "电子数据库", + "图书馆荐购", + "教工/学生邮箱的报刊减免", + "文档模版", + ]); + }, + }, + { + key: "study", + title: "学在南科", + repoPath: "docs/study/README.md", + sitePath: "/study/", + include(section): boolean { + return matchesSectionTitle(section.title, [ + "讲座信息", + "学号(SID)", + "课程详述", + "📖学习建议与攻略", + "毕业生质量报告", + "GPA换算表(本科)", + "GPA换算表(研究生)", + "学生手册", + "培养方案(本科)", + "📗教材与图书借还", + ]); + }, + }, + { + key: "transport", + title: "交通", + repoPath: "docs/transport/README.md", + sitePath: "/transport/", + include(section): boolean { + return matchesSectionTitle(section.title, [ + "🚌校园巴士", + "来往南方科技大学的交通", + "市内交通", + "🗺抵达南方科技大学", + "来往附近城市的交通", + ]); + }, + }, + { + key: "life", + title: "生活在南科", + repoPath: "docs/life/README.md", + sitePath: "/life/", + include(section): boolean { + return matchesSectionTitle(section.title, [ + "住宿", + "校内介绍", + "超市", + "理发店", + "🏊‍♀️运动设施", + "Tips", + ]); + }, + }, + { + key: "facility", + title: "建筑与设施", + repoPath: "docs/facility/README.md", + sitePath: "/facility/", + include(section): boolean { + return matchesSectionTitle(section.title, [ + "校园街景", + "校园地图与主要建筑", + "琳恩图书馆", + "行政楼", + "第一教学楼", + "第三教学楼", + "湖畔宿舍群", + "游泳馆", + "二期宿舍", + "工学院", + ]); + }, + }, + { + key: "calendar", + title: "校历", + repoPath: "docs/calendar/README.md", + sitePath: "/calendar/", + include(section): boolean { + return section.level === 2; + }, + }, +]; + +const ONLINE_MANUAL_SOURCE_MAP = new Map(ONLINE_MANUAL_SOURCES.map((source) => [source.key, source])); +const ONLINE_MANUAL_REPO_PATHS = new Set(ONLINE_MANUAL_SOURCES.map((source) => source.repoPath)); +const ONLINE_MANUAL_SITE_PATHS = new Set(ONLINE_MANUAL_SOURCES.map((source) => source.sitePath)); +const ONLINE_MANUAL_LINK_LIMIT = 8; + +export const ONLINE_MANUAL_ENDPOINTS = ONLINE_MANUAL_SOURCES.flatMap((source) => [ + onlineManualRawUrl(source.repoPath), + onlineManualSiteUrl(source.sitePath), +]); + +export function createOnlineManualAdapter(fetchImpl: typeof fetch = globalThis.fetch): ServiceAdapter { + return createFetchAdapter(fetchImpl, "sustech-online-manual"); +} + +export function onlineManualRawUrl(repoPath: string): string { + assertAllowedManualRepoPath(repoPath); + const segments = repoPath.split("/").map((segment) => encodeURIComponent(segment)); + return `${ONLINE_RAW_ORIGIN}/${ONLINE_REPO_OWNER}/${ONLINE_REPO_NAME}/${ONLINE_REPO_BRANCH}/${segments.join("/")}`; +} + +export function onlineManualSiteUrl(sitePath: string): string { + assertAllowedManualSitePath(sitePath); + return new URL(sitePath, ONLINE_SITE_ORIGIN).toString(); +} + +export async function loadOnlineManualCorpus(options: OnlineManualQueryOptions = {}): Promise { + const sources = resolveManualSources(options.source); + const loaded = await Promise.all( + sources.map(async (definition): Promise => { + try { + const document = await fetchOnlineManualMarkdownDocument(definition, options); + return { definition, document }; + } catch (error) { + return { definition, error: error instanceof Error ? error : new Error(String(error)) }; + } + }), + ); + const records: OnlineManualRecord[] = []; + const sourceStatuses: OnlineManualSourceStatus[] = []; + for (const source of loaded) { + const fetchedAt = source.document?.fetchedAt ?? options.fetchedAt ?? new Date().toISOString(); + if (source.error) { + sourceStatuses.push({ + sourceKey: source.definition.key, + sourceTitle: source.definition.title, + sourceRepoPath: source.definition.repoPath, + sourceUrl: onlineManualSiteUrl(source.definition.sitePath), + status: "error", + fetchedAt, + pageMetadataAvailable: false, + recordCount: 0, + message: source.error.message, + }); + continue; + } + try { + const parsed = parseOnlineManualSource(source.definition, source.document!, { + staleAfterDays: options.staleAfterDays, + }); + records.push(...parsed.records); + sourceStatuses.push(parsed.status); + } catch (error) { + sourceStatuses.push({ + sourceKey: source.definition.key, + sourceTitle: source.definition.title, + sourceRepoPath: source.definition.repoPath, + sourceUrl: onlineManualSiteUrl(source.definition.sitePath), + status: "error", + fetchedAt, + pageMetadataAvailable: source.document?.pageMetadataAvailable ?? false, + recordCount: 0, + message: error instanceof Error ? error.message : String(error), + }); + } + } + return { + records: records.sort(compareManualRecords), + sourceStatuses, + }; +} + +export async function listOnlineManualRecords(options: OnlineManualQueryOptions = {}): Promise { + return (await listOnlineManualRecordsWithStatus(options)).records; +} + +export async function listOnlineManualRecordsWithStatus( + options: OnlineManualQueryOptions = {}, +): Promise { + const corpus = await loadOnlineManualCorpus({ ...options, limit: undefined }); + ensureManualCorpusUsable(corpus); + return { + records: applyManualLimit(corpus.records, options.limit), + matchedTotal: corpus.records.length, + sourceStatuses: corpus.sourceStatuses, + partial: corpus.sourceStatuses.some((status) => status.status !== "ok"), + }; +} + +export async function searchOnlineManual(query: string, options: OnlineManualQueryOptions = {}): Promise { + return (await searchOnlineManualWithStatus(query, options)).records; +} + +export async function searchOnlineManualWithStatus( + query: string, + options: OnlineManualQueryOptions = {}, +): Promise { + const needle = query.trim(); + if (!needle) throw new CliError("A search query is required.", "USAGE", 2); + const corpus = await loadOnlineManualCorpus({ ...options, limit: undefined }); + ensureManualCorpusUsable(corpus); + const ranked = corpus.records + .map((record) => ({ + record, + score: scoreSearchMatch(needle, [ + { value: record.id, weight: 8 }, + { value: record.title, weight: 10 }, + { value: record.sectionPath, weight: 7 }, + { value: record.summary, weight: 5 }, + { value: record.content, weight: 2 }, + { value: record.links.map((link) => link.text).join(" "), weight: 3 }, + ]), + })) + .filter((entry) => entry.score > 0) + .sort((left, right) => right.score - left.score || compareManualRecords(left.record, right.record)) + .map((entry) => entry.record); + return { + records: applyManualLimit(ranked, options.limit), + matchedTotal: ranked.length, + sourceStatuses: corpus.sourceStatuses, + partial: corpus.sourceStatuses.some((status) => status.status !== "ok"), + }; +} + +export async function getOnlineManualRecord(identifier: string, options: OnlineManualQueryOptions = {}): Promise { + return (await getOnlineManualRecordWithStatus(identifier, options)).record; +} + +export async function getOnlineManualRecordWithStatus( + identifier: string, + options: OnlineManualQueryOptions = {}, +): Promise { + const needle = identifier.trim(); + if (!needle) throw new CliError("A manual record id or exact title is required.", "USAGE", 2); + const corpus = await loadOnlineManualCorpus({ ...options, limit: undefined }); + ensureManualCorpusUsable(corpus); + const exactId = corpus.records.find((record) => record.id === needle); + if (exactId) { + return { + record: exactId, + sourceStatuses: corpus.sourceStatuses, + partial: corpus.sourceStatuses.some((status) => status.status !== "ok"), + }; + } + const exactSectionPath = corpus.records.find((record) => record.sectionPath === needle); + if (exactSectionPath) { + return { + record: exactSectionPath, + sourceStatuses: corpus.sourceStatuses, + partial: corpus.sourceStatuses.some((status) => status.status !== "ok"), + }; + } + const titleMatches = corpus.records.filter((record) => record.title === needle); + if (titleMatches.length === 1) { + return { + record: titleMatches[0]!, + sourceStatuses: corpus.sourceStatuses, + partial: corpus.sourceStatuses.some((status) => status.status !== "ok"), + }; + } + if (titleMatches.length > 1) { + throw new CliError( + "Multiple public SUSTech Online manual records matched that exact title; use the deterministic id or full section path instead.", + "ONLINE_MANUAL_LOOKUP_AMBIGUOUS", + 1, + { + query: needle, + matches: titleMatches.map((record) => ({ + id: record.id, + sourceKey: record.sourceKey, + sectionPath: record.sectionPath, + })), + }, + ); + } + const incomplete = corpus.sourceStatuses.filter((status) => status.status !== "ok"); + if (incomplete.length > 0) { + throw new CliError( + "The SUSTech Online manual lookup was incomplete; one or more allowlisted sources could not be verified.", + "ONLINE_MANUAL_LOOKUP_INCOMPLETE", + 1, + { query: needle, sources: incomplete.map((status) => ({ sourceKey: status.sourceKey, status: status.status })) }, + ); + } + throw new CliError("No public SUSTech Online manual record matched that exact id or title.", "ONLINE_MANUAL_NOT_FOUND", 1, { + query: needle, + }); +} + +function ensureManualCorpusUsable(corpus: OnlineManualCorpus): void { + if (corpus.sourceStatuses.some((status) => status.status === "ok")) return; + throw new CliError("No allowlisted SUSTech Online manual source could be read and parsed safely.", "ONLINE_MANUAL_UNAVAILABLE", 1, { + sources: corpus.sourceStatuses.map((status) => ({ sourceKey: status.sourceKey, status: status.status })), + }); +} + +export function parseOnlineManualSource( + definition: Pick, + document: OnlineManualMarkdownDocument, + options: { staleAfterDays?: number } = {}, +): { records: OnlineManualRecord[]; status: OnlineManualSourceStatus } { + const sections = parseManualSections(document.markdown); + const provenance = buildOnlineProvenance( + onlineManualSiteUrl(definition.sitePath), + definition.repoPath, + document.fetchedAt, + document.pageUpdatedAt, + options.staleAfterDays ?? ONLINE_DEFAULT_STALE_AFTER_DAYS, + { aiProcessed: false, sourceMetadataAvailable: document.pageMetadataAvailable }, + ); + const records = sections + .filter((section) => definition.include(section)) + .map((section) => sectionToManualRecord(definition, section, provenance)) + .filter((record): record is OnlineManualRecord => record !== undefined) + .sort(compareManualRecords); + const status: OnlineManualSourceStatus = { + sourceKey: definition.key, + sourceTitle: definition.title, + sourceRepoPath: definition.repoPath, + sourceUrl: onlineManualSiteUrl(definition.sitePath), + status: records.length > 0 ? "ok" : "invalid", + fetchedAt: document.fetchedAt, + ...(document.pageUpdatedAt ? { sourceUpdatedAt: document.pageUpdatedAt } : {}), + pageMetadataAvailable: document.pageMetadataAvailable, + recordCount: records.length, + ...(records.length === 0 ? { message: "No allowlisted manual sections were parsed from this source." } : {}), + }; + return { records, status }; +} + +async function fetchOnlineManualMarkdownDocument( + definition: Pick, + options: OnlineFetchOptions = {}, +): Promise { + assertAllowedManualRepoPath(definition.repoPath); + assertAllowedManualSitePath(definition.sitePath); + const adapter = options.adapter ?? createOnlineManualAdapter(); + const [markdownResult, pageResult] = await Promise.allSettled([ + fetchAllowlistedManualText(adapter, onlineManualRawUrl(definition.repoPath), { timeoutMs: options.timeoutMs, kind: "raw" }), + fetchAllowlistedManualText(adapter, onlineManualSiteUrl(definition.sitePath), { timeoutMs: options.timeoutMs, kind: "site" }), + ]); + if (markdownResult.status === "rejected") throw markdownResult.reason; + const pageHtml = pageResult.status === "fulfilled" ? pageResult.value : undefined; + return { + markdown: stripBom(markdownResult.value), + ...(pageHtml ? { pageUpdatedAt: extractLastUpdatedFromHtml(pageHtml) } : {}), + pageMetadataAvailable: pageResult.status === "fulfilled", + fetchedAt: options.fetchedAt ?? new Date().toISOString(), + }; +} + +function parseManualSections(markdown: string): ParsedManualSection[] { + const lines = stripBom(markdown).replace(/\r\n?/gu, "\n").split("\n"); + const headings: Array<{ index: number; level: number; title: string; path: string[] }> = []; + const stack: Array<{ level: number; title: string }> = []; + for (let index = 0; index < lines.length; index += 1) { + const match = /^(#{2,4})\s+(.+)$/u.exec(lines[index] ?? ""); + if (!match) continue; + const level = match[1].length; + const title = cleanHeadingTitle(match[2]); + if (!title) continue; + while (stack.length > 0 && stack[stack.length - 1]!.level >= level) stack.pop(); + stack.push({ level, title }); + headings.push({ index, level, title, path: stack.map((entry) => entry.title) }); + } + const sections: ParsedManualSection[] = []; + for (let index = 0; index < headings.length; index += 1) { + const current = headings[index]!; + let end = lines.length; + for (let nextIndex = index + 1; nextIndex < headings.length; nextIndex += 1) { + if (headings[nextIndex]!.level <= current.level) { + end = headings[nextIndex]!.index; + break; + } + } + const bodyMarkdown = lines.slice(current.index + 1, end).join("\n").trim(); + sections.push({ + level: current.level, + title: current.title, + path: current.path, + bodyMarkdown, + }); + } + return sections; +} + +function sectionToManualRecord( + definition: Pick, + section: ParsedManualSection, + provenance: OnlineProvenance, +): OnlineManualRecord | undefined { + const content = sanitizeManualText(section.bodyMarkdown); + const links = extractSafeManualLinks(section.bodyMarkdown, onlineManualSiteUrl(definition.sitePath)); + if (!content || (content.length < 12 && links.length === 0)) return undefined; + return { + kind: "manual", + id: makeOnlineId(`manual-${definition.key}`, section.path.join(" / ")), + sourceKey: definition.key, + sourceTitle: definition.title, + title: section.title, + headingPath: [...section.path], + sectionPath: section.path.join(" / "), + sectionLevel: section.level, + pageUrl: onlineManualSiteUrl(definition.sitePath), + pageRepoPath: definition.repoPath, + summary: sampleText(content, 220), + content, + links, + provenance, + }; +} + +function sanitizeManualText(value: string): string { + const withoutFences = value + .replace(/```[\s\S]*?```/gu, " ") + .replace(//giu, " ") + .replace(//giu, " ") + .replace(/!\[[^\]]*\]\(([^)]+)\)/gu, " ") + .replace(/]*>/giu, " ") + .replace(/<\/a>/giu, " ") + .replace(/\[([^\]]+)\]\(([^)]+)\)/gu, "$1") + .replace(/]*\/>/gu, " ") + .replace(/:::\s*(?:tip|warning|details)[^\n]*\n/giu, " ") + .replace(/^:::\s*$/gmu, " ") + .replace(/^\s*[-+*]\s+/gmu, " ") + .replace(/^\s*\d+\.\s+/gmu, " ") + .replace(/^\s*>\s*/gmu, " ") + .replace(/^\s*\|?(?:\s*:?-{3,}:?\s*\|)+\s*$/gmu, " ") + .replace(/\|/gu, " ") + .replace(/`([^`]+)`/gu, "$1") + .replace(/[*_~#]/gu, " ") + .replace(/<\/?[^>]+>/gu, " "); + return collapseWhitespace(withoutFences); +} + +function extractSafeManualLinks(markdown: string, pageUrl: string): OnlineManualLink[] { + const links: OnlineManualLink[] = []; + const seen = new Set(); + const markdownMatches = markdown.matchAll(/\[([^\]]+)\]\(([^)]+)\)/gu); + for (const match of markdownMatches) { + if (typeof match.index === "number" && match.index > 0 && markdown[match.index - 1] === "!") continue; + const text = collapseWhitespace(match[1] ?? ""); + const url = normalizeSafeManualUrl(match[2] ?? "", pageUrl); + if (!text || !url || seen.has(url)) continue; + seen.add(url); + links.push({ text, url }); + if (links.length >= ONLINE_MANUAL_LINK_LIMIT) return links; + } + const htmlMatches = markdown.matchAll(/]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/giu); + for (const match of htmlMatches) { + const text = collapseWhitespace(sanitizeManualText(match[2] ?? "")); + const url = normalizeSafeManualUrl(match[1] ?? "", pageUrl); + if (!text || !url || seen.has(url)) continue; + seen.add(url); + links.push({ text, url }); + if (links.length >= ONLINE_MANUAL_LINK_LIMIT) return links; + } + return links; +} + +function normalizeSafeManualUrl(value: string, pageUrl: string): string | undefined { + const candidate = value.trim(); + if (!candidate || candidate.startsWith("#")) return undefined; + try { + const url = new URL(candidate, pageUrl); + if (!/^https?:$/u.test(url.protocol)) return undefined; + return isAllowedManualLinkHostname(url.hostname) ? url.toString() : undefined; + } catch { + return undefined; + } +} + +function isAllowedManualLinkHostname(hostname: string): boolean { + const normalized = hostname.toLocaleLowerCase("en-US"); + return normalized === "sustech.online" + || normalized === "sustech.edu.cn" + || normalized.endsWith(".sustech.edu.cn"); +} + +function resolveManualSources(source: OnlineManualQueryOptions["source"]): ManualSourceDefinition[] { + if (source === undefined) return [...ONLINE_MANUAL_SOURCES]; + const keys = Array.isArray(source) ? source : [source]; + const resolved = keys.map((key) => ONLINE_MANUAL_SOURCE_MAP.get(key)); + if (resolved.some((entry) => entry === undefined)) { + throw new CliError("Manual source filters must use an allowlisted source key.", "USAGE", 2, { + source, + }); + } + return resolved as ManualSourceDefinition[]; +} + +function applyManualLimit(records: readonly OnlineManualRecord[], limit: number | undefined): OnlineManualRecord[] { + if (limit === undefined) return [...records]; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200) { + throw new CliError("Manual result limits must be integers from 1 to 200.", "USAGE", 2); + } + return records.slice(0, limit); +} + +function compareManualRecords(left: OnlineManualRecord, right: OnlineManualRecord): number { + return ( + left.sourceKey.localeCompare(right.sourceKey, "en-US") || + left.sectionPath.localeCompare(right.sectionPath, "zh-Hans-CN") || + left.id.localeCompare(right.id, "en-US") + ); +} + +function cleanHeadingTitle(value: string): string { + return collapseWhitespace( + value + .replace(/]*\/>/gu, " ") + .replace(/`([^`]+)`/gu, "$1") + .replace(/\[([^\]]+)\]\(([^)]+)\)/gu, "$1") + .replace(/[*_~#]/gu, " ") + .replace(/<\/?[^>]+>/gu, " "), + ).replace(/^[^\p{L}\p{N}]+/u, ""); +} + +function matchesSectionTitle(title: string, allowlistedTitles: readonly string[]): boolean { + const normalizedTitle = normalizeManualHeadingForMatch(title); + return allowlistedTitles.some((candidate) => normalizedTitle === normalizeManualHeadingForMatch(candidate)); +} + +function normalizeManualHeadingForMatch(value: string): string { + return collapseWhitespace(value) + .replace(/^[^\p{L}\p{N}]+/u, "") + .toLocaleLowerCase("zh-Hans-CN"); +} + +function assertAllowedManualRepoPath(repoPath: string): void { + if (ONLINE_MANUAL_REPO_PATHS.has(repoPath)) return; + throw new CliError("The requested SUSTech Online manual source is outside the allowlist.", "ONLINE_SOURCE_NOT_ALLOWED", 2, { + sourceRepoPath: repoPath, + }); +} + +function assertAllowedManualSitePath(sitePath: string): void { + if (ONLINE_MANUAL_SITE_PATHS.has(sitePath)) return; + throw new CliError("The requested SUSTech Online manual page is outside the allowlist.", "ONLINE_SOURCE_NOT_ALLOWED", 2, { + sourceUrl: sitePath, + }); +} + +function extractLastUpdatedFromHtml(html: string): string | undefined { + const match = /]+datetime="([^"]+)"/iu.exec(html); + const value = match?.[1]?.trim(); + if (!value) return undefined; + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : undefined; +} + +function stripBom(value: string): string { + return value.replace(/^\uFEFF/u, ""); +} + +async function fetchAllowlistedManualText( + adapter: ServiceAdapter, + url: string, + options: { timeoutMs?: number; kind: "raw" | "site" }, +): Promise { + let response: Response; + try { + response = await adapter.fetch(url, { + headers: { + accept: options.kind === "raw" ? "text/markdown, text/plain;q=0.9, */*;q=0.1" : "text/html, */*;q=0.1", + "user-agent": USER_AGENT, + }, + redirect: "error", + signal: AbortSignal.timeout(options.timeoutMs ?? ONLINE_DEFAULT_TIMEOUT_MS), + }); + } catch (error) { + throw new ServiceError("Could not reach the SUSTech Online manual source.", { + url, + cause: error instanceof Error ? error.message : String(error), + }); + } + validateFetchedManualUrl(response.url || url, url, options.kind); + const advertisedLength = Number(response.headers.get("content-length")); + if (Number.isFinite(advertisedLength) && advertisedLength > ONLINE_MAX_DOCUMENT_BYTES) { + throw new ServiceError("SUSTech Online manual returned an oversized document.", { + url, + status: response.status, + }); + } + const bytes = await readBoundedManualBody(response, url); + const text = new TextDecoder().decode(bytes); + if (!response.ok) { + throw new ServiceError("SUSTech Online manual returned an HTTP error.", { + url, + status: response.status, + bodySample: sampleText(text), + }); + } + return text; +} + +async function readBoundedManualBody(response: Response, url: string): Promise { + if (!response.body) return new Uint8Array(); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + for (;;) { + const chunk = await reader.read(); + if (chunk.done) break; + size += chunk.value.byteLength; + if (size > ONLINE_MAX_DOCUMENT_BYTES) { + await reader.cancel().catch(() => undefined); + throw new ServiceError("SUSTech Online manual returned an oversized document.", { + url, + status: response.status, + }); + } + chunks.push(chunk.value); + } + } catch (error) { + await reader.cancel().catch(() => undefined); + throw error; + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +function validateFetchedManualUrl(value: string, expected: string, kind: "raw" | "site"): void { + const url = new URL(value); + const expectedUrl = new URL(expected); + if (url.href !== expectedUrl.href) { + throw new CliError( + "The fetched SUSTech Online manual source escaped its exact allowlist target.", + "ONLINE_SOURCE_NOT_ALLOWED", + 2, + { sourceUrl: value }, + ); + } + if (kind === "raw") { + const expectedPrefix = `/${ONLINE_REPO_OWNER}/${ONLINE_REPO_NAME}/${ONLINE_REPO_BRANCH}/docs/`; + if (url.origin !== ONLINE_RAW_ORIGIN || !url.pathname.startsWith(expectedPrefix)) { + throw new CliError("The fetched SUSTech Online manual source escaped the raw allowlist.", "ONLINE_SOURCE_NOT_ALLOWED", 2, { + sourceUrl: value, + }); + } + assertAllowedManualRepoPath(decodeOnlinePath(url.pathname.slice(expectedPrefix.length - "docs/".length))); + return; + } + if (url.origin !== ONLINE_SITE_ORIGIN) { + throw new CliError("The fetched SUSTech Online manual page escaped the site allowlist.", "ONLINE_SOURCE_NOT_ALLOWED", 2, { + sourceUrl: value, + }); + } + assertAllowedManualSitePath(url.pathname); +} + +function decodeOnlinePath(pathname: string): string { + return pathname + .split("/") + .filter(Boolean) + .map((segment) => decodeURIComponent(segment)) + .join("/"); +} diff --git a/src/online/search.ts b/src/online/search.ts index 9673a9b..4a8011d 100644 --- a/src/online/search.ts +++ b/src/online/search.ts @@ -1,63 +1,140 @@ import { CliError } from "../core/errors.js"; import { searchOnlineContacts, contactSearchSnippet } from "./contact.js"; -import { formatOnlineAdvisories, scoreSearchMatch } from "./shared.js"; +import { + searchOnlineManualWithStatus, + type OnlineManualSourceKey, + type OnlineManualSourceStatus, +} from "./manual.js"; +import { formatOnlineAdvisories, ONLINE_SITE_ORIGIN, scoreSearchMatch } from "./shared.js"; import { searchOnlineTalks, talkSearchSnippet } from "./talks.js"; import type { OnlineFetchOptions } from "./shared.js"; import type { OnlineSearchHit } from "./types.js"; export interface OnlineSearchOptions extends OnlineFetchOptions { limit?: number; - section?: "talks" | "contact"; + section?: "talks" | "contact" | "manual"; + source?: OnlineManualSourceKey | readonly OnlineManualSourceKey[]; since?: string; until?: string; } +export interface OnlineSearchReport { + hits: OnlineSearchHit[]; + partial: boolean; + manualSourceStatuses: OnlineManualSourceStatus[]; + manualMatchedTotal?: number; +} + export async function searchOnline(query: string, options: OnlineSearchOptions = {}): Promise { + return (await searchOnlineWithStatus(query, options)).hits; +} + +export async function searchOnlineWithStatus( + query: string, + options: OnlineSearchOptions = {}, +): Promise { const withoutLimit = { ...options, limit: undefined }; - const [talks, contacts] = await Promise.all([ - options.section === "contact" ? Promise.resolve([]) : searchOnlineTalks(query, withoutLimit), - options.section === "talks" ? Promise.resolve([]) : searchOnlineContacts(query, withoutLimit), + const [talks, contacts, manualReport] = await Promise.all([ + options.section === "contact" || options.section === "manual" ? Promise.resolve([]) : searchOnlineTalks(query, withoutLimit), + options.section === "talks" || options.section === "manual" ? Promise.resolve([]) : searchOnlineContacts(query, withoutLimit), + options.section === "manual" + ? searchOnlineManualWithStatus(query, { + ...withoutLimit, + ...(options.source === undefined ? {} : { source: options.source }), + }) + : Promise.resolve({ records: [], matchedTotal: 0, sourceStatuses: [], partial: false }), ]); - const hits = [ + const candidates: Array<{ hit: OnlineSearchHit; extraSearchText?: string }> = [ ...talks.map((talk) => ({ - kind: "talk" as const, - id: talk.id, - title: talk.title, - subtitle: [talk.date, talk.timeText, talk.speakerLine].filter(Boolean).join(" · "), - snippet: talkSearchSnippet(talk), - url: talk.detailUrl, - provenance: talk.provenance, + hit: { + kind: "talk" as const, + id: talk.id, + title: talk.title, + subtitle: [talk.date, talk.timeText, talk.speakerLine].filter(Boolean).join(" · "), + snippet: talkSearchSnippet(talk), + url: talk.detailUrl, + provenance: talk.provenance, + }, })), ...contacts.map((contact) => ({ - kind: "contact" as const, - id: contact.id, - title: contact.name, - subtitle: contact.category, - snippet: contactSearchSnippet(contact), - url: contact.websiteUrl, - provenance: contact.provenance, + hit: { + kind: "contact" as const, + id: contact.id, + title: contact.name, + subtitle: contact.category, + snippet: contactSearchSnippet(contact), + url: contact.websiteUrl, + provenance: contact.provenance, + }, + })), + ...manualReport.records.map((record) => ({ + hit: { + kind: "manual" as const, + id: record.id, + title: record.title, + subtitle: `${record.sourceTitle} · ${record.sectionPath}`, + snippet: record.summary, + url: preferredManualResultUrl(record), + provenance: record.provenance, + }, + extraSearchText: `${record.content} ${record.links.map((link) => link.text).join(" ")}`, })), ]; - const ranked = hits - .map((hit) => ({ + const ranked = candidates + .map(({ hit, extraSearchText }) => ({ hit, score: scoreSearchMatch(query, [ { value: hit.title, weight: 10 }, { value: hit.subtitle, weight: 7 }, { value: hit.snippet, weight: 3 }, + { value: extraSearchText, weight: 2 }, ]), })) .sort((left, right) => right.score - left.score || left.hit.title.localeCompare(right.hit.title, "zh-Hans-CN")) .map((entry) => entry.hit); - if (options.limit === undefined) return ranked; - if (!Number.isSafeInteger(options.limit) || options.limit < 1 || options.limit > 200) { + const hits = options.limit === undefined ? ranked : ranked.slice(0, validatedOnlineLimit(options.limit)); + return { + hits, + partial: manualReport.partial, + manualSourceStatuses: manualReport.sourceStatuses, + ...(options.section === "manual" ? { manualMatchedTotal: manualReport.matchedTotal } : {}), + }; +} + +function preferredManualResultUrl(record: { + pageUrl: string; + links: readonly { url: string }[]; +}): string { + for (const link of record.links) { + try { + const url = new URL(link.url); + if (url.origin === ONLINE_SITE_ORIGIN) return url.toString(); + } catch { + continue; + } + } + return record.pageUrl; +} + +function validatedOnlineLimit(limit: number): number { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200) { throw new CliError("Online search limits must be integers from 1 to 200.", "USAGE", 2); } - return ranked.slice(0, options.limit); + return limit; } -export function formatOnlineSearchHits(hits: readonly OnlineSearchHit[], query: string): string { - if (hits.length === 0) return `SUSTech Online search · ${query}\n\nNo public community-maintained records matched.`; +export function formatOnlineSearchHits( + hits: readonly OnlineSearchHit[], + query: string, + options: { partial?: boolean; manualSourceStatuses?: readonly OnlineManualSourceStatus[] } = {}, +): string { + const incomplete = options.manualSourceStatuses?.filter((status) => status.status !== "ok") ?? []; + const statusLine = options.partial + ? `\n\nPartial result: ${incomplete.length} manual source(s) were unavailable or no longer matched the allowlist.` + : ""; + if (hits.length === 0) { + return `SUSTech Online search · ${query}\n\nNo matching public community-maintained records.${statusLine}`; + } const blocks = hits.map((hit, index) => [ `${index + 1}. [${hit.kind}] ${hit.title}`, hit.subtitle ? ` ${hit.subtitle}` : "", @@ -65,5 +142,5 @@ export function formatOnlineSearchHits(hits: readonly OnlineSearchHit[], query: ` Source: ${hit.provenance.sourceRepoPath} · ${hit.provenance.license}`, ` Advisories: ${formatOnlineAdvisories(hit.provenance.advisories)}`, ].filter(Boolean).join("\n")); - return `SUSTech Online search · ${query}\n\n${blocks.join("\n\n")}\n\n${hits.length} hit(s).`; + return `SUSTech Online search · ${query}\n\n${blocks.join("\n\n")}\n\n${hits.length} hit(s).${statusLine}`; } diff --git a/src/online/types.ts b/src/online/types.ts index fb8b539..315b9a6 100644 --- a/src/online/types.ts +++ b/src/online/types.ts @@ -60,7 +60,7 @@ export interface OnlineContactRecord { } export interface OnlineSearchHit { - kind: "talk" | "contact"; + kind: "talk" | "contact" | "manual"; id: string; title: string; subtitle?: string; diff --git a/src/services/base.ts b/src/services/base.ts index c8de7a8..a1ce72f 100644 --- a/src/services/base.ts +++ b/src/services/base.ts @@ -89,6 +89,26 @@ export async function fetchText(adapter: ServiceAdapter, url: string, init?: Req return text; } +export async function fetchTextResponse( + adapter: ServiceAdapter, + url: string, + init?: RequestInit, +): Promise<{ text: string; finalUrl: string }> { + const response = await fetchResponse(adapter, url, init); + const text = await response.text(); + if (!response.ok) { + throw new ServiceError("Upstream service returned an HTTP error.", { + url, + status: response.status, + bodySample: sampleText(text), + }); + } + return { + text, + finalUrl: response.url || url, + }; +} + export function parseJson(text: string, url?: string): T { try { return JSON.parse(text) as T; @@ -116,7 +136,15 @@ export function decodeHtml(value: string): string { .replace(/</gi, "<") .replace(/>/gi, ">") .replace(/"/gi, "\"") - .replace(/'/gi, "'"); + .replace(/'/gi, "'") + .replace(/&#x([0-9a-f]+);/giu, (_, rawHex: string) => { + const codePoint = Number.parseInt(rawHex, 16); + return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : _; + }) + .replace(/&#([0-9]+);/gu, (_, rawDecimal: string) => { + const codePoint = Number.parseInt(rawDecimal, 10); + return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : _; + }); } export function collapseWhitespace(value: string): string { diff --git a/src/services/blackboard-browser.ts b/src/services/blackboard-browser.ts new file mode 100644 index 0000000..bce6981 --- /dev/null +++ b/src/services/blackboard-browser.ts @@ -0,0 +1,349 @@ +import { existsSync } from "node:fs"; +import { USER_AGENT } from "../core/version.js"; +import { CliError } from "../core/errors.js"; +import type { ServiceAdapter } from "./base.js"; +import { BLACKBOARD_BASE } from "./blackboard.js"; +import { redactBrowserDiagnostic } from "./library-browser.js"; +import { chromium } from "playwright-core"; +import type { Browser, BrowserContext, LaunchOptions, Page } from "playwright-core"; + +const BLACKBOARD_SERVICE_URL = `${BLACKBOARD_BASE}/webapps/bb-sso-BBLEARN/index.jsp`; +const DEFAULT_RENDER_TIMEOUT_MS = 30_000; +const DEFAULT_MANUAL_AUTH_TIMEOUT_MS = 5 * 60_000; + +export const BLACKBOARD_BROWSER_AUTH_POLICY = { + mode: "human-only" as const, + credentialsAcceptedByCli: false, + challengeAutomation: false, + cookiesPersisted: false, + retryPolicy: "DO_NOT_RETRY_AUTOMATICALLY" as const, +}; + +export interface BlackboardBrowserOptions { + interactive?: boolean; + executablePath?: string; + renderTimeoutMs?: number; + manualAuthTimeoutMs?: number; +} + +export interface BlackboardBrowserCookie { + name: string; + value: string; + domain: string; + path: string; + secure: boolean; + expires?: number; +} + +export interface BlackboardBrowserSession { + authenticatedUrl: string; + cookies: BlackboardBrowserCookie[]; + authentication: typeof BLACKBOARD_BROWSER_AUTH_POLICY; +} + +export interface BlackboardBrowserRuntime { + authenticate(options?: BlackboardBrowserOptions): Promise; +} + +export async function createBlackboardBrowserAdapter( + options: BlackboardBrowserOptions = {}, + runtime: BlackboardBrowserRuntime = new PlaywrightBlackboardBrowserRuntime(), + fetchImpl: typeof fetch = globalThis.fetch, +): Promise { + const session = await runtime.authenticate(options); + return { + name: "bb-browser", + async fetch(input: string, init: RequestInit = {}): Promise { + const url = safeBlackboardRequestUrl(String(input)); + const method = (init.method ?? "GET").toUpperCase(); + if (method !== "GET" || init.body !== undefined) { + throw new CliError( + "Blackboard browser authentication currently supports read-only GET requests only.", + "BROWSER_METHOD_BLOCKED", + 2, + { + method, + ...BLACKBOARD_BROWSER_AUTH_POLICY, + }, + ); + } + const headers = new Headers(init.headers); + headers.set("user-agent", USER_AGENT); + const cookie = cookieHeader(url, session.cookies); + if (!cookie) { + throw new CliError( + "The Blackboard browser session did not yield a reusable cookie for this request.", + "SERVICE_SESSION_EXPIRED", + 1, + { + interactiveOption: "--interactive", + ...BLACKBOARD_BROWSER_AUTH_POLICY, + }, + ); + } + headers.set("cookie", cookie); + let response: Response; + try { + response = await fetchImpl(url.toString(), { + ...init, + method: "GET", + body: undefined, + headers, + }); + } catch (error) { + throw new CliError( + "The browser-backed Blackboard request could not be completed.", + "BROWSER_NETWORK_ERROR", + 1, + { + cause: redactBrowserDiagnostic(error), + ...BLACKBOARD_BROWSER_AUTH_POLICY, + }, + ); + } + const finalUrl = response.url ? new URL(response.url) : url; + if (isCasPage(finalUrl.toString())) { + throw new CliError( + "The Blackboard browser session expired before the request could complete. Re-run the command with --browser --interactive.", + "SERVICE_SESSION_EXPIRED", + 1, + { + interactiveOption: "--interactive", + ...BLACKBOARD_BROWSER_AUTH_POLICY, + }, + ); + } + if (finalUrl.origin !== BLACKBOARD_BASE) { + throw new CliError( + "A browser-backed Blackboard request attempted to leave its configured origin.", + "UNSAFE_SERVICE_URL", + 1, + { + host: finalUrl.hostname, + path: finalUrl.pathname, + }, + ); + } + return response; + }, + }; +} + +export class PlaywrightBlackboardBrowserRuntime implements BlackboardBrowserRuntime { + public async authenticate(options: BlackboardBrowserOptions = {}): Promise { + return this.withPage(options, async (page, context) => { + const renderTimeoutMs = boundedTimeout(options.renderTimeoutMs, DEFAULT_RENDER_TIMEOUT_MS); + try { + await page.goto(BLACKBOARD_SERVICE_URL, { waitUntil: "domcontentloaded", timeout: renderTimeoutMs }); + } catch (error) { + throw browserAuthError("Blackboard browser authentication could not open the login entrypoint.", page.url() || BLACKBOARD_SERVICE_URL, error); + } + + if (isCasPage(page.url())) { + if (!options.interactive) { + throw new CliError( + "Blackboard requires an interactive CAS login for browser-backed reads.", + "MANUAL_AUTH_REQUIRED", + 2, + { + interactiveOption: "--interactive", + ...BLACKBOARD_BROWSER_AUTH_POLICY, + }, + ); + } + const authTimeoutMs = boundedTimeout(options.manualAuthTimeoutMs, DEFAULT_MANUAL_AUTH_TIMEOUT_MS); + try { + await page.waitForURL((candidate) => !isCasPage(candidate.toString()), { timeout: authTimeoutMs }); + } catch (error) { + throw new CliError( + "Manual Blackboard CAS login did not complete within the allowed time.", + "MANUAL_AUTH_NOT_COMPLETED", + 2, + { + timeoutMs: authTimeoutMs, + cause: redactBrowserDiagnostic(error), + ...BLACKBOARD_BROWSER_AUTH_POLICY, + }, + ); + } + } + + try { + await page.waitForLoadState("domcontentloaded", { timeout: renderTimeoutMs }); + } catch { + // Ignore: if Blackboard already reached a stable document, cookies are enough. + } + + const finalUrl = new URL(page.url() || BLACKBOARD_SERVICE_URL); + if (isCasPage(finalUrl.toString())) { + throw new CliError( + "Blackboard browser authentication did not leave the CAS challenge page.", + "MANUAL_AUTH_NOT_COMPLETED", + 2, + { + interactiveOption: "--interactive", + ...BLACKBOARD_BROWSER_AUTH_POLICY, + }, + ); + } + if (finalUrl.origin !== BLACKBOARD_BASE) { + throw new CliError( + "Blackboard browser authentication left the configured Blackboard origin.", + "UNSAFE_SERVICE_URL", + 1, + { + host: finalUrl.hostname, + path: finalUrl.pathname, + }, + ); + } + + const cookies = (await context.cookies(BLACKBOARD_BASE)) + .filter((cookie) => domainMatches(new URL(BLACKBOARD_BASE).hostname, cookie.domain)) + .map((cookie) => ({ + name: cookie.name, + value: cookie.value, + domain: normaliseCookieDomain(cookie.domain), + path: cookie.path || "/", + secure: Boolean(cookie.secure), + ...(Number.isFinite(cookie.expires) && cookie.expires > 0 ? { expires: cookie.expires } : {}), + })); + if (cookies.length === 0) { + throw new CliError( + "Blackboard browser authentication did not yield a reusable session cookie.", + "MANUAL_AUTH_NOT_COMPLETED", + 2, + { + interactiveOption: "--interactive", + ...BLACKBOARD_BROWSER_AUTH_POLICY, + }, + ); + } + return { + authenticatedUrl: finalUrl.toString(), + cookies, + authentication: BLACKBOARD_BROWSER_AUTH_POLICY, + }; + }); + } + + private async withPage( + options: BlackboardBrowserOptions, + operation: (page: Page, context: BrowserContext) => Promise, + ): Promise { + const browser = await launchLocalChromium(options); + let context: BrowserContext | undefined; + try { + context = await browser.newContext(); + const page = await context.newPage(); + return await operation(page, context); + } finally { + await context?.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } + } +} + +async function launchLocalChromium(options: BlackboardBrowserOptions): Promise { + const headless = !options.interactive; + const explicitPath = options.executablePath?.trim() || process.env.SUSTECH_BROWSER_EXECUTABLE?.trim(); + const candidates: LaunchOptions[] = []; + if (explicitPath) { + candidates.push({ headless, executablePath: explicitPath }); + } else { + const bundledPath = chromium.executablePath(); + if (bundledPath && existsSync(bundledPath)) candidates.push({ headless, executablePath: bundledPath }); + candidates.push({ headless, channel: "chrome" }); + candidates.push({ headless, channel: "msedge" }); + } + + let lastMessage = "No supported local Chromium browser was found."; + for (const candidate of candidates) { + try { + return await chromium.launch(candidate); + } catch (error) { + lastMessage = redactBrowserDiagnostic(error); + } + } + throw new CliError( + "Blackboard browser authentication requires a local Chrome/Chromium browser.", + "BROWSER_RUNTIME_UNAVAILABLE", + 1, + { + browser: "chromium", + interactive: Boolean(options.interactive), + cause: lastMessage, + hint: "Install Google Chrome or set SUSTECH_BROWSER_EXECUTABLE to a compatible Chromium executable.", + }, + ); +} + +function safeBlackboardRequestUrl(input: string): URL { + let url: URL; + try { + url = new URL(input, BLACKBOARD_BASE); + } catch { + throw new CliError("Blackboard browser-backed request URL was invalid.", "UNSAFE_SERVICE_URL", 1); + } + if (url.protocol !== "https:" || url.origin !== BLACKBOARD_BASE) { + throw new CliError("A browser-backed Blackboard request attempted to leave its configured origin.", "UNSAFE_SERVICE_URL", 1, { + host: url.hostname, + path: url.pathname, + }); + } + return url; +} + +function cookieHeader(url: URL, cookies: readonly BlackboardBrowserCookie[]): string | undefined { + const values = cookies + .filter((cookie) => domainMatches(url.hostname, cookie.domain)) + .filter((cookie) => url.pathname.startsWith(cookie.path || "/")) + .map((cookie) => `${cookie.name}=${cookie.value}`); + return values.length > 0 ? values.join("; ") : undefined; +} + +function normaliseCookieDomain(value: string): string { + return value.replace(/^\./, "").toLowerCase(); +} + +function domainMatches(host: string, domain: string): boolean { + const normalizedHost = host.toLowerCase(); + const normalizedDomain = normaliseCookieDomain(domain); + return normalizedHost === normalizedDomain || normalizedHost.endsWith(`.${normalizedDomain}`); +} + +function boundedTimeout(value: number | undefined, fallback: number): number { + if (value === undefined || !Number.isFinite(value) || value < 1_000) return fallback; + return Math.min(Math.floor(value), 10 * 60_000); +} + +function isCasPage(value: string): boolean { + try { + const url = new URL(value); + return url.hostname.toLowerCase() === "cas.sustech.edu.cn" || /\/(?:cas|authserver)\//i.test(url.pathname); + } catch { + return false; + } +} + +function browserAuthError(message: string, url: string, error: unknown): CliError { + return new CliError(message, "BROWSER_RENDER_FAILED", 1, { + url: safeBrowserUrl(url), + cause: redactBrowserDiagnostic(error), + retryPolicy: "DO_NOT_RETRY_AUTOMATICALLY", + }); +} + +function safeBrowserUrl(value: string): string { + try { + const url = new URL(value); + url.username = ""; + url.password = ""; + for (const key of [...url.searchParams.keys()]) { + if (/(?:auth|password|secret|session|ticket|token)/i.test(key)) url.searchParams.set(key, "[REDACTED]"); + } + return url.toString(); + } catch { + return value.split("?", 1)[0] ?? ""; + } +} diff --git a/src/services/blackboard.ts b/src/services/blackboard.ts index 9ec4e8a..960efcd 100644 --- a/src/services/blackboard.ts +++ b/src/services/blackboard.ts @@ -8,7 +8,10 @@ import { arrayValue, booleanValue, cleanText, + collapseWhitespace, fetchJson, + fetchText, + fetchTextResponse, numberValue, recordValue, requestUrl, @@ -24,20 +27,33 @@ export const BLACKBOARD_STATUS: ServiceStatus = { availability: "adapter_required", auth: "cookie-session", campusNetwork: false, - browser: false, - summary: "Blackboard REST reads, native calendar-feed handling, safe content-attachment downloads, and Classic assignment submission are implemented.", + browser: true, + summary: "Blackboard REST reads, local file downloads, and guarded assignment submission are implemented, with an explicit browser-auth fallback for read-only and local-download paths.", notes: [ "The adapter must provide Blackboard cookies for bb.sustech.edu.cn.", "The CLI CAS bridge completed an opt-in live courses read on 2026-08-26.", + "A human-only --browser --interactive fallback can mint an ephemeral Blackboard session cookie when CAS blocks password-only login with an interactive challenge.", "Native shared-calendar links are separate bearer-like secrets stored only in the operating-system credential store.", + "Course announcements follow the official Learn REST announcement endpoints; system announcements are included when the user's Blackboard role can read them.", + "Course discussion forums, threads, and replies use the official Learn REST discussion endpoints when the target course exposes them; Blackboard Original courses can reject that API as unsupported.", + "Course-message folders, message lists, and participant lists follow the official Learn REST course-message endpoints with explicit paging and server-side folder filters.", "Teacher-provided files use the Learn content-attachment endpoint or same-origin BBML links.", - "Student submission files use the official Learn REST attempt/files flow and currently target Classic/Original assignments.", + "Student submission files use the official Learn REST attempt/files flow and remain limited to Classic/Original assignments; supported Blackboard assignment targets can also submit text through the official attempt payload.", "No Blackboard write path has been live-submitted from this repository yet.", ], endpoints: [ "/learn/api/public/v1/users/me", "/learn/api/public/v1/users/{uid}/courses", "/learn/api/public/v1/courses/{courseId}", + "/learn/api/public/v1/announcements", + "/learn/api/public/v1/courses/{courseId}/announcements", + "/learn/api/public/v1/courses/{courseId}/discussions", + "/learn/api/public/v1/courses/{courseId}/discussions/{discussionId}", + "/learn/api/public/v1/courses/{courseId}/discussions/{discussionId}/messages", + "/learn/api/public/v1/courses/{courseId}/discussions/{discussionId}/messages/{messageId}/replies", + "/learn/api/public/v1/courses/{courseId}/messages", + "/learn/api/public/v1/courses/{courseId}/messages/folders", + "/learn/api/public/v1/courses/{courseId}/messages/{messageId}/participants", "/learn/api/public/v1/calendars", "/learn/api/public/v1/calendars/items", "/webapps/calendar/calendarFeed/{opaque}/learn.ics", @@ -160,6 +176,20 @@ export interface BlackboardAttemptFile { downloadUrl: string; } +export interface BlackboardAttemptFileReference { + id: string; + name: string; +} + +export interface BlackboardAttemptFileDownload { + file: BlackboardAttemptFile; + destination: string; + size: number; + sha256: string; + contentType: string; + overwritten: boolean; +} + export interface BlackboardUploadSettings { maxUploadSizeInBytes?: number; supportsInlineRender: boolean; @@ -173,6 +203,18 @@ export interface BlackboardSubmissionFile { sha256: string; } +export interface BlackboardSubmissionText { + path: string; + absolutePath: string; + size: number; + sha256: string; + charCount: number; +} + +export type BlackboardSubmissionMaterial = + | { kind: "file"; file: BlackboardSubmissionFile } + | { kind: "text"; text: BlackboardSubmissionText }; + export interface BlackboardUploadedFileReference { id: string; } @@ -198,10 +240,17 @@ export interface BlackboardSubmissionPayload { bytes: Uint8Array; } +export interface BlackboardSubmissionTextPayload { + textFile: BlackboardSubmissionText; + text: string; +} + export type BlackboardFailureStage = | "courses" + | "announcements" | "calendar-items" | "assignments" + | "attempts" | "content" | "content-item" | "attachments" @@ -221,10 +270,97 @@ export interface BlackboardOperationFailure { courseName?: string; parentId?: string; contentId?: string; + columnId?: string; attachmentId?: string; path?: string; } +export type BlackboardAssignmentSubmissionState = + | "not_attempted" + | "in_progress" + | "submitted" + | "completed" + | "mixed" + | "other"; + +export interface BlackboardAssignmentAttemptSummary { + state: BlackboardAssignmentSubmissionState; + totalAttempts: number; + submittedAttempts: number; + completedAttempts: number; + inProgressAttempts: number; + latestAttemptId?: string; + latestStatus?: BlackboardAttemptStatus | ""; + latestAttemptDate?: string; + latestSubmissionDate?: string; + latestDisplayGradeText?: string; +} + +export interface BlackboardAssignmentWithAttempts { + assignment: BlackboardAssignment; + attemptSummary?: BlackboardAssignmentAttemptSummary; +} + +export interface BlackboardAssignmentsWithAttemptsReport { + generatedAt: string; + courseId: string; + totalAssignments: number; + completedAttemptFetches: number; + attemptedAssignments: number; + partial: boolean; + assignments: BlackboardAssignmentWithAttempts[]; + failures: BlackboardOperationFailure[]; +} + +export interface BlackboardScopedAssignment { + courseId: string; + courseCode: string; + courseName: string; + assignment: BlackboardAssignment; + attemptSummary?: BlackboardAssignmentAttemptSummary; +} + +export interface BlackboardAssignmentsAggregateReport { + generatedAt: string; + courseQuery?: string; + withAttempts: boolean; + submissionState?: BlackboardAssignmentSubmissionState; + coursesMatched: number; + coursesScanned: number; + totalAssignments: number; + completedAttemptFetches: number; + attemptedAssignments: number; + partial: boolean; + assignments: BlackboardScopedAssignment[]; + failures: BlackboardOperationFailure[]; +} + +export interface BlackboardGradeEntry extends BlackboardScopedAssignment { + attemptSummary: BlackboardAssignmentAttemptSummary; +} + +export interface BlackboardGradesReport { + generatedAt: string; + courseQuery?: string; + submissionState?: Exclude; + limit?: number; + coursesMatched: number; + coursesScanned: number; + totalAssignments: number; + completedAttemptFetches: number; + attemptedAssignments: number; + partial: boolean; + grades: BlackboardGradeEntry[]; + failures: BlackboardOperationFailure[]; +} + +export function filterBlackboardAssignmentsBySubmissionState( + assignments: readonly BlackboardAssignmentWithAttempts[], + state: BlackboardAssignmentSubmissionState, +): BlackboardAssignmentWithAttempts[] { + return assignments.filter((item) => item.attemptSummary?.state === state); +} + export interface BlackboardDeadline { courseId: string; courseCode: string; @@ -237,14 +373,363 @@ export interface BlackboardDeadline { availability: string; scorePossible?: number; attemptsAllowed?: number; + attemptSummary?: BlackboardAssignmentAttemptSummary; +} + +export interface BlackboardAnnouncement { + id: string; + source: "system" | "course"; + title: string; + body: string; + created: string; + modified: string; + creator?: string; + draft?: boolean; + availabilityType?: "Permanent" | "Restricted" | ""; + availableFrom?: string; + availableUntil?: string; + showAtLogin?: boolean; + showInCourses?: boolean; + courseId?: string; + courseCode?: string; + courseName?: string; +} + +export interface BlackboardAnnouncementsReport { + generatedAt: string; + courseQuery?: string; + days?: number; + coursesMatched: number; + coursesScanned: number; + systemAnnouncements: number; + courseAnnouncements: number; + partial: boolean; + announcements: BlackboardAnnouncement[]; + failures: BlackboardOperationFailure[]; +} + +export type BlackboardDiscussionMessageStatus = + | "Published" + | "Deleted" + | "Draft" + | ""; + +export interface BlackboardDiscussionMessage { + id: string; + discussionId: string; + parentId: string; + threadId: string; + userId: string; + groupId: string; + givenName: string; + familyName: string; + author: string; + status: BlackboardDiscussionMessageStatus; + body: string; + postDate: string; + editDate: string; + createdDate: string; + modifiedDate: string; + isRead: boolean; + subject?: string; + source?: "learn-rest" | "original-html"; + metadataPartial?: boolean; + unreadPosts?: number; + unreadRepliesToMe?: number; + totalPosts?: number; +} + +export interface BlackboardDiscussion { + id: string; + title: string; + available: boolean; + gradable: boolean; + groupDiscussion: boolean; + createdDate: string; + modifiedDate: string; + gradebookColumnId?: string; + source?: "learn-rest" | "original-html"; + metadataPartial?: boolean; + description?: string; + totalPosts?: number; + unreadPosts?: number; + unreadRepliesToMe?: number; + totalParticipants?: number; + topic?: BlackboardDiscussionMessage; +} + +export interface BlackboardDiscussionsPage { + generatedAt: string; + courseId: string; + courseCode: string; + courseName: string; + title?: string; + gradable?: boolean; + sort?: string; + page: number; + pageSize: number; + returned: number; + hasMore: boolean; + nextPage?: number; + discussions: BlackboardDiscussion[]; +} + +export interface BlackboardDiscussionGroup { + groupId: string; + discussionId: string; + threadId: string; +} + +export interface BlackboardDiscussionGroupsPage { + generatedAt: string; + courseId: string; + courseCode: string; + courseName: string; + discussion: BlackboardDiscussion; + sort?: string; + page: number; + pageSize: number; + returned: number; + hasMore: boolean; + nextPage?: number; + groups: BlackboardDiscussionGroup[]; +} + +export interface BlackboardDiscussionMessagesPage { + generatedAt: string; + courseId: string; + courseCode: string; + courseName: string; + discussion: BlackboardDiscussion; + groupId?: string; + userId?: string; + status?: Exclude; + isRead?: boolean; + sort?: string; + page: number; + pageSize: number; + returned: number; + hasMore: boolean; + nextPage?: number; + messages: BlackboardDiscussionMessage[]; +} + +export interface BlackboardDiscussionRepliesPage { + generatedAt: string; + courseId: string; + courseCode: string; + courseName: string; + discussionId: string; + messageId: string; + groupId?: string; + userId?: string; + status?: Exclude; + isRead?: boolean; + sort?: string; + page: number; + pageSize: number; + returned: number; + hasMore: boolean; + nextPage?: number; + replies: BlackboardDiscussionMessage[]; +} + +export interface BlackboardDiscussionMessageWriteInput { + body: string; + groupId?: string; + status?: Exclude; +} + +export type BlackboardCourseMessageFolderType = + | "Inbox" + | "Sent" + | "Delete" + | "Custom" + | ""; + +export interface BlackboardCourseMessageFolder { + name: string; + label: string; + type: BlackboardCourseMessageFolderType; + totalCount: number; + unreadCount: number; +} + +export type BlackboardParticipantDisplayPreference = + | "GivenName" + | "OtherName" + | "Both" + | ""; + +export interface BlackboardParticipantUser { + id: string; + userName: string; + otherName: string; + givenName: string; + familyName: string; + middleName: string; + suffix: string; + title: string; + preferredDisplayName: BlackboardParticipantDisplayPreference; + displayName: string; +} + +export interface BlackboardCourseMessageAttachment { + id: string; + fileName: string; + mimeType: string; + fileLocation: string; +} + +export type BlackboardCourseMessageType = + | "System" + | "Normal" + | ""; + +export interface BlackboardCourseMessage { + id: string; + subject: string; + body: string; + postedDate: string; + isRead: boolean; + type: BlackboardCourseMessageType; + senderId: string; + sender?: BlackboardParticipantUser; + attachment?: BlackboardCourseMessageAttachment; + toUsers: string[]; + ccUsers: string[]; + bccUsers: string[]; + isExistingAttachment: boolean; + isReply: boolean; +} + +export interface BlackboardCourseMessageWriteInput { + subject?: string; + body: string; + toUsers: string[]; + ccUsers?: string[]; + bccUsers?: string[]; +} + +export interface BlackboardCourseMessageFoldersPage { + generatedAt: string; + courseId: string; + courseCode: string; + courseName: string; + page: number; + pageSize: number; + returned: number; + hasMore: boolean; + nextPage?: number; + folders: BlackboardCourseMessageFolder[]; +} + +export interface BlackboardCourseMessagesPage { + generatedAt: string; + courseId: string; + courseCode: string; + courseName: string; + folderType?: Exclude; + folderName?: string; + sort?: string; + page: number; + pageSize: number; + returned: number; + hasMore: boolean; + nextPage?: number; + messages: BlackboardCourseMessage[]; +} + +export type BlackboardCourseMessageParticipationType = + | "From" + | "To" + | "Cc" + | "Bcc" + | ""; + +export interface BlackboardCourseMessageParticipant { + messageId: string; + userId: string; + participationType: BlackboardCourseMessageParticipationType; + displayName: string; + user?: BlackboardParticipantUser; +} + +export interface BlackboardCourseMessageParticipantsPage { + generatedAt: string; + courseId: string; + courseCode: string; + courseName: string; + messageId: string; + participationType?: Exclude; + sort?: string; + page: number; + pageSize: number; + returned: number; + hasMore: boolean; + nextPage?: number; + participants: BlackboardCourseMessageParticipant[]; +} + +export type BlackboardCourseMembershipAvailability = + | "Yes" + | "No" + | "Disabled" + | ""; + +export interface BlackboardCourseRosterUser { + id: string; + userName: string; + displayName: string; + givenName: string; + familyName: string; + otherName: string; + email: string; + institutionEmail: string; + avatarUrl: string; + availability: BlackboardCourseMembershipAvailability; +} + +export interface BlackboardCourseMembership { + id: string; + userId: string; + courseId: string; + childCourseId: string; + created: string; + modified: string; + availability: BlackboardCourseMembershipAvailability; + courseRoleId: string; + lastAccessed: string; + dueDateExceptionType: string; + timeLimitExceptionType: string; + displayOrder?: number; + user?: BlackboardCourseRosterUser; +} + +export interface BlackboardCourseRosterPage { + generatedAt: string; + courseId: string; + courseCode: string; + courseName: string; + role?: string; + availability?: Exclude; + sort?: string; + page: number; + pageSize: number; + returned: number; + hasMore: boolean; + nextPage?: number; + memberships: BlackboardCourseMembership[]; } export interface BlackboardDeadlineReport { generatedAt: string; courseQuery?: string; days?: number; + submissionState?: BlackboardAssignmentSubmissionState; coursesMatched: number; coursesScanned: number; + partial: boolean; deadlines: BlackboardDeadline[]; failures: BlackboardOperationFailure[]; } @@ -365,42 +850,102 @@ export interface BlackboardSearchReport { failures: BlackboardOperationFailure[]; } -export interface BlackboardSyncFile { +export interface BlackboardContentKindCount { + kind: BlackboardContentItem["kind"]; + count: number; +} + +export interface BlackboardContentHandlerCount { + handler: string; + count: number; +} + +export interface BlackboardContentTypesCourse { courseId: string; courseCode: string; courseName: string; - contentId: string; - attachmentId: string; - contentPath: string; - relativePath: string; - destination: string; - source: BlackboardContentAttachmentSource; - size: number; - sha256: string; - contentType: string; - overwritten: boolean; + totalItems: number; + kindCounts: BlackboardContentKindCount[]; + handlerCounts: BlackboardContentHandlerCount[]; } -export interface BlackboardSyncReport { +export interface BlackboardContentTypesReport { generatedAt: string; - courseId: string; - courseCode: string; - courseName: string; - destination: string; - rootContentId?: string; - plannedFiles: number; - downloadedFiles: number; + courseQuery?: string; + coursesMatched: number; + coursesScanned: number; + totalItems: number; partial: boolean; - files: BlackboardSyncFile[]; + totals: BlackboardContentKindCount[]; + courses: BlackboardContentTypesCourse[]; failures: BlackboardOperationFailure[]; } -export async function getBlackboardUser(adapter: ServiceAdapter): Promise { - const raw = await fetchJson(adapter, buildBlackboardUrl("/learn/api/public/v1/users/me")); - return normaliseBlackboardUser(raw); -} - -export async function listBlackboardCourses( +export interface BlackboardContentTreeEntry { + courseId: string; + courseCode: string; + courseName: string; + contentId: string; + parentId: string; + title: string; + kind: BlackboardContentItem["kind"]; + handler: string; + hasChildren: boolean; + depth: number; + path: string; + pathTitles: readonly string[]; +} + +export interface BlackboardContentTreeReport { + generatedAt: string; + courseId: string; + courseCode: string; + courseName: string; + rootContentId?: string; + maxItems: number; + returnedItems: number; + truncated: boolean; + partial: boolean; + entries: BlackboardContentTreeEntry[]; + failures: BlackboardOperationFailure[]; +} + +export interface BlackboardSyncFile { + courseId: string; + courseCode: string; + courseName: string; + contentId: string; + attachmentId: string; + contentPath: string; + relativePath: string; + destination: string; + source: BlackboardContentAttachmentSource; + size: number; + sha256: string; + contentType: string; + overwritten: boolean; +} + +export interface BlackboardSyncReport { + generatedAt: string; + courseId: string; + courseCode: string; + courseName: string; + destination: string; + rootContentId?: string; + plannedFiles: number; + downloadedFiles: number; + partial: boolean; + files: BlackboardSyncFile[]; + failures: BlackboardOperationFailure[]; +} + +export async function getBlackboardUser(adapter: ServiceAdapter): Promise { + const raw = await fetchJson(adapter, buildBlackboardUrl("/learn/api/public/v1/users/me")); + return normaliseBlackboardUser(raw); +} + +export async function listBlackboardCourses( adapter: ServiceAdapter, options: { query?: string } = {}, ): Promise { @@ -606,19 +1151,1653 @@ export async function getBlackboardAssignment( return normaliseBlackboardAssignment(raw); } -export async function listBlackboardAttempts( +async function listBlackboardSystemAnnouncements(adapter: ServiceAdapter): Promise { + const page = await fetchBlackboardPage(adapter, "/learn/api/public/v1/announcements"); + return page.results.map((item) => normaliseBlackboardAnnouncement(item, { source: "system" })); +} + +async function listBlackboardCourseAnnouncements( + adapter: ServiceAdapter, + courseId: string, +): Promise { + const page = await fetchBlackboardPage(adapter, `/learn/api/public/v1/courses/${canonicalCourseId(courseId)}/announcements`); + return page.results.map((item) => normaliseBlackboardAnnouncement(item, { source: "course" })); +} + +export async function listBlackboardDiscussions( + adapter: ServiceAdapter, + options: { + courseId: string; + title?: string; + gradable?: boolean; + page?: number; + pageSize?: number; + sort?: string; + }, +): Promise { + const page = validatedBlackboardPage(options.page); + const pageSize = validatedBlackboardPageSize(options.pageSize); + const course = await resolveBlackboardCourseContext(adapter, options.courseId); + const title = options.title?.trim() || undefined; + const sort = options.sort?.trim() || undefined; + const query: Record = { + offset: String((page - 1) * pageSize), + limit: String(pageSize), + }; + if (title) query.title = title; + if (options.gradable !== undefined) query.gradable = options.gradable ? "true" : "false"; + if (sort) query.sort = sort; + try { + const response = await fetchBlackboardPageChunk( + adapter, + buildBlackboardUrl(`/learn/api/public/v1/courses/${course.id}/discussions`, query), + { absolute: true }, + ); + const discussions = response.results.map((item) => normaliseBlackboardDiscussion(item)); + return { + generatedAt: new Date().toISOString(), + courseId: course.id, + courseCode: course.courseCode, + courseName: course.name, + ...(title ? { title } : {}), + ...(options.gradable !== undefined ? { gradable: options.gradable } : {}), + ...(sort ? { sort } : {}), + page, + pageSize, + returned: discussions.length, + hasMore: Boolean(response.nextPage), + ...(response.nextPage ? { nextPage: page + 1 } : {}), + discussions, + }; + } catch (error) { + if ( + !isBlackboardOriginalDiscussionUnsupported(error) + && !(sort && isBlackboardDiscussionSortRejected(error)) + ) { + throw normalizeBlackboardDiscussionUnsupported(error, { + operation: "list", + courseId: options.courseId, + }); + } + } + return listBlackboardOriginalDiscussions(adapter, course, { + ...(title ? { title } : {}), + ...(options.gradable !== undefined ? { gradable: options.gradable } : {}), + ...(sort ? { sort } : {}), + page, + pageSize, + }); +} + +const BLACKBOARD_ORIGINAL_DISCUSSION_MAX_ITEMS = 500; +const BLACKBOARD_ORIGINAL_DISCUSSION_MESSAGE_MAX_ITEMS = 500; +const BLACKBOARD_ORIGINAL_DISCUSSION_BODY_CONCURRENCY = 4; + +async function listBlackboardOriginalDiscussions( + adapter: ServiceAdapter, + course: BlackboardCourse, + options: { + title?: string; + gradable?: boolean; + sort?: string; + page: number; + pageSize: number; + }, +): Promise { + if (options.gradable !== undefined) { + throw new CliError( + "Original Blackboard discussion forums do not expose a trustworthy gradable flag through the HTML fallback. Remove --gradable for this course.", + "BLACKBOARD_DISCUSSIONS_FILTER_UNSUPPORTED", + 2, + { + courseId: course.id, + filter: "gradable", + source: "original-html", + retryPolicy: "DO_NOT_RETRY_AUTOMATICALLY", + }, + ); + } + const catalog = await loadBlackboardOriginalDiscussionCatalog(adapter, course, { + requireAll: + options.title !== undefined + || options.sort !== undefined + || options.page !== 1, + }); + const filtered = options.title + ? catalog.items.filter((discussion) => + normaliseLookupTextForBlackboardFallback(discussion.title).includes(normaliseLookupTextForBlackboardFallback(options.title ?? "")) + ) + : catalog.items; + const sorted = sortBlackboardOriginalDiscussions(filtered, options.sort, course.id); + const offset = (options.page - 1) * options.pageSize; + const discussions = sorted.slice(offset, offset + options.pageSize); + const hasMore = offset + discussions.length < sorted.length; + return { + generatedAt: new Date().toISOString(), + courseId: course.id, + courseCode: course.courseCode, + courseName: course.name, + ...(options.title ? { title: options.title } : {}), + ...(options.sort ? { sort: options.sort } : {}), + page: options.page, + pageSize: options.pageSize, + returned: discussions.length, + hasMore, + ...(hasMore ? { nextPage: options.page + 1 } : {}), + discussions, + }; +} + +async function loadBlackboardOriginalDiscussionCatalog( + adapter: ServiceAdapter, + course: BlackboardCourse, + options: { requireAll?: boolean } = {}, +): Promise { + const launch = await fetchTextResponse( + adapter, + buildBlackboardUrl("/webapps/blackboard/content/launchLink.jsp", { + course_id: course.id, + tool_id: "_142_1", + tool_type: "TOOL", + mode: "reset", + }), + { headers: { accept: "text/html, */*;q=0.1" } }, + ); + const initialPage = parseBlackboardOriginalDiscussionPage(launch.text, launch.finalUrl); + if (initialPage.total <= initialPage.items.length) return initialPage; + if (!options.requireAll) return initialPage; + if (!initialPage.confId) { + throw new CliError( + "Original Blackboard discussion fallback could not resolve the forum conference id needed to load the complete HTML forum list.", + "BLACKBOARD_DISCUSSIONS_FALLBACK_UNAVAILABLE", + 1, + { + courseId: course.id, + returned: initialPage.items.length, + total: initialPage.total, + source: "original-html", + }, + ); + } + if (initialPage.total > BLACKBOARD_ORIGINAL_DISCUSSION_MAX_ITEMS) { + throw new CliError( + "Original Blackboard discussion forum fallback would need to load too many HTML rows. Narrow the course or use the native Blackboard UI.", + "BLACKBOARD_DISCUSSIONS_FALLBACK_LIMIT_EXCEEDED", + 1, + { + courseId: course.id, + total: initialPage.total, + limit: BLACKBOARD_ORIGINAL_DISCUSSION_MAX_ITEMS, + source: "original-html", + }, + ); + } + const full = await fetchTextResponse( + adapter, + buildBlackboardUrl("/webapps/discussionboard/do/conference", { + action: "list_forums", + course_id: course.id, + conf_id: initialPage.confId, + nav: "discussion_board_entry", + toggle_mode: "read", + mode: "view", + showAll: "true", + startIndex: "0", + sortCol: "position", + sortDir: "ASCENDING", + }), + { headers: { accept: "text/html, */*;q=0.1" } }, + ); + const fullPage = parseBlackboardOriginalDiscussionPage(full.text, full.finalUrl); + if (fullPage.total > fullPage.items.length) { + throw new CliError( + "Original Blackboard discussion forum fallback did not return a complete HTML forum list.", + "BLACKBOARD_DISCUSSIONS_FALLBACK_UNAVAILABLE", + 1, + { + courseId: course.id, + total: fullPage.total, + returned: fullPage.items.length, + source: "original-html", + }, + ); + } + return fullPage; +} + +interface BlackboardOriginalDiscussionContext { + confId: string; + discussion: BlackboardDiscussion; +} + +async function getBlackboardOriginalDiscussionContext( + adapter: ServiceAdapter, + course: BlackboardCourse, + discussionId: string, +): Promise { + const catalog = await loadBlackboardOriginalDiscussionCatalog(adapter, course, { requireAll: true }); + const canonicalDiscussionId = canonicalIdBody(discussionId); + const discussion = catalog.items.find((item) => item.id === canonicalDiscussionId); + if (!discussion) { + throw new CliError( + "The requested Blackboard Original discussion forum was not found in the HTML fallback catalog.", + "BLACKBOARD_DISCUSSION_NOT_FOUND", + 1, + { + courseId: course.id, + discussionId: canonicalCourseId(discussionId), + source: "original-html", + }, + ); + } + if (!catalog.confId) { + throw new CliError( + "Original Blackboard discussion fallback could not resolve the forum conference id needed to open this discussion thread.", + "BLACKBOARD_DISCUSSIONS_FALLBACK_UNAVAILABLE", + 1, + { + courseId: course.id, + discussionId: canonicalCourseId(discussionId), + source: "original-html", + }, + ); + } + return { + confId: catalog.confId, + discussion, + }; +} + +interface BlackboardOriginalDiscussionMessagesParsedPage { + total: number; + items: BlackboardDiscussionMessage[]; +} + +interface BlackboardOriginalDiscussionRepliesParsedPage { + total: number; + items: BlackboardDiscussionMessage[]; +} + +async function listBlackboardOriginalDiscussionMessages( + adapter: ServiceAdapter, + course: BlackboardCourse, + options: { + discussionId: string; + groupId?: string; + userId?: string; + status?: Exclude; + isRead?: boolean; + page: number; + pageSize: number; + sort?: string; + }, +): Promise { + if (options.groupId) throw blackboardOriginalDiscussionUnsupportedFilter(course.id, "groupId"); + if (options.userId) throw blackboardOriginalDiscussionUnsupportedFilter(course.id, "userId"); + const discussionId = canonicalCourseId(options.discussionId); + const context = await getBlackboardOriginalDiscussionContext(adapter, course, discussionId); + const html = await fetchText( + adapter, + buildBlackboardUrl("/webapps/discussionboard/do/forum", { + action: "list_threads", + course_id: course.id, + nav: "discussion_board_entry", + conf_id: context.confId, + forum_id: discussionId, + forum_view: "list", + showAll: "true", + }), + { headers: { accept: "text/html, */*;q=0.1" } }, + ); + const parsed = parseBlackboardOriginalDiscussionMessagesPage(html, { + discussionId: context.discussion.id, + }); + if (parsed.total > BLACKBOARD_ORIGINAL_DISCUSSION_MESSAGE_MAX_ITEMS) { + throw new CliError( + "Original Blackboard discussion thread fallback would need to load too many HTML rows. Narrow the discussion or use the native Blackboard UI.", + "BLACKBOARD_DISCUSSIONS_FALLBACK_LIMIT_EXCEEDED", + 1, + { + courseId: course.id, + discussionId, + total: parsed.total, + limit: BLACKBOARD_ORIGINAL_DISCUSSION_MESSAGE_MAX_ITEMS, + source: "original-html", + }, + ); + } + if (parsed.total > parsed.items.length) { + throw new CliError( + "Original Blackboard discussion thread fallback did not return a complete HTML thread list.", + "BLACKBOARD_DISCUSSIONS_FALLBACK_UNAVAILABLE", + 1, + { + courseId: course.id, + discussionId, + total: parsed.total, + returned: parsed.items.length, + source: "original-html", + }, + ); + } + let messages = parsed.items; + if (options.status) messages = messages.filter((message) => message.status === options.status); + if (options.isRead !== undefined) messages = messages.filter((message) => message.isRead === options.isRead); + const sorted = sortBlackboardOriginalDiscussionMessages(messages, options.sort, course.id, context.discussion.id); + const offset = (options.page - 1) * options.pageSize; + const pageItems = sorted.slice(offset, offset + options.pageSize); + const hydrated = await hydrateBlackboardOriginalDiscussionMessageBodies( + adapter, + course.id, + context.confId, + context.discussion.id, + pageItems, + ); + const hasMore = offset + hydrated.length < sorted.length; + return { + generatedAt: new Date().toISOString(), + courseId: course.id, + courseCode: course.courseCode, + courseName: course.name, + discussion: context.discussion, + ...(options.status ? { status: options.status } : {}), + ...(options.isRead !== undefined ? { isRead: options.isRead } : {}), + ...(options.sort ? { sort: options.sort } : {}), + page: options.page, + pageSize: options.pageSize, + returned: hydrated.length, + hasMore, + ...(hasMore ? { nextPage: options.page + 1 } : {}), + messages: hydrated, + }; +} + +function parseBlackboardOriginalDiscussionMessagesPage( + html: string, + context: { discussionId: string }, +): BlackboardOriginalDiscussionMessagesParsedPage { + const items = [...html.matchAll(//giu)] + .map((match) => parseBlackboardOriginalDiscussionMessageRow(match[0], context)) + .filter((message): message is BlackboardDiscussionMessage => message !== null); + const count = parseBlackboardOriginalDiscussionCount(html); + return { + total: count?.total ?? items.length, + items, + }; +} + +function parseBlackboardOriginalDiscussionMessageRow( + rowHtml: string, + context: { discussionId: string }, +): BlackboardDiscussionMessage | null { + const subjectMatch = /]*href="([^"]*action=list_messages[^"]*)"[^>]*>([\s\S]*?)<\/a>/iu.exec(rowHtml); + if (!subjectMatch) return null; + let href: URL; + try { + href = new URL((subjectMatch[1] ?? "").replace(/&/giu, "&"), BLACKBOARD_BASE); + } catch { + return null; + } + const messageId = canonicalIdBody(href.searchParams.get("message_id")); + if (!messageId) return null; + const dateHtml = originalDiscussionCellHtml(rowHtml, ["Date", "日期"]); + const dateText = cleanText(dateHtml); + const postDate = normalizeBlackboardOriginalDiscussionDate(dateText); + const author = originalDiscussionCellText(rowHtml, ["Author", "作者"]); + return { + id: messageId, + discussionId: canonicalIdBody(href.searchParams.get("forum_id")) || context.discussionId, + parentId: "", + threadId: messageId, + userId: "", + groupId: "", + givenName: "", + familyName: "", + author: author || "匿名", + status: normaliseBlackboardOriginalDiscussionStatus(originalDiscussionCellText(rowHtml, ["Status", "状态"])), + body: "", + postDate, + editDate: "", + createdDate: postDate, + modifiedDate: postDate, + isRead: !/\bunreadmessage\b/iu.test(dateHtml), + subject: cleanText(subjectMatch[2]), + source: "original-html", + metadataPartial: true, + unreadPosts: originalDiscussionNumericCell(rowHtml, ["Unread Posts", "未读帖子"]), + unreadRepliesToMe: originalDiscussionNumericCell(rowHtml, ["Unread Replies To Me", "未读对我的回复"]), + totalPosts: originalDiscussionNumericCell(rowHtml, ["Total Posts", "帖子总数"]), + }; +} + +async function hydrateBlackboardOriginalDiscussionMessageBodies( + adapter: ServiceAdapter, + courseId: string, + confId: string, + discussionId: string, + messages: readonly BlackboardDiscussionMessage[], +): Promise { + const hydrated = messages.map((message) => ({ ...message })); + let nextIndex = 0; + const workers = Array.from({ length: Math.min(BLACKBOARD_ORIGINAL_DISCUSSION_BODY_CONCURRENCY, hydrated.length) }, async () => { + for (;;) { + const index = nextIndex; + nextIndex += 1; + if (index >= hydrated.length) return; + const current = hydrated[index]!; + const body = await fetchBlackboardOriginalDiscussionMessageBody( + adapter, + courseId, + confId, + discussionId, + current.id, + current.threadId, + ); + hydrated[index] = { + ...current, + body, + }; + } + }); + await Promise.all(workers); + return hydrated; +} + +async function fetchBlackboardOriginalDiscussionMessageBody( + adapter: ServiceAdapter, + courseId: string, + confId: string, + discussionId: string, + messageId: string, + threadId: string, +): Promise { + try { + const html = await fetchText( + adapter, + buildBlackboardUrl("/webapps/discussionboard/do/message", { + action: "message_frame", + course_id: canonicalCourseId(courseId), + nav: "discussion_board_entry", + conf_id: canonicalCourseId(confId), + forum_id: canonicalCourseId(discussionId), + message_id: canonicalCourseId(messageId), + thread_id: canonicalCourseId(threadId || messageId), + }), + { headers: { accept: "text/html, */*;q=0.1" } }, + ); + const body = /
([\s\S]*?)<\/div>/iu.exec(html)?.[1] + ?? /
]*>([\s\S]*?)
${escaped}:\\s*<\\/span>([\\s\\S]*?)<\\/td>`, + "iu", + ).exec(rowHtml); + if (match?.[1]) return match[1]; + } + return ""; +} + +function originalDiscussionCellText(rowHtml: string, labels: readonly string[]): string { + return cleanText(originalDiscussionCellHtml(rowHtml, labels)); +} + +function normalizeBlackboardOriginalDiscussionDate(value: string): string { + const text = collapseWhitespace(value); + if (!text) return ""; + const direct = Date.parse(text); + if (Number.isFinite(direct)) return new Date(direct).toISOString(); + const match = /^(\d{2,4})-(\d{1,2})-(\d{1,2})\s+(.+)$/u.exec(text); + if (!match) return ""; + const rawYear = numberValue(match[1]); + const month = numberValue(match[2]); + const day = numberValue(match[3]); + const remainder = collapseWhitespace(match[4] ?? ""); + const timeMatch = /(\d{1,2})(?::(\d{2}))(?::(\d{2}))?/u.exec(remainder); + if (!timeMatch) return ""; + let hour = numberValue(timeMatch[1]); + const minute = numberValue(timeMatch[2]); + const second = numberValue(timeMatch[3]); + const normalizedRemainder = remainder.toLocaleLowerCase("en-US"); + const isPm = /下午|pm/u.test(normalizedRemainder); + const isAm = /上午|am/u.test(normalizedRemainder); + if (isPm && hour < 12) hour += 12; + if (isAm && hour === 12) hour = 0; + const year = match[1].length === 2 ? 2000 + rawYear : rawYear; + if (!Number.isFinite(year) || year < 2000 || month < 1 || month > 12 || day < 1 || day > 31) return ""; + return new Date(Date.UTC(year, month - 1, day, hour - 8, minute, second)).toISOString(); +} + +function normaliseBlackboardOriginalDiscussionStatus(value: string): BlackboardDiscussionMessageStatus { + const normalized = collapseWhitespace(value).toLocaleLowerCase("zh-Hans-CN"); + if (!normalized) return ""; + if (normalized === "published" || normalized === "已发布") return "Published"; + if (normalized === "draft" || normalized === "草稿") return "Draft"; + if (normalized === "deleted" || normalized === "已删除") return "Deleted"; + return ""; +} + +function sortBlackboardOriginalDiscussionMessages( + items: readonly BlackboardDiscussionMessage[], + sort: string | undefined, + courseId: string, + discussionId: string, +): BlackboardDiscussionMessage[] { + if (!sort) return [...items]; + const match = /^([A-Za-z_][A-Za-z0-9_]*)(?:\((desc)\))?$/u.exec(sort.trim()); + if (!match) { + throw new CliError("Original Blackboard discussion thread sort must use FIELD or FIELD(desc).", "USAGE", 2, { + courseId, + discussionId, + sort, + source: "original-html", + }); + } + const field = match[1]; + const descending = match[2] === "desc"; + const sorted = [...items]; + const direction = descending ? -1 : 1; + const selector = originalDiscussionMessageSortSelector(field); + if (!selector) { + throw new CliError( + "Original Blackboard discussion thread fallback supports only position, subject, title, author, status, postDate, createdDate, modifiedDate, totalPosts, unreadPosts, or unreadRepliesToMe sorting.", + "BLACKBOARD_DISCUSSIONS_SORT_UNSUPPORTED", + 2, + { + courseId, + discussionId, + sort, + source: "original-html", + retryPolicy: "DO_NOT_RETRY_AUTOMATICALLY", + }, + ); + } + if (selector === "position") return descending ? [...sorted].reverse() : sorted; + if (selector === "subject" || selector === "author" || selector === "status") { + sorted.sort((left, right) => + direction * ( + (selector === "subject" ? (left.subject ?? "") : left[selector]).localeCompare( + selector === "subject" ? (right.subject ?? "") : right[selector], + "zh-Hans-CN", + ) + || (left.author || left.id).localeCompare(right.author || right.id, "zh-Hans-CN") + ) + ); + return sorted; + } + if (selector === "postDate" || selector === "createdDate" || selector === "modifiedDate") { + sorted.sort((left, right) => + direction * ( + blackboardSortableTimestamp(left[selector]) - blackboardSortableTimestamp(right[selector]) + || (left.subject ?? left.author ?? left.id).localeCompare(right.subject ?? right.author ?? right.id, "zh-Hans-CN") + ) + ); + return sorted; + } + sorted.sort((left, right) => + direction * ( + (left[selector] ?? 0) - (right[selector] ?? 0) + || (left.subject ?? left.author ?? left.id).localeCompare(right.subject ?? right.author ?? right.id, "zh-Hans-CN") + ) + ); + return sorted; +} + +function blackboardSortableTimestamp(value: string): number { + if (!value) return Number.NEGATIVE_INFINITY; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : Number.NEGATIVE_INFINITY; +} + +interface BlackboardOriginalDiscussionPage { + confId: string; + total: number; + items: BlackboardDiscussion[]; +} + +function parseBlackboardOriginalDiscussionPage(html: string, responseUrl?: string): BlackboardOriginalDiscussionPage { + const items = [...html.matchAll(//giu)] + .map((match) => parseBlackboardOriginalDiscussionRow(match[0])) + .filter((discussion): discussion is BlackboardDiscussion => discussion !== null); + const count = parseBlackboardOriginalDiscussionCount(html); + const confId = parseBlackboardOriginalDiscussionConfId(html, responseUrl); + return { + confId, + total: count?.total ?? items.length, + items, + }; +} + +function parseBlackboardOriginalDiscussionConfId(html: string, responseUrl?: string): string { + const hidden = /]*type="hidden"[^>]*name="conf_id"[^>]*value="([^"]+)"[^>]*>/iu.exec(html)?.[1]; + const linked = /[?&]conf_id=(_\d+_1)\b/iu.exec(html)?.[1]; + let resolvedFromUrl = ""; + if (responseUrl) { + try { + resolvedFromUrl = new URL(responseUrl, BLACKBOARD_BASE).searchParams.get("conf_id") ?? ""; + } catch { + resolvedFromUrl = ""; + } + } + const confId = hidden || linked || resolvedFromUrl || ""; + return canonicalCourseId(confId); +} + +function parseBlackboardOriginalDiscussionCount( + html: string, +): { start: number; end: number; total: number } | null { + const match = /Displaying\s*(\d+)<\/strong>\s*to\s*(\d+)<\/strong>\s*of\s*(\d+)<\/strong>\s*items/iu.exec(html); + if (!match) return null; + return { + start: numberValue(match[1]), + end: numberValue(match[2]), + total: numberValue(match[3]), + }; +} + +function parseBlackboardOriginalDiscussionRow(rowHtml: string): BlackboardDiscussion | null { + const hrefMatch = /]*href="([^"]*action=list_threads[^"]*)"[^>]*>([\s\S]*?)<\/a>/iu.exec(rowHtml); + let forumId = ""; + if (hrefMatch?.[1]) { + try { + forumId = canonicalIdBody(new URL((hrefMatch[1] ?? "").replace(/&/giu, "&"), BLACKBOARD_BASE).searchParams.get("forum_id")); + } catch { + forumId = ""; + } + } + const rowId = /\s*]*>([\s\S]*?)<\/a>/iu.exec(rowHtml)?.[1]); + if (!title) return null; + const description = cleanText(/
([\s\S]*?)<\/div>/iu.exec(rowHtml)?.[1]); + return { + id: discussionId, + title, + available: true, + gradable: false, + groupDiscussion: false, + createdDate: "", + modifiedDate: "", + source: "original-html", + metadataPartial: true, + ...(description ? { description } : {}), + totalPosts: originalDiscussionNumericCell(rowHtml, "Total Posts"), + unreadPosts: originalDiscussionNumericCell(rowHtml, "Unread Posts"), + unreadRepliesToMe: originalDiscussionNumericCell(rowHtml, "Unread Replies To Me"), + totalParticipants: originalDiscussionNumericCell(rowHtml, "Total Participants"), + }; +} + +function originalDiscussionNumericCell(rowHtml: string, label: string | readonly string[]): number { + const labels = Array.isArray(label) ? label : [label]; + return numberValue(/\d+/u.exec(originalDiscussionCellText(rowHtml, labels))?.[0]); +} + +function normaliseLookupTextForBlackboardFallback(value: string): string { + return collapseWhitespace(value).toLocaleLowerCase("zh-Hans-CN"); +} + +function sortBlackboardOriginalDiscussions( + items: readonly BlackboardDiscussion[], + sort: string | undefined, + courseId: string, +): BlackboardDiscussion[] { + if (!sort) return [...items]; + const match = /^([A-Za-z_][A-Za-z0-9_]*)(?:\((desc)\))?$/u.exec(sort.trim()); + if (!match) { + throw new CliError("Original Blackboard discussion forum sort must use FIELD or FIELD(desc).", "USAGE", 2, { + courseId, + sort, + source: "original-html", + }); + } + const field = match[1]; + const descending = match[2] === "desc"; + const sorted = [...items]; + const direction = descending ? -1 : 1; + const numericField = originalDiscussionSortSelector(field); + if (numericField === "title") { + sorted.sort((left, right) => direction * left.title.localeCompare(right.title, "zh-Hans-CN")); + return sorted; + } + if (numericField === "position") return descending ? [...sorted].reverse() : sorted; + if (!numericField) { + throw new CliError( + "Original Blackboard discussion forum fallback supports only title, totalPosts, unreadPosts, unreadRepliesToMe, totalParticipants, or position sorting.", + "BLACKBOARD_DISCUSSIONS_SORT_UNSUPPORTED", + 2, + { + courseId, + sort, + source: "original-html", + retryPolicy: "DO_NOT_RETRY_AUTOMATICALLY", + }, + ); + } + sorted.sort((left, right) => + direction * ( + (left[numericField] ?? 0) - (right[numericField] ?? 0) + || left.title.localeCompare(right.title, "zh-Hans-CN") + ) + ); + return sorted; +} + +function originalDiscussionSortSelector( + field: string, +): "title" | "position" | "totalPosts" | "unreadPosts" | "unreadRepliesToMe" | "totalParticipants" | null { + if (field === "title") return "title"; + if (field === "position") return "position"; + if (field === "totalPosts" || field === "total_posts") return "totalPosts"; + if (field === "unreadPosts" || field === "unread_posts") return "unreadPosts"; + if (field === "unreadRepliesToMe" || field === "unread_replies_to_me_posts") return "unreadRepliesToMe"; + if (field === "totalParticipants" || field === "total_participants") return "totalParticipants"; + return null; +} + +function originalDiscussionMessageSortSelector( + field: string, +): "position" | "subject" | "author" | "status" | "postDate" | "createdDate" | "modifiedDate" | "totalPosts" | "unreadPosts" | "unreadRepliesToMe" | null { + if (field === "position") return "position"; + if (field === "subject" || field === "title") return "subject"; + if (field === "author") return "author"; + if (field === "status") return "status"; + if (field === "date" || field === "postDate" || field === "post_date") return "postDate"; + if (field === "createdDate" || field === "created_date") return "createdDate"; + if (field === "modifiedDate" || field === "modified_date") return "modifiedDate"; + if (field === "totalPosts" || field === "total_posts") return "totalPosts"; + if (field === "unreadPosts" || field === "unread_posts") return "unreadPosts"; + if (field === "unreadRepliesToMe" || field === "unread_replies_to_me" || field === "unread_replies_to_me_posts") return "unreadRepliesToMe"; + return null; +} + +export async function getBlackboardDiscussion( + adapter: ServiceAdapter, + courseId: string, + discussionId: string, +): Promise { + try { + const raw = await fetchJson( + adapter, + buildBlackboardUrl( + `/learn/api/public/v1/courses/${canonicalCourseId(courseId)}/discussions/${canonicalCourseId(discussionId)}`, + ), + ); + return normaliseBlackboardDiscussion(raw); + } catch (error) { + throw normalizeBlackboardDiscussionUnsupported(error, { + operation: "get", + courseId, + discussionId, + }); + } +} + +export async function listBlackboardDiscussionGroups( + adapter: ServiceAdapter, + options: { + courseId: string; + discussionId: string; + page?: number; + pageSize?: number; + sort?: string; + }, +): Promise { + try { + const page = validatedBlackboardPage(options.page); + const pageSize = validatedBlackboardPageSize(options.pageSize); + const courseId = canonicalCourseId(options.courseId); + const discussionId = canonicalCourseId(options.discussionId); + const sort = options.sort?.trim() || undefined; + const query: Record = { + offset: String((page - 1) * pageSize), + limit: String(pageSize), + }; + if (sort) query.sort = sort; + const [course, discussion, response] = await Promise.all([ + resolveBlackboardCourseContext(adapter, courseId), + getBlackboardDiscussion(adapter, courseId, discussionId), + fetchBlackboardPageChunk( + adapter, + buildBlackboardUrl(`/learn/api/public/v1/courses/${courseId}/discussions/${discussionId}/groups`, query), + { absolute: true }, + ), + ]); + const groups = response.results.map((item) => normaliseBlackboardDiscussionGroup(item)); + return { + generatedAt: new Date().toISOString(), + courseId: course.id, + courseCode: course.courseCode, + courseName: course.name, + discussion, + ...(sort ? { sort } : {}), + page, + pageSize, + returned: groups.length, + hasMore: Boolean(response.nextPage), + ...(response.nextPage ? { nextPage: page + 1 } : {}), + groups, + }; + } catch (error) { + throw normalizeBlackboardDiscussionUnsupported(error, { + operation: "list-groups", + courseId: options.courseId, + discussionId: options.discussionId, + }); + } +} + +export async function getBlackboardDiscussionMessages( + adapter: ServiceAdapter, + options: { + courseId: string; + discussionId: string; + groupId?: string; + userId?: string; + status?: Exclude; + isRead?: boolean; + page?: number; + pageSize?: number; + sort?: string; + }, +): Promise { + const page = validatedBlackboardPage(options.page); + const pageSize = validatedBlackboardPageSize(options.pageSize); + const courseId = canonicalCourseId(options.courseId); + const discussionId = canonicalCourseId(options.discussionId); + const groupId = options.groupId ? canonicalCourseId(options.groupId) : undefined; + const userId = options.userId ? canonicalCourseId(options.userId) : undefined; + const sort = options.sort?.trim() || undefined; + try { + const query: Record = { + offset: String((page - 1) * pageSize), + limit: String(pageSize), + }; + if (groupId) query.groupId = groupId; + if (userId) query.userId = userId; + if (options.status) query.status = options.status; + if (options.isRead !== undefined) query.isRead = options.isRead ? "true" : "false"; + if (sort) query.sort = sort; + const course = await resolveBlackboardCourseContext(adapter, courseId); + const [discussion, response] = await Promise.all([ + getBlackboardDiscussion(adapter, courseId, discussionId), + fetchBlackboardPageChunk( + adapter, + buildBlackboardUrl(`/learn/api/public/v1/courses/${courseId}/discussions/${discussionId}/messages`, query), + { absolute: true }, + ), + ]); + const messages = response.results.map((item) => normaliseBlackboardDiscussionMessage(item)); + return { + generatedAt: new Date().toISOString(), + courseId: course.id, + courseCode: course.courseCode, + courseName: course.name, + discussion, + ...(groupId ? { groupId } : {}), + ...(userId ? { userId } : {}), + ...(options.status ? { status: options.status } : {}), + ...(options.isRead !== undefined ? { isRead: options.isRead } : {}), + ...(sort ? { sort } : {}), + page, + pageSize, + returned: messages.length, + hasMore: Boolean(response.nextPage), + ...(response.nextPage ? { nextPage: page + 1 } : {}), + messages, + }; + } catch (error) { + if ( + isBlackboardOriginalDiscussionUnsupported(error) + || (sort && isBlackboardDiscussionSortRejected(error)) + ) { + try { + const course = await resolveBlackboardCourseContext(adapter, courseId); + return listBlackboardOriginalDiscussionMessages(adapter, course, { + discussionId, + ...(groupId ? { groupId } : {}), + ...(userId ? { userId } : {}), + ...(options.status ? { status: options.status } : {}), + ...(options.isRead !== undefined ? { isRead: options.isRead } : {}), + ...(sort ? { sort } : {}), + page, + pageSize, + }); + } catch (fallbackError) { + if (isBlackboardOriginalDiscussionUnsupported(error)) throw fallbackError; + } + } + throw normalizeBlackboardDiscussionUnsupported(error, { + operation: "list-messages", + courseId: options.courseId, + discussionId: options.discussionId, + }); + } +} + +export async function listBlackboardDiscussionReplies( + adapter: ServiceAdapter, + options: { + courseId: string; + discussionId: string; + messageId: string; + groupId?: string; + userId?: string; + status?: Exclude; + isRead?: boolean; + page?: number; + pageSize?: number; + sort?: string; + }, +): Promise { + try { + const page = validatedBlackboardPage(options.page); + const pageSize = validatedBlackboardPageSize(options.pageSize); + const course = await resolveBlackboardCourseContext(adapter, options.courseId); + const discussionId = canonicalCourseId(options.discussionId); + const messageId = canonicalCourseId(options.messageId); + const groupId = options.groupId ? canonicalCourseId(options.groupId) : undefined; + const userId = options.userId ? canonicalCourseId(options.userId) : undefined; + const sort = options.sort?.trim() || undefined; + const query: Record = { + offset: String((page - 1) * pageSize), + limit: String(pageSize), + }; + if (groupId) query.groupId = groupId; + if (userId) query.userId = userId; + if (options.status) query.status = options.status; + if (options.isRead !== undefined) query.isRead = options.isRead ? "true" : "false"; + if (sort) query.sort = sort; + const response = await fetchBlackboardPageChunk( + adapter, + buildBlackboardUrl(`/learn/api/public/v1/courses/${course.id}/discussions/${discussionId}/messages/${messageId}/replies`, query), + { absolute: true }, + ); + const replies = response.results.map((item) => normaliseBlackboardDiscussionMessage(item)); + return { + generatedAt: new Date().toISOString(), + courseId: course.id, + courseCode: course.courseCode, + courseName: course.name, + discussionId: canonicalIdBody(discussionId), + messageId: canonicalIdBody(messageId), + ...(groupId ? { groupId } : {}), + ...(userId ? { userId } : {}), + ...(options.status ? { status: options.status } : {}), + ...(options.isRead !== undefined ? { isRead: options.isRead } : {}), + ...(sort ? { sort } : {}), + page, + pageSize, + returned: replies.length, + hasMore: Boolean(response.nextPage), + ...(response.nextPage ? { nextPage: page + 1 } : {}), + replies, + }; + } catch (error) { + if ( + isBlackboardOriginalDiscussionUnsupported(error) + || (options.sort?.trim() && isBlackboardDiscussionSortRejected(error)) + ) { + try { + const course = await resolveBlackboardCourseContext(adapter, options.courseId); + return listBlackboardOriginalDiscussionReplies(adapter, course, { + discussionId: options.discussionId, + messageId: options.messageId, + ...(options.groupId ? { groupId: options.groupId } : {}), + ...(options.userId ? { userId: options.userId } : {}), + ...(options.status ? { status: options.status } : {}), + ...(options.isRead !== undefined ? { isRead: options.isRead } : {}), + ...(options.sort?.trim() ? { sort: options.sort.trim() } : {}), + page: validatedBlackboardPage(options.page), + pageSize: validatedBlackboardPageSize(options.pageSize), + }); + } catch (fallbackError) { + throw fallbackError; + } + } + throw normalizeBlackboardDiscussionUnsupported(error, { + operation: "list-replies", + courseId: options.courseId, + discussionId: options.discussionId, + messageId: options.messageId, + }); + } +} + +async function listBlackboardOriginalDiscussionReplies( + adapter: ServiceAdapter, + course: BlackboardCourse, + options: { + discussionId: string; + messageId: string; + groupId?: string; + userId?: string; + status?: Exclude; + isRead?: boolean; + page: number; + pageSize: number; + sort?: string; + }, +): Promise { + if (options.groupId) throw blackboardOriginalDiscussionUnsupportedFilter(course.id, "groupId"); + if (options.userId) throw blackboardOriginalDiscussionUnsupportedFilter(course.id, "userId"); + const discussionId = canonicalCourseId(options.discussionId); + const messageId = canonicalCourseId(options.messageId); + const context = await getBlackboardOriginalDiscussionContext(adapter, course, discussionId); + const rootMessageId = canonicalIdBody(messageId); + const html = await fetchText( + adapter, + buildBlackboardUrl("/webapps/discussionboard/do/message", { + action: "list_messages", + course_id: course.id, + nav: "discussion_board_entry", + conf_id: context.confId, + forum_id: discussionId, + message_id: messageId, + thread_id: messageId, + }), + { headers: { accept: "text/html, */*;q=0.1" } }, + ); + const parsed = parseBlackboardOriginalDiscussionRepliesPage(html, { + discussionId: context.discussion.id, + threadId: rootMessageId || canonicalIdBody(messageId), + rootMessageId, + }); + if (parsed.total > BLACKBOARD_ORIGINAL_DISCUSSION_MESSAGE_MAX_ITEMS) { + throw new CliError( + "Original Blackboard discussion reply fallback would need to load too many HTML rows. Narrow the thread or use the native Blackboard UI.", + "BLACKBOARD_DISCUSSIONS_FALLBACK_LIMIT_EXCEEDED", + 1, + { + courseId: course.id, + discussionId, + messageId, + total: parsed.total, + limit: BLACKBOARD_ORIGINAL_DISCUSSION_MESSAGE_MAX_ITEMS, + source: "original-html", + }, + ); + } + if (parsed.total > parsed.items.length) { + throw new CliError( + "Original Blackboard discussion reply fallback did not return a complete HTML reply list.", + "BLACKBOARD_DISCUSSIONS_FALLBACK_UNAVAILABLE", + 1, + { + courseId: course.id, + discussionId, + messageId, + total: parsed.total, + returned: parsed.items.length, + source: "original-html", + }, + ); + } + let replies = parsed.items.filter((item) => item.id !== rootMessageId); + if (options.status) replies = replies.filter((reply) => reply.status === options.status); + if (options.isRead !== undefined) replies = replies.filter((reply) => reply.isRead === options.isRead); + const sorted = sortBlackboardOriginalDiscussionMessages(replies, options.sort, course.id, context.discussion.id); + const offset = (options.page - 1) * options.pageSize; + const pageItems = sorted.slice(offset, offset + options.pageSize); + const hydrated = await hydrateBlackboardOriginalDiscussionMessageBodies( + adapter, + course.id, + context.confId, + context.discussion.id, + pageItems, + ); + const hasMore = offset + hydrated.length < sorted.length; + return { + generatedAt: new Date().toISOString(), + courseId: course.id, + courseCode: course.courseCode, + courseName: course.name, + discussionId: context.discussion.id, + messageId: rootMessageId, + ...(options.status ? { status: options.status } : {}), + ...(options.isRead !== undefined ? { isRead: options.isRead } : {}), + ...(options.sort ? { sort: options.sort } : {}), + page: options.page, + pageSize: options.pageSize, + returned: hydrated.length, + hasMore, + ...(hasMore ? { nextPage: options.page + 1 } : {}), + replies: hydrated, + }; +} + +function parseBlackboardOriginalDiscussionRepliesPage( + html: string, + context: { discussionId: string; threadId: string; rootMessageId: string }, +): BlackboardOriginalDiscussionRepliesParsedPage { + const items = [...html.matchAll(//giu)] + .map((match) => parseBlackboardOriginalDiscussionReplyRow(match[0], context)) + .filter((message): message is BlackboardDiscussionMessage => message !== null); + const count = parseBlackboardOriginalDiscussionCount(html); + return { + total: count?.total ?? items.length, + items, + }; +} + +function parseBlackboardOriginalDiscussionReplyRow( + rowHtml: string, + context: { discussionId: string; threadId: string; rootMessageId: string }, +): BlackboardDiscussionMessage | null { + const subjectMatch = /]*href="([^"]*action=(?:list_messages|message_frame)[^"]*message_id=[^"]*)"[^>]*>([\s\S]*?)<\/a>/iu.exec(rowHtml); + if (!subjectMatch) return null; + let href: URL; + try { + href = new URL((subjectMatch[1] ?? "").replace(/&/giu, "&"), BLACKBOARD_BASE); + } catch { + return null; + } + const messageId = canonicalIdBody(href.searchParams.get("message_id")); + if (!messageId) return null; + const dateHtml = originalDiscussionCellHtml(rowHtml, ["Date", "日期", "Posted Date", "发布日期"]); + const dateText = cleanText(dateHtml); + const postDate = normalizeBlackboardOriginalDiscussionDate(dateText); + const author = originalDiscussionCellText(rowHtml, ["Author", "作者"]); + const parentId = canonicalIdBody(href.searchParams.get("parent_id")) || (messageId === context.rootMessageId ? "" : context.rootMessageId); + return { + id: messageId, + discussionId: canonicalIdBody(href.searchParams.get("forum_id")) || context.discussionId, + parentId, + threadId: canonicalIdBody(href.searchParams.get("thread_id")) || context.threadId || context.rootMessageId || messageId, + userId: "", + groupId: "", + givenName: "", + familyName: "", + author: author || "匿名", + status: normaliseBlackboardOriginalDiscussionStatus(originalDiscussionCellText(rowHtml, ["Status", "状态"])), + body: "", + postDate, + editDate: "", + createdDate: postDate, + modifiedDate: postDate, + isRead: !/\bunreadmessage\b/iu.test(dateHtml), + subject: cleanText(subjectMatch[2]), + source: "original-html", + metadataPartial: true, + unreadPosts: originalDiscussionNumericCell(rowHtml, ["Unread Posts", "未读帖子"]), + unreadRepliesToMe: originalDiscussionNumericCell(rowHtml, ["Unread Replies To Me", "未读对我的回复"]), + totalPosts: originalDiscussionNumericCell(rowHtml, ["Total Posts", "帖子总数"]), + }; +} + +export async function createBlackboardDiscussionMessage( + adapter: ServiceAdapter, + courseId: string, + discussionId: string, + input: BlackboardDiscussionMessageWriteInput, +): Promise { + try { + const raw = await fetchJson( + adapter, + buildBlackboardUrl( + `/learn/api/public/v1/courses/${canonicalCourseId(courseId)}/discussions/${canonicalCourseId(discussionId)}/messages`, + ), + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(blackboardDiscussionWritePayload(input)), + }, + ); + return normaliseBlackboardDiscussionMessage(raw); + } catch (error) { + throw normalizeBlackboardDiscussionUnsupported(error, { + operation: "create-message", + courseId, + discussionId, + }); + } +} + +export async function createBlackboardDiscussionReply( + adapter: ServiceAdapter, + courseId: string, + discussionId: string, + messageId: string, + input: BlackboardDiscussionMessageWriteInput, +): Promise { + try { + const raw = await fetchJson( + adapter, + buildBlackboardUrl( + `/learn/api/public/v1/courses/${canonicalCourseId(courseId)}/discussions/${canonicalCourseId(discussionId)}/messages/${canonicalCourseId(messageId)}/replies`, + ), + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(blackboardDiscussionWritePayload(input)), + }, + ); + return normaliseBlackboardDiscussionMessage(raw); + } catch (error) { + throw normalizeBlackboardDiscussionUnsupported(error, { + operation: "create-reply", + courseId, + discussionId, + messageId, + }); + } +} + +export async function createBlackboardCourseMessage( + adapter: ServiceAdapter, + courseId: string, + input: BlackboardCourseMessageWriteInput, +): Promise { + const raw = await fetchJson( + adapter, + buildBlackboardUrl(`/learn/api/public/v1/courses/${canonicalCourseId(courseId)}/messages`), + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(blackboardCourseMessageWritePayload(input)), + }, + ); + return normaliseBlackboardCourseMessage(raw); +} + +export async function listBlackboardCourseMessageFolders( + adapter: ServiceAdapter, + options: { + courseId: string; + page?: number; + pageSize?: number; + }, +): Promise { + const page = validatedBlackboardPage(options.page); + const pageSize = validatedBlackboardPageSize(options.pageSize); + const course = await resolveBlackboardCourseContext(adapter, options.courseId); + const response = await fetchBlackboardPageChunk( + adapter, + buildBlackboardUrl(`/learn/api/public/v1/courses/${course.id}/messages/folders`, { + offset: String((page - 1) * pageSize), + limit: String(pageSize), + }), + { absolute: true }, + ); + const folders = response.results.map((item) => normaliseBlackboardCourseMessageFolder(item)); + return { + generatedAt: new Date().toISOString(), + courseId: course.id, + courseCode: course.courseCode, + courseName: course.name, + page, + pageSize, + returned: folders.length, + hasMore: Boolean(response.nextPage), + ...(response.nextPage ? { nextPage: page + 1 } : {}), + folders, + }; +} + +export async function listBlackboardCourseMessages( + adapter: ServiceAdapter, + options: { + courseId: string; + folderType?: Exclude; + folderName?: string; + page?: number; + pageSize?: number; + sort?: string; + }, +): Promise { + const page = validatedBlackboardPage(options.page); + const pageSize = validatedBlackboardPageSize(options.pageSize); + const course = await resolveBlackboardCourseContext(adapter, options.courseId); + const folderName = options.folderName?.trim() || undefined; + const sort = options.sort?.trim() || undefined; + const query: Record = { + offset: String((page - 1) * pageSize), + limit: String(pageSize), + }; + if (options.folderType) query.folderType = options.folderType; + if (folderName) query.folderName = folderName; + if (sort) query.sort = sort; + query.expand = "sender"; + const response = await fetchBlackboardPageChunk( + adapter, + buildBlackboardUrl(`/learn/api/public/v1/courses/${course.id}/messages`, query), + { absolute: true }, + ); + const messages = response.results.map((item) => normaliseBlackboardCourseMessage(item)); + return { + generatedAt: new Date().toISOString(), + courseId: course.id, + courseCode: course.courseCode, + courseName: course.name, + ...(options.folderType ? { folderType: options.folderType } : {}), + ...(folderName ? { folderName } : {}), + ...(sort ? { sort } : {}), + page, + pageSize, + returned: messages.length, + hasMore: Boolean(response.nextPage), + ...(response.nextPage ? { nextPage: page + 1 } : {}), + messages, + }; +} + +export async function listBlackboardCourseMessageParticipants( + adapter: ServiceAdapter, + options: { + courseId: string; + messageId: string; + participationType?: Exclude; + page?: number; + pageSize?: number; + sort?: string; + }, +): Promise { + const page = validatedBlackboardPage(options.page); + const pageSize = validatedBlackboardPageSize(options.pageSize); + const course = await resolveBlackboardCourseContext(adapter, options.courseId); + const messageId = canonicalCourseId(options.messageId); + const sort = options.sort?.trim() || undefined; + const query: Record = { + offset: String((page - 1) * pageSize), + limit: String(pageSize), + }; + if (options.participationType) query.participationType = options.participationType; + if (sort) query.sort = sort; + query.expand = "user"; + const response = await fetchBlackboardPageChunk( + adapter, + buildBlackboardUrl(`/learn/api/public/v1/courses/${course.id}/messages/${messageId}/participants`, query), + { absolute: true }, + ); + const participants = response.results.map((item) => normaliseBlackboardCourseMessageParticipant(item)); + return { + generatedAt: new Date().toISOString(), + courseId: course.id, + courseCode: course.courseCode, + courseName: course.name, + messageId: canonicalIdBody(messageId), + ...(options.participationType ? { participationType: options.participationType } : {}), + ...(sort ? { sort } : {}), + page, + pageSize, + returned: participants.length, + hasMore: Boolean(response.nextPage), + ...(response.nextPage ? { nextPage: page + 1 } : {}), + participants, + }; +} + +export async function listBlackboardCourseRoster( + adapter: ServiceAdapter, + options: { + courseId: string; + role?: string; + availability?: Exclude; + page?: number; + pageSize?: number; + sort?: string; + }, +): Promise { + const page = validatedBlackboardPage(options.page); + const pageSize = validatedBlackboardPageSize(options.pageSize); + const course = await resolveBlackboardCourseContext(adapter, options.courseId); + const role = options.role?.trim() || undefined; + const sort = options.sort?.trim() || undefined; + const query: Record = { + offset: String((page - 1) * pageSize), + limit: String(pageSize), + }; + if (role) query.role = role; + if (options.availability) query["availability.available"] = options.availability; + if (sort) query.sort = sort; + query.expand = "user"; + const response = await fetchBlackboardPageChunk( + adapter, + buildBlackboardUrl(`/learn/api/public/v1/courses/${course.id}/users`, query), + { absolute: true }, + ); + const memberships = response.results.map((item) => normaliseBlackboardCourseMembership(item)); + return { + generatedAt: new Date().toISOString(), + courseId: course.id, + courseCode: course.courseCode, + courseName: course.name, + ...(role ? { role } : {}), + ...(options.availability ? { availability: options.availability } : {}), + ...(sort ? { sort } : {}), + page, + pageSize, + returned: memberships.length, + hasMore: Boolean(response.nextPage), + ...(response.nextPage ? { nextPage: page + 1 } : {}), + memberships, + }; +} + +export async function listBlackboardAttempts( + adapter: ServiceAdapter, + courseId: string, + columnId: string, + options: { status?: BlackboardAttemptStatus } = {}, +): Promise { + const user = await getBlackboardUser(adapter); + return listBlackboardAttemptsForUser(adapter, courseId, columnId, user.id, options); +} + +async function listBlackboardAttemptsForUser( + adapter: ServiceAdapter, + courseId: string, + columnId: string, + userId: string, + options: { status?: BlackboardAttemptStatus } = {}, +): Promise { + const url = buildBlackboardUrl(`/learn/api/public/v2/courses/${canonicalCourseId(courseId)}/gradebook/columns/${canonicalCourseId(columnId)}/attempts`, { + userId, + ...(options.status ? { attemptStatuses: options.status } : {}), + }); + const page = await fetchBlackboardPage(adapter, url, { absolute: true }); + return page.results.map((item) => normaliseBlackboardAttempt(item)); +} + +export async function listBlackboardAssignmentsWithAttempts( + adapter: ServiceAdapter, + courseId: string, +): Promise { + const canonical = canonicalCourseId(courseId); + const assignments = await listBlackboardAssignments(adapter, canonical); + if (assignments.length === 0) { + return { + generatedAt: new Date().toISOString(), + courseId: canonical, + totalAssignments: 0, + completedAttemptFetches: 0, + attemptedAssignments: 0, + partial: false, + assignments: [], + failures: [], + }; + } + + const user = await getBlackboardUser(adapter); + const items: BlackboardAssignmentWithAttempts[] = []; + const failures: BlackboardOperationFailure[] = []; + let completedAttemptFetches = 0; + + for (const assignment of assignments) { + try { + const attempts = await listBlackboardAttemptsForUser(adapter, canonical, assignment.id, user.id); + const attemptSummary = summariseBlackboardAssignmentAttempts(attempts); + completedAttemptFetches += 1; + items.push({ assignment, attemptSummary }); + } catch (error) { + items.push({ assignment }); + failures.push(blackboardOperationFailure(error, { + stage: "attempts", + courseId: canonical, + contentId: assignment.contentId, + columnId: assignment.id, + })); + } + } + + return { + generatedAt: new Date().toISOString(), + courseId: canonical, + totalAssignments: assignments.length, + completedAttemptFetches, + attemptedAssignments: items.filter((item) => (item.attemptSummary?.totalAttempts ?? 0) > 0).length, + partial: failures.length > 0, + assignments: items, + failures, + }; +} + +export async function listBlackboardAssignmentsAcrossCourses( + adapter: ServiceAdapter, + options: { + courseQuery?: string; + withAttempts?: boolean; + submissionState?: BlackboardAssignmentSubmissionState; + } = {}, +): Promise { + const withAttempts = options.withAttempts === true || options.submissionState !== undefined; + const report: BlackboardAssignmentsAggregateReport = { + generatedAt: new Date().toISOString(), + ...(options.courseQuery ? { courseQuery: options.courseQuery } : {}), + withAttempts, + ...(options.submissionState ? { submissionState: options.submissionState } : {}), + coursesMatched: 0, + coursesScanned: 0, + totalAssignments: 0, + completedAttemptFetches: 0, + attemptedAssignments: 0, + partial: false, + assignments: [], + failures: [], + }; + const courses = await listBlackboardCoursesForAggregation(adapter, report.failures, options.courseQuery); + report.coursesMatched = courses.length; + const user = withAttempts ? await getBlackboardUser(adapter) : undefined; + for (const course of courses) { + report.coursesScanned += 1; + let assignments: BlackboardAssignment[]; + try { + assignments = await listBlackboardAssignments(adapter, course.id); + } catch (error) { + report.failures.push( + blackboardOperationFailure(error, { + stage: "assignments", + courseId: course.id, + courseCode: course.courseCode, + courseName: course.name, + }), + ); + continue; + } + report.totalAssignments += assignments.length; + for (const assignment of assignments) { + if (!user) { + report.assignments.push({ + courseId: course.id, + courseCode: course.courseCode, + courseName: course.name, + assignment, + }); + continue; + } + try { + const attempts = await listBlackboardAttemptsForUser(adapter, course.id, assignment.id, user.id); + const attemptSummary = summariseBlackboardAssignmentAttempts(attempts); + report.completedAttemptFetches += 1; + if (options.submissionState && attemptSummary.state !== options.submissionState) continue; + report.assignments.push({ + courseId: course.id, + courseCode: course.courseCode, + courseName: course.name, + assignment, + attemptSummary, + }); + } catch (error) { + report.failures.push( + blackboardOperationFailure(error, { + stage: "attempts", + courseId: course.id, + courseCode: course.courseCode, + courseName: course.name, + contentId: assignment.contentId, + columnId: assignment.id, + }), + ); + if (options.submissionState) continue; + report.assignments.push({ + courseId: course.id, + courseCode: course.courseCode, + courseName: course.name, + assignment, + }); + } + } + } + report.partial = report.failures.length > 0; + report.attemptedAssignments = report.assignments.filter((item) => (item.attemptSummary?.totalAttempts ?? 0) > 0).length; + report.assignments.sort(compareBlackboardScopedAssignments); + return report; +} + +export async function listBlackboardGrades( adapter: ServiceAdapter, - courseId: string, - columnId: string, - options: { status?: BlackboardAttemptStatus } = {}, -): Promise { - const user = await getBlackboardUser(adapter); - const url = buildBlackboardUrl(`/learn/api/public/v2/courses/${canonicalCourseId(courseId)}/gradebook/columns/${canonicalCourseId(columnId)}/attempts`, { - userId: user.id, - ...(options.status ? { attemptStatuses: options.status } : {}), + options: { + courseQuery?: string; + submissionState?: Exclude; + limit?: number; + } = {}, +): Promise { + const aggregate = await listBlackboardAssignmentsAcrossCourses(adapter, { + ...(options.courseQuery ? { courseQuery: options.courseQuery } : {}), + withAttempts: true, + ...(options.submissionState ? { submissionState: options.submissionState } : {}), }); - const page = await fetchBlackboardPage(adapter, url, { absolute: true }); - return page.results.map((item) => normaliseBlackboardAttempt(item)); + const grades = aggregate.assignments + .filter((item): item is BlackboardGradeEntry => (item.attemptSummary?.totalAttempts ?? 0) > 0) + .sort(compareBlackboardGradeEntries); + const limited = options.limit === undefined ? grades : grades.slice(0, options.limit); + return { + generatedAt: aggregate.generatedAt, + ...(aggregate.courseQuery ? { courseQuery: aggregate.courseQuery } : {}), + ...(options.submissionState ? { submissionState: options.submissionState } : {}), + ...(options.limit !== undefined ? { limit: options.limit } : {}), + coursesMatched: aggregate.coursesMatched, + coursesScanned: aggregate.coursesScanned, + totalAssignments: aggregate.totalAssignments, + completedAttemptFetches: aggregate.completedAttemptFetches, + attemptedAssignments: aggregate.attemptedAssignments, + partial: aggregate.partial, + grades: limited, + failures: aggregate.failures, + }; } export async function getBlackboardAttempt( @@ -638,7 +2817,7 @@ export async function createBlackboardAttempt( adapter: ServiceAdapter, courseId: string, columnId: string, - input: { studentComments?: string } = {}, + input: { studentComments?: string; studentSubmission?: string } = {}, ): Promise { const raw = await fetchJson( adapter, @@ -676,11 +2855,87 @@ export async function listBlackboardAttemptFiles( courseId: string, attemptId: string, ): Promise { + const canonicalCourse = canonicalCourseId(courseId); + const canonicalAttempt = canonicalCourseId(attemptId); const page = await fetchBlackboardPage( adapter, - `/learn/api/public/v1/courses/${canonicalCourseId(courseId)}/gradebook/attempts/${canonicalCourseId(attemptId)}/files`, + `/learn/api/public/v1/courses/${canonicalCourse}/gradebook/attempts/${canonicalAttempt}/files`, ); - return page.results.map((item) => normaliseBlackboardAttemptFile(item)); + return page.results.map((item) => completeBlackboardAttemptFile(normaliseBlackboardAttemptFile(item), canonicalCourse, canonicalAttempt)); +} + +export async function downloadBlackboardAttemptFile( + adapter: ServiceAdapter, + courseId: string, + attemptId: string, + fileId: string, + destination: string, + options: { overwrite?: boolean } = {}, +): Promise { + const output = await inspectBlackboardDownloadDestination(destination, options.overwrite === true); + const files = await listBlackboardAttemptFiles(adapter, courseId, attemptId); + const requestedId = canonicalIdBody(fileId); + const file = files.find((entry) => entry.id === requestedId); + if (!file) { + throw new CliError( + "The requested Blackboard attempt file was not found.", + "BLACKBOARD_ATTEMPT_FILE_NOT_FOUND", + 2, + { + courseId: canonicalCourseId(courseId), + attemptId: canonicalCourseId(attemptId), + fileId: requestedId, + availableFiles: files.map((entry) => ({ id: entry.id, name: entry.name })), + }, + ); + } + if (!file.downloadUrl) { + throw new CliError( + "Blackboard did not expose a downloadable URL for the selected attempt file.", + "BLACKBOARD_ATTEMPT_FILE_UNAVAILABLE", + 2, + { + courseId: canonicalCourseId(courseId), + attemptId: canonicalCourseId(attemptId), + fileId: requestedId, + }, + ); + } + + let response: Response; + try { + response = await fetchBlackboardAttachmentResponse(adapter, file.downloadUrl); + } catch (error) { + const status = blackboardDownloadStatus(error); + if (status === 404) { + throw new CliError( + "Blackboard did not expose a downloadable URL for the selected attempt file.", + "BLACKBOARD_ATTEMPT_FILE_UNAVAILABLE", + 2, + { + courseId: canonicalCourseId(courseId), + attemptId: canonicalCourseId(attemptId), + fileId: requestedId, + }, + ); + } + throw error; + } + const tempPath = join(dirname(output.destination), `.${basename(output.destination)}.sustech-${randomUUID()}.tmp`); + try { + const streamed = await streamBlackboardAttachment(response, tempPath, ""); + await finishBlackboardDownload(tempPath, output.destination, options.overwrite === true); + return { + file, + destination: output.destination, + size: streamed.size, + sha256: streamed.sha256, + contentType: streamed.contentType, + overwritten: output.existed, + }; + } finally { + await rm(tempPath, { force: true }).catch(() => undefined); + } } export async function attachBlackboardAttemptFile( @@ -729,6 +2984,10 @@ export async function inspectBlackboardSubmissionFile(path: string): Promise { + return (await readBlackboardSubmissionTextPayload(path)).textFile; +} + export async function readBlackboardSubmissionPayload(path: string): Promise { const absolutePath = resolvePath(path); let info; @@ -775,22 +3034,159 @@ export async function readBlackboardSubmissionPayload(path: string): Promise { + const absolutePath = resolvePath(path); + let info; + let buffer: Buffer; + try { + info = await stat(absolutePath); + buffer = await readFile(absolutePath); + } catch (error) { + throw new CliError( + "The Blackboard submission text file could not be read.", + "BLACKBOARD_TEXT_FILE_NOT_READABLE", + 2, + { + file: absolutePath, + cause: error instanceof Error ? error.message : String(error), + }, + ); + } + if (!info.isFile()) { + throw new CliError( + "The Blackboard submission text target must be a regular file.", + "BLACKBOARD_TEXT_FILE_NOT_REGULAR", + 2, + { file: absolutePath }, + ); + } + let text = ""; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(buffer); + } catch (error) { + throw new CliError( + "The Blackboard submission text file must be valid UTF-8.", + "BLACKBOARD_TEXT_FILE_NOT_UTF8", + 2, + { + file: absolutePath, + cause: error instanceof Error ? error.message : String(error), + }, + ); + } + if (buffer.length === 0) { + throw new CliError( + "The Blackboard submission text file cannot be empty.", + "BLACKBOARD_TEXT_FILE_EMPTY", + 2, + { file: absolutePath }, + ); + } + return { + textFile: { + path, + absolutePath, + size: info.size, + sha256: createHash("sha256").update(buffer).digest("hex"), + charCount: [...text].length, + }, + text, + }; +} + +export async function listBlackboardAnnouncements( adapter: ServiceAdapter, options: { now?: Date; days?: number; courseQuery?: string } = {}, +): Promise { + const now = options.now ?? new Date(); + const minimumTimestamp = options.days === undefined ? undefined : now.getTime() - options.days * 86_400_000; + const report: BlackboardAnnouncementsReport = { + generatedAt: now.toISOString(), + ...(options.courseQuery ? { courseQuery: options.courseQuery } : {}), + ...(options.days !== undefined ? { days: options.days } : {}), + coursesMatched: 0, + coursesScanned: 0, + systemAnnouncements: 0, + courseAnnouncements: 0, + partial: false, + announcements: [], + failures: [], + }; + + if (!options.courseQuery) { + try { + const systemAnnouncements = await listBlackboardSystemAnnouncements(adapter); + for (const announcement of systemAnnouncements) { + if (!blackboardAnnouncementMatchesWindow(announcement, minimumTimestamp)) continue; + report.announcements.push(announcement); + report.systemAnnouncements += 1; + } + } catch (error) { + report.failures.push(blackboardOperationFailure(error, { stage: "announcements" })); + } + } + + const courses = await listBlackboardCoursesForAggregation(adapter, report.failures, options.courseQuery); + report.coursesMatched = courses.length; + for (const course of courses) { + report.coursesScanned += 1; + let announcements: BlackboardAnnouncement[]; + try { + announcements = await listBlackboardCourseAnnouncements(adapter, course.id); + } catch (error) { + if (shouldSkipBlackboardCourseAnnouncements(error)) continue; + report.failures.push( + blackboardOperationFailure(error, { + stage: "announcements", + courseId: course.id, + courseCode: course.courseCode, + courseName: course.name, + }), + ); + continue; + } + for (const announcement of announcements) { + const scoped = { + ...announcement, + courseId: course.id, + courseCode: course.courseCode, + courseName: course.name, + } satisfies BlackboardAnnouncement; + if (!blackboardAnnouncementMatchesWindow(scoped, minimumTimestamp)) continue; + report.announcements.push(scoped); + report.courseAnnouncements += 1; + } + } + + report.announcements.sort(compareBlackboardAnnouncements); + report.partial = report.failures.length > 0; + return report; +} + +export async function listBlackboardDeadlines( + adapter: ServiceAdapter, + options: { + now?: Date; + days?: number; + courseQuery?: string; + submissionState?: BlackboardAssignmentSubmissionState; + } = {}, ): Promise { const now = options.now ?? new Date(); const report: BlackboardDeadlineReport = { generatedAt: now.toISOString(), ...(options.courseQuery ? { courseQuery: options.courseQuery } : {}), ...(options.days !== undefined ? { days: options.days } : {}), + ...(options.submissionState ? { submissionState: options.submissionState } : {}), coursesMatched: 0, coursesScanned: 0, + partial: false, deadlines: [], failures: [], }; const courses = await listBlackboardCoursesForAggregation(adapter, report.failures, options.courseQuery); report.coursesMatched = courses.length; + const user = options.submissionState ? await getBlackboardUser(adapter) : undefined; for (const course of courses) { report.coursesScanned += 1; let assignments: BlackboardAssignment[]; @@ -826,6 +3222,26 @@ export async function listBlackboardDeadlines( if (due.getTime() < now.getTime()) continue; const daysLeft = blackboardDaysLeft(now, due); if (options.days !== undefined && daysLeft > options.days) continue; + let attemptSummary: BlackboardAssignmentAttemptSummary | undefined; + if (user && options.submissionState) { + try { + const attempts = await listBlackboardAttemptsForUser(adapter, course.id, assignment.id, user.id); + attemptSummary = summariseBlackboardAssignmentAttempts(attempts); + } catch (error) { + report.failures.push( + blackboardOperationFailure(error, { + stage: "attempts", + courseId: course.id, + courseCode: course.courseCode, + courseName: course.name, + contentId: assignment.contentId, + columnId: assignment.id, + }), + ); + continue; + } + if (attemptSummary.state !== options.submissionState) continue; + } report.deadlines.push({ courseId: course.id, courseCode: course.courseCode, @@ -838,19 +3254,179 @@ export async function listBlackboardDeadlines( availability: assignment.availability, ...(assignment.scorePossible !== undefined ? { scorePossible: assignment.scorePossible } : {}), ...(assignment.grading.attemptsAllowed !== undefined ? { attemptsAllowed: assignment.grading.attemptsAllowed } : {}), + ...(attemptSummary ? { attemptSummary } : {}), + }); + } + } + report.partial = report.failures.length > 0; + report.deadlines.sort((left, right) => + Date.parse(left.dueAt) - Date.parse(right.dueAt) + || left.courseCode.localeCompare(right.courseCode) + || left.title.localeCompare(right.title), + ); + return report; +} + +export function nextBlackboardDeadline(report: BlackboardDeadlineReport): BlackboardDeadline | null { + return report.deadlines[0] ?? null; +} + +export function nextBlackboardAnnouncement(report: BlackboardAnnouncementsReport): BlackboardAnnouncement | null { + return report.announcements[0] ?? null; +} + +export async function listBlackboardContentTree( + adapter: ServiceAdapter, + options: { + courseId: string; + rootContentId?: string; + maxItems?: number; + }, +): Promise { + const maxItems = validatedBlackboardTreeMaxItems(options.maxItems ?? 500); + const course = await resolveBlackboardCourseContext(adapter, options.courseId); + const failures: BlackboardOperationFailure[] = []; + const entries: BlackboardContentTreeEntry[] = []; + const visited = new Set(); + let truncated = false; + + const visit = async (item: BlackboardContentItem, ancestors: readonly string[]): Promise => { + if (truncated || visited.has(item.id)) return; + visited.add(item.id); + const pathTitles = [...ancestors, item.title || item.id]; + const path = blackboardContentPath(course, pathTitles); + entries.push({ + courseId: course.id, + courseCode: course.courseCode, + courseName: course.name, + contentId: item.id, + parentId: item.parentId, + title: item.title, + kind: item.kind, + handler: item.handler, + hasChildren: item.hasChildren, + depth: ancestors.length, + path, + pathTitles, + }); + if (entries.length >= maxItems) { + truncated = true; + failures.push({ + stage: "content", + courseId: course.id, + courseCode: course.courseCode, + courseName: course.name, + contentId: item.id, + path, + message: `Blackboard tree stopped after ${maxItems} content items; narrow with --content-id or raise --max.`, }); + return; + } + if (!item.hasChildren) return; + let children: BlackboardContentItem[]; + try { + children = await listBlackboardContent(adapter, course.id, item.id); + } catch (error) { + failures.push( + blackboardOperationFailure(error, { + stage: "content", + courseId: course.id, + courseCode: course.courseCode, + courseName: course.name, + parentId: item.id, + contentId: item.id, + path, + }), + ); + return; + } + for (const child of children) { + await visit(child, pathTitles); + if (truncated) break; } + }; + + const roots = options.rootContentId + ? [await getBlackboardContentItem(adapter, course.id, options.rootContentId)] + : await listBlackboardContent(adapter, course.id); + for (const item of roots) { + await visit(item, []); + if (truncated) break; + } + + return { + generatedAt: new Date().toISOString(), + courseId: course.id, + courseCode: course.courseCode, + courseName: course.name, + ...(options.rootContentId ? { rootContentId: canonicalIdBody(options.rootContentId) } : {}), + maxItems, + returnedItems: entries.length, + truncated, + partial: failures.length > 0, + entries, + failures, + }; +} + +export async function summarizeBlackboardContentTypes( + adapter: ServiceAdapter, + options: { + courseQuery?: string; + } = {}, +): Promise { + const failures: BlackboardOperationFailure[] = []; + const matchedCourses = await listBlackboardCoursesForAggregation(adapter, failures, options.courseQuery); + const totals = new Map(); + const courses: BlackboardContentTypesCourse[] = []; + let coursesScanned = 0; + let totalItems = 0; + + for (const course of matchedCourses) { + coursesScanned += 1; + const kindCounts = new Map(); + const handlerCounts = new Map(); + let courseItems = 0; + await walkBlackboardCourseContents(adapter, course, { + onFailure(failure): void { + failures.push(failure); + }, + async visit(entry): Promise { + courseItems += 1; + totalItems += 1; + incrementBlackboardCount(kindCounts, entry.item.kind); + incrementBlackboardCount(totals, entry.item.kind); + if (entry.item.handler) incrementBlackboardCount(handlerCounts, entry.item.handler); + }, + }); + courses.push({ + courseId: course.id, + courseCode: course.courseCode, + courseName: course.name, + totalItems: courseItems, + kindCounts: blackboardKindCountEntries(kindCounts), + handlerCounts: blackboardStringCountEntries(handlerCounts), + }); } - report.deadlines.sort((left, right) => - Date.parse(left.dueAt) - Date.parse(right.dueAt) - || left.courseCode.localeCompare(right.courseCode) - || left.title.localeCompare(right.title), + + courses.sort((left, right) => + right.totalItems - left.totalItems + || left.courseCode.localeCompare(right.courseCode, "zh-Hans-CN") + || left.courseName.localeCompare(right.courseName, "zh-Hans-CN") + || left.courseId.localeCompare(right.courseId, "zh-Hans-CN") ); - return report; -} -export function nextBlackboardDeadline(report: BlackboardDeadlineReport): BlackboardDeadline | null { - return report.deadlines[0] ?? null; + return { + generatedAt: new Date().toISOString(), + ...(options.courseQuery ? { courseQuery: options.courseQuery } : {}), + coursesMatched: matchedCourses.length, + coursesScanned, + totalItems, + partial: failures.length > 0, + totals: blackboardKindCountEntries(totals), + courses, + failures, + }; } export async function searchBlackboardContentTree( @@ -1136,7 +3712,7 @@ export function evaluateBlackboardSubmissionPreflight(input: { assignment: BlackboardAssignment; content: BlackboardContentItem; attempts: readonly BlackboardAttempt[]; - file: BlackboardSubmissionFile; + submission: BlackboardSubmissionMaterial; uploadSettings?: BlackboardUploadSettings; now?: Date; }): BlackboardSubmissionPreflight { @@ -1157,16 +3733,39 @@ export function evaluateBlackboardSubmissionPreflight(input: { ).length; const attemptsAllowed = input.assignment.grading.attemptsAllowed; - if (input.content.kind !== "assignment") { + const contentHandler = input.content.handler || ""; + const scoreProviderHandle = input.assignment.scoreProviderHandle || ""; + const classicFileSubmission = input.content.kind === "assignment" + && contentHandler === "resource/x-bb-assignment" + && (!scoreProviderHandle || scoreProviderHandle === "resource/x-bb-assignment"); + const ultraTextSubmission = ( + contentHandler === "resource/x-bb-asmt-test-link" + || contentHandler === "resource/x-bb-assessment" + ) && scoreProviderHandle === "resource/x-bb-assessment"; + + if ( + (input.submission.kind === "file" && !classicFileSubmission) + || (input.submission.kind === "text" && !classicFileSubmission && !ultraTextSubmission) + ) { blockers.push({ code: "UNSUPPORTED_CONTENT_TYPE", - message: `Content handler ${input.content.handler || "unknown"} is not a Classic/Original assignment.`, + message: input.submission.kind === "file" + ? `Content handler ${contentHandler || "unknown"} does not support official REST file attachment; current Blackboard support is limited to Classic/Original assignments.` + : `Content handler ${contentHandler || "unknown"} is not a supported Blackboard assignment submission target.`, }); } - if (input.assignment.scoreProviderHandle && input.assignment.scoreProviderHandle !== "resource/x-bb-assignment") { + if ( + (input.submission.kind === "file" && scoreProviderHandle && scoreProviderHandle !== "resource/x-bb-assignment") + || (input.submission.kind === "text" + && scoreProviderHandle + && scoreProviderHandle !== "resource/x-bb-assignment" + && scoreProviderHandle !== "resource/x-bb-assessment") + ) { blockers.push({ code: "UNSUPPORTED_SCORE_PROVIDER", - message: `Score provider ${input.assignment.scoreProviderHandle} is not supported by the official attempt-file endpoint.`, + message: input.submission.kind === "file" + ? `Score provider ${scoreProviderHandle} does not support the official attempt-file endpoint.` + : `Score provider ${scoreProviderHandle} is not a supported Blackboard assignment submission target.`, }); } if (input.assignment.availability && input.assignment.availability !== "Yes") { @@ -1194,12 +3793,15 @@ export function evaluateBlackboardSubmissionPreflight(input: { }); } if ( + input.submission.kind === "file" + && ( input.uploadSettings?.maxUploadSizeInBytes !== undefined - && input.file.size > input.uploadSettings.maxUploadSizeInBytes + && input.submission.file.size > input.uploadSettings.maxUploadSizeInBytes + ) ) { blockers.push({ code: "FILE_TOO_LARGE", - message: `The file is ${input.file.size} bytes; Blackboard's reported limit is ${input.uploadSettings.maxUploadSizeInBytes} bytes.`, + message: `The file is ${input.submission.file.size} bytes; Blackboard's reported limit is ${input.uploadSettings.maxUploadSizeInBytes} bytes.`, }); } @@ -1241,7 +3843,7 @@ export function normaliseBlackboardUser(raw: unknown): BlackboardUser { return { id: stringValue(record.id), userName: stringValue(record.userName ?? record.userNameOrId), - displayName: cleanText(record.name ?? record.displayName ?? record.userName), + displayName: normaliseBlackboardUserDisplayName(record), }; } @@ -1262,12 +3864,13 @@ export function normaliseBlackboardCourse(enrollment: unknown, detail?: unknown) const detailRecord = recordValue(detail); const id = stringValue(detailRecord.id ?? enrollmentRecord.courseId ?? enrollmentRecord.id); const availabilityRecord = recordValue(detailRecord.availability); + const externalId = stringValue(detailRecord.externalId ?? detailRecord.courseId); return { id, numericId: numericIdFromBlackboardId(id), name: cleanText(detailRecord.name ?? enrollmentRecord.courseName ?? enrollmentRecord.name), - courseCode: stringValue(detailRecord.courseCode ?? detailRecord.externalId), - externalId: stringValue(detailRecord.externalId), + courseCode: resolveBlackboardCourseCode(detailRecord, externalId), + externalId, roleId: stringValue(enrollmentRecord.courseRoleId ?? enrollmentRecord.roleId), availability: stringValue(availabilityRecord.available ?? availabilityRecord.type ?? detailRecord.availability), }; @@ -1307,6 +3910,311 @@ export function normaliseBlackboardAssignment(raw: unknown): BlackboardAssignmen }; } +export function normaliseBlackboardAnnouncement( + raw: unknown, + context: { + source: "system" | "course"; + courseId?: string; + courseCode?: string; + courseName?: string; + }, +): BlackboardAnnouncement { + const record = recordValue(raw); + const availability = recordValue(record.availability); + const duration = recordValue(availability.duration); + const availabilityType = stringValue(duration.type) as BlackboardAnnouncement["availabilityType"]; + return { + id: canonicalIdBody(record.id), + source: context.source, + title: cleanText(record.title), + body: cleanText(record.body), + created: stringValue(record.created), + modified: stringValue(record.modified), + ...(record.creator !== undefined ? { creator: stringValue(record.creator) } : {}), + ...(record.draft !== undefined ? { draft: booleanValue(record.draft) } : {}), + ...(availabilityType ? { availabilityType } : {}), + ...(duration.start !== undefined ? { availableFrom: stringValue(duration.start) } : {}), + ...(duration.end !== undefined ? { availableUntil: stringValue(duration.end) } : {}), + ...(record.showAtLogin !== undefined ? { showAtLogin: booleanValue(record.showAtLogin) } : {}), + ...(record.showInCourses !== undefined ? { showInCourses: booleanValue(record.showInCourses) } : {}), + ...(context.courseId ? { courseId: context.courseId } : {}), + ...(context.courseCode ? { courseCode: context.courseCode } : {}), + ...(context.courseName ? { courseName: context.courseName } : {}), + }; +} + +export function normaliseBlackboardDiscussion(raw: unknown): BlackboardDiscussion { + const record = recordValue(raw); + const topic = record.topic ? normaliseBlackboardDiscussionMessage(record.topic) : undefined; + return { + id: canonicalIdBody(record.id), + title: cleanText(record.title), + available: booleanValue(record.available), + gradable: booleanValue(record.gradable), + groupDiscussion: booleanValue(record.groupDiscussion), + createdDate: stringValue(record.createdDate), + modifiedDate: stringValue(record.modifiedDate), + ...(record.gradebookColumnId !== undefined ? { gradebookColumnId: canonicalIdBody(record.gradebookColumnId) } : {}), + source: "learn-rest", + ...(topic ? { topic } : {}), + }; +} + +export function normaliseBlackboardDiscussionMessage(raw: unknown): BlackboardDiscussionMessage { + const record = recordValue(raw); + const givenName = cleanText(record.givenName); + const familyName = cleanText(record.familyName); + const userId = stringValue(record.userId); + return { + id: canonicalIdBody(record.id), + discussionId: canonicalIdBody(record.discussionId), + parentId: canonicalIdBody(record.parentId), + threadId: canonicalIdBody(record.threadId), + userId, + groupId: canonicalIdBody(record.groupId), + givenName, + familyName, + author: cleanText([givenName, familyName].filter(Boolean).join(" ")) || userId, + status: stringValue(record.status) as BlackboardDiscussionMessageStatus, + body: cleanText(record.body), + postDate: stringValue(record.postDate), + editDate: stringValue(record.editDate), + createdDate: stringValue(record.createdDate), + modifiedDate: stringValue(record.modifiedDate), + isRead: booleanValue(record.isRead), + source: "learn-rest", + }; +} + +function blackboardDiscussionWritePayload( + input: BlackboardDiscussionMessageWriteInput, +): BlackboardDiscussionMessageWriteInput { + const body = input.body; + if (!cleanText(body)) { + throw new CliError( + "Blackboard discussion messages cannot be blank after trimming whitespace.", + "BLACKBOARD_DISCUSSION_TEXT_EMPTY", + 2, + ); + } + return { + body, + ...(input.groupId ? { groupId: canonicalCourseId(input.groupId) } : {}), + ...(input.status ? { status: input.status } : {}), + }; +} + +function blackboardCourseMessageWritePayload( + input: BlackboardCourseMessageWriteInput, +): { + subject?: string; + body: string; + toUsers?: Array<{ id: string }>; + ccUsers?: Array<{ id: string }>; + bccUsers?: Array<{ id: string }>; +} { + const body = input.body; + if (!cleanText(body)) { + throw new CliError( + "Blackboard course messages cannot be blank after trimming whitespace.", + "BLACKBOARD_MESSAGE_TEXT_EMPTY", + 2, + ); + } + const subject = cleanText(input.subject); + const toUsers = normaliseBlackboardMessageRecipientIds(input.toUsers); + const ccUsers = normaliseBlackboardMessageRecipientIds(input.ccUsers); + const bccUsers = normaliseBlackboardMessageRecipientIds(input.bccUsers); + if (toUsers.length + ccUsers.length + bccUsers.length === 0) { + throw new CliError( + "Blackboard course messages require at least one recipient.", + "BLACKBOARD_MESSAGE_RECIPIENTS_EMPTY", + 2, + ); + } + assertDistinctBlackboardMessageRecipients({ toUsers, ccUsers, bccUsers }); + return { + ...(subject ? { subject } : {}), + body, + ...(toUsers.length > 0 ? { toUsers: toUsers.map((id) => ({ id })) } : {}), + ...(ccUsers.length > 0 ? { ccUsers: ccUsers.map((id) => ({ id })) } : {}), + ...(bccUsers.length > 0 ? { bccUsers: bccUsers.map((id) => ({ id })) } : {}), + }; +} + +function normaliseBlackboardMessageRecipientIds(values: readonly string[] | undefined): string[] { + const seen = new Set(); + const items: string[] = []; + for (const value of values ?? []) { + const canonical = canonicalCourseId(value); + if (!canonical || seen.has(canonical)) continue; + seen.add(canonical); + items.push(canonical); + } + return items; +} + +function assertDistinctBlackboardMessageRecipients(input: { + toUsers: readonly string[]; + ccUsers: readonly string[]; + bccUsers: readonly string[]; +}): void { + const seen = new Map(); + for (const [bucket, values] of [ + ["toUsers", input.toUsers], + ["ccUsers", input.ccUsers], + ["bccUsers", input.bccUsers], + ] as const) { + for (const value of values) { + const existing = seen.get(value); + if (existing) { + throw new CliError( + "Blackboard course message recipients must not appear in multiple recipient groups.", + "BLACKBOARD_MESSAGE_RECIPIENT_DUPLICATE", + 2, + { userId: canonicalIdBody(value), firstGroup: existing, secondGroup: bucket }, + ); + } + seen.set(value, bucket); + } + } +} + +export function normaliseBlackboardDiscussionGroup(raw: unknown): BlackboardDiscussionGroup { + const record = recordValue(raw); + return { + groupId: canonicalIdBody(record.groupId), + discussionId: canonicalIdBody(record.discussionId), + threadId: canonicalIdBody(record.threadId), + }; +} + +export function normaliseBlackboardCourseMessageFolder(raw: unknown): BlackboardCourseMessageFolder { + const record = recordValue(raw); + const counts = recordValue(record.courseMessagesCounts); + return { + name: stringValue(record.name), + label: cleanText(record.label), + type: stringValue(record.type) as BlackboardCourseMessageFolderType, + totalCount: numberValue(counts.totalCount), + unreadCount: numberValue(counts.unreadCount), + }; +} + +export function normaliseBlackboardParticipantUser(raw: unknown): BlackboardParticipantUser { + const record = recordValue(raw); + const user = { + id: stringValue(record.id), + userName: blackboardRawText(record.userName), + otherName: blackboardRawText(record.otherName), + givenName: blackboardRawText(record.givenName), + familyName: blackboardRawText(record.familyName), + middleName: blackboardRawText(record.middleName), + suffix: blackboardRawText(record.suffix), + title: blackboardRawText(record.title), + preferredDisplayName: stringValue(record.preferredDisplayName) as BlackboardParticipantDisplayPreference, + }; + return { + ...user, + displayName: blackboardParticipantDisplayName(user), + }; +} + +export function normaliseBlackboardCourseMessageAttachment(raw: unknown): BlackboardCourseMessageAttachment { + const record = recordValue(raw); + return { + id: stringValue(record.id), + fileName: stringValue(record.fileName), + mimeType: stringValue(record.mimeType), + fileLocation: stringValue(record.fileLocation), + }; +} + +export function normaliseBlackboardCourseMessage(raw: unknown): BlackboardCourseMessage { + const record = recordValue(raw); + const sender = record.sender ? normaliseBlackboardParticipantUser(record.sender) : undefined; + const attachment = record.attachment ? normaliseBlackboardCourseMessageAttachment(record.attachment) : undefined; + return { + id: canonicalIdBody(record.id), + subject: cleanText(record.subject), + body: cleanText(record.body), + postedDate: stringValue(record.postedDate), + isRead: booleanValue(record.isRead), + type: stringValue(record.type) as BlackboardCourseMessageType, + senderId: stringValue(record.senderId), + ...(sender ? { sender } : {}), + ...(attachment ? { attachment } : {}), + toUsers: arrayValue(record.toUsers).map((item) => stringValue(item)).filter(Boolean), + ccUsers: arrayValue(record.ccUsers).map((item) => stringValue(item)).filter(Boolean), + bccUsers: arrayValue(record.bccUsers).map((item) => stringValue(item)).filter(Boolean), + isExistingAttachment: booleanValue(record.isExistingAttachment), + isReply: booleanValue(record.isReply), + }; +} + +export function normaliseBlackboardCourseMessageParticipant(raw: unknown): BlackboardCourseMessageParticipant { + const record = recordValue(raw); + const user = record.user ? normaliseBlackboardParticipantUser(record.user) : undefined; + const userId = stringValue(record.userId); + return { + messageId: canonicalIdBody(record.messageId), + userId, + participationType: stringValue(record.participationType) as BlackboardCourseMessageParticipationType, + displayName: user?.displayName || userId, + ...(user ? { user } : {}), + }; +} + +export function normaliseBlackboardCourseRosterUser(raw: unknown): BlackboardCourseRosterUser { + const record = recordValue(raw); + const name = recordValue(record.name); + const contact = recordValue(record.contact); + const availability = recordValue(record.availability); + const base = { + id: stringValue(record.id), + userName: stringValue(record.userName), + otherName: cleanText(name.other), + givenName: cleanText(name.given), + familyName: cleanText(name.family), + middleName: cleanText(name.middle), + suffix: cleanText(name.suffix), + preferredDisplayName: stringValue(name.preferredDisplayName) as BlackboardParticipantDisplayPreference, + }; + return { + id: base.id, + userName: base.userName, + displayName: blackboardParticipantDisplayName(base), + givenName: base.givenName, + familyName: base.familyName, + otherName: base.otherName, + email: stringValue(contact.email), + institutionEmail: stringValue(contact.institutionEmail), + avatarUrl: stringValue(recordValue(record.avatar).viewUrl), + availability: stringValue(availability.available) as BlackboardCourseMembershipAvailability, + }; +} + +export function normaliseBlackboardCourseMembership(raw: unknown): BlackboardCourseMembership { + const record = recordValue(raw); + const availability = recordValue(record.availability); + const user = record.user ? normaliseBlackboardCourseRosterUser(record.user) : undefined; + return { + id: canonicalIdBody(record.id), + userId: stringValue(record.userId), + courseId: canonicalCourseId(stringValue(record.courseId)), + childCourseId: stringValue(record.childCourseId), + created: stringValue(record.created), + modified: stringValue(record.modified), + availability: stringValue(availability.available ?? record.availability) as BlackboardCourseMembershipAvailability, + courseRoleId: stringValue(record.courseRoleId), + lastAccessed: stringValue(record.lastAccessed), + dueDateExceptionType: stringValue(record.dueDateExceptionType), + timeLimitExceptionType: stringValue(record.timeLimitExceptionType), + ...(record.displayOrder === undefined ? {} : { displayOrder: numberValue(record.displayOrder) }), + ...(user ? { user } : {}), + }; +} + export function normaliseBlackboardCalendarItem(raw: unknown): BlackboardCalendarItem { const record = recordValue(raw); const calendarId = stringValue(record.calendarId); @@ -1370,14 +4278,35 @@ export function normaliseBlackboardAttemptReceipt(raw: unknown): BlackboardAttem export function normaliseBlackboardAttemptFile(raw: unknown): BlackboardAttemptFile { const record = recordValue(raw); + const rawViewUrl = stringValue(record.viewUrl); + const rawDownloadUrl = stringValue(record.downloadUrl); return { id: canonicalIdBody(record.id), name: stringValue(record.name), - viewUrl: stringValue(record.viewUrl), - downloadUrl: stringValue(record.downloadUrl), + viewUrl: rawViewUrl ? safeBlackboardDownloadUrl(rawViewUrl).toString() : "", + downloadUrl: rawDownloadUrl ? safeBlackboardDownloadUrl(rawDownloadUrl).toString() : "", + }; +} + +function completeBlackboardAttemptFile( + file: BlackboardAttemptFile, + courseId: string, + attemptId: string, +): BlackboardAttemptFile { + if (file.downloadUrl) return file; + return { + ...file, + downloadUrl: buildBlackboardUrl(`/learn/api/public/v1/courses/${courseId}/gradebook/attempts/${attemptId}/files/${canonicalCourseId(file.id)}/download`), }; } +function blackboardDownloadStatus(error: unknown): number | undefined { + if (!(error instanceof CliError)) return undefined; + if (typeof error.details !== "object" || error.details === null) return undefined; + const details = error.details as { status?: unknown }; + return typeof details.status === "number" ? details.status : undefined; +} + export function normaliseBlackboardUploadSettings(raw: unknown): BlackboardUploadSettings { const record = recordValue(raw); return { @@ -1429,6 +4358,7 @@ export function classifyBlackboardHandler(handler: string): BlackboardContentIte case "resource/x-bb-folder": return "folder"; case "resource/x-bb-assignment": + case "resource/x-bb-asmt-test-link": return "assignment"; case "resource/x-bb-document": return "document"; @@ -1444,6 +4374,26 @@ export function buildBlackboardUrl(path: string, query: Record = const BLACKBOARD_CALENDAR_DEFAULT_WINDOW_MS = 14 * 86_400_000; const BLACKBOARD_CALENDAR_MAX_WINDOW_MS = 16 * 7 * 86_400_000; +async function fetchBlackboardPageChunk( + adapter: ServiceAdapter, + pathOrUrl: string, + options: { absolute?: boolean } = {}, +): Promise<{ results: unknown[]; nextPage?: string }> { + const rawUrl = options.absolute ? pathOrUrl : buildBlackboardUrl(pathOrUrl); + const parsedUrl = new URL(rawUrl, BLACKBOARD_BASE); + if (parsedUrl.origin !== BLACKBOARD_BASE) { + throw new ServiceError("Blackboard pagination attempted to leave its configured origin.", { url: parsedUrl.toString() }); + } + const url = parsedUrl.toString(); + const raw = await fetchJson(adapter, url); + const record = recordValue(raw); + const nextPage = stringValue(recordValue(record.paging).nextPage); + return { + results: arrayValue(record.results), + ...(nextPage ? { nextPage } : {}), + }; +} + async function fetchBlackboardPage( adapter: ServiceAdapter, pathOrUrl: string, @@ -1462,13 +4412,10 @@ async function fetchBlackboardPage( throw new ServiceError("Blackboard pagination returned a repeated next-page URL.", { url }); } visited.add(url); - - const raw = await fetchJson(adapter, url); - const record = recordValue(raw); - results.push(...arrayValue(record.results)); - const nextPage = stringValue(recordValue(record.paging).nextPage); - if (!nextPage) return { results }; - url = new URL(nextPage, url).toString(); + const pageChunk = await fetchBlackboardPageChunk(adapter, url, { absolute: true }); + results.push(...pageChunk.results); + if (!pageChunk.nextPage) return { results }; + url = new URL(pageChunk.nextPage, url).toString(); } throw new ServiceError("Blackboard pagination exceeded the safe page limit.", { url }); } @@ -1656,6 +4603,15 @@ function publicBlackboardContentAttachment( }; } +export function publicBlackboardAttemptFile( + file: BlackboardAttemptFile, +): BlackboardAttemptFileReference { + return { + id: file.id, + name: file.name, + }; +} + function htmlAttribute(attributes: string, name: string): string { const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const match = new RegExp(`(?:^|\\s)${escapedName}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(attributes); @@ -1907,6 +4863,24 @@ interface BlackboardTraversalEntry { path: string; } +async function resolveBlackboardCourseContext(adapter: ServiceAdapter, courseId: string): Promise { + const canonical = canonicalCourseId(courseId); + try { + const detail = await fetchJson(adapter, buildBlackboardUrl(`/learn/api/public/v1/courses/${canonical}`)); + return normaliseBlackboardCourse({ courseId: canonical }, detail); + } catch { + return { + id: canonical, + numericId: numericIdFromBlackboardId(canonical), + name: canonical, + courseCode: canonical, + externalId: "", + roleId: "", + availability: "", + }; + } +} + async function listBlackboardCoursesForAggregation( adapter: ServiceAdapter, failures: BlackboardOperationFailure[], @@ -2045,6 +5019,261 @@ function blackboardContentPath(course: BlackboardCourse, pathTitles: readonly st return [courseLabel, ...pathTitles].join(" / "); } +function blackboardAnnouncementMatchesWindow( + announcement: BlackboardAnnouncement, + minimumTimestamp: number | undefined, +): boolean { + if (minimumTimestamp === undefined) return true; + const activityTimestamp = blackboardAnnouncementActivityTimestamp(announcement); + return activityTimestamp !== undefined && activityTimestamp >= minimumTimestamp; +} + +function blackboardAnnouncementActivityTimestamp(announcement: BlackboardAnnouncement): number | undefined { + const candidates = [announcement.modified, announcement.created]; + for (const value of candidates) { + if (!value) continue; + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +} + +function shouldSkipBlackboardCourseAnnouncements(error: unknown): boolean { + const status = error instanceof CliError && Number.isFinite(Number(error.details?.status)) + ? Number(error.details?.status) + : error instanceof ServiceError && Number.isFinite(Number(error.details?.status)) + ? Number(error.details?.status) + : undefined; + if (status !== 400 && status !== 403) return false; + const bodySample = error instanceof CliError + ? stringValue(error.details?.bodySample).toLowerCase() + : error instanceof ServiceError + ? stringValue(error.details?.bodySample).toLowerCase() + : ""; + return bodySample.includes("announcement tool for current course is not available") + || bodySample.includes("course.announcements.view"); +} + +function normalizeBlackboardDiscussionUnsupported( + error: unknown, + options: { + operation: string; + courseId: string; + discussionId?: string; + messageId?: string; + }, +): Error { + const status = error instanceof CliError && Number.isFinite(Number(error.details?.status)) + ? Number(error.details?.status) + : error instanceof ServiceError && Number.isFinite(Number(error.details?.status)) + ? Number(error.details?.status) + : undefined; + const bodySample = error instanceof CliError + ? stringValue(error.details?.bodySample).toLowerCase() + : error instanceof ServiceError + ? stringValue(error.details?.bodySample).toLowerCase() + : ""; + if (status !== 400 || !bodySample.includes("original courses are not supported by this api")) { + return error instanceof Error ? error : new Error(String(error)); + } + return new CliError( + "Blackboard's public discussion REST API does not support this Original course. Forum listing may still work through the CLI's Original-course HTML fallback, but this operation is unavailable through the current REST path.", + "BLACKBOARD_DISCUSSIONS_UNSUPPORTED", + 1, + { + operation: options.operation, + courseId: canonicalCourseId(options.courseId), + ...(options.discussionId ? { discussionId: canonicalCourseId(options.discussionId) } : {}), + ...(options.messageId ? { messageId: canonicalCourseId(options.messageId) } : {}), + status, + bodySample, + retryPolicy: "DO_NOT_RETRY_AUTOMATICALLY", + }, + ); +} + +function isBlackboardOriginalDiscussionUnsupported(error: unknown): boolean { + const status = error instanceof CliError && Number.isFinite(Number(error.details?.status)) + ? Number(error.details?.status) + : error instanceof ServiceError && Number.isFinite(Number(error.details?.status)) + ? Number(error.details?.status) + : undefined; + const bodySample = error instanceof CliError + ? stringValue(error.details?.bodySample).toLowerCase() + : error instanceof ServiceError + ? stringValue(error.details?.bodySample).toLowerCase() + : ""; + return status === 400 && bodySample.includes("original courses are not supported by this api"); +} + +function isBlackboardDiscussionSortRejected(error: unknown): boolean { + const status = error instanceof CliError && Number.isFinite(Number(error.details?.status)) + ? Number(error.details?.status) + : error instanceof ServiceError && Number.isFinite(Number(error.details?.status)) + ? Number(error.details?.status) + : undefined; + const bodySample = error instanceof CliError + ? stringValue(error.details?.bodySample).toLowerCase() + : error instanceof ServiceError + ? stringValue(error.details?.bodySample).toLowerCase() + : ""; + return status === 400 + && bodySample.includes("field error") + && bodySample.includes("field 'sort'"); +} + +function compareBlackboardAnnouncements(left: BlackboardAnnouncement, right: BlackboardAnnouncement): number { + const leftTimestamp = blackboardAnnouncementActivityTimestamp(left) ?? Number.NEGATIVE_INFINITY; + const rightTimestamp = blackboardAnnouncementActivityTimestamp(right) ?? Number.NEGATIVE_INFINITY; + if (leftTimestamp !== rightTimestamp) return rightTimestamp - leftTimestamp; + if ((left.courseCode ?? "") !== (right.courseCode ?? "")) { + return (left.courseCode ?? "").localeCompare(right.courseCode ?? "", "zh-Hans-CN"); + } + if (left.title !== right.title) return left.title.localeCompare(right.title, "zh-Hans-CN"); + return left.id.localeCompare(right.id, "zh-Hans-CN"); +} + +function compareBlackboardScopedAssignments(left: BlackboardScopedAssignment, right: BlackboardScopedAssignment): number { + const leftDue = left.assignment.grading.due; + const rightDue = right.assignment.grading.due; + if (leftDue && rightDue) { + const diff = compareBlackboardCalendarDateTime(leftDue, rightDue); + if (diff !== 0) return diff; + } else if (leftDue || rightDue) { + return leftDue ? -1 : 1; + } + if (left.courseCode !== right.courseCode) return left.courseCode.localeCompare(right.courseCode, "zh-Hans-CN"); + if (left.courseName !== right.courseName) return left.courseName.localeCompare(right.courseName, "zh-Hans-CN"); + if (left.assignment.title !== right.assignment.title) return left.assignment.title.localeCompare(right.assignment.title, "zh-Hans-CN"); + return left.assignment.id.localeCompare(right.assignment.id, "zh-Hans-CN"); +} + +function compareBlackboardGradeEntries(left: BlackboardGradeEntry, right: BlackboardGradeEntry): number { + const leftTimestamp = blackboardGradeActivityTimestamp(left.attemptSummary); + const rightTimestamp = blackboardGradeActivityTimestamp(right.attemptSummary); + if (leftTimestamp !== rightTimestamp) return rightTimestamp - leftTimestamp; + if (left.courseCode !== right.courseCode) return left.courseCode.localeCompare(right.courseCode, "zh-Hans-CN"); + if (left.assignment.title !== right.assignment.title) return left.assignment.title.localeCompare(right.assignment.title, "zh-Hans-CN"); + return left.assignment.id.localeCompare(right.assignment.id, "zh-Hans-CN"); +} + +function blackboardGradeActivityTimestamp(summary: BlackboardAssignmentAttemptSummary): number { + const value = summary.latestSubmissionDate || summary.latestAttemptDate; + if (!value) return Number.NEGATIVE_INFINITY; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : Number.NEGATIVE_INFINITY; +} + +function incrementBlackboardCount(target: Map, key: T): void { + target.set(key, (target.get(key) ?? 0) + 1); +} + +function validatedBlackboardTreeMaxItems(maxItems: number): number { + if (!Number.isSafeInteger(maxItems) || maxItems < 1 || maxItems > 5_000) { + throw new CliError("Blackboard tree limits must be integers from 1 to 5000.", "USAGE", 2); + } + return maxItems; +} + +function validatedBlackboardPage(page = 1): number { + if (!Number.isSafeInteger(page) || page < 1 || page > 10_000) { + throw new CliError("Blackboard page numbers must be integers from 1 to 10000.", "USAGE", 2); + } + return page; +} + +function validatedBlackboardPageSize(pageSize = 25): number { + if (!Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100) { + throw new CliError("Blackboard page sizes must be integers from 1 to 100.", "USAGE", 2); + } + return pageSize; +} + +function blackboardKindCountEntries( + counts: ReadonlyMap, +): BlackboardContentKindCount[] { + return [...counts.entries()] + .sort((left, right) => + right[1] - left[1] + || compareBlackboardContentKind(left[0], right[0]) + ) + .map(([kind, count]) => ({ kind, count })); +} + +function blackboardStringCountEntries( + counts: ReadonlyMap, +): BlackboardContentHandlerCount[] { + return [...counts.entries()] + .sort((left, right) => + right[1] - left[1] + || left[0].localeCompare(right[0], "zh-Hans-CN") + ) + .map(([handler, count]) => ({ handler, count })); +} + +function compareBlackboardContentKind(left: BlackboardContentItem["kind"], right: BlackboardContentItem["kind"]): number { + const order: readonly BlackboardContentItem["kind"][] = ["document", "assignment", "file", "folder", "unknown"]; + return order.indexOf(left) - order.indexOf(right) || left.localeCompare(right, "zh-Hans-CN"); +} + +function summariseBlackboardAssignmentAttempts( + attempts: readonly BlackboardAttempt[], +): BlackboardAssignmentAttemptSummary { + const ordered = [...attempts].sort((left, right) => + compareBlackboardCalendarDateTime(right.attemptReceipt?.submissionDate || right.attemptDate || right.modified || right.created, left.attemptReceipt?.submissionDate || left.attemptDate || left.modified || left.created) + || compareBlackboardCalendarDateTime(right.modified || right.created, left.modified || left.created) + || right.id.localeCompare(left.id, "zh-Hans-CN") + ); + const latest = ordered[0]; + const inProgressAttempts = attempts.filter((attempt) => blackboardAttemptIsInProgress(attempt.status)).length; + const completedAttempts = attempts.filter((attempt) => attempt.status === "Completed").length; + const submittedAttempts = attempts.filter((attempt) => blackboardAttemptIsSubmitted(attempt.status)).length; + return { + state: blackboardAssignmentSubmissionState({ totalAttempts: attempts.length, inProgressAttempts, completedAttempts, submittedAttempts, latestStatus: latest?.status }), + totalAttempts: attempts.length, + submittedAttempts, + completedAttempts, + inProgressAttempts, + ...(latest ? { + latestAttemptId: latest.id, + latestStatus: latest.status, + latestAttemptDate: latest.attemptDate || latest.modified || latest.created, + latestSubmissionDate: latest.attemptReceipt?.submissionDate, + latestDisplayGradeText: latest.displayGradeText, + } : {}), + }; +} + +function blackboardAssignmentSubmissionState(input: { + totalAttempts: number; + inProgressAttempts: number; + completedAttempts: number; + submittedAttempts: number; + latestStatus?: BlackboardAttemptStatus | ""; +}): BlackboardAssignmentSubmissionState { + if (input.totalAttempts === 0) return "not_attempted"; + if (input.latestStatus === "Completed") return input.inProgressAttempts > 0 ? "mixed" : "completed"; + if (input.latestStatus === "NeedsGrading" || input.latestStatus === "NeedsGradingAgain") { + return input.inProgressAttempts > 0 ? "mixed" : "submitted"; + } + if (input.latestStatus && blackboardAttemptIsInProgress(input.latestStatus)) { + return input.submittedAttempts > 0 || input.completedAttempts > 0 ? "mixed" : "in_progress"; + } + if (input.inProgressAttempts > 0 && (input.submittedAttempts > 0 || input.completedAttempts > 0)) return "mixed"; + if (input.completedAttempts > 0) return "completed"; + if (input.submittedAttempts > 0) return "submitted"; + if (input.inProgressAttempts > 0) return "in_progress"; + return "other"; +} + +function blackboardAttemptIsInProgress(status: BlackboardAttemptStatus | ""): boolean { + return status === "InProgress" || status === "InProgressAgain" || status === "Suspended"; +} + +function blackboardAttemptIsSubmitted(status: BlackboardAttemptStatus | ""): boolean { + return status === "NeedsGrading" || status === "NeedsGradingAgain" || status === "Completed"; +} + function blackboardCalendarKind(value: string): BlackboardCalendarKind { if (value === "PERSONAL") return "personal"; if (value === "INSTITUTION") return "institution"; @@ -2343,6 +5572,75 @@ function canonicalIdBody(value: unknown): string { return text; } +function blackboardRawText(value: unknown): string { + if (typeof value === "string") return cleanText(value); + const record = recordValue(value); + return cleanText( + stringValue(record.value) + || stringValue(record.text) + || stringValue(record.rawText) + || stringValue(record.formattedText) + || stringValue(record.displayText) + || stringValue(record.plainText) + || stringValue(record.html), + ); +} + +function normaliseBlackboardUserDisplayName(record: Record): string { + const explicit = cleanText(record.displayName ?? (typeof record.name === "string" ? record.name : undefined)); + if (explicit) return explicit; + const name = recordValue(record.name); + const structuredExplicit = blackboardRawText( + name.displayName + ?? name.formattedName + ?? name.fullName + ?? name.formattedText, + ); + if (structuredExplicit) return structuredExplicit; + const givenName = blackboardRawText(name.given ?? name.givenName); + const otherName = blackboardRawText(name.other ?? name.otherName); + const candidate = blackboardParticipantDisplayName({ + id: stringValue(record.id), + userName: stringValue(record.userName ?? record.userNameOrId), + otherName, + givenName, + familyName: blackboardRawText(name.family ?? name.familyName), + middleName: blackboardRawText(name.middle ?? name.middleName), + suffix: blackboardRawText(name.suffix), + preferredDisplayName: stringValue(name.preferredDisplayName) as BlackboardParticipantDisplayPreference, + }); + return givenName || otherName || candidate; +} + +function blackboardParticipantDisplayName( + user: Pick, +): string { + const givenFamily = cleanText([user.givenName, user.middleName, user.familyName, user.suffix].filter(Boolean).join(" ")); + const otherFamily = cleanText([user.otherName, user.familyName, user.suffix].filter(Boolean).join(" ")); + const both = cleanText([ + user.otherName, + user.givenName && user.givenName !== user.otherName ? user.givenName : "", + user.middleName, + user.familyName, + user.suffix, + ].filter(Boolean).join(" ")); + if (user.preferredDisplayName === "OtherName" && otherFamily) return otherFamily; + if (user.preferredDisplayName === "Both" && both) return both; + return givenFamily || otherFamily || both || user.userName || user.id; +} + +function resolveBlackboardCourseCode(detailRecord: Record, externalId: string): string { + const explicit = stringValue(detailRecord.courseCode); + if (explicit) return explicit; + const derived = deriveBlackboardCourseCode(externalId || stringValue(detailRecord.courseId)); + return derived || externalId; +} + +function deriveBlackboardCourseCode(value: string): string { + const candidate = value.trim().split("-", 1)[0]?.trim().toUpperCase() ?? ""; + return /^[A-Z]{2,}\d{2,}[A-Z0-9]*$/u.test(candidate) ? candidate : ""; +} + function numericIdFromBlackboardId(value: string): string { const match = /_(\d+)_/.exec(value); return match?.[1] ?? value.replace(/^_/, "").replace(/_1$/, ""); diff --git a/src/services/index.ts b/src/services/index.ts index 815f381..422e155 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -38,6 +38,7 @@ export function formatServiceStatuses(statuses: readonly ServiceStatus[]): strin export * from "./base.js"; export * from "./blackboard.js"; +export * from "./blackboard-browser.js"; export * from "./blackboard-calendar.js"; export * from "./booking-auth.js"; export * from "./booking.js"; diff --git a/src/services/nces.ts b/src/services/nces.ts index 4d751ad..795b8c9 100644 --- a/src/services/nces.ts +++ b/src/services/nces.ts @@ -21,15 +21,22 @@ export const NCES_STATUS: ServiceStatus = { auth: "none", campusNetwork: false, browser: false, - summary: "NCES exposes public JSON APIs for course search, browse, detail, and reviews.", + summary: "NCES exposes public JSON APIs for exact by-code lookup, course search, browse, detail, reviews, stats, and teacher profiles.", notes: [ "The API is public but rate-limited; callers should avoid aggressive polling.", + "Ratings, reviews, teacher associations, and NCES AI summaries are community references, not official academic records.", ], endpoints: [ "/api/v1/search", "/api/v1/course", + "/api/v1/course/filter-options", + "/api/v1/stats", + "/api/v1/stats/rankings", + "/api/v1/course/by-code/{code}", "/api/v1/course/{id}", "/api/v1/course/{id}/reviews", + "/api/v1/course/{id}/stats", + "/api/v1/teacher/{id}", ], }; @@ -45,12 +52,12 @@ export interface NcesCourseSummary { teacher: string; semester: string; semesters: string[]; - rating: number; + rating: number | null; reviewCount: number; - difficulty: NcesDimension; - workload: NcesDimension; - grading: NcesDimension; - takeaways: NcesDimension; + difficulty: NcesDimension | null; + workload: NcesDimension | null; + grading: NcesDimension | null; + takeaways: NcesDimension | null; directUrl: string; } @@ -67,8 +74,218 @@ export interface NcesReview { takeaways?: string; } +export type NcesReviewSort = + | "helpful" + | "newest" + | "oldest" + | "rating-high" + | "rating-low"; + +export type NcesSearchType = + | "all" + | "course" + | "teacher" + | "review"; + +export interface NcesReviewPage { + courseId: number; + items: NcesReview[]; + total: number; + page: number; + perPage: number; + pages: number; + sort: NcesReviewSort; + term?: string; + rating?: number; +} + +export interface NcesCourseFilterOptions { + offeringUnits: string[]; +} + +export interface NcesTeacherSummary { + teacherId: number; + name: string; + email: string; + title: string; + image: string; + directUrl: string; +} + +export interface NcesTeacherDetail extends NcesTeacherSummary { + accessCount: number; + reviewCount: number; + averageRate: number | null; + normalizedRate: number | null; + gender: string; + description: string; + homepage: string; + researchInterest: string; + officePhone: string; + courses: NcesCourseSummary[]; +} + +export interface NcesCourseTerm { + id: number; + termId: string; + term: string; + courseries: string; + kcid: string; + courseMajor: string; + courseType: string; + courseLevel: string; + joinType: string; + teachingType: string; + gradingType: string; + credit?: number; + hours?: number; + hoursPerWeek?: number; + campus: string; + startWeek?: number; + endWeek?: number; +} + +export interface NcesCourseStatsTerm { + term: string; + reviewCount: number; + ratingAverage?: number; +} + +export interface NcesCourseStats { + reviewCount: number; + ratingDistribution: Record; + termDistribution: Record; + termStats: NcesCourseStatsTerm[]; +} + +export interface NcesDistributionPoint { + label: string; + value: number; + cumulative?: number; +} + +export interface NcesGlobalStats { + userCount: number; + courseCount: number; + reviewCount: number; + teacherCount: number; + registeredTeacherCount: number; + runningDays: number; + courseAverageRating: number; + averageReviewsPerCourse: number; + reviewRateDistribution: NcesDistributionPoint[]; + courseRateDistribution: NcesDistributionPoint[]; + courseReviewCountDistribution: NcesDistributionPoint[]; + userReviewCountDistribution: NcesDistributionPoint[]; + reviewMonthlyDistribution: NcesDistributionPoint[]; + userMonthlyDistribution: NcesDistributionPoint[]; +} + +export interface NcesAiSummary { + overview: string; + strengths: string[]; + caveats: string[]; + assessment: string[]; + sourceReviewCount: number; + generatedAt: string; + authority: "community"; + generatedBy: "NCES"; + advisory: string; +} + +export interface NcesTeacherCourseGroup { + teacher: NcesTeacherSummary; + courses: NcesCourseSummary[]; +} + +export type NcesRankingCategory = + | "top-teachers" + | "top-rated-courses" + | "popular-courses" + | "top-reviews" + | "long-reviews" + | "top-users"; + +export interface NcesTeacherRanking extends NcesTeacherSummary { + department: string; + courseCount: number; + reviewCount: number; + normalizedRating: number; +} + +export interface NcesRankedCourse extends NcesCourseSummary { + normalizedRating: number; +} + +export interface NcesReviewRanking { + courseId: number; + courseName: string; + reviewId: number; + author: string; + anonymous: boolean; + upvotes: number; + contentLength: number; + courseUrl: string; +} + +export interface NcesUserRanking { + userId: number; + username: string; + identity: string; + avatar: string; + reviewCount: number; + reviewUpvotes: number; + reviewLength: number; + score: number; +} + +export interface NcesRankingStats { + averageRating: number; + averageReviewCount: number; + averageReviewUpvotes: number; + averageReviewLength: number; +} + +export interface NcesRankings { + stats: NcesRankingStats; + topTeachers: NcesTeacherRanking[]; + topRatedCourses: NcesRankedCourse[]; + popularCourses: NcesRankedCourse[]; + topReviews: NcesReviewRanking[]; + longReviews: NcesReviewRanking[]; + topUsers: NcesUserRanking[]; +} + export interface NcesCourseDetail extends NcesCourseSummary { department: string; + courseries: string; + courseMaterialCode: string; + introduction: string; + homepage: string; + adminAnnouncement: string; + accessCount: number; + credit?: number; + hours?: number; + hoursPerWeek?: number; + description: string; + descriptionEng: string; + teachingMaterial: string; + referenceMaterial: string; + studentRequirements: string; + campus: string; + courseMajor: string; + courseType: string; + gradingType: string; + reviewTerms: string[]; + reviewResultsTotal: number; + reviewResultsPages: number; + reviewResultsPerPage: number; + reviewFilterTerm?: string; + teachers: NcesTeacherSummary[]; + terms: NcesCourseTerm[]; + relatedCourses: NcesCourseSummary[]; + sameTeacherCourses: NcesTeacherCourseGroup[]; + aiSummary?: NcesAiSummary; reviews: NcesReview[]; } @@ -110,6 +327,56 @@ export interface NcesCourseLookupBatch { failures: Array<{ key: string; message: string }>; } +export type NcesSearchAggregateItem = + | { kind: "course"; item: NcesCourseSummary } + | { kind: "teacher"; item: NcesTeacherSummary } + | { kind: "review"; item: NcesReview }; + +interface NcesSearchResultBase { + total: number; + pages: number; + aggregateTotal: number; + aggregateItems: NcesSearchAggregateItem[]; + aggregateShown: number; + courseItems: NcesCourseSummary[]; + courseTotal: number; + coursePages: number; + sampleReviews: NcesReview[]; + reviewTotal: number; + reviewPages: number; + teachers: NcesTeacherSummary[]; + teacherTotal: number; + teacherPages: number; + page: number; + perPage: number; +} + +export interface NcesCourseSearchResult extends NcesSearchResultBase { + items: NcesCourseSummary[]; + type: "all" | "course"; + selectedBucket: "course"; + selectedItems: NcesCourseSummary[]; +} + +export interface NcesTeacherSearchResult extends NcesSearchResultBase { + items: NcesTeacherSummary[]; + type: "teacher"; + selectedBucket: "teacher"; + selectedItems: NcesTeacherSummary[]; +} + +export interface NcesReviewSearchResult extends NcesSearchResultBase { + items: NcesReview[]; + type: "review"; + selectedBucket: "review"; + selectedItems: NcesReview[]; +} + +export type NcesSearchResult = + | NcesCourseSearchResult + | NcesTeacherSearchResult + | NcesReviewSearchResult; + const DIMENSION_LABELS = { difficulty: [ [33, "Hard"], @@ -134,56 +401,230 @@ const DIMENSION_LABELS = { } as const; export async function browseNces( - options: { page?: number; perPage?: number; sort?: "rating" | "reviews" | "name"; adapter?: ServiceAdapter } = {}, -): Promise<{ items: NcesCourseSummary[]; total: number; page: number; perPage: number; pages: number }> { + options: { + page?: number; + perPage?: number; + sort?: "rating" | "reviews" | "name"; + offeringUnit?: string; + adapter?: ServiceAdapter; + } = {}, +): Promise<{ + items: NcesCourseSummary[]; + total: number; + page: number; + perPage: number; + pages: number; + offeringUnit?: string; +}> { const adapter = options.adapter ?? createFetchAdapter(); const page = options.page ?? 1; const perPage = Math.max(1, Math.min(options.perPage ?? 30, 50)); - const raw = await fetchJson(adapter, requestUrl(NCES_BASE, "/api/v1/course", { page, per_page: perPage })); + const offeringUnit = cleanLookupText(options.offeringUnit); + const raw = await fetchJson(adapter, requestUrl(NCES_BASE, "/api/v1/course", { + page, + per_page: perPage, + sort_by: browseSortParameter(options.sort ?? "rating"), + ...(offeringUnit ? { offering_unit: offeringUnit } : {}), + })); const record = recordValue(raw); const items = arrayValue(record.items).map((item) => normaliseNcesCourse(item)); - items.sort(compareNcesCourses(options.sort ?? "rating")); return { items, total: numberValue(record.total), - page, - perPage, + page: numberValue(record.page) || page, + perPage: numberValue(record.per_page) || perPage, pages: numberValue(record.pages), + ...(offeringUnit ? { offeringUnit } : {}), }; } +export async function getNcesCourseFilterOptions( + options: { adapter?: ServiceAdapter } = {}, +): Promise { + const adapter = options.adapter ?? createFetchAdapter(); + const raw = await fetchJson(adapter, requestUrl(NCES_BASE, "/api/v1/course/filter-options")); + const record = recordValue(raw); + return { + offeringUnits: arrayValue(record.offering_units) + .map((item) => cleanText(item)) + .filter(Boolean), + }; +} + +export async function getNcesGlobalStats( + options: { adapter?: ServiceAdapter } = {}, +): Promise { + const adapter = options.adapter ?? createFetchAdapter(); + const raw = await fetchJson(adapter, requestUrl(NCES_BASE, "/api/v1/stats")); + return normaliseNcesGlobalStats(raw); +} + +export async function getNcesRankings( + options: { adapter?: ServiceAdapter } = {}, +): Promise { + const adapter = options.adapter ?? createFetchAdapter(); + const raw = await fetchJson(adapter, requestUrl(NCES_BASE, "/api/v1/stats/rankings")); + return normaliseNcesRankings(raw); +} + export async function searchNces( query: string, - options: { adapter?: ServiceAdapter } = {}, -): Promise<{ items: NcesCourseSummary[]; total: number; sampleReviews: NcesReview[] }> { + options?: { + page?: number; + perPage?: number; + type?: "all" | "course"; + adapter?: ServiceAdapter; + }, +): Promise; +export async function searchNces( + query: string, + options: { + page?: number; + perPage?: number; + type: "teacher"; + adapter?: ServiceAdapter; + }, +): Promise; +export async function searchNces( + query: string, + options: { + page?: number; + perPage?: number; + type: "review"; + adapter?: ServiceAdapter; + }, +): Promise; +export async function searchNces( + query: string, + options: { + page?: number; + perPage?: number; + type?: NcesSearchType; + adapter?: ServiceAdapter; + } = {}, +): Promise { const adapter = options.adapter ?? createFetchAdapter(); - const raw = await fetchJson(adapter, requestUrl(NCES_BASE, "/api/v1/search", { q: query })); + const page = options.page ?? 1; + const perPage = Math.max(1, Math.min(options.perPage ?? 20, 50)); + const type = options.type ?? "all"; + const raw = await fetchJson(adapter, requestUrl(NCES_BASE, "/api/v1/search", { + q: query, + ...(options.type !== undefined ? { type } : {}), + ...(options.page !== undefined ? { page } : {}), + ...(options.perPage !== undefined ? { per_page: perPage } : {}), + })); const record = recordValue(raw); const courses = recordValue(record.courses); const reviews = recordValue(record.reviews); + const teachers = recordValue(record.teachers); + const courseTotal = numberValue(courses.total); + const coursePages = numberValue(courses.pages); + const coursePage = numberValue(courses.page) || page; + const coursePerPage = numberValue(courses.per_page) || perPage; + const teacherTotal = numberValue(teachers.total); + const teacherPages = numberValue(teachers.pages); + const teacherPage = numberValue(teachers.page) || page; + const teacherPerPage = numberValue(teachers.per_page) || perPage; + const reviewTotal = numberValue(reviews.total); + const reviewPages = numberValue(reviews.pages); + const reviewPage = numberValue(reviews.page) || page; + const reviewPerPage = numberValue(reviews.per_page) || perPage; + const courseItems = arrayValue(courses.items).map((item) => normaliseNcesCourse(item)); + const reviewItems = arrayValue(reviews.items).map((item) => normaliseNcesReview(item)); + const teacherItems = arrayValue(teachers.items).map((item) => normaliseNcesTeacherSummary(item)); + const aggregateItems: NcesSearchAggregateItem[] = [ + ...courseItems.map((item) => ({ kind: "course" as const, item })), + ...teacherItems.map((item) => ({ kind: "teacher" as const, item })), + ...reviewItems.map((item) => ({ kind: "review" as const, item })), + ]; + const common = { + aggregateTotal: courseTotal + teacherTotal + reviewTotal, + aggregateItems, + aggregateShown: aggregateItems.length, + courseItems, + courseTotal, + coursePages, + sampleReviews: reviewItems, + reviewTotal, + reviewPages, + teachers: teacherItems, + teacherTotal, + teacherPages, + page, + perPage, + }; + if (type === "teacher") { + return { + ...common, + items: teacherItems, + total: teacherTotal, + pages: teacherPages, + page: teacherPage, + perPage: teacherPerPage, + type, + selectedBucket: "teacher", + selectedItems: teacherItems, + }; + } + if (type === "review") { + return { + ...common, + items: reviewItems, + total: reviewTotal, + pages: reviewPages, + page: reviewPage, + perPage: reviewPerPage, + type, + selectedBucket: "review", + selectedItems: reviewItems, + }; + } return { - items: arrayValue(courses.items).map((item) => normaliseNcesCourse(item)), - total: numberValue(courses.total), - sampleReviews: arrayValue(reviews.items).map((item) => normaliseNcesReview(item)), + ...common, + items: courseItems, + total: courseTotal, + pages: coursePages, + page: coursePage, + perPage: coursePerPage, + type, + selectedBucket: "course", + selectedItems: courseItems, }; } export async function getNcesCourseDetail( id: number, - options: { adapter?: ServiceAdapter } = {}, + options: { adapter?: ServiceAdapter; reviewTerm?: string; preferredTerm?: string; allReviews?: boolean } = {}, ): Promise { const adapter = options.adapter ?? createFetchAdapter(); const courseResponse = await fetchOptionalJson(adapter, requestUrl(NCES_BASE, `/api/v1/course/${id}`)); if (courseResponse === null) return null; const course = recordValue(courseResponse); - const reviewResponse = await fetchJson(adapter, requestUrl(NCES_BASE, `/api/v1/course/${id}/reviews`)); + const reviewPage = options.allReviews + ? await loadCompleteNcesCourseReviews(id, adapter, options.reviewTerm) + : await loadInitialNcesCourseReviews(id, adapter, options.reviewTerm); const rate = recordValue(course.rate); + const preferredTerm = options.preferredTerm ?? options.reviewTerm; + const rawTerms = preferMatchingCourseTerms(arrayValue(course.terms), preferredTerm); + const offeringTermIds = preferMatchingTermIds([...new Set(rawTerms + .map((item) => { + const term = recordValue(item); + return stringValue(term.term ?? term.term_id); + }) + .filter(Boolean))], preferredTerm); + const explicitTermIds = preferMatchingTermIds(arrayValue(course.term_ids).map((item) => stringValue(item)).filter(Boolean), preferredTerm); + const reviewTermIds = preferMatchingTermIds(arrayValue(course.review_term_list).map((item) => stringValue(item)).filter(Boolean), preferredTerm); + const summaryTermIds = preferMatchingTermIds(offeringTermIds.length > 0 + ? offeringTermIds + : explicitTermIds.length > 0 + ? explicitTermIds + : reviewTermIds, preferredTerm); const base = normaliseNcesCourse({ id, course_code: course.course_code ?? course.courseries, name: course.name, teacher_names: course.teacher_names ?? arrayValue(course.teachers).map((item) => stringValue(recordValue(item).name)).join(", "), - term_ids: course.term_ids ?? course.review_term_list, + term_ids: summaryTermIds, rate_average: rate.rate_average ?? rate.average_rate, review_count: rate.review_count, difficulty_score: rate.difficulty_score, @@ -191,17 +632,115 @@ export async function getNcesCourseDetail( grading_score: rate.grading_score, gain_score: rate.gain_score, }); + const aiSummary = normaliseNcesAiSummary(course.ai_summary); return { ...base, department: stringValue(course.dept), - reviews: arrayValue(recordValue(reviewResponse).items).map((item) => normaliseNcesReview(item)), + courseries: stringValue(course.courseries), + courseMaterialCode: stringValue(course.course_material_code), + introduction: cleanText(course.introduction), + homepage: stringValue(course.homepage), + adminAnnouncement: cleanText(course.admin_announcement), + accessCount: numberValue(course.access_count), + ...(course.credit !== undefined ? { credit: numberValue(course.credit) } : {}), + ...(course.hours !== undefined ? { hours: numberValue(course.hours) } : {}), + ...(course.hours_per_week !== undefined ? { hoursPerWeek: numberValue(course.hours_per_week) } : {}), + description: cleanText(course.description), + descriptionEng: cleanText(course.description_eng), + teachingMaterial: cleanText(course.teaching_material), + referenceMaterial: cleanText(course.reference_material), + studentRequirements: cleanText(course.student_requirements), + campus: stringValue(course.campus), + courseMajor: stringValue(course.course_major), + courseType: stringValue(course.course_type), + gradingType: stringValue(course.grading_type), + reviewTerms: reviewTermIds, + reviewResultsTotal: reviewPage.total, + reviewResultsPages: reviewPage.pages, + reviewResultsPerPage: reviewPage.perPage, + ...(options.reviewTerm ? { reviewFilterTerm: options.reviewTerm } : {}), + teachers: arrayValue(course.teachers).map((item) => normaliseNcesTeacherSummary(item)), + terms: rawTerms.map((item) => normaliseNcesCourseTerm(item)), + relatedCourses: arrayValue(course.related_courses).map((item) => normaliseNcesCourse(item)), + sameTeacherCourses: arrayValue(course.same_teacher_courses).map((item) => normaliseNcesTeacherCourseGroup(item)), + ...(aiSummary ? { aiSummary } : {}), + reviews: reviewPage.items, }; } +export async function listNcesCourseReviews( + id: number, + options: { + page?: number; + perPage?: number; + sort?: NcesReviewSort; + term?: string; + rating?: number; + adapter?: ServiceAdapter; + } = {}, +): Promise { + const adapter = options.adapter ?? createFetchAdapter(); + const page = options.page ?? 1; + const perPage = Math.max(1, Math.min(options.perPage ?? 20, 50)); + const sort = options.sort ?? "helpful"; + const raw = await fetchJson(adapter, requestUrl(NCES_BASE, `/api/v1/course/${id}/reviews`, { + page, + per_page: perPage, + sort_by: reviewSortParameter(sort), + ...(options.term ? { term: options.term } : {}), + ...(options.rating !== undefined ? { rating: options.rating } : {}), + })); + const record = recordValue(raw); + return { + courseId: id, + items: arrayValue(record.items).map((item) => normaliseNcesReview(item)), + total: numberValue(record.total), + page: numberValue(record.page) || page, + perPage: numberValue(record.per_page) || perPage, + pages: numberValue(record.pages), + sort, + ...(options.term ? { term: options.term } : {}), + ...(options.rating !== undefined ? { rating: options.rating } : {}), + }; +} + +export async function getNcesCourseStats( + id: number, + options: { adapter?: ServiceAdapter } = {}, +): Promise { + const adapter = options.adapter ?? createFetchAdapter(); + const statsResponse = await fetchOptionalJson(adapter, requestUrl(NCES_BASE, `/api/v1/course/${id}/stats`)); + return statsResponse === null ? null : normaliseNcesCourseStats(statsResponse); +} + +export async function getNcesTeacherDetail( + id: number, + options: { adapter?: ServiceAdapter } = {}, +): Promise { + const adapter = options.adapter ?? createFetchAdapter(); + const teacherResponse = await fetchOptionalJson(adapter, requestUrl(NCES_BASE, `/api/v1/teacher/${id}`)); + return teacherResponse === null ? null : normaliseNcesTeacherDetail(teacherResponse); +} + +export async function getNcesCourseByCode( + code: string, + options: { term?: string; adapter?: ServiceAdapter; allReviews?: boolean } = {}, +): Promise { + const adapter = options.adapter ?? createFetchAdapter(); + const courseId = await lookupNcesCourseIdByCode(code, { ...options, adapter }); + if (courseId === null) return null; + return getNcesCourseDetail(courseId, { + adapter, + ...(options.allReviews ? { allReviews: true } : {}), + ...(options.term ? { reviewTerm: options.term, preferredTerm: options.term } : {}), + }); +} + export async function resolveNcesCourseLookup( lookup: NcesCourseLookup, options: { termId?: string; includeDetail?: boolean; adapter?: ServiceAdapter } = {}, ): Promise { + const adapter = options.adapter ?? createFetchAdapter(); const code = normaliseLookupCode(lookup.code); const name = cleanLookupText(lookup.name); const query = code || name; @@ -227,14 +766,60 @@ export async function resolveNcesCourseLookup( }; } - const search = await searchNces(query, options.adapter ? { adapter: options.adapter } : {}); + let exactCandidate: NcesCourseSummary | undefined; + let exactDetail: NcesCourseDetail | undefined; + let exactLookupFailed = false; + if (code && options.termId) { + try { + if (options.includeDetail) { + exactDetail = (await getNcesCourseByCode(code, { term: options.termId, adapter })) ?? undefined; + exactCandidate = exactDetail; + } else { + exactCandidate = (await getNcesCourseByCodeSummary(code, { term: options.termId, adapter })) ?? undefined; + } + } catch { + exactLookupFailed = true; + } + } + + let search: NcesCourseSearchResult; + try { + search = await searchNces(query, { + adapter, + type: "course", + perPage: 50, + }); + } catch (error) { + if (!exactCandidate) throw error; + const signals = candidateSignals(exactCandidate, { code, name, teachers: lookup.teachers ?? [] }, options.termId); + return { + query, + queryKind, + ...(options.termId ? { termId: options.termId } : {}), + searchTotal: 0, + items: [], + matchedCandidates: [exactCandidate], + picked: exactCandidate, + ...(options.includeDetail ? { detail: exactDetail } : {}), + status: "matched", + confidence: resolveExactLookupConfidence(signals, { name, teachers: lookup.teachers ?? [] }), + signals, + notes: [ + "NCES exact code lookup resolved the requested semester directly after search was unavailable.", + ...lookupNotes("matched", 1, signals), + ], + }; + } const matchedCandidates = sortLookupCandidates( - search.items.filter((item) => lookupMatchesCandidate({ code, name }, item)), + mergeExactLookupCandidate( + search.items.filter((item) => lookupMatchesCandidate({ code, name }, item)), + exactCandidate, + ), lookup.teachers ?? [], options.termId, { code, name }, ); - if (matchedCandidates.length === 0) { + if (matchedCandidates.length === 0 && !exactCandidate) { return { query, queryKind, @@ -251,11 +836,17 @@ export async function resolveNcesCourseLookup( teacherMatches: [], termMatched: false, }, - notes: ["NCES search returned results, but none matched the course code or exact course name."], + notes: [ + "NCES search returned results, but none matched the course code or exact course name.", + ...(exactLookupFailed ? ["NCES exact code lookup was unavailable, so the resolver fell back to search candidates only."] : []), + ], }; } - const picked = matchedCandidates[0]; + const teacherMatchedCandidates = matchedCandidates.filter((candidate) => + candidateSignals(candidate, { code, name, teachers: lookup.teachers ?? [] }, options.termId).teacherMatches.length > 0 + ); + const picked = teacherMatchedCandidates[0] ?? exactCandidate ?? matchedCandidates[0]; const signals = picked ? candidateSignals(picked, { code, name, teachers: lookup.teachers ?? [] }, options.termId) : { @@ -265,10 +856,20 @@ export async function resolveNcesCourseLookup( teacherMatches: [], termMatched: false, }; - const confidence = resolveLookupConfidence(signals, matchedCandidates.length); - const status = confidence === "low" && matchedCandidates.length > 1 ? "ambiguous" : "matched"; + const usedExactCandidate = Boolean(exactCandidate && picked && exactCandidate.ncesId === picked.ncesId); + const confidence = usedExactCandidate + ? resolveExactLookupConfidence(signals, { name, teachers: lookup.teachers ?? [] }) + : resolveLookupConfidence(signals, matchedCandidates.length); + const status = usedExactCandidate + ? "matched" + : confidence === "low" && matchedCandidates.length > 1 ? "ambiguous" : "matched"; const detail = options.includeDetail && picked - ? await getNcesCourseDetail(picked.ncesId, options.adapter ? { adapter: options.adapter } : {}) + ? exactDetail && exactDetail.ncesId === picked.ncesId + ? exactDetail + : await getNcesCourseDetail(picked.ncesId, { + adapter, + ...(options.termId ? { reviewTerm: options.termId, preferredTerm: options.termId } : {}), + }) : undefined; return { @@ -283,7 +884,15 @@ export async function resolveNcesCourseLookup( status, confidence, signals, - notes: lookupNotes(status, matchedCandidates.length, signals), + notes: [ + ...(usedExactCandidate + ? ["NCES exact code lookup matched the requested semester directly."] + : exactCandidate + ? ["NCES exact code lookup returned a same-semester candidate, but teacher-aware ranking selected a different section."] + : []), + ...lookupNotes(status, matchedCandidates.length, signals), + ...(exactLookupFailed ? ["NCES exact code lookup was unavailable, so the resolver fell back to search candidates only."] : []), + ], }; } @@ -293,10 +902,14 @@ export async function resolveNcesCourseLookups( ): Promise { const items: Record = {}; const failures: Array<{ key: string; message: string }> = []; + const cache = new Map>(); for (const lookup of lookups) { try { - items[lookup.key] = await resolveNcesCourseLookup(lookup, options); + const cacheKey = ncesLookupCacheKey(lookup, options); + const pending = cache.get(cacheKey) ?? resolveNcesCourseLookup(lookup, options); + if (!cache.has(cacheKey)) cache.set(cacheKey, pending); + items[lookup.key] = await pending; } catch (error) { const message = safeErrorMessage(error); failures.push({ key: lookup.key, message }); @@ -329,6 +942,23 @@ export async function resolveNcesCourseLookups( }; } +function ncesLookupCacheKey( + lookup: Pick, + options: { termId?: string; includeDetail?: boolean }, +): string { + const teachers = [...new Set((lookup.teachers ?? []) + .map((teacher) => cleanLookupText(teacher)) + .filter(Boolean))] + .sort((left, right) => left.localeCompare(right, "zh-Hans-CN")); + return JSON.stringify({ + code: normaliseLookupCode(lookup.code), + name: cleanLookupText(lookup.name), + teachers, + termId: options.termId ?? "", + includeDetail: Boolean(options.includeDetail), + }); +} + export function normaliseNcesCourse(raw: unknown): NcesCourseSummary { const record = recordValue(raw); const termIds = arrayValue(record.term_ids).map((item) => stringValue(item)).filter(Boolean); @@ -336,25 +966,27 @@ export function normaliseNcesCourse(raw: unknown): NcesCourseSummary { return { ncesId: numberValue(record.id), code, - name: stringValue(record.name), - teacher: stringValue(record.teacher_names), + name: cleanText(record.name), + teacher: cleanText(record.teacher_names), semester: termIdToDisplay(termIds[0] ?? ""), semesters: termIds.map((termId) => termIdToDisplay(termId)), - rating: numberValue(record.rate_average), + rating: nullableNumberValue(record.rate_average), reviewCount: numberValue(record.review_count), - difficulty: scoreToLabel("difficulty", record.difficulty_score), - workload: scoreToLabel("workload", record.homework_score), - grading: scoreToLabel("grading", record.grading_score), - takeaways: scoreToLabel("takeaways", record.gain_score), + difficulty: scoreToLabelOrNull("difficulty", record.difficulty_score), + workload: scoreToLabelOrNull("workload", record.homework_score), + grading: scoreToLabelOrNull("grading", record.grading_score), + takeaways: scoreToLabelOrNull("takeaways", record.gain_score), directUrl: `${NCES_BASE}/course/${numberValue(record.id)}/`, }; } export function normaliseNcesReview(raw: unknown): NcesReview { const record = recordValue(raw); + const rawAuthor = record.author; + const author = typeof rawAuthor === "object" && rawAuthor !== null ? recordValue(rawAuthor) : {}; return { id: numberValue(record.id), - author: stringValue(record.author ?? record.user_name), + author: stringValue(record.author_name ?? record.user_name ?? (typeof rawAuthor === "string" ? rawAuthor : undefined) ?? author.username) || "匿名用户", term: termIdToDisplay(stringValue(record.term)), rating: numberValue(record.rate), upvotes: numberValue(record.upvote_count), @@ -366,6 +998,198 @@ export function normaliseNcesReview(raw: unknown): NcesReview { }; } +export function normaliseNcesTeacherSummary(raw: unknown): NcesTeacherSummary { + const record = recordValue(raw); + const teacherId = numberValue(record.id); + return { + teacherId, + name: cleanText(record.name), + email: stringValue(record.email), + title: cleanText(record.title), + image: normaliseNcesAssetUrl(stringValue(record.image)), + directUrl: `${NCES_BASE}/teacher/${teacherId}`, + }; +} + +export function normaliseNcesTeacherDetail(raw: unknown): NcesTeacherDetail { + const record = recordValue(raw); + const base = normaliseNcesTeacherSummary(record); + return { + ...base, + accessCount: numberValue(record.access_count), + reviewCount: numberValue(record.review_count), + averageRate: nullableNumberValue(record.average_rate), + normalizedRate: nullableNumberValue(record.normalized_rate), + gender: stringValue(record.gender), + description: cleanText(record.description), + homepage: stringValue(record.homepage), + researchInterest: cleanText(record.research_interest), + officePhone: stringValue(record.office_phone), + courses: arrayValue(record.courses).map((item) => normaliseNcesCourse(item)), + }; +} + +export function normaliseNcesCourseTerm(raw: unknown): NcesCourseTerm { + const record = recordValue(raw); + const termId = stringValue(record.term ?? record.term_id); + return { + id: numberValue(record.id), + termId, + term: termIdToDisplay(termId), + courseries: stringValue(record.courseries), + kcid: stringValue(record.kcid), + courseMajor: stringValue(record.course_major), + courseType: stringValue(record.course_type), + courseLevel: stringValue(record.course_level), + joinType: stringValue(record.join_type), + teachingType: stringValue(record.teaching_type), + gradingType: stringValue(record.grading_type), + ...(record.credit !== undefined ? { credit: numberValue(record.credit) } : {}), + ...(record.hours !== undefined ? { hours: numberValue(record.hours) } : {}), + ...(record.hours_per_week !== undefined ? { hoursPerWeek: numberValue(record.hours_per_week) } : {}), + campus: stringValue(record.campus), + ...(record.start_week !== undefined ? { startWeek: numberValue(record.start_week) } : {}), + ...(record.end_week !== undefined ? { endWeek: numberValue(record.end_week) } : {}), + }; +} + +export function normaliseNcesCourseStats(raw: unknown): NcesCourseStats { + const record = recordValue(raw); + return { + reviewCount: numberValue(record.review_count), + ratingDistribution: numberRecord(record.rating_distribution), + termDistribution: numberRecord(record.term_distribution), + termStats: arrayValue(record.term_stats).map((item) => { + const term = recordValue(item); + const ratingAverage = nullableNumberValue(term.rate_average); + return { + term: termIdToDisplay(stringValue(term.term)), + reviewCount: numberValue(term.review_count), + ...(ratingAverage === null ? {} : { ratingAverage }), + }; + }), + }; +} + +export function normaliseNcesGlobalStats(raw: unknown): NcesGlobalStats { + const record = recordValue(raw); + return { + userCount: numberValue(record.user_count), + courseCount: numberValue(record.course_count), + reviewCount: numberValue(record.review_count), + teacherCount: numberValue(record.teacher_count), + registeredTeacherCount: numberValue(record.registered_teacher_count), + runningDays: numberValue(record.running_days), + courseAverageRating: numberValue(record.course_avg_rate), + averageReviewsPerCourse: numberValue(record.course_avg_rate_count), + reviewRateDistribution: normaliseNcesDistributionSeries(record.review_rate_distribution), + courseRateDistribution: normaliseNcesDistributionSeries(record.course_rate_distribution), + courseReviewCountDistribution: normaliseNcesDistributionSeries(record.course_review_count_distribution), + userReviewCountDistribution: normaliseNcesDistributionSeries(record.user_review_count_distribution), + reviewMonthlyDistribution: normaliseNcesDistributionSeries(record.review_monthly_distribution), + userMonthlyDistribution: normaliseNcesDistributionSeries(record.user_monthly_distribution), + }; +} + +export function normaliseNcesRankings(raw: unknown): NcesRankings { + const record = recordValue(raw); + const stats = recordValue(record.stats); + return { + stats: { + averageRating: numberValue(stats.avg_rate), + averageReviewCount: numberValue(stats.avg_rate_count), + averageReviewUpvotes: numberValue(stats.avg_review_upvotes), + averageReviewLength: numberValue(stats.avg_review_length), + }, + topTeachers: arrayValue(record.top_teachers).map((item) => normaliseNcesTeacherRanking(item)), + topRatedCourses: arrayValue(record.top_rated_courses).map((item) => normaliseNcesRankedCourse(item)), + popularCourses: arrayValue(record.popular_courses).map((item) => normaliseNcesRankedCourse(item)), + topReviews: arrayValue(record.top_reviews).map((item) => normaliseNcesReviewRanking(item)), + longReviews: arrayValue(record.long_reviews).map((item) => normaliseNcesReviewRanking(item)), + topUsers: arrayValue(record.top_users).map((item) => normaliseNcesUserRanking(item)), + }; +} + +export function normaliseNcesAiSummary(raw: unknown): NcesAiSummary | undefined { + const record = recordValue(raw); + const overview = cleanText(record.overview); + const strengths = arrayValue(record.strengths).map((item) => cleanText(item)).filter(Boolean); + const caveats = arrayValue(record.caveats).map((item) => cleanText(item)).filter(Boolean); + const assessment = arrayValue(record.assessment).map((item) => cleanText(item)).filter(Boolean); + if (!overview && strengths.length === 0 && caveats.length === 0 && assessment.length === 0) return undefined; + return { + overview, + strengths, + caveats, + assessment, + sourceReviewCount: numberValue(record.source_review_count), + generatedAt: stringValue(record.generated_at), + authority: "community", + generatedBy: "NCES", + advisory: "AI-generated summary of community reviews; verify important claims against the underlying reviews and official course information.", + }; +} + +export function normaliseNcesTeacherCourseGroup(raw: unknown): NcesTeacherCourseGroup { + const record = recordValue(raw); + return { + teacher: normaliseNcesTeacherSummary(record.teacher), + courses: arrayValue(record.courses).map((item) => normaliseNcesCourse(item)), + }; +} + +export function normaliseNcesTeacherRanking(raw: unknown): NcesTeacherRanking { + const record = recordValue(raw); + const base = normaliseNcesTeacherSummary(record); + return { + ...base, + department: cleanText(record.dept), + courseCount: numberValue(record.course_count), + reviewCount: numberValue(record.review_count), + normalizedRating: numberValue(record.normalized_rate), + }; +} + +export function normaliseNcesRankedCourse(raw: unknown): NcesRankedCourse { + const record = recordValue(raw); + return { + ...normaliseNcesCourse(record), + normalizedRating: numberValue(record.normalized_rate), + }; +} + +export function normaliseNcesReviewRanking(raw: unknown): NcesReviewRanking { + const record = recordValue(raw); + const rawAuthor = record.author; + const author = typeof rawAuthor === "object" && rawAuthor !== null ? recordValue(rawAuthor) : {}; + const courseId = numberValue(record.course_id); + return { + courseId, + courseName: cleanText(record.course_name), + reviewId: numberValue(record.review_id), + author: stringValue(record.author_name ?? (typeof rawAuthor === "string" ? rawAuthor : undefined) ?? author.username) || "匿名用户", + anonymous: Boolean(record.is_anonymous), + upvotes: numberValue(record.upvote_count), + contentLength: numberValue(record.content_length), + courseUrl: `${NCES_BASE}/course/${courseId}/`, + }; +} + +export function normaliseNcesUserRanking(raw: unknown): NcesUserRanking { + const record = recordValue(raw); + const user = recordValue(record.user); + return { + userId: numberValue(user.id), + username: stringValue(user.username), + identity: stringValue(user.identity), + avatar: normaliseNcesAssetUrl(stringValue(user.avatar)), + reviewCount: numberValue(record.reviews_count), + reviewUpvotes: numberValue(record.review_upvotes_count), + reviewLength: numberValue(record.review_length), + score: numberValue(record.score), + }; +} + export function scoreToLabel( dimension: keyof typeof DIMENSION_LABELS, value: unknown, @@ -377,6 +1201,14 @@ export function scoreToLabel( return { label: DIMENSION_LABELS[dimension][DIMENSION_LABELS[dimension].length - 1][1], pct }; } +export function scoreToLabelOrNull( + dimension: keyof typeof DIMENSION_LABELS, + value: unknown, +): NcesDimension | null { + const pct = nullableNumberValue(value); + return pct === null ? null : scoreToLabel(dimension, pct); +} + export function termIdToDisplay(termId: string): string { if (!termId || termId.length < 5) return termId; const season = { "1": "秋", "2": "春", "3": "夏" }[termId[4]] ?? ""; @@ -463,7 +1295,7 @@ function compareLookupCandidate( [leftSignals.teacherMatches.length, rightSignals.teacherMatches.length], [leftSignals.termMatched ? 1 : 0, rightSignals.termMatched ? 1 : 0], [left.reviewCount, right.reviewCount], - [left.rating, right.rating], + [sortableNullableNumber(left.rating), sortableNullableNumber(right.rating)], ]; for (const [leftValue, rightValue] of numericComparisons) { if (leftValue !== rightValue) return rightValue - leftValue; @@ -507,6 +1339,18 @@ function resolveLookupConfidence( return "low"; } +function resolveExactLookupConfidence( + signals: NcesResolvedCourse["signals"], + lookup: { name: string; teachers: readonly string[] }, +): NcesResolvedCourse["confidence"] { + if (!signals.exactCode || !signals.termMatched) return resolveLookupConfidence(signals, 1); + if (lookup.name && !signals.name) { + return signals.teacherMatches.length > 0 || lookup.teachers.length === 0 ? "medium" : "low"; + } + if (lookup.teachers.length > 0 && signals.teacherMatches.length === 0) return "medium"; + return "high"; +} + function lookupNotes( status: NcesResolvedCourse["status"], matchedCount: number, @@ -529,13 +1373,26 @@ function normaliseCode(value: string): string { return value.replaceAll(/[\s_-]+/g, "").toUpperCase(); } +function nullableNumberValue(value: unknown): number | null { + if (value === null || value === undefined) return null; + if (typeof value === "string" && value.trim() === "") return null; + const parsed = numberValue(value, Number.NaN); + return Number.isFinite(parsed) ? parsed : null; +} + +function sortableNullableNumber(value: number | null): number { + return value ?? -1; +} + function normaliseName(value: string): string { return value.replaceAll(/[\s·•()()\-—_/]+/g, "").trim().toLowerCase(); } function baseCodeMatches(left: string, right: string): boolean { if (!left || !right || left === right) return false; - return left.startsWith(right) || right.startsWith(left); + if (!left.startsWith(right)) return false; + const suffix = left.slice(right.length); + return /^[A-Z][A-Z0-9]*$/u.test(suffix); } function safeErrorMessage(error: unknown): string { @@ -571,16 +1428,263 @@ async function fetchOptionalJson(adapter: ServiceAdapter, url: string): Promise< return parseJson(text, url); } +const NCES_DETAIL_REVIEW_PAGE_LIMIT = 200; +const NCES_DETAIL_REVIEW_PAGE_CONCURRENCY = 5; + +async function loadInitialNcesCourseReviews( + id: number, + adapter: ServiceAdapter, + term: string | undefined, +): Promise<{ items: NcesReview[]; total: number; pages: number; perPage: number }> { + const raw = await fetchJson(adapter, requestUrl(NCES_BASE, `/api/v1/course/${id}/reviews`, { + ...(term ? { term } : {}), + })); + const record = recordValue(raw); + const items = arrayValue(record.items).map((item) => normaliseNcesReview(item)); + const total = numberValue(record.total); + const pages = numberValue(record.pages); + const reportedPerPage = numberValue(record.per_page); + return { + items, + total, + pages, + perPage: reportedPerPage > 0 ? reportedPerPage : items.length > 0 ? items.length : 20, + }; +} + +async function loadCompleteNcesCourseReviews( + id: number, + adapter: ServiceAdapter, + term: string | undefined, +): Promise<{ items: NcesReview[]; total: number; pages: number; perPage: number }> { + const baseUrl = requestUrl(NCES_BASE, `/api/v1/course/${id}/reviews`, { + ...(term ? { term } : {}), + }); + const firstPageRaw = await fetchJson(adapter, baseUrl); + const firstPage = recordValue(firstPageRaw); + const initialItems = arrayValue(firstPage.items).map((item) => normaliseNcesReview(item)); + const total = numberValue(firstPage.total); + const pages = numberValue(firstPage.pages); + const reportedPerPage = numberValue(firstPage.per_page); + const perPage = reportedPerPage > 0 + ? reportedPerPage + : initialItems.length > 0 + ? initialItems.length + : 20; + const effectivePages = pages > 0 + ? pages + : total > perPage && perPage > 0 + ? Math.ceil(total / perPage) + : initialItems.length > 0 + ? 1 + : 0; + if (effectivePages > NCES_DETAIL_REVIEW_PAGE_LIMIT) { + throw new ServiceError("NCES course detail review pagination exceeded the safety limit.", { + url: baseUrl, + cause: `total=${total}; pages=${effectivePages}; limit=${NCES_DETAIL_REVIEW_PAGE_LIMIT}`, + }); + } + if (effectivePages <= 1) { + return { + items: dedupeNcesReviews(initialItems), + total: total > 0 ? total : initialItems.length, + pages: effectivePages, + perPage, + }; + } + const remainingPages = await fetchNcesReviewPagesInBatches( + Array.from({ length: effectivePages - 1 }, (_, index) => index + 2), + async (page) => { + const raw = await fetchJson(adapter, requestUrl(NCES_BASE, `/api/v1/course/${id}/reviews`, { + page, + per_page: perPage, + ...(term ? { term } : {}), + })); + return arrayValue(recordValue(raw).items).map((item) => normaliseNcesReview(item)); + }, + ); + const items = dedupeNcesReviews([...initialItems, ...remainingPages.flat()]); + return { + items, + total: total > 0 ? total : items.length, + pages: effectivePages, + perPage, + }; +} + +async function fetchNcesReviewPagesInBatches( + pages: readonly number[], + loadPage: (page: number) => Promise, +): Promise { + const results: T[] = []; + for (let start = 0; start < pages.length; start += NCES_DETAIL_REVIEW_PAGE_CONCURRENCY) { + const batch = pages.slice(start, start + NCES_DETAIL_REVIEW_PAGE_CONCURRENCY); + results.push(...await Promise.all(batch.map((page) => loadPage(page)))); + } + return results; +} + +function dedupeNcesReviews(reviews: readonly NcesReview[]): NcesReview[] { + const seen = new Set(); + const items: NcesReview[] = []; + for (const review of reviews) { + if (seen.has(review.id)) continue; + seen.add(review.id); + items.push(review); + } + return items; +} + +function mergeExactLookupCandidate( + items: readonly NcesCourseSummary[], + exactDetail: NcesCourseSummary | null | undefined, +): NcesCourseSummary[] { + if (!exactDetail) return [...items]; + const merged: NcesCourseSummary[] = [exactDetail]; + const seen = new Set([exactDetail.ncesId]); + for (const item of items) { + if (seen.has(item.ncesId)) continue; + seen.add(item.ncesId); + merged.push(item); + } + return merged; +} + +async function lookupNcesCourseIdByCode( + code: string, + options: { term?: string; adapter?: ServiceAdapter } = {}, +): Promise { + const adapter = options.adapter ?? createFetchAdapter(); + const normalizedCode = cleanLookupText(code).toUpperCase(); + if (!normalizedCode) return null; + const lookupUrl = requestUrl( + NCES_BASE, + `/api/v1/course/by-code/${encodeURIComponent(normalizedCode)}`, + options.term ? { term: options.term } : {}, + ); + const lookupResponse = await fetchOptionalJson(adapter, lookupUrl); + if (lookupResponse === null) return null; + const courseId = numberValue(recordValue(lookupResponse).course_id); + if (!Number.isSafeInteger(courseId) || courseId < 1) { + throw new ServiceError("NCES exact code lookup did not return a valid course ID.", { url: lookupUrl }); + } + return courseId; +} + +async function getNcesCourseByCodeSummary( + code: string, + options: { term?: string; adapter?: ServiceAdapter } = {}, +): Promise { + const adapter = options.adapter ?? createFetchAdapter(); + const courseId = await lookupNcesCourseIdByCode(code, { ...options, adapter }); + if (courseId === null) return null; + const courseResponse = await fetchOptionalJson(adapter, requestUrl(NCES_BASE, `/api/v1/course/${courseId}`)); + if (courseResponse === null) return null; + return normaliseNcesCourseSummaryFromDetailRecord(courseId, recordValue(courseResponse), options.term); +} + +function normaliseNcesCourseSummaryFromDetailRecord( + id: number, + course: Record, + preferredTerm: string | undefined, +): NcesCourseSummary { + const rate = recordValue(course.rate); + const rawTerms = preferMatchingCourseTerms(arrayValue(course.terms), preferredTerm); + const offeringTermIds = preferMatchingTermIds([...new Set(rawTerms + .map((item) => { + const term = recordValue(item); + return stringValue(term.term ?? term.term_id); + }) + .filter(Boolean))], preferredTerm); + const explicitTermIds = preferMatchingTermIds(arrayValue(course.term_ids).map((item) => stringValue(item)).filter(Boolean), preferredTerm); + const reviewTermIds = preferMatchingTermIds(arrayValue(course.review_term_list).map((item) => stringValue(item)).filter(Boolean), preferredTerm); + const summaryTermIds = preferMatchingTermIds(offeringTermIds.length > 0 + ? offeringTermIds + : explicitTermIds.length > 0 + ? explicitTermIds + : reviewTermIds, preferredTerm); + return normaliseNcesCourse({ + id, + course_code: course.course_code ?? course.courseries, + name: course.name, + teacher_names: course.teacher_names ?? arrayValue(course.teachers).map((item) => stringValue(recordValue(item).name)).join(", "), + term_ids: summaryTermIds, + rate_average: rate.rate_average ?? rate.average_rate, + review_count: rate.review_count, + difficulty_score: rate.difficulty_score, + homework_score: rate.homework_score, + grading_score: rate.grading_score, + gain_score: rate.gain_score, + }); +} + function compareTuple(a: [number, number, number], b: [number, number, number]): number { if (a[0] !== b[0]) return a[0] - b[0]; if (a[1] !== b[1]) return a[1] - b[1]; return a[2] - b[2]; } -function compareNcesCourses(sort: "rating" | "reviews" | "name") { - return (left: NcesCourseSummary, right: NcesCourseSummary): number => { - if (sort === "name") return left.name.localeCompare(right.name, "zh-Hans-CN"); - if (sort === "reviews") return right.reviewCount - left.reviewCount; - return right.rating - left.rating; - }; +function numberRecord(raw: unknown): Record { + const record = recordValue(raw); + return Object.fromEntries( + Object.entries(record) + .map(([key, value]) => [key, numberValue(value)] as const) + .filter(([, value]) => Number.isFinite(value)), + ); +} + +function normaliseNcesDistributionSeries(raw: unknown): NcesDistributionPoint[] { + return arrayValue(raw).map((item) => { + const record = recordValue(item); + return { + label: stringValue(record.label), + value: numberValue(record.value), + ...(record.cumulative === undefined ? {} : { cumulative: numberValue(record.cumulative) }), + }; + }); +} + +function normaliseNcesAssetUrl(value: string): string { + if (!value) return ""; + try { + const url = new URL(value, NCES_BASE); + if (url.protocol === "http:" || url.protocol === "https:") return url.toString(); + } catch { + // ignore invalid URLs and return an empty string below + } + return ""; +} + +function preferMatchingCourseTerms(rawTerms: readonly unknown[], preferredTerm: string | undefined): unknown[] { + if (!preferredTerm) return [...rawTerms]; + return [...rawTerms].sort((left, right) => { + const leftTerm = stringValue(recordValue(left).term ?? recordValue(left).term_id); + const rightTerm = stringValue(recordValue(right).term ?? recordValue(right).term_id); + const leftMatch = leftTerm === preferredTerm ? 1 : 0; + const rightMatch = rightTerm === preferredTerm ? 1 : 0; + if (leftMatch !== rightMatch) return rightMatch - leftMatch; + return 0; + }); +} + +function preferMatchingTermIds(termIds: readonly string[], preferredTerm: string | undefined): string[] { + if (!preferredTerm) return [...termIds]; + const normalized = termIds.filter(Boolean); + const matches = normalized.filter((termId) => termId === preferredTerm); + const rest = normalized.filter((termId) => termId !== preferredTerm); + return [...matches, ...rest]; +} + +function browseSortParameter(sort: "rating" | "reviews" | "name"): string { + if (sort === "reviews") return "review_count"; + if (sort === "name") return "name"; + return "rate"; +} + +function reviewSortParameter(sort: NcesReviewSort): string { + if (sort === "newest") return "pubtime_desc"; + if (sort === "oldest") return "pubtime"; + if (sort === "rating-high") return "score_desc"; + if (sort === "rating-low") return "score"; + return "upvote"; } diff --git a/src/services/sustech-online.ts b/src/services/sustech-online.ts index 882131c..94fe2ea 100644 --- a/src/services/sustech-online.ts +++ b/src/services/sustech-online.ts @@ -9,6 +9,7 @@ import { ONLINE_TALKS_INDEX_REPO_PATH, ONLINE_TALKS_INDEX_SITE_PATH, } from "../online/shared.js"; +import { ONLINE_MANUAL_ENDPOINTS } from "../online/manual.js"; import type { ServiceStatus } from "./base.js"; export const SUSTECH_ONLINE_STATUS: ServiceStatus = { @@ -17,15 +18,16 @@ export const SUSTECH_ONLINE_STATUS: ServiceStatus = { auth: "none", campusNetwork: false, browser: false, - summary: "Selected public talks and institutional contacts are read from the community-maintained SUSTech Online project.", + summary: "Selected public handbook sections, talks, and institutional contacts are read from the community-maintained SUSTech Online project.", notes: [ "Results retain community authority, source, freshness, and CC BY-SA attribution metadata.", - "High-stakes, financial, personal, dining/chat, and professor-list contact sections are excluded.", + "Handbook ingestion is constrained to a fixed allowlist; high-stakes, financial, personal, dining/chat, and professor-list content is excluded.", ], endpoints: [ `${ONLINE_RAW_ORIGIN}/${ONLINE_REPO_OWNER}/${ONLINE_REPO_NAME}/${ONLINE_REPO_BRANCH}/${ONLINE_TALKS_INDEX_REPO_PATH}`, `${ONLINE_RAW_ORIGIN}/${ONLINE_REPO_OWNER}/${ONLINE_REPO_NAME}/${ONLINE_REPO_BRANCH}/${ONLINE_CONTACT_REPO_PATH}`, `${ONLINE_SITE_ORIGIN}${ONLINE_TALKS_INDEX_SITE_PATH}`, `${ONLINE_SITE_ORIGIN}${ONLINE_CONTACT_SITE_PATH}`, + ...ONLINE_MANUAL_ENDPOINTS, ], }; diff --git a/src/services/text.ts b/src/services/text.ts index c09dc95..43cf3b1 100644 --- a/src/services/text.ts +++ b/src/services/text.ts @@ -1,20 +1,42 @@ import type { BlackboardAssignment, + BlackboardAssignmentsAggregateReport, + BlackboardAssignmentsWithAttemptsReport, + BlackboardGradesReport, + BlackboardAnnouncement, + BlackboardAnnouncementsReport, BlackboardAttempt, BlackboardAttemptFile, + BlackboardAttemptFileDownload, + BlackboardCourseMembership, + BlackboardCourseMessageFoldersPage, + BlackboardCourseMessageParticipantsPage, + BlackboardCourseMessagesPage, + BlackboardCourseRosterPage, BlackboardCourse, + BlackboardDiscussion, + BlackboardDiscussionGroup, + BlackboardDiscussionMessage, + BlackboardDiscussionGroupsPage, + BlackboardDiscussionMessagesPage, + BlackboardDiscussionRepliesPage, + BlackboardDiscussionsPage, BlackboardDeadline, BlackboardDeadlineReport, BlackboardCalendarItemsReport, BlackboardContentAttachment, BlackboardContentAttachmentDownload, + BlackboardContentTreeReport, + BlackboardContentTypesReport, BlackboardContentItem, BlackboardSearchMatch, BlackboardSearchReport, BlackboardSubmissionFile, + BlackboardSubmissionText, BlackboardSyncReport, BlackboardUser, } from "./blackboard.js"; +import { sampleText } from "./base.js"; import type { BookingUserProfile } from "./booking-auth.js"; import type { BookingCancelPreview, @@ -37,7 +59,25 @@ import type { PrimoCatalogSearchPage, LibraryReservation, } from "./library.js"; -import type { NcesCourseDetail, NcesCourseSummary } from "./nces.js"; +import { + termIdToDisplay, + type NcesCourseDetail, + type NcesCourseFilterOptions, + type NcesGlobalStats, + type NcesRankedCourse, + type NcesRankings, + type NcesRankingCategory, + type NcesCourseStats, + type NcesCourseSummary, + type NcesDistributionPoint, + type NcesReview, + type NcesReviewPage, + type NcesReviewRanking, + type NcesTeacherDetail, + type NcesTeacherRanking, + type NcesTeacherSummary, + type NcesUserRanking, +} from "./nces.js"; import type { OpenAccessPdfDownload, PaperSummary } from "./papers.js"; import type { PmsPrintDeletePreview, @@ -456,22 +496,235 @@ export function formatNcesCourses(courses: readonly NcesCourseSummary[], title: `${title} · ${courses.length}`, ...courses.map((course) => [ `${course.code.padEnd(10)} ${course.name} · ${course.teacher || "teacher unavailable"}`, - ` rating ${course.rating} / reviews ${course.reviewCount} · ${course.semester}`, - ` difficulty ${course.difficulty.label} · workload ${course.workload.label} · grading ${course.grading.label} · takeaways ${course.takeaways.label}`, + ` rating ${formatNullableNcesRating(course.rating)} / reviews ${course.reviewCount} · ${course.semester}`, + ` difficulty ${formatNullableNcesDimension(course.difficulty)} · workload ${formatNullableNcesDimension(course.workload)} · grading ${formatNullableNcesDimension(course.grading)} · takeaways ${formatNullableNcesDimension(course.takeaways)}`, ` ${course.directUrl}`, ].join("\n")), ].join("\n"); } +export function formatNcesFilterOptions(options: NcesCourseFilterOptions): string { + if (options.offeringUnits.length === 0) return "NCES browse filters\nNo live offering-unit filters were returned."; + return [ + `NCES browse filters · ${options.offeringUnits.length} offering unit(s)`, + ...options.offeringUnits.map((unit) => `- ${unit}`), + ].join("\n"); +} + +export function formatNcesGlobalStats(stats: NcesGlobalStats): string { + return [ + `NCES global stats · ${stats.courseCount} course(s) · ${stats.reviewCount} review(s)`, + `${stats.userCount} user(s) · ${stats.teacherCount} teacher(s) · ${stats.registeredTeacherCount} registered teacher(s) · running ${stats.runningDays} day(s)`, + `Average course rating ${stats.courseAverageRating} · average reviews per course ${stats.averageReviewsPerCourse}`, + `Review scores · ${formatDistributionPreview(stats.reviewRateDistribution)}`, + `Course rating buckets · ${formatDistributionPreview(stats.courseRateDistribution)}`, + `Recent review months · ${formatDistributionTail(stats.reviewMonthlyDistribution, 3)}`, + `Recent new-user months · ${formatDistributionTail(stats.userMonthlyDistribution, 3)}`, + "Community-maintained evaluation data; not an official academic record.", + ].join("\n"); +} + export function formatNcesDetail(course: NcesCourseDetail | null): string { if (!course) return "NCES course\nCourse not found."; - return [ + const lines = [ formatNcesCourses([course], "NCES course"), - `Reviews · ${course.reviews.length}`, + ...([course.department, course.courseType, course.credit === undefined ? "" : `${course.credit} credits`].filter(Boolean).length > 0 + ? [[course.department, course.courseType, course.credit === undefined ? "" : `${course.credit} credits`].filter(Boolean).join(" · ")] + : []), + ...(course.teachers.length > 0 ? [`Teachers · ${course.teachers.map((teacher) => teacher.name).join(", ")}`] : []), + ...(course.description ? [`Description · ${course.description}`] : []), + formatNcesDetailReviewSummary(course), + ...course.reviews.map((review) => ` ${review.rating}★ · ${review.term} · +${review.upvotes}\n ${review.content}`), + ]; + if (course.aiSummary) { + lines.push( + `NCES AI summary · ${course.aiSummary.sourceReviewCount} source review(s) · ${course.aiSummary.generatedAt || "generation time unavailable"}`, + course.aiSummary.overview, + `Advisory · ${course.aiSummary.advisory}`, + ); + } + return lines.join("\n\n"); +} + +export function formatNcesSearch( + query: string, + courses: readonly NcesCourseSummary[], + teachers: readonly NcesTeacherSummary[], + reviews: readonly NcesReview[], + options: { + type?: "all" | "course" | "teacher" | "review"; + courseTotal?: number; + teacherTotal?: number; + reviewTotal?: number; + page?: number; + perPage?: number; + } = {}, +): string { + const blocks: string[] = []; + const header = options.type === "all" + ? `NCES search · ${query}\nBucket totals · courses ${options.courseTotal ?? courses.length} · teachers ${options.teacherTotal ?? teachers.length} · reviews ${options.reviewTotal ?? reviews.length}${options.page !== undefined && options.perPage !== undefined ? ` · page ${options.page} · page size ${options.perPage}` : ""}` + : ""; + if (courses.length > 0) blocks.push(formatNcesCourses(courses, `NCES courses · ${query}`)); + if (teachers.length > 0) { + blocks.push([ + `NCES teachers · ${teachers.length}`, + ...teachers.map((teacher) => `${teacher.teacherId} · ${teacher.name}${teacher.title ? ` · ${teacher.title}` : ""}\n ${teacher.directUrl}`), + ].join("\n")); + } + if (reviews.length > 0) { + blocks.push([ + `NCES review matches · ${reviews.length}`, + ...reviews.map((review) => `${review.rating}★ · ${review.author} · ${review.term}\n ${review.content}`), + ].join("\n")); + } + if (blocks.length === 0) return `${header || `NCES search · ${query}`}\n${header ? "" : ""}${header ? "\n\n" : "\n"}No matching public community records.`; + return header ? `${header}\n\n${blocks.join("\n\n")}` : blocks.join("\n\n"); +} + +export function formatNcesTeacher(teacher: NcesTeacherDetail | null): string { + if (!teacher) return "NCES teacher\nTeacher not found."; + return [ + `NCES teacher · ${teacher.name}`, + [teacher.title, teacher.email, teacher.officePhone].filter(Boolean).join(" · ") || "Public profile details unavailable.", + `Community rating ${formatNullableNcesRating(teacher.reviewCount > 0 ? teacher.averageRate : null)} · ${teacher.reviewCount} review(s)`, + ...(teacher.researchInterest ? [`Research · ${teacher.researchInterest}`] : []), + ...(teacher.description ? [`Profile · ${teacher.description}`] : []), + formatNcesCourses(teacher.courses, "Courses"), + teacher.directUrl, + ].join("\n"); +} + +export function formatNcesStats(courseId: number, stats: NcesCourseStats | null): string { + if (!stats) return `NCES course stats · ${courseId}\nCourse stats not found.`; + return [ + `NCES course stats · ${courseId} · ${stats.reviewCount} review(s)`, + `Ratings · ${Object.entries(stats.ratingDistribution).map(([rating, count]) => `${rating}★:${count}`).join(" · ") || "unavailable"}`, + ...stats.termStats.map((term) => `${term.term} · ${term.reviewCount} review(s)${term.ratingAverage === undefined ? "" : ` · ${term.ratingAverage}★`}`), + "Community-maintained evaluation data; not an official academic record.", + ].join("\n"); +} + +export function formatNcesRankings( + rankings: NcesRankings, + category: NcesRankingCategory, + items: readonly NcesTeacherRanking[] | readonly NcesRankedCourse[] | readonly NcesReviewRanking[] | readonly NcesUserRanking[], +): string { + return [ + `NCES rankings · ${category} · ${items.length}`, + `Community averages · rating ${rankings.stats.averageRating} · reviews/course ${rankings.stats.averageReviewCount} · upvotes/review ${rankings.stats.averageReviewUpvotes} · chars/review ${rankings.stats.averageReviewLength}`, + ...formatRankingItems(category, items), + "Community-maintained evaluation data; not an official academic record.", + ].join("\n"); +} + +export function formatNcesReviews(page: NcesReviewPage): string { + return [ + `NCES reviews · course ${page.courseId} · ${page.items.length}/${page.total} · page ${page.page}/${page.pages || "?"}`, + ...page.items.map((review) => `${review.rating}★ · ${review.author} · ${review.term} · +${review.upvotes}\n ${review.content}`), + "Community-maintained reviews; verify important course facts against official sources.", + ].join("\n"); +} + +export function formatNcesCourseByCode( + code: string, + term: string | undefined, + course: NcesCourseDetail | null, +): string { + if (!course) { + return `NCES by code\n${code}${term ? ` · ${term}` : ""}\nCourse not found.`; + } + const availableTerms = [...new Set([ + ...course.terms.map((item) => item.termId).filter(Boolean), + ...course.reviewTerms.filter(Boolean), + ])]; + const termMatched = term === undefined + ? undefined + : availableTerms.includes(term) || course.semesters.includes(termIdToDisplay(term)); + return [ + `NCES by code · ${code}${term ? ` · requested ${termIdToDisplay(term)}` : ""}`, + `${course.code.padEnd(10)} ${course.name} · ${course.teacher || "teacher unavailable"}`, + `Department ${course.department || "unavailable"}${course.courseType ? ` · ${course.courseType}` : ""}`, + `Community rating ${formatNullableNcesRating(course.rating)} / reviews ${course.reviewCount}`, + `Available terms · ${availableTerms.length > 0 ? availableTerms.map((termId) => termIdToDisplay(termId)).join(", ") : "unavailable"}`, + ...(term ? [`Requested term match · ${termMatched ? "yes" : "not confirmed in course offerings"}`] : []), + course.directUrl, + formatNcesDetailReviewSummary(course), ...course.reviews.map((review) => ` ${review.rating}★ · ${review.term} · +${review.upvotes}\n ${review.content}`), + ...(course.aiSummary + ? [ + `NCES AI summary · ${course.aiSummary.sourceReviewCount} source review(s) · ${course.aiSummary.generatedAt || "generation time unavailable"}`, + course.aiSummary.overview, + `Advisory · ${course.aiSummary.advisory}`, + ] + : []), ].join("\n\n"); } +function formatNcesDetailReviewSummary(course: NcesCourseDetail): string { + const reviewWindow = `${course.reviews.length}/${course.reviewResultsTotal || course.reviews.length}`; + const reviewPages = course.reviewResultsPages > 0 ? course.reviewResultsPages : 0; + if (course.reviewFilterTerm) { + return `Reviews loaded · ${reviewWindow} for ${termIdToDisplay(course.reviewFilterTerm)} across ${reviewPages} page(s) · course total ${course.reviewCount}; use \`nces reviews ${course.ncesId} --term ${course.reviewFilterTerm}\` for paginated inspection.`; + } + if (course.reviews.length < course.reviewResultsTotal) { + return `Reviews loaded · ${reviewWindow} across ${reviewPages} page(s) from NCES · course total ${course.reviewCount}; use \`nces reviews ${course.ncesId}\` for paginated inspection or rerun with \`--all-reviews\` to load every current page.`; + } + if (course.reviewCount !== course.reviewResultsTotal) { + return `Reviews loaded · ${reviewWindow} across ${reviewPages} page(s) from NCES · course total ${course.reviewCount}.`; + } + return `Reviews loaded · ${reviewWindow} across ${reviewPages} page(s) from NCES.`; +} + +function formatDistributionPreview(points: readonly NcesDistributionPoint[]): string { + if (points.length === 0) return "unavailable"; + return points.map((point) => `${point.label}:${point.value}`).join(" · "); +} + +function formatDistributionTail(points: readonly NcesDistributionPoint[], count: number): string { + if (points.length === 0) return "unavailable"; + return points.slice(-count).map((point) => `${point.label}:${point.value}`).join(" · "); +} + +function formatRankingItems( + category: NcesRankingCategory, + items: readonly NcesTeacherRanking[] | readonly NcesRankedCourse[] | readonly NcesReviewRanking[] | readonly NcesUserRanking[], +): string[] { + if (category === "top-teachers") { + return (items as readonly NcesTeacherRanking[]).map((teacher, index) => [ + `${index + 1}. ${teacher.name}${teacher.department ? ` · ${teacher.department}` : ""}`, + ` normalized ${teacher.normalizedRating} · courses ${teacher.courseCount} · reviews ${teacher.reviewCount}`, + ` ${teacher.directUrl}`, + ].join("\n")); + } + if (category === "top-rated-courses" || category === "popular-courses") { + return (items as readonly NcesRankedCourse[]).map((course, index) => [ + `${index + 1}. ${course.code} ${course.name} · ${course.teacher || "teacher unavailable"}`, + ` normalized ${course.normalizedRating} · rating ${formatNullableNcesRating(course.rating)} · reviews ${course.reviewCount} · ${course.semester}`, + ` ${course.directUrl}`, + ].join("\n")); + } + if (category === "top-reviews" || category === "long-reviews") { + return (items as readonly NcesReviewRanking[]).map((review, index) => [ + `${index + 1}. ${review.courseName} · review ${review.reviewId}`, + ` ${review.author} · anonymous ${review.anonymous ? "yes" : "no"} · upvotes ${review.upvotes} · length ${review.contentLength}`, + ` ${review.courseUrl}`, + ].join("\n")); + } + return (items as readonly NcesUserRanking[]).map((user, index) => [ + `${index + 1}. ${user.username}${user.identity ? ` · ${user.identity}` : ""}`, + ` reviews ${user.reviewCount} · upvotes ${user.reviewUpvotes} · length ${user.reviewLength} · score ${user.score}`, + ...(user.avatar ? [` avatar ${user.avatar}`] : []), + ].join("\n")); +} + +function formatNullableNcesRating(value: number | null): string { + return value === null ? "unavailable" : String(value); +} + +function formatNullableNcesDimension(value: NcesCourseSummary["difficulty"]): string { + return value?.label ?? "unavailable"; +} + export function formatBlackboardUser(user: BlackboardUser): string { return `Blackboard user\n${user.displayName || user.userName}\nID ${user.id}`; } @@ -535,13 +788,445 @@ export function formatBlackboardAssignments(items: readonly BlackboardAssignment ].join("\n"); } +export function formatBlackboardAssignmentsWithAttempts( + report: BlackboardAssignmentsWithAttemptsReport, + options: { + assignments?: readonly BlackboardAssignmentsWithAttemptsReport["assignments"][number][]; + submissionState?: string; + } = {}, +): string { + const assignments = options.assignments ?? report.assignments; + if (assignments.length === 0) { + return options.submissionState + ? `Blackboard assignments\nNo assignment columns matched submission state ${options.submissionState}.` + : "Blackboard assignments\nNo assignment columns."; + } + const attemptedShown = assignments.filter((item) => (item.attemptSummary?.totalAttempts ?? 0) > 0).length; + return [ + `Blackboard assignments · ${assignments.length}/${report.totalAssignments}${options.submissionState ? ` · state ${options.submissionState}` : ""} · ${attemptedShown} with attempt(s)${report.failures.length > 0 ? ` · ${report.failures.length} failure(s)` : ""}`, + ...assignments.map(({ assignment, attemptSummary }) => [ + `${assignment.contentId.padEnd(12)} column ${assignment.id.padEnd(8)} ${assignment.title}${assignment.scorePossible === undefined ? "" : ` · ${assignment.scorePossible} points`}`, + ` ${assignment.grading.due ? `due ${assignment.grading.due}` : "no due date returned"}${assignment.grading.attemptsAllowed === undefined || assignment.grading.attemptsAllowed === 0 ? "" : ` · ${assignment.grading.attemptsAllowed} attempt(s)`}`, + attemptSummary + ? ` ${formatBlackboardAssignmentAttemptSummary(attemptSummary)}` + : " attempt summary unavailable", + ].join("\n")), + ].join("\n"); +} + +export function formatBlackboardAssignmentsAcrossCourses( + report: BlackboardAssignmentsAggregateReport, +): string { + const header = `Blackboard assignments · ${report.assignments.length}/${report.totalAssignments} across ${report.coursesMatched} course(s)` + + `${report.courseQuery ? ` · query ${report.courseQuery}` : ""}` + + `${report.withAttempts ? " · attempts included" : ""}` + + `${report.submissionState ? ` · state ${report.submissionState}` : ""}` + + `${report.failures.length > 0 ? ` · ${report.failures.length} failure(s)` : ""}`; + if (report.assignments.length === 0) { + return report.submissionState + ? `${header}\nNo assignment columns matched submission state ${report.submissionState}.` + : `${header}\nNo assignment columns returned.`; + } + return [ + header, + ...report.assignments.map(({ courseId, courseCode, courseName, assignment, attemptSummary }) => [ + `${courseCode || courseId} · ${courseName || courseId}`, + ` ${assignment.contentId.padEnd(12)} column ${assignment.id.padEnd(8)} ${assignment.title}${assignment.scorePossible === undefined ? "" : ` · ${assignment.scorePossible} points`}`, + ` ${assignment.grading.due ? `due ${assignment.grading.due}` : "no due date returned"}${assignment.grading.attemptsAllowed === undefined || assignment.grading.attemptsAllowed === 0 ? "" : ` · ${assignment.grading.attemptsAllowed} attempt(s)`}`, + ...(report.withAttempts ? [attemptSummary ? ` ${formatBlackboardAssignmentAttemptSummary(attemptSummary)}` : " attempt summary unavailable"] : []), + ].join("\n")), + ].join("\n"); +} + +export function formatBlackboardGrades(report: BlackboardGradesReport): string { + const header = `Blackboard grades · ${report.grades.length}/${report.attemptedAssignments} attempted item(s)` + + `${report.courseQuery ? ` · query ${report.courseQuery}` : ""}` + + `${report.submissionState ? ` · state ${report.submissionState}` : ""}` + + `${report.limit !== undefined ? ` · limit ${report.limit}` : ""}` + + `${report.failures.length > 0 ? ` · ${report.failures.length} failure(s)` : ""}`; + if (report.grades.length === 0) { + return report.submissionState + ? `${header}\nNo attempted Blackboard items matched submission state ${report.submissionState}.` + : `${header}\nNo attempted Blackboard items returned.`; + } + return [ + header, + ...report.grades.map(({ courseId, courseCode, courseName, assignment, attemptSummary }) => [ + `${courseCode || courseId} · ${courseName || courseId}`, + ` ${assignment.title}${assignment.scorePossible === undefined ? "" : ` · ${assignment.scorePossible} points`}`, + ` ${formatBlackboardAssignmentAttemptSummary(attemptSummary)}`, + ...(assignment.grading.due ? [` due ${assignment.grading.due} · content ${assignment.contentId} · column ${assignment.id}`] : [` content ${assignment.contentId} · column ${assignment.id}`]), + ].join("\n")), + ].join("\n"); +} + +export function formatBlackboardAnnouncements(report: BlackboardAnnouncementsReport): string { + const header = `Blackboard announcements · ${report.announcements.length}` + + `${report.days !== undefined ? ` within ${report.days} day(s)` : ""}` + + `${report.failures.length > 0 ? ` · ${report.failures.length} failure(s)` : ""}`; + if (report.announcements.length === 0) { + return `${header}\nNo announcements returned.`; + } + return [ + header, + ...report.announcements.map((announcement) => formatBlackboardAnnouncement(announcement)), + ].join("\n"); +} + +export function formatBlackboardDiscussions(report: BlackboardDiscussionsPage): string { + const originalFallback = report.discussions.some((discussion) => discussion.source === "original-html"); + const header = `Blackboard discussions · ${report.courseCode || report.courseId}` + + `${report.title ? ` · title ${report.title}` : ""}` + + `${report.gradable !== undefined ? ` · gradable ${report.gradable ? "true" : "false"}` : ""}` + + `${report.sort ? ` · sort ${report.sort}` : ""}` + + ` · page ${report.page}` + + `${originalFallback ? " · Original HTML fallback" : ""}` + + `${report.hasMore ? " · more available" : ""}`; + if (report.discussions.length === 0) { + return `${header}\nNo discussion forums returned.`; + } + return [ + header, + ...report.discussions.map((discussion) => [ + `${discussion.id.padEnd(10)} ${discussion.title || "Untitled discussion"}` + + `${discussion.gradable ? " · gradable" : ""}` + + `${discussion.groupDiscussion ? " · group" : ""}` + + `${discussion.source === "original-html" ? " · original-html" : ""}` + + `${discussion.available ? "" : " · unavailable"}`, + ` created ${discussion.createdDate || "unknown"} · updated ${discussion.modifiedDate || "unknown"}` + + `${discussion.gradebookColumnId ? ` · column ${discussion.gradebookColumnId}` : ""}`, + ...( + discussion.totalPosts !== undefined + ? [ + ` posts ${discussion.totalPosts} · unread ${discussion.unreadPosts ?? 0} · unread replies to me ${discussion.unreadRepliesToMe ?? 0} · participants ${discussion.totalParticipants ?? 0}` + + `${discussion.metadataPartial ? " · metadata partial" : ""}`, + ] + : discussion.metadataPartial + ? [" metadata partial"] + : [] + ), + ...(discussion.description ? [` description ${sampleText(discussion.description, 180)}`] : []), + ...(discussion.topic?.body ? [` topic ${sampleText(discussion.topic.body, 180)}`] : []), + ].join("\n")), + ...(report.hasMore ? [`Next page: ${report.nextPage}`] : []), + ].join("\n"); +} + +export function formatBlackboardDiscussionGroups(report: BlackboardDiscussionGroupsPage): string { + const discussion = report.discussion; + const header = `Blackboard discussion groups · ${report.courseCode || report.courseId} · ${discussion.title || discussion.id}` + + `${discussion.groupDiscussion ? " · group" : ""}` + + `${report.sort ? ` · sort ${report.sort}` : ""}` + + ` · page ${report.page}` + + `${report.hasMore ? " · more available" : ""}`; + if (report.groups.length === 0) { + return `${header}\nNo discussion groups returned.`; + } + return [ + header, + ...report.groups.map(formatBlackboardDiscussionGroupLine), + ...(report.hasMore ? [`Next page: ${report.nextPage}`] : []), + ].join("\n"); +} + +export function formatBlackboardDiscussion(report: BlackboardDiscussionMessagesPage): string { + const discussion = report.discussion; + const originalFallback = discussion.source === "original-html" || report.messages.some((message) => message.source === "original-html"); + const header = `Blackboard discussion · ${report.courseCode || report.courseId} · ${discussion.title || discussion.id}` + + `${discussion.gradable ? " · gradable" : ""}` + + `${discussion.groupDiscussion ? " · group" : ""}` + + `${report.status ? ` · status ${report.status}` : ""}` + + `${report.userId ? ` · user ${report.userId}` : ""}` + + `${report.groupId ? ` · groupId ${report.groupId}` : ""}` + + `${report.isRead !== undefined ? ` · isRead ${report.isRead ? "true" : "false"}` : ""}` + + `${report.sort ? ` · sort ${report.sort}` : ""}` + + ` · page ${report.page}` + + `${originalFallback ? " · Original HTML fallback" : ""}` + + `${report.hasMore ? " · more available" : ""}`; + const lines = [ + header, + `Discussion ID ${discussion.id} · available ${discussion.available ? "yes" : "no"} · created ${discussion.createdDate || "unknown"} · updated ${discussion.modifiedDate || "unknown"}` + + `${discussion.gradebookColumnId ? ` · column ${discussion.gradebookColumnId}` : ""}` + + `${discussion.metadataPartial ? " · metadata partial" : ""}`, + ]; + if (discussion.totalPosts !== undefined) { + lines.push( + `Discussion stats · posts ${discussion.totalPosts} · unread ${discussion.unreadPosts ?? 0} · unread replies to me ${discussion.unreadRepliesToMe ?? 0} · participants ${discussion.totalParticipants ?? 0}`, + ); + } + if (discussion.topic?.body) { + lines.push(`Topic ${sampleText(discussion.topic.body, 240)}`); + } + if (report.messages.length === 0) { + lines.push("No discussion messages returned."); + return lines.join("\n"); + } + lines.push(...report.messages.map((message) => [ + `${message.id.padEnd(10)} ${message.subject ? `${message.subject} · ` : ""}${message.author || message.userId || "Unknown author"} · ${message.status || "status unavailable"}${message.isRead ? " · read" : " · unread"}${message.source === "original-html" ? " · original-html" : ""}`, + ` posted ${message.postDate || message.createdDate || "unknown"}${message.groupId ? ` · group ${message.groupId}` : ""}${message.parentId ? ` · parent ${message.parentId}` : ""}`, + ...( + message.totalPosts !== undefined + ? [` posts ${message.totalPosts} · unread ${message.unreadPosts ?? 0} · unread replies to me ${message.unreadRepliesToMe ?? 0}${message.metadataPartial ? " · metadata partial" : ""}`] + : message.metadataPartial + ? [" metadata partial"] + : [] + ), + ` ${sampleText(message.body, 240) || "(empty)"}`, + ].join("\n"))); + if (report.hasMore) lines.push(`Next page: ${report.nextPage}`); + return lines.join("\n"); +} + +function formatBlackboardDiscussionGroupLine(group: BlackboardDiscussionGroup): string { + return `${group.groupId.padEnd(10)} thread ${group.threadId || "unavailable"} · discussion ${group.discussionId}`; +} + +export function formatBlackboardDiscussionReplies(report: BlackboardDiscussionRepliesPage): string { + const originalFallback = report.replies.some((reply) => reply.source === "original-html"); + const header = `Blackboard discussion replies · ${report.courseCode || report.courseId} · discussion ${report.discussionId} · message ${report.messageId}` + + `${report.status ? ` · status ${report.status}` : ""}` + + `${report.userId ? ` · user ${report.userId}` : ""}` + + `${report.groupId ? ` · groupId ${report.groupId}` : ""}` + + `${report.isRead !== undefined ? ` · isRead ${report.isRead ? "true" : "false"}` : ""}` + + `${report.sort ? ` · sort ${report.sort}` : ""}` + + ` · page ${report.page}` + + `${originalFallback ? " · Original HTML fallback" : ""}` + + `${report.hasMore ? " · more available" : ""}`; + if (report.replies.length === 0) { + return `${header}\nNo discussion replies returned.`; + } + return [ + header, + ...report.replies.map((reply) => [ + `${reply.id.padEnd(10)} ${reply.author || reply.userId || "Unknown author"} · ${reply.status || "status unavailable"}${reply.isRead ? " · read" : " · unread"}${reply.source === "original-html" ? " · original-html" : ""}`, + ` posted ${reply.postDate || reply.createdDate || "unknown"}${reply.parentId ? ` · parent ${reply.parentId}` : ""}`, + ...(reply.totalPosts !== undefined + ? [` posts ${reply.totalPosts} · unread ${reply.unreadPosts ?? 0} · unread replies to me ${reply.unreadRepliesToMe ?? 0}${reply.metadataPartial ? " · metadata partial" : ""}`] + : reply.metadataPartial + ? [" metadata partial"] + : []), + ` ${sampleText(reply.body, 240) || "(empty)"}`, + ].join("\n")), + ...(report.hasMore ? [`Next page: ${report.nextPage}`] : []), + ].join("\n"); +} + +export function formatBlackboardDiscussionWritePreview(input: { + target: { mode: "post" | "reply"; courseId: string; discussionId: string; messageId?: string; groupId?: string; status: string }; + courseCode: string; + discussion: BlackboardDiscussion; + group?: BlackboardDiscussionGroup; + parentMessage?: BlackboardDiscussionMessage; + body: { textFile: BlackboardSubmissionText; preview: string }; + blockers: readonly { code: string; message: string }[]; + warnings: readonly { code: string; message: string }[]; + applyAllowed: boolean; + confirmation: { available: boolean; command?: string }; +}): string { + const header = `Blackboard discussion ${input.target.mode === "reply" ? "reply" : "post"} preview · ${input.courseCode || input.target.courseId} · ${input.discussion.title || input.discussion.id}`; + const lines = [ + header, + `Discussion ID ${input.discussion.id} · status ${input.target.status}${input.target.groupId ? ` · group ${input.target.groupId}` : ""}${input.target.messageId ? ` · parent ${input.target.messageId}` : ""}`, + `Text file: ${input.body.textFile.absolutePath}`, + `SHA-256: ${input.body.textFile.sha256} · chars ${input.body.textFile.charCount}`, + `Body preview: ${input.body.preview || "(empty)"}`, + ]; + if (input.group) lines.push(`Resolved group thread: ${input.group.threadId || "unavailable"}`); + if (input.parentMessage) lines.push(`Parent message: ${input.parentMessage.id} · ${input.parentMessage.author || input.parentMessage.userId || "unknown author"}`); + if (input.blockers.length > 0) { + lines.push(...input.blockers.map((entry) => `Blocker ${entry.code}: ${entry.message}`)); + } + if (input.warnings.length > 0) { + lines.push(...input.warnings.map((entry) => `Warning ${entry.code}: ${entry.message}`)); + } + lines.push( + input.applyAllowed && input.confirmation.available + ? `Apply command: ${input.confirmation.command}` + : "Apply command unavailable until blockers are resolved.", + ); + return lines.join("\n"); +} + +export function formatBlackboardDiscussionWriteSuccess(input: { + target: { mode: "post" | "reply"; groupId?: string; messageId?: string }; + courseCode: string; + discussion: BlackboardDiscussion; + group?: BlackboardDiscussionGroup; + parentMessage?: BlackboardDiscussionMessage; + body: { textFile: BlackboardSubmissionText; preview: string }; + message: BlackboardDiscussionMessage; + verification: { status: string; message: string }; +}): string { + const header = `Blackboard discussion ${input.target.mode === "reply" ? "reply" : "post"} applied · ${input.courseCode} · ${input.discussion.title || input.discussion.id}`; + return [ + header, + `Message ID ${input.message.id} · status ${input.message.status || "unknown"}${input.target.groupId ? ` · group ${input.target.groupId}` : ""}${input.target.messageId ? ` · parent ${input.target.messageId}` : ""}`, + `Text file: ${input.body.textFile.absolutePath}`, + `Body preview: ${input.body.preview || "(empty)"}`, + ...(input.group ? [`Resolved group thread: ${input.group.threadId || "unavailable"}`] : []), + ...(input.parentMessage ? [`Parent message: ${input.parentMessage.id} · ${input.parentMessage.author || input.parentMessage.userId || "unknown author"}`] : []), + `Verification: ${input.verification.status} · ${input.verification.message}`, + ].join("\n"); +} + +export function formatBlackboardMessageFolders(report: BlackboardCourseMessageFoldersPage): string { + const header = `Blackboard message folders · ${report.courseCode || report.courseId} · page ${report.page}` + + `${report.hasMore ? " · more available" : ""}`; + if (report.folders.length === 0) { + return `${header}\nNo message folders returned.`; + } + return [ + header, + ...report.folders.map((folder) => + `${(folder.label || folder.name || "Unnamed folder").padEnd(20)} ${folder.type || "type unavailable"} · unread ${folder.unreadCount} / total ${folder.totalCount}` + + `${folder.name && folder.label !== folder.name ? ` · name ${folder.name}` : ""}`), + ...(report.hasMore ? [`Next page: ${report.nextPage}`] : []), + ].join("\n"); +} + +export function formatBlackboardMessages(report: BlackboardCourseMessagesPage): string { + const header = `Blackboard messages · ${report.courseCode || report.courseId}` + + `${report.folderType ? ` · folder ${report.folderType}` : ""}` + + `${report.folderName ? `/${report.folderName}` : ""}` + + `${report.sort ? ` · sort ${report.sort}` : ""}` + + ` · page ${report.page}` + + `${report.hasMore ? " · more available" : ""}`; + if (report.messages.length === 0) { + return `${header}\nNo course messages returned.`; + } + return [ + header, + ...report.messages.map((message) => [ + `${message.id.padEnd(10)} ${message.subject || "(no subject)"} · ${message.type || "type unavailable"}${message.isRead ? " · read" : " · unread"}${message.isReply ? " · reply" : ""}`, + ` posted ${message.postedDate || "unknown"} · sender ${message.sender?.displayName || message.senderId || "unknown"}` + + `${message.toUsers.length > 0 ? ` · to ${message.toUsers.length}` : ""}` + + `${message.ccUsers.length > 0 ? ` · cc ${message.ccUsers.length}` : ""}` + + `${message.bccUsers.length > 0 ? ` · bcc ${message.bccUsers.length}` : ""}`, + ...(message.attachment?.fileName ? [` attachment ${message.attachment.fileName}${message.attachment.mimeType ? ` · ${message.attachment.mimeType}` : ""}`] : []), + ` ${sampleText(message.body, 240) || "(empty)"}`, + ].join("\n")), + ...(report.hasMore ? [`Next page: ${report.nextPage}`] : []), + ].join("\n"); +} + +export function formatBlackboardMessageParticipants(report: BlackboardCourseMessageParticipantsPage): string { + const header = `Blackboard message participants · ${report.courseCode || report.courseId} · message ${report.messageId}` + + `${report.participationType ? ` · type ${report.participationType}` : ""}` + + `${report.sort ? ` · sort ${report.sort}` : ""}` + + ` · page ${report.page}` + + `${report.hasMore ? " · more available" : ""}`; + if (report.participants.length === 0) { + return `${header}\nNo message participants returned.`; + } + return [ + header, + ...report.participants.map((participant) => + `${participant.userId.padEnd(10)} ${participant.displayName || participant.userId || "Unknown participant"} · ${participant.participationType || "type unavailable"}` + + `${participant.user?.userName ? ` · username ${participant.user.userName}` : ""}`), + ...(report.hasMore ? [`Next page: ${report.nextPage}`] : []), + ].join("\n"); +} + +export function formatBlackboardMessageWritePreview(input: { + target: { courseId: string; subject?: string; toUsers: string[]; ccUsers: string[]; bccUsers: string[] }; + courseCode: string; + courseName: string; + recipients: { + toUsers: Array<{ userId: string; displayName: string; courseRoleId: string }>; + ccUsers: Array<{ userId: string; displayName: string; courseRoleId: string }>; + bccUsers: Array<{ userId: string; displayName: string; courseRoleId: string }>; + }; + body: { textFile: BlackboardSubmissionText; preview: string }; + blockers: readonly { code: string; message: string }[]; + warnings: readonly { code: string; message: string }[]; + applyAllowed: boolean; + confirmation: { available: boolean; command?: string }; +}): string { + const header = `Blackboard message-send preview · ${input.courseCode || input.target.courseId} · ${input.courseName || input.courseCode || input.target.courseId}`; + const lines = [ + header, + `Subject: ${input.target.subject || "(no subject)"}`, + `Text file: ${input.body.textFile.absolutePath}`, + `SHA-256: ${input.body.textFile.sha256} · chars ${input.body.textFile.charCount}`, + `Recipients: to ${input.recipients.toUsers.length} · cc ${input.recipients.ccUsers.length} · bcc ${input.recipients.bccUsers.length}`, + `Body preview: ${input.body.preview || "(empty)"}`, + ]; + for (const [label, recipients] of [ + ["To", input.recipients.toUsers], + ["Cc", input.recipients.ccUsers], + ["Bcc", input.recipients.bccUsers], + ] as const) { + if (recipients.length === 0) continue; + lines.push(`${label}: ${recipients.map((entry) => `${entry.userId} ${entry.displayName} (${entry.courseRoleId || "role unavailable"})`).join(" | ")}`); + } + if (input.blockers.length > 0) { + lines.push(...input.blockers.map((entry) => `Blocker ${entry.code}: ${entry.message}`)); + } + if (input.warnings.length > 0) { + lines.push(...input.warnings.map((entry) => `Warning ${entry.code}: ${entry.message}`)); + } + lines.push( + input.applyAllowed && input.confirmation.available + ? `Apply command: ${input.confirmation.command}` + : "Apply command unavailable until blockers are resolved.", + ); + return lines.join("\n"); +} + +export function formatBlackboardMessageWriteSuccess(input: { + target: { subject?: string }; + courseCode: string; + courseName: string; + body: { textFile: BlackboardSubmissionText; preview: string }; + message: BlackboardCourseMessagesPage["messages"][number]; + verification: { status: string; message: string }; +}): string { + const header = `Blackboard message-send applied · ${input.courseCode} · ${input.courseName || input.courseCode}`; + return [ + header, + `Message ID ${input.message.id} · subject ${input.message.subject || "(no subject)"}`, + `Text file: ${input.body.textFile.absolutePath}`, + `Recipients: to ${input.message.toUsers.length} · cc ${input.message.ccUsers.length} · bcc ${input.message.bccUsers.length}`, + `Body preview: ${input.body.preview || "(empty)"}`, + `Verification: ${input.verification.status} · ${input.verification.message}`, + ].join("\n"); +} + +export function formatBlackboardRoster(report: BlackboardCourseRosterPage): string { + const header = `Blackboard roster · ${report.courseCode || report.courseId}` + + `${report.role ? ` · role ${report.role}` : ""}` + + `${report.availability ? ` · availability ${report.availability}` : ""}` + + `${report.sort ? ` · sort ${report.sort}` : ""}` + + ` · page ${report.page}` + + `${report.hasMore ? " · more available" : ""}`; + if (report.memberships.length === 0) { + return `${header}\nNo course memberships returned.`; + } + return [ + header, + ...report.memberships.map(formatBlackboardMembershipLine), + ...(report.hasMore ? [`Next page: ${report.nextPage}`] : []), + ].join("\n"); +} + +function formatBlackboardMembershipLine(membership: BlackboardCourseMembership): string { + const user = membership.user; + const email = user?.institutionEmail || user?.email; + return `${membership.userId.padEnd(10)} ${(user?.displayName || membership.userId || "Unknown user").padEnd(24)} ${membership.courseRoleId || "role unavailable"}` + + `${membership.availability ? ` · ${membership.availability}` : ""}` + + `${email ? ` · ${email}` : ""}` + + `${membership.lastAccessed ? ` · last accessed ${membership.lastAccessed}` : ""}`; +} + export function formatBlackboardDeadlines(report: BlackboardDeadlineReport): string { if (report.deadlines.length === 0) { const partial = report.failures.length > 0 ? ` (${report.failures.length} failure${report.failures.length === 1 ? "" : "s"})` : ""; - return `Blackboard deadlines${partial}\nNo upcoming assignment deadlines.`; + return report.submissionState + ? `Blackboard deadlines${partial}\nNo upcoming assignment deadlines matched submission state ${report.submissionState}.` + : `Blackboard deadlines${partial}\nNo upcoming assignment deadlines.`; } return [ - `Blackboard deadlines · ${report.deadlines.length}${report.days !== undefined ? ` within ${report.days} day(s)` : ""}${report.failures.length > 0 ? ` · ${report.failures.length} failure(s)` : ""}`, + `Blackboard deadlines · ${report.deadlines.length}${report.days !== undefined ? ` within ${report.days} day(s)` : ""}${report.submissionState ? ` · state ${report.submissionState}` : ""}${report.failures.length > 0 ? ` · ${report.failures.length} failure(s)` : ""}`, ...report.deadlines.map((item) => formatBlackboardDeadlineLine(item)), ].join("\n"); } @@ -567,6 +1252,31 @@ export function formatBlackboardCalendar(report: BlackboardCalendarItemsReport): ].join("\n"); } +export function formatBlackboardTypes(report: BlackboardContentTypesReport): string { + if (report.courses.length === 0) return "Blackboard content types\nNo matching courses."; + return [ + `Blackboard content types · ${report.coursesMatched} course(s) · ${report.totalItems} item(s)`, + `Totals · ${formatBlackboardKindCounts(report.totals)}`, + ...(report.partial ? [`Partial failures · ${report.failures.length}`] : []), + ...report.courses.map((course) => [ + `${course.courseCode || course.courseId} · ${course.courseName || course.courseId}`, + ` ${course.totalItems} item(s) · ${formatBlackboardKindCounts(course.kindCounts)}`, + ].join("\n")), + ].join("\n"); +} + +export function formatBlackboardTree(report: BlackboardContentTreeReport): string { + const header = `Blackboard tree · ${report.courseCode || report.courseId} · ${report.returnedItems} item(s)` + + `${report.rootContentId ? ` · root ${report.rootContentId}` : ""}` + + `${report.truncated ? ` · truncated at ${report.maxItems}` : ""}` + + `${report.failures.length > 0 ? ` · ${report.failures.length} failure(s)` : ""}`; + if (report.entries.length === 0) return `${header}\nNo content returned.`; + return [ + header, + ...report.entries.map((entry) => `${" ".repeat(entry.depth)}- ${entry.kind} · ${entry.title || entry.contentId}${entry.hasChildren ? " [+]" : ""}`), + ].join("\n"); +} + export function formatBlackboardSearch(report: BlackboardSearchReport): string { if (report.results.length === 0) { return `Blackboard search · ${report.query}\nNo matching content.${report.failures.length > 0 ? ` ${report.failures.length} failure(s) recorded.` : ""}`; @@ -612,6 +1322,55 @@ export function formatBlackboardAttempts( ].join("\n"); } +function formatBlackboardAssignmentAttemptSummary( + summary: BlackboardAssignmentsWithAttemptsReport["assignments"][number]["attemptSummary"], +): string { + if (!summary) return "attempt summary unavailable"; + const labels = [ + `state ${summary.state}`, + `attempts ${summary.totalAttempts}`, + summary.submittedAttempts > 0 ? `submitted ${summary.submittedAttempts}` : "", + summary.inProgressAttempts > 0 ? `in-progress ${summary.inProgressAttempts}` : "", + summary.completedAttempts > 0 ? `completed ${summary.completedAttempts}` : "", + summary.latestStatus ? `latest ${summary.latestStatus}` : "", + summary.latestDisplayGradeText ? `grade ${summary.latestDisplayGradeText}` : "", + summary.latestSubmissionDate + ? `submitted at ${summary.latestSubmissionDate}` + : summary.latestAttemptDate + ? `latest activity ${summary.latestAttemptDate}` + : "", + ].filter(Boolean); + return labels.join(" · "); +} + +export function formatBlackboardAttemptFiles( + attemptId: string, + files: readonly BlackboardAttemptFile[], +): string { + if (files.length === 0) { + return `Blackboard attempt files · attempt ${attemptId}\nNo submitted files returned.`; + } + return [ + `Blackboard attempt files · attempt ${attemptId} · ${files.length}`, + ...files.map((file) => `${file.id.padEnd(10)} ${file.name}`), + ].join("\n"); +} + +export function formatBlackboardAttemptFileDownload( + result: BlackboardAttemptFileDownload, + attemptId: string, +): string { + return [ + `Blackboard attempt file downloaded · attempt ${attemptId}`, + `File: ${result.file.name} · ${result.file.id}`, + `Saved to: ${result.destination}`, + `Size: ${result.size} bytes`, + `SHA-256: ${result.sha256}`, + `Content type: ${result.contentType || "unavailable"}`, + `Overwritten: ${result.overwritten ? "yes" : "no"}`, + ].join("\n"); +} + export function formatBlackboardSubmitPreview(input: { target: { courseId: string; contentId?: string; columnId?: string }; assignment: BlackboardAssignment; @@ -619,7 +1378,9 @@ export function formatBlackboardSubmitPreview(input: { attemptsUsed: number; remainingAttempts?: number; inProgressAttempts: number; - file: BlackboardSubmissionFile; + submission: + | { kind: "file"; file: BlackboardSubmissionFile } + | { kind: "text"; textFile: BlackboardSubmissionText }; commentSummary: { present: boolean; length: number }; blockers: readonly { code: string; message: string }[]; warnings: readonly { code: string; message: string }[]; @@ -630,6 +1391,19 @@ export function formatBlackboardSubmitPreview(input: { const attemptsSummary = input.assignment.grading.attemptsAllowed !== undefined && input.assignment.grading.attemptsAllowed > 0 ? `${input.attemptsUsed}/${input.assignment.grading.attemptsAllowed}` : `${input.attemptsUsed}`; + const submissionLines = input.submission.kind === "file" + ? [ + `File: ${input.submission.file.absolutePath}`, + `Filename: ${input.submission.file.name}`, + `Size: ${input.submission.file.size} bytes`, + `SHA-256: ${input.submission.file.sha256}`, + ] + : [ + `Text file: ${input.submission.textFile.absolutePath}`, + `Size: ${input.submission.textFile.size} bytes`, + `Characters: ${input.submission.textFile.charCount}`, + `SHA-256: ${input.submission.textFile.sha256}`, + ]; return [ "Blackboard submission preview — authenticated read-only checks completed; no mutation was performed.", "", @@ -641,10 +1415,7 @@ export function formatBlackboardSubmitPreview(input: { ...(input.assignment.grading.due ? [`Due: ${input.assignment.grading.due}${input.late ? " (past due)" : ""}`] : []), `Attempts used: ${attemptsSummary}${input.remainingAttempts !== undefined ? ` · remaining ${input.remainingAttempts}` : ""}`, `In-progress attempts: ${input.inProgressAttempts}`, - `File: ${input.file.absolutePath}`, - `Filename: ${input.file.name}`, - `Size: ${input.file.size} bytes`, - `SHA-256: ${input.file.sha256}`, + ...submissionLines, ...(input.commentSummary.present ? [`Comment: present (${input.commentSummary.length} chars)`] : []), ...(input.blockers.length > 0 ? ["", "Blockers:", ...input.blockers.map((issue) => `- [${issue.code}] ${issue.message}`)] : []), ...(input.warnings.length > 0 ? ["", "Warnings:", ...input.warnings.map((issue) => `- [${issue.code}] ${issue.message}`)] : []), @@ -657,13 +1428,18 @@ export function formatBlackboardSubmitPreview(input: { export function formatBlackboardSubmissionSuccess(input: { assignment: BlackboardAssignment; + submission: + | { kind: "file"; file: BlackboardSubmissionFile } + | { kind: "text"; textFile: BlackboardSubmissionText }; attempt: BlackboardAttempt; files: readonly BlackboardAttemptFile[]; verification: { status: "confirmed" | "not_observed" | "unavailable"; message: string }; }): string { - const fileLine = input.files.length > 0 - ? input.files.map((file) => file.name).join(", ") - : "No files were read back."; + const submissionLine = input.submission.kind === "file" + ? input.files.length > 0 + ? `Files: ${input.files.map((file) => file.name).join(", ")}` + : "Files: No files were read back." + : `Text file: ${input.submission.textFile.absolutePath} · ${input.submission.textFile.charCount} chars`; return [ input.verification.status === "confirmed" ? "Blackboard submission confirmed by read-back." @@ -671,7 +1447,7 @@ export function formatBlackboardSubmissionSuccess(input: { `Assignment: ${input.assignment.title}`, `Attempt: ${input.attempt.id}`, `Status: ${input.attempt.status || "unknown"}`, - `Files: ${fileLine}`, + submissionLine, ...(input.attempt.attemptReceipt ? [`Receipt: ${input.attempt.attemptReceipt.receiptId} · ${input.attempt.attemptReceipt.submissionDate}`] : []), @@ -684,9 +1460,29 @@ function formatBlackboardDeadlineLine(item: BlackboardDeadline): string { return [ `${item.courseCode.padEnd(12)} ${item.title}`, ` due ${item.dueAt} · in ${item.daysLeft} day(s) · content ${item.contentId} · column ${item.columnId}`, + ...(item.attemptSummary ? [` ${formatBlackboardAssignmentAttemptSummary(item.attemptSummary)}`] : []), ].join("\n"); } +function formatBlackboardAnnouncement(item: BlackboardAnnouncement): string { + const owner = item.source === "system" + ? `system${item.showAtLogin ? " · login" : ""}${item.showInCourses ? " · courses" : ""}` + : [item.courseCode, item.courseName].filter(Boolean).join(" · ") || item.courseId || "course"; + const timing = item.modified || item.created || "time unavailable"; + const snippet = item.body.length <= 160 ? item.body : `${item.body.slice(0, 157)}...`; + return [ + `${timing} · ${owner} · ${item.title || "Untitled announcement"}`, + ` ${snippet || "No body returned."}`, + ].join("\n"); +} + +function formatBlackboardKindCounts( + counts: readonly { kind: string; count: number }[], +): string { + if (counts.length === 0) return "no content"; + return counts.map((entry) => `${entry.kind} ${entry.count}`).join(" · "); +} + export function formatWsPrograms(programs: readonly WsProgramSummary[]): string { if (programs.length === 0) return "SUSTech Global programs\nNo matching programs."; return [ diff --git a/src/sso/cas.ts b/src/sso/cas.ts index 9617202..69e7217 100644 --- a/src/sso/cas.ts +++ b/src/sso/cas.ts @@ -218,10 +218,14 @@ export class CasSession { } const response = await this.requestRaw(url.toString(), init); if (!response.ok) { + const bodySample = await response.clone().text() + .then((text) => collapseText(text).slice(0, 160)) + .catch(() => undefined); throw new CliError(`${this.config.name} request failed.`, "SERVICE_HTTP_ERROR", 1, { service: this.config.name, path: url.pathname, status: response.status, + ...(bodySample ? { bodySample } : {}), }); } return response; @@ -310,6 +314,10 @@ function singleSetCookie(headers: Headers): string[] { return value ? [value] : []; } +function collapseText(value: string): string { + return value.replace(/\s+/gu, " ").trim(); +} + function domainMatches(hostname: string, domain: string): boolean { return hostname === domain || hostname.endsWith(`.${domain}`); } diff --git a/src/test/argv.test.ts b/src/test/argv.test.ts index f091772..cde9f33 100644 --- a/src/test/argv.test.ts +++ b/src/test/argv.test.ts @@ -20,6 +20,17 @@ test("command inference skips option values in machine-readable errors", () => { assert.equal(inferCommandName(["tis", "degree", "missing", "--semester", "2026-2027-1", "--json"]), "tis degree missing"); assert.equal(inferCommandName(["bb", "calendar", "--since", "2026-08-01T00:00:00Z", "--json"]), "bb calendar"); assert.equal(inferCommandName(["bb", "calendar-link", "show", "--reveal", "--json"]), "bb calendar-link show"); + assert.equal(inferCommandName(["bb", "roster", "_8343_1", "--role", "Student", "--json"]), "bb roster"); + assert.equal(inferCommandName(["bb", "message-folders", "_8343_1", "--json"]), "bb message-folders"); + assert.equal(inferCommandName(["bb", "messages", "_8343_1", "--folder-type", "Inbox", "--json"]), "bb messages"); + assert.equal(inferCommandName(["bb", "message-participants", "_8343_1", "_71_1", "--json"]), "bb message-participants"); + assert.equal(inferCommandName(["bb", "message-send", "preview", "_8343_1", "--to-user", "_1_1", "--text-file", "/tmp/msg.txt", "--json"]), "bb message-send preview"); + assert.equal(inferCommandName(["bb", "discussions", "_8343_1", "--page", "2", "--json"]), "bb discussions"); + assert.equal(inferCommandName(["bb", "discussion-groups", "_8343_1", "_65_1", "--json"]), "bb discussion-groups"); + assert.equal(inferCommandName(["bb", "discussion", "_8343_1", "_65_1", "--status", "Published", "--json"]), "bb discussion"); + assert.equal(inferCommandName(["bb", "discussion-replies", "_8343_1", "_65_1", "_71_1", "--json"]), "bb discussion-replies"); + assert.equal(inferCommandName(["bb", "discussion-post", "preview", "_8343_1", "_65_1", "--text-file", "/tmp/post.txt", "--json"]), "bb discussion-post preview"); + assert.equal(inferCommandName(["bb", "discussion-reply", "apply", "_8343_1", "_65_1", "_71_1", "--text-file", "/tmp/reply.txt", "--expected-sha256", "a".repeat(64), "--confirm", "--json"]), "bb discussion-reply apply"); assert.equal(inferCommandName(["academic", "snapshot", "save", "--destination", "/tmp/state.json", "--json"]), "academic snapshot save"); assert.equal(inferCommandName(["academic", "snapshot", "diff", "before.json", "after.json", "--json"]), "academic snapshot diff"); assert.equal(inferCommandName(["academic", "changes", "before.json", "after.json", "--json"]), "academic changes"); diff --git a/src/test/auth.test.ts b/src/test/auth.test.ts index 51c5aea..c3e1330 100644 --- a/src/test/auth.test.ts +++ b/src/test/auth.test.ts @@ -1,8 +1,12 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { authenticateBlackboardBrowserSession, authenticateCredentials } from "../core/auth-check.js"; import { CliError } from "../core/errors.js"; +import { BLACKBOARD_BROWSER_AUTH_POLICY, type BlackboardBrowserRuntime, type BlackboardBrowserSession } from "../services/blackboard-browser.js"; import { TisSession } from "../tis/auth.js"; +const credentials = { sid: "12345678", password: "secret", source: "environment" as const }; + test("CAS authentication refuses redirects outside SUSTech HTTPS hosts", async () => { const original = globalThis.fetch; let calls = 0; @@ -32,3 +36,141 @@ test("CAS authentication refuses redirects outside SUSTech HTTPS hosts", async ( globalThis.fetch = original; } }); + +test("Blackboard auth checks read the official users/me endpoint before succeeding", async () => { + const original = globalThis.fetch; + const requests: string[] = []; + const serviceUrl = "https://bb.sustech.edu.cn/webapps/bb-sso-BBLEARN/index.jsp"; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + const method = init?.method ?? "GET"; + requests.push(`${method} ${url}`); + if (url.startsWith("https://cas.sustech.edu.cn/cas/login?") && method === "GET") { + return responseWithUrl('
', 200, url, { + "content-type": "text/html", + }); + } + if (url.startsWith("https://cas.sustech.edu.cn/cas/login?") && method === "POST") { + return responseWithUrl(null, 302, url, { location: serviceUrl }); + } + if (url === serviceUrl) return responseWithUrl("signed in", 200, url); + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/me") { + return responseWithUrl(JSON.stringify({ + id: "_1_1", + userName: "12200000", + name: "Student Name", + }), 200, url, { + "content-type": "application/json", + }); + } + throw new Error(`Unexpected URL ${url}`); + }) as typeof fetch; + + try { + const result = await authenticateCredentials(credentials, "bb"); + assert.deepEqual(result, { + authenticated: true, + credentialSource: "environment", + identity: "Student Name", + }); + assert.deepEqual(requests, [ + `GET ${new URL(`https://cas.sustech.edu.cn/cas/login?service=${encodeURIComponent(serviceUrl)}`).toString()}`, + `POST ${new URL(`https://cas.sustech.edu.cn/cas/login?service=${encodeURIComponent(serviceUrl)}`).toString()}`, + `GET ${serviceUrl}`, + "GET https://bb.sustech.edu.cn/learn/api/public/v1/users/me", + ]); + } finally { + globalThis.fetch = original; + } +}); + +test("Blackboard auth checks fail when the REST user read is unavailable after CAS login", async () => { + const original = globalThis.fetch; + const requests: string[] = []; + const serviceUrl = "https://bb.sustech.edu.cn/webapps/bb-sso-BBLEARN/index.jsp"; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + const method = init?.method ?? "GET"; + requests.push(`${method} ${url}`); + if (url.startsWith("https://cas.sustech.edu.cn/cas/login?") && method === "GET") { + return responseWithUrl('
', 200, url, { + "content-type": "text/html", + }); + } + if (url.startsWith("https://cas.sustech.edu.cn/cas/login?") && method === "POST") { + return responseWithUrl(null, 302, url, { location: serviceUrl }); + } + if (url === serviceUrl) return responseWithUrl("signed in", 200, url); + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/me") { + return responseWithUrl("upstream unavailable", 503, url, { + "content-type": "text/plain", + }); + } + throw new Error(`Unexpected URL ${url}`); + }) as typeof fetch; + + try { + await assert.rejects( + authenticateCredentials(credentials, "bb"), + (error: unknown) => { + assert.ok(error instanceof CliError); + assert.equal(error.code, "SERVICE_HTTP_ERROR"); + assert.equal(error.details?.bodySample, "upstream unavailable"); + return true; + }, + ); + assert.equal(requests.at(-1), "GET https://bb.sustech.edu.cn/learn/api/public/v1/users/me"); + } finally { + globalThis.fetch = original; + } +}); + +test("Blackboard browser auth checks read the official users/me endpoint without stored credentials", async () => { + const requests: string[] = []; + const result = await authenticateBlackboardBrowserSession({ + interactive: true, + runtime: new FakeBlackboardRuntime(), + fetchImpl: async (input, init) => { + const url = String(input); + requests.push(`${init?.method ?? "GET"} ${url}`); + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/me") { + return responseWithUrl(JSON.stringify({ + id: "_1_1", + userName: "12200000", + name: "Student Name", + }), 200, url, { + "content-type": "application/json", + }); + } + throw new Error(`Unexpected URL ${url}`); + }, + }); + + assert.deepEqual(result, { + authenticated: true, + credentialSource: "browser-session", + identity: "Student Name", + }); + assert.deepEqual(requests, ["GET https://bb.sustech.edu.cn/learn/api/public/v1/users/me"]); +}); + +class FakeBlackboardRuntime implements BlackboardBrowserRuntime { + public async authenticate(): Promise { + return { + authenticatedUrl: "https://bb.sustech.edu.cn/ultra/institution-page", + cookies: [{ name: "BbRouter", value: "cookie-1", domain: "bb.sustech.edu.cn", path: "/", secure: true }], + authentication: BLACKBOARD_BROWSER_AUTH_POLICY, + }; + } +} + +function responseWithUrl( + body: string | null, + status: number, + url: string, + headers?: Record, +): Response { + const response = new Response(body, { status, headers }); + Object.defineProperty(response, "url", { value: url }); + return response; +} diff --git a/src/test/blackboard_announcements.test.ts b/src/test/blackboard_announcements.test.ts new file mode 100644 index 0000000..46e8f39 --- /dev/null +++ b/src/test/blackboard_announcements.test.ts @@ -0,0 +1,400 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { listBlackboardAnnouncements, nextBlackboardAnnouncement } from "../services/blackboard.js"; +import type { ServiceAdapter } from "../services/base.js"; + +test("Blackboard announcements merge system and course items, sort newest first, and strip HTML", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/announcements") { + return jsonResponse({ + results: [{ + id: "_500_1", + title: "System alert", + body: "

Bring & ID

", + created: "2026-08-25T00:00:00.000Z", + modified: "2026-08-25T09:00:00.000Z", + availability: { duration: { type: "Permanent" } }, + }], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/me") { + return jsonResponse({ id: "_1_1", userName: "12200000", name: "Student Name" }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/_1_1/courses") { + return jsonResponse({ results: [{ courseId: "_8343_1", courseRoleId: "Student" }] }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1") { + return jsonResponse({ + id: "_8343_1", + name: "Physical Chemistry", + courseCode: "CHEM201", + externalId: "CHEM201-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/announcements") { + return jsonResponse({ + results: [ + { + id: "_601_1", + title: "Week 2", + body: "
Slides ready
", + created: "2026-08-26T08:00:00.000Z", + modified: "2026-08-26T08:00:00.000Z", + availability: { duration: { type: "Restricted", start: "2026-08-26T00:00:00.000Z" } }, + }, + { + id: "_602_1", + title: "Alpha", + body: "

A

", + created: "2026-08-24T08:00:00.000Z", + modified: "2026-08-24T08:00:00.000Z", + availability: { duration: { type: "Permanent" } }, + }, + { + id: "_603_1", + title: "Beta", + body: "

B

", + created: "2026-08-24T08:00:00.000Z", + modified: "2026-08-24T08:00:00.000Z", + availability: { duration: { type: "Permanent" } }, + }, + ], + }); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const report = await listBlackboardAnnouncements(adapter, { + now: new Date("2026-08-26T12:00:00.000Z"), + }); + + assert.equal(report.partial, false); + assert.equal(report.systemAnnouncements, 1); + assert.equal(report.courseAnnouncements, 3); + assert.equal(report.coursesMatched, 1); + assert.equal(report.coursesScanned, 1); + assert.deepEqual( + report.announcements.map((announcement) => announcement.title), + ["Week 2", "System alert", "Alpha", "Beta"], + ); + assert.equal(report.announcements[0]?.body, "Slides ready"); + assert.equal(report.announcements[0]?.source, "course"); + assert.equal(report.announcements[0]?.courseId, "_8343_1"); + assert.equal(report.announcements[0]?.courseCode, "CHEM201"); + assert.equal(report.announcements[0]?.courseName, "Physical Chemistry"); + assert.equal(report.announcements[1]?.body, "Bring & ID"); + assert.equal(report.announcements[1]?.source, "system"); + assert.equal(nextBlackboardAnnouncement(report)?.id, "601"); +}); + +test("Blackboard announcements respect the days window using modified then created timestamps", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/announcements") { + return jsonResponse({ + results: [ + { + id: "_500_1", + title: "Boundary", + body: "included", + created: "2026-08-20T00:00:00.000Z", + modified: "2026-08-24T12:00:00.000Z", + }, + { + id: "_501_1", + title: "Too old", + body: "excluded", + created: "2026-08-20T00:00:00.000Z", + modified: "2026-08-24T11:59:59.000Z", + }, + ], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/me") { + return jsonResponse({ id: "_1_1", userName: "12200000", name: "Student Name" }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/_1_1/courses") { + return jsonResponse({ results: [{ courseId: "_8343_1", courseRoleId: "Student" }] }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1") { + return jsonResponse({ + id: "_8343_1", + name: "Physical Chemistry", + courseCode: "CHEM201", + externalId: "CHEM201-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/announcements") { + return jsonResponse({ + results: [ + { + id: "_601_1", + title: "Created fallback", + body: "included", + created: "2026-08-24T13:00:00.000Z", + modified: "", + }, + { + id: "_602_1", + title: "No parseable time", + body: "excluded", + created: "not-a-date", + modified: "", + }, + ], + }); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const report = await listBlackboardAnnouncements(adapter, { + now: new Date("2026-08-26T12:00:00.000Z"), + days: 2, + }); + + assert.equal(report.days, 2); + assert.deepEqual( + report.announcements.map((announcement) => announcement.title), + ["Created fallback", "Boundary"], + ); +}); + +test("Blackboard announcements skip system announcements when courseQuery is set", async () => { + let systemCalls = 0; + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/announcements") { + systemCalls += 1; + throw new Error(`System announcements should be skipped when filtering courses: ${url}`); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/me") { + return jsonResponse({ id: "_1_1", userName: "12200000", name: "Student Name" }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/_1_1/courses") { + return jsonResponse({ + results: [ + { courseId: "_8343_1", courseRoleId: "Student" }, + { courseId: "_9000_1", courseRoleId: "Student" }, + ], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1") { + return jsonResponse({ + id: "_8343_1", + name: "Physical Chemistry", + courseCode: "CHEM201", + externalId: "CHEM201-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_9000_1") { + return jsonResponse({ + id: "_9000_1", + name: "Advanced Writing", + courseCode: "HUMN201", + externalId: "HUMN201-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/announcements") { + return jsonResponse({ + results: [{ + id: "_601_1", + title: "Lab reminder", + body: "bring notebook", + created: "2026-08-26T08:00:00.000Z", + modified: "2026-08-26T08:00:00.000Z", + }], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_9000_1/announcements") { + throw new Error(`Unmatched course should not be scanned: ${url}`); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const report = await listBlackboardAnnouncements(adapter, { + now: new Date("2026-08-26T12:00:00.000Z"), + courseQuery: "chem201", + }); + + assert.equal(systemCalls, 0); + assert.equal(report.courseQuery, "chem201"); + assert.equal(report.systemAnnouncements, 0); + assert.equal(report.coursesMatched, 1); + assert.equal(report.coursesScanned, 1); + assert.deepEqual(report.announcements.map((announcement) => announcement.title), ["Lab reminder"]); + assert.equal(report.announcements[0]?.source, "course"); +}); + +test("Blackboard announcements keep successes and record per-course failures as partial", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/announcements") { + return jsonResponse({ results: [] }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/me") { + return jsonResponse({ id: "_1_1", userName: "12200000", name: "Student Name" }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/_1_1/courses") { + return jsonResponse({ + results: [ + { courseId: "_8343_1", courseRoleId: "Student" }, + { courseId: "_9000_1", courseRoleId: "Student" }, + ], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1") { + return jsonResponse({ + id: "_8343_1", + name: "Physical Chemistry", + courseCode: "CHEM201", + externalId: "CHEM201-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_9000_1") { + return jsonResponse({ + id: "_9000_1", + name: "Materials Science", + courseCode: "MSE201", + externalId: "MSE201-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/announcements") { + return jsonResponse({ + results: [{ + id: "_601_1", + title: "Week 2", + body: "all good", + created: "2026-08-26T08:00:00.000Z", + modified: "2026-08-26T08:00:00.000Z", + }], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_9000_1/announcements") { + return jsonResponse({ message: "upstream unavailable" }, 503); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const report = await listBlackboardAnnouncements(adapter, { + now: new Date("2026-08-26T12:00:00.000Z"), + }); + + assert.equal(report.partial, true); + assert.equal(report.coursesMatched, 2); + assert.equal(report.coursesScanned, 2); + assert.equal(report.courseAnnouncements, 1); + assert.equal(report.announcements.length, 1); + assert.equal(report.announcements[0]?.title, "Week 2"); + assert.equal(report.failures.length, 1); + assert.deepEqual(report.failures[0], { + stage: "announcements", + message: "Upstream service returned an HTTP error.", + code: "SERVICE_ERROR", + status: 503, + courseId: "_9000_1", + courseCode: "MSE201", + courseName: "Materials Science", + }); +}); + +test("Blackboard announcements skip courses whose announcement tool is unavailable or unauthorized", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/announcements") { + return jsonResponse({ results: [] }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/me") { + return jsonResponse({ id: "_1_1", userName: "12200000", name: "Student Name" }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/_1_1/courses") { + return jsonResponse({ + results: [ + { courseId: "_8343_1", courseRoleId: "Student" }, + { courseId: "_9000_1", courseRoleId: "Student" }, + { courseId: "_9100_1", courseRoleId: "Student" }, + ], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1") { + return jsonResponse({ + id: "_8343_1", + name: "Physical Chemistry", + courseCode: "CHEM201", + externalId: "CHEM201-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_9000_1") { + return jsonResponse({ + id: "_9000_1", + name: "Materials Science", + courseCode: "MSE201", + externalId: "MSE201-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_9100_1") { + return jsonResponse({ + id: "_9100_1", + name: "Algorithms", + courseCode: "CS208", + externalId: "CS208-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/announcements") { + return jsonResponse({ + results: [{ + id: "_601_1", + title: "Week 2", + body: "all good", + created: "2026-08-26T08:00:00.000Z", + modified: "2026-08-26T08:00:00.000Z", + }], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_9000_1/announcements") { + return jsonResponse({ + status: 400, + message: "The announcement tool for current course is not available!", + }, 400); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_9100_1/announcements") { + return jsonResponse({ + status: 403, + message: "Current user doesn't have corresponding permission. (course.announcements.VIEW)", + }, 403); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const report = await listBlackboardAnnouncements(adapter, { + now: new Date("2026-08-26T12:00:00.000Z"), + }); + + assert.equal(report.partial, false); + assert.equal(report.coursesMatched, 3); + assert.equal(report.coursesScanned, 3); + assert.equal(report.courseAnnouncements, 1); + assert.equal(report.announcements.length, 1); + assert.equal(report.announcements[0]?.courseCode, "CHEM201"); + assert.deepEqual(report.failures, []); +}); + +function routeAdapter(route: (url: string, init?: RequestInit) => Response | Promise): ServiceAdapter { + return { + name: "fixture", + fetch(input: string, init?: RequestInit): Promise { + return Promise.resolve(route(String(input), init)); + }, + }; +} + +function jsonResponse(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json" }, + }); +} diff --git a/src/test/blackboard_browser.test.ts b/src/test/blackboard_browser.test.ts new file mode 100644 index 0000000..0c69fa1 --- /dev/null +++ b/src/test/blackboard_browser.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + BLACKBOARD_BROWSER_AUTH_POLICY, + createBlackboardBrowserAdapter, + type BlackboardBrowserCookie, + type BlackboardBrowserOptions, + type BlackboardBrowserRuntime, + type BlackboardBrowserSession, +} from "../services/blackboard-browser.js"; + +test("Blackboard browser auth policy is manual-only and ephemeral", () => { + assert.deepEqual(BLACKBOARD_BROWSER_AUTH_POLICY, { + mode: "human-only", + credentialsAcceptedByCli: false, + challengeAutomation: false, + cookiesPersisted: false, + retryPolicy: "DO_NOT_RETRY_AUTOMATICALLY", + }); +}); + +test("Blackboard browser adapter reuses authenticated cookies for same-origin GET reads", async () => { + let seenUrl = ""; + let seenHeaders = new Headers(); + let seenMethod = ""; + const runtime = new FakeBlackboardRuntime({ + cookies: [{ name: "BbRouter", value: "cookie-1", domain: "bb.sustech.edu.cn", path: "/", secure: true }], + }); + const adapter = await createBlackboardBrowserAdapter( + { interactive: true }, + runtime, + async (input, init) => { + seenUrl = String(input); + seenHeaders = new Headers(init?.headers); + seenMethod = String(init?.method ?? "GET"); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + ); + + const response = await adapter.fetch("https://bb.sustech.edu.cn/learn/api/public/v1/users/me", { + headers: { accept: "application/json" }, + }); + + assert.equal(runtime.lastOptions?.interactive, true); + assert.equal(seenUrl, "https://bb.sustech.edu.cn/learn/api/public/v1/users/me"); + assert.equal(seenMethod, "GET"); + assert.equal(seenHeaders.get("accept"), "application/json"); + assert.match(seenHeaders.get("cookie") ?? "", /BbRouter=cookie-1/u); + assert.deepEqual(await response.json(), { ok: true }); +}); + +test("Blackboard browser adapter rejects cross-origin or non-GET requests", async () => { + const runtime = new FakeBlackboardRuntime({ + cookies: [{ name: "BbRouter", value: "cookie-1", domain: "bb.sustech.edu.cn", path: "/", secure: true }], + }); + const adapter = await createBlackboardBrowserAdapter( + {}, + runtime, + async () => new Response("ok", { status: 200 }), + ); + + await assert.rejects( + adapter.fetch("https://evil.example/learn/api/public/v1/users/me"), + (error: unknown) => Boolean(error && typeof error === "object" && "code" in error && error.code === "UNSAFE_SERVICE_URL"), + ); + + await assert.rejects( + adapter.fetch("https://bb.sustech.edu.cn/learn/api/public/v1/users/me", { method: "POST" }), + (error: unknown) => Boolean(error && typeof error === "object" && "code" in error && error.code === "BROWSER_METHOD_BLOCKED"), + ); +}); + +class FakeBlackboardRuntime implements BlackboardBrowserRuntime { + public lastOptions?: BlackboardBrowserOptions; + + public constructor(private readonly fixtures: { + cookies: BlackboardBrowserCookie[]; + }) {} + + public async authenticate(options?: BlackboardBrowserOptions): Promise { + this.lastOptions = options; + return { + authenticatedUrl: "https://bb.sustech.edu.cn/ultra/institution-page", + cookies: this.fixtures.cookies, + authentication: BLACKBOARD_BROWSER_AUTH_POLICY, + }; + } +} diff --git a/src/test/blackboard_discussion_write.test.ts b/src/test/blackboard_discussion_write.test.ts new file mode 100644 index 0000000..ab34922 --- /dev/null +++ b/src/test/blackboard_discussion_write.test.ts @@ -0,0 +1,290 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildBlackboardDiscussionWritePreflight, + type BlackboardDiscussionWriteInput, + type BlackboardDiscussionWriteTarget, +} from "../cli.js"; +import { + createBlackboardDiscussionMessage, + createBlackboardDiscussionReply, +} from "../services/blackboard.js"; +import type { ServiceAdapter } from "../services/base.js"; + +test("Blackboard discussion write helpers follow the official discussion message and reply endpoints", async () => { + const calls: string[] = []; + const adapter = routeAdapter((url, init) => { + calls.push(`${init?.method ?? "GET"} ${url}`); + + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/discussions/_65_1/messages" && init?.method === "POST") { + assert.equal(new Headers(init.headers).get("content-type"), "application/json"); + assert.deepEqual(JSON.parse(String(init.body)), { + body: "Hello everyone", + groupId: "_778_1", + status: "Published", + }); + return jsonResponse({ + id: "_8801_1", + discussionId: "_65_1", + parentId: "", + threadId: "_8801_1", + userId: "_1_1", + groupId: "_778_1", + givenName: "Alice", + familyName: "Student", + status: "Published", + body: "

Hello everyone

", + postDate: "2026-09-03T10:00:00.000Z", + createdDate: "2026-09-03T10:00:00.000Z", + modifiedDate: "2026-09-03T10:00:00.000Z", + isRead: false, + }, 201); + } + + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/discussions/_65_1/messages/_8801_1/replies" && init?.method === "POST") { + assert.equal(new Headers(init.headers).get("content-type"), "application/json"); + assert.deepEqual(JSON.parse(String(init.body)), { + body: "Thanks!", + status: "Draft", + }); + return jsonResponse({ + id: "_8802_1", + discussionId: "_65_1", + parentId: "_8801_1", + threadId: "_8801_1", + userId: "_1_1", + groupId: "", + givenName: "Alice", + familyName: "Student", + status: "Draft", + body: "Thanks!", + postDate: "2026-09-03T10:01:00.000Z", + createdDate: "2026-09-03T10:01:00.000Z", + modifiedDate: "2026-09-03T10:01:00.000Z", + isRead: false, + }, 201); + } + + throw new Error(`Unexpected URL ${url}`); + }); + + const created = await createBlackboardDiscussionMessage(adapter, "8343", "65", { + body: "Hello everyone", + groupId: "778", + status: "Published", + }); + assert.deepEqual(created, { + id: "8801", + discussionId: "65", + parentId: "", + threadId: "8801", + userId: "_1_1", + groupId: "778", + givenName: "Alice", + familyName: "Student", + author: "Alice Student", + status: "Published", + body: "Hello everyone", + postDate: "2026-09-03T10:00:00.000Z", + editDate: "", + createdDate: "2026-09-03T10:00:00.000Z", + modifiedDate: "2026-09-03T10:00:00.000Z", + isRead: false, + source: "learn-rest", + }); + + const reply = await createBlackboardDiscussionReply(adapter, "8343", "65", "8801", { + body: "Thanks!", + status: "Draft", + }); + assert.deepEqual(reply, { + id: "8802", + discussionId: "65", + parentId: "8801", + threadId: "8801", + userId: "_1_1", + groupId: "", + givenName: "Alice", + familyName: "Student", + author: "Alice Student", + status: "Draft", + body: "Thanks!", + postDate: "2026-09-03T10:01:00.000Z", + editDate: "", + createdDate: "2026-09-03T10:01:00.000Z", + modifiedDate: "2026-09-03T10:01:00.000Z", + isRead: false, + source: "learn-rest", + }); + + assert.deepEqual(calls, [ + "POST https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/discussions/_65_1/messages", + "POST https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/discussions/_65_1/messages/_8801_1/replies", + ]); +}); + +test("Blackboard discussion write helpers reject blank text before network access", async () => { + let called = false; + const adapter = routeAdapter(() => { + called = true; + return jsonResponse({}); + }); + + await assert.rejects( + createBlackboardDiscussionMessage(adapter, "8343", "65", { body: " \n\t " }), + hasCode("BLACKBOARD_DISCUSSION_TEXT_EMPTY"), + ); + assert.equal(called, false); +}); + +test("Blackboard discussion-post preflight fails closed for Original HTML fallback discussions", async () => { + const preflight = await buildBlackboardDiscussionWritePreflight( + originalDiscussionWritePreviewAdapter(1), + {}, + { + mode: "post", + courseId: "_8537_1", + discussionId: "28850", + status: "Published", + } satisfies BlackboardDiscussionWriteTarget, + sampleDiscussionWriteInput("课程讨论回复"), + ); + + assert.equal(preflight.discussion.source, "original-html"); + assert.equal(preflight.applyAllowed, false); + assert.equal(preflight.confirmation.available, false); + assert.equal(preflight.confirmation.expectedSha256, "abc123"); + assert.ok(preflight.blockers.some((entry) => entry.code === "REST_SURFACE_REQUIRED")); + assert.match( + preflight.blockers.find((entry) => entry.code === "REST_SURFACE_REQUIRED")?.message ?? "", + /Original HTML fallback/u, + ); + assert.equal(preflight.confirmation.command, undefined); +}); + +test("Blackboard discussion-reply preflight fails closed for Original HTML fallback parent messages", async () => { + const preflight = await buildBlackboardDiscussionWritePreflight( + originalDiscussionWritePreviewAdapter(100), + {}, + { + mode: "reply", + courseId: "_8537_1", + discussionId: "28850", + messageId: "133567", + status: "Published", + } satisfies BlackboardDiscussionWriteTarget, + sampleDiscussionWriteInput("我会准备好。"), + ); + + assert.equal(preflight.discussion.source, "original-html"); + assert.equal(preflight.parentMessage?.id, "133567"); + assert.equal(preflight.parentMessage?.source, "original-html"); + assert.equal(preflight.applyAllowed, false); + assert.equal(preflight.confirmation.available, false); + assert.ok(preflight.blockers.some((entry) => entry.code === "REST_SURFACE_REQUIRED")); +}); + +function routeAdapter(route: (url: string, init?: RequestInit) => Response | Promise): ServiceAdapter { + return { + name: "blackboard-discussion-write", + fetch(input: string, init?: RequestInit): Promise { + return Promise.resolve(route(String(input), init)); + }, + }; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function hasCode(code: string): (error: unknown) => boolean { + return (error: unknown) => Boolean(error && typeof error === "object" && "code" in error && error.code === code); +} + +function sampleDiscussionWriteInput(body: string): BlackboardDiscussionWriteInput { + return { + textFile: { + path: "discussion.txt", + absolutePath: "/tmp/discussion.txt", + size: Buffer.byteLength(body, "utf8"), + sha256: "abc123", + charCount: body.length, + }, + body, + }; +} + +function originalDiscussionWritePreviewAdapter(messageLimit: 1 | 100): ServiceAdapter { + return routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8537_1") { + return jsonResponse({ + id: "_8537_1", + name: "专业实习(2026夏)", + courseCode: "BMEB470", + externalId: "BMEB470-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8537_1/discussions/_28850_1") { + return jsonResponse({ status: 400, message: "Original courses are not supported by this API" }, 400); + } + if (url === `https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8537_1/discussions/_28850_1/messages?offset=0&limit=${messageLimit}`) { + return jsonResponse({ status: 400, message: "Original courses are not supported by this API" }, 400); + } + if (url === "https://bb.sustech.edu.cn/webapps/blackboard/content/launchLink.jsp?course_id=_8537_1&tool_id=_142_1&tool_type=TOOL&mode=reset") { + return textResponse(` + + + +
+ Displaying 1 to 1 of 1 items +
+ + + + + + +
Choose TopicDescription:
Pick a topic and post your report.
Total Posts: 4
+ + + `, "text/html"); + } + if (url === "https://bb.sustech.edu.cn/webapps/discussionboard/do/forum?action=list_threads&course_id=_8537_1&nav=discussion_board_entry&conf_id=_20961_1&forum_id=_28850_1&forum_view=list&showAll=true") { + return textResponse(` + + +
+ Displaying 1 to 1 of 1 items +
+ + + + + + + + + + +
第一周课前思考日期: 20-12-28 下午5:30作者: 匿名状态: 已发布未读帖子: 1未读对我的回复: 0帖子总数: 1
+ + + `, "text/html"); + } + if (url === "https://bb.sustech.edu.cn/webapps/discussionboard/do/message?action=message_frame&course_id=_8537_1&nav=discussion_board_entry&conf_id=_20961_1&forum_id=_28850_1&message_id=_133567_1&thread_id=_133567_1") { + return textResponse(`

请大家先思考课程目标。

`, "text/html"); + } + throw new Error(`Unexpected URL ${url}`); + }); +} + +function textResponse(body: string, contentType: string): Response { + return new Response(body, { + status: 200, + headers: { "content-type": contentType }, + }); +} diff --git a/src/test/blackboard_discussions.test.ts b/src/test/blackboard_discussions.test.ts new file mode 100644 index 0000000..5e2af59 --- /dev/null +++ b/src/test/blackboard_discussions.test.ts @@ -0,0 +1,939 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { CliError } from "../core/errors.js"; +import { + getBlackboardDiscussion, + getBlackboardDiscussionMessages, + listBlackboardDiscussionGroups, + listBlackboardDiscussionReplies, + listBlackboardDiscussions, +} from "../services/blackboard.js"; +import type { ServiceAdapter } from "../services/base.js"; +import { + formatBlackboardDiscussion, + formatBlackboardDiscussionGroups, + formatBlackboardDiscussionReplies, + formatBlackboardDiscussions, +} from "../services/text.js"; + +test("Blackboard discussions normalize course forums and paging metadata", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1") { + return jsonResponse({ + id: "_8343_1", + name: "Physical Chemistry", + courseCode: "CHEM201", + externalId: "CHEM201-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/discussions?offset=25&limit=25&title=Lab&gradable=true&sort=modifiedDate%28desc%29") { + return jsonResponse({ + results: [ + { + id: "_65_1", + title: "Lab board", + available: true, + gradable: true, + gradebookColumnId: "_991_1", + groupDiscussion: false, + createdDate: "2026-08-20T08:00:00.000Z", + modifiedDate: "2026-08-26T08:30:00.000Z", + topic: { + id: "_70_1", + discussionId: "_65_1", + parentId: "", + threadId: "_70_1", + userId: "_7_1", + givenName: "Alice", + familyName: "Chen", + status: "Published", + body: "

Start here

", + postDate: "2026-08-20T08:00:00.000Z", + editDate: "", + createdDate: "2026-08-20T08:00:00.000Z", + modifiedDate: "2026-08-20T08:00:00.000Z", + isRead: true, + }, + }, + { + id: "_66_1", + title: "Group project Q&A", + available: false, + gradable: false, + groupDiscussion: true, + createdDate: "2026-08-18T10:00:00.000Z", + modifiedDate: "2026-08-24T10:00:00.000Z", + }, + ], + paging: { + nextPage: "/learn/api/public/v1/courses/_8343_1/discussions?offset=50&limit=25", + }, + }); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const report = await listBlackboardDiscussions(adapter, { + courseId: "8343", + title: "Lab", + gradable: true, + page: 2, + pageSize: 25, + sort: "modifiedDate(desc)", + }); + + assert.equal(report.courseId, "_8343_1"); + assert.equal(report.courseCode, "CHEM201"); + assert.equal(report.courseName, "Physical Chemistry"); + assert.equal(report.page, 2); + assert.equal(report.pageSize, 25); + assert.equal(report.returned, 2); + assert.equal(report.hasMore, true); + assert.equal(report.nextPage, 3); + assert.equal(report.discussions[0]?.id, "65"); + assert.equal(report.discussions[0]?.title, "Lab board"); + assert.equal(report.discussions[0]?.gradebookColumnId, "991"); + assert.equal(report.discussions[0]?.topic?.body, "Start here"); + assert.equal(report.discussions[0]?.topic?.author, "Alice Chen"); + assert.equal(report.discussions[1]?.groupDiscussion, true); + assert.equal(report.discussions[1]?.available, false); + assert.match(formatBlackboardDiscussions(report), /Next page: 3/u); +}); + +test("Blackboard discussions fall back to Original-course HTML forum lists when REST rejects the course", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8537_1") { + return jsonResponse({ + id: "_8537_1", + name: "专业实习(2026夏)", + courseCode: "BMEB470", + externalId: "BMEB470-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8537_1/discussions?offset=0&limit=25") { + return jsonResponse({ status: 400, message: "Original courses are not supported by this API" }, 400); + } + if (url === "https://bb.sustech.edu.cn/webapps/blackboard/content/launchLink.jsp?course_id=_8537_1&tool_id=_142_1&tool_type=TOOL&mode=reset") { + return textResponse(` + + + +
+ Displaying 1 to 2 of 2 items +
+ + + + + + + + + + + + + + + + + +
Choose TopicDescription:
Pick a topic and post your report.
Total Posts: 4Unread Posts: 1Unread Replies To Me: 0Total Participants: 4
Progress Q&ADescription:
Total Posts: 0Unread Posts: 0Unread Replies To Me: 0Total Participants: 0
+ + + `, "text/html"); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const report = await listBlackboardDiscussions(adapter, { courseId: "_8537_1" }); + assert.equal(report.courseCode, "BMEB470"); + assert.equal(report.returned, 2); + assert.equal(report.hasMore, false); + assert.equal(report.discussions[0]?.id, "28850"); + assert.equal(report.discussions[0]?.source, "original-html"); + assert.equal(report.discussions[0]?.metadataPartial, true); + assert.equal(report.discussions[0]?.description, "Pick a topic and post your report."); + assert.equal(report.discussions[0]?.totalPosts, 4); + assert.equal(report.discussions[0]?.unreadPosts, 1); + assert.equal(report.discussions[0]?.totalParticipants, 4); + assert.equal(report.discussions[1]?.title, "Progress Q&A"); + assert.match(formatBlackboardDiscussions(report), /Original HTML fallback/u); + assert.match(formatBlackboardDiscussions(report), /metadata partial/u); +}); + +test("Blackboard discussion fallback rejects gradable filtering for Original-course HTML forums", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8537_1") { + return jsonResponse({ + id: "_8537_1", + name: "专业实习(2026夏)", + courseCode: "BMEB470", + externalId: "BMEB470-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8537_1/discussions?offset=0&limit=25&gradable=true") { + return jsonResponse({ status: 400, message: "Original courses are not supported by this API" }, 400); + } + throw new Error(`Unexpected URL ${url}`); + }); + + await assert.rejects( + listBlackboardDiscussions(adapter, { courseId: "_8537_1", gradable: true }), + (error: unknown) => { + assert.ok(error instanceof CliError); + assert.equal(error.code, "BLACKBOARD_DISCUSSIONS_FILTER_UNSUPPORTED"); + assert.equal(error.details?.courseId, "_8537_1"); + assert.equal(error.details?.filter, "gradable"); + return true; + }, + ); +}); + +test("Blackboard Original forum fallback still lists parseable forums when the HTML omits conf_id", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8537_1") { + return jsonResponse({ + id: "_8537_1", + name: "专业实习(2026夏)", + courseCode: "BMEB470", + externalId: "BMEB470-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8537_1/discussions?offset=0&limit=25") { + return jsonResponse({ status: 400, message: "Original courses are not supported by this API" }, 400); + } + if (url === "https://bb.sustech.edu.cn/webapps/blackboard/content/launchLink.jsp?course_id=_8537_1&tool_id=_142_1&tool_type=TOOL&mode=reset") { + return textResponse(` + + +
+ Displaying 1 to 2 of 2 items +
+ + + +
Choose Topic
Progress Q&A
+ + + `, "text/html"); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const report = await listBlackboardDiscussions(adapter, { courseId: "_8537_1" }); + + assert.equal(report.courseCode, "BMEB470"); + assert.equal(report.returned, 2); + assert.equal(report.hasMore, false); + assert.deepEqual(report.discussions.map((discussion) => discussion.id), ["28850", "28851"]); + assert.deepEqual(report.discussions.map((discussion) => discussion.title), ["Choose Topic", "Progress Q&A"]); +}); + +test("Blackboard discussion messages normalize filters and forum metadata", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1") { + return jsonResponse({ + id: "_8343_1", + name: "Physical Chemistry", + courseCode: "CHEM201", + externalId: "CHEM201-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/discussions/_65_1") { + return jsonResponse({ + id: "_65_1", + title: "Lab board", + available: true, + gradable: true, + gradebookColumnId: "_991_1", + groupDiscussion: false, + createdDate: "2026-08-20T08:00:00.000Z", + modifiedDate: "2026-08-26T08:30:00.000Z", + topic: { + id: "_70_1", + discussionId: "_65_1", + parentId: "", + threadId: "_70_1", + userId: "_7_1", + givenName: "Alice", + familyName: "Chen", + status: "Published", + body: "

Start here

", + postDate: "2026-08-20T08:00:00.000Z", + editDate: "", + createdDate: "2026-08-20T08:00:00.000Z", + modifiedDate: "2026-08-20T08:00:00.000Z", + isRead: true, + }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/discussions/_65_1/messages?offset=0&limit=2&userId=_1_1&status=Published&isRead=false&sort=postDate%28desc%29") { + return jsonResponse({ + results: [ + { + id: "_71_1", + discussionId: "_65_1", + parentId: "", + threadId: "_71_1", + userId: "_1_1", + givenName: "Student", + familyName: "One", + status: "Published", + body: "

First post

", + postDate: "2026-08-26T10:00:00.000Z", + editDate: "", + createdDate: "2026-08-26T09:59:00.000Z", + modifiedDate: "2026-08-26T10:00:00.000Z", + isRead: false, + }, + { + id: "_72_1", + discussionId: "_65_1", + parentId: "_71_1", + threadId: "_71_1", + userId: "_2_1", + givenName: "", + familyName: "", + status: "Published", + body: "
TA reply
", + postDate: "2026-08-26T10:05:00.000Z", + editDate: "", + createdDate: "2026-08-26T10:04:00.000Z", + modifiedDate: "2026-08-26T10:05:00.000Z", + isRead: false, + }, + ], + }); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const report = await getBlackboardDiscussionMessages(adapter, { + courseId: "_8343_1", + discussionId: "65", + userId: "_1_1", + status: "Published", + isRead: false, + page: 1, + pageSize: 2, + sort: "postDate(desc)", + }); + + assert.equal(report.courseCode, "CHEM201"); + assert.equal(report.discussion.id, "65"); + assert.equal(report.discussion.topic?.body, "Start here"); + assert.equal(report.returned, 2); + assert.equal(report.hasMore, false); + assert.equal(report.messages[0]?.id, "71"); + assert.equal(report.messages[0]?.author, "Student One"); + assert.equal(report.messages[0]?.body, "First post"); + assert.equal(report.messages[0]?.isRead, false); + assert.equal(report.messages[1]?.author, "_2_1"); + assert.equal(report.messages[1]?.parentId, "71"); + assert.equal(report.messages[1]?.threadId, "71"); + assert.match(formatBlackboardDiscussion(report), /Lab board/u); + assert.match(formatBlackboardDiscussion(report), /unread/u); +}); + +test("Blackboard discussion messages fall back to Original-course HTML thread lists when REST rejects the course", async () => { + const seen: string[] = []; + const adapter = routeAdapter((url) => { + seen.push(url); + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8537_1") { + return jsonResponse({ + id: "_8537_1", + name: "专业实习(2026夏)", + courseCode: "BMEB470", + externalId: "BMEB470-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8537_1/discussions/_28850_1") { + return jsonResponse({ status: 400, message: "Original courses are not supported by this API" }, 400); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8537_1/discussions/_28850_1/messages?offset=0&limit=2&status=Published&isRead=false&sort=postDate%28desc%29") { + return jsonResponse({ status: 400, message: "Original courses are not supported by this API" }, 400); + } + if (url === "https://bb.sustech.edu.cn/webapps/blackboard/content/launchLink.jsp?course_id=_8537_1&tool_id=_142_1&tool_type=TOOL&mode=reset") { + return textResponse(` + + + +
+ Displaying 1 to 1 of 1 items +
+ + + + + + + + + +
Choose TopicDescription:
Pick a topic and post your report.
Total Posts: 4Unread Posts: 1Unread Replies To Me: 0Total Participants: 4
+ + + `, "text/html"); + } + if (url === "https://bb.sustech.edu.cn/webapps/discussionboard/do/conference?action=list_forums&course_id=_8537_1&conf_id=_20961_1&nav=discussion_board_entry&toggle_mode=read&mode=view&showAll=true&startIndex=0&sortCol=position&sortDir=ASCENDING") { + return textResponse(` + + + +
+ Displaying 1 to 1 of 1 items +
+ + + + + + + + + +
Choose TopicDescription:
Pick a topic and post your report.
Total Posts: 4Unread Posts: 1Unread Replies To Me: 0Total Participants: 4
+ + + `, "text/html"); + } + if (url === "https://bb.sustech.edu.cn/webapps/discussionboard/do/forum?action=list_threads&course_id=_8537_1&nav=discussion_board_entry&conf_id=_20961_1&forum_id=_28850_1&forum_view=list&showAll=true") { + return textResponse(` + + +
+ Displaying 1 to 2 of 2 items +
+ + + + + + + + + + + + + + + + + + + +
第一周课前思考日期: 20-12-28 下午5:30作者: 匿名状态: 已发布未读帖子: 1未读对我的回复: 0帖子总数: 1
第一章预习要求日期: 20-12-27 下午4:05作者: 张三状态: 草稿未读帖子: 0未读对我的回复: 0帖子总数: 3
+ + + `, "text/html"); + } + if (url === "https://bb.sustech.edu.cn/webapps/discussionboard/do/message?action=message_frame&course_id=_8537_1&nav=discussion_board_entry&conf_id=_20961_1&forum_id=_28850_1&message_id=_133567_1&thread_id=_133567_1") { + return textResponse(`

请大家先思考课程目标。

`, "text/html"); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const report = await getBlackboardDiscussionMessages(adapter, { + courseId: "_8537_1", + discussionId: "28850", + status: "Published", + isRead: false, + page: 1, + pageSize: 2, + sort: "postDate(desc)", + }); + + assert.equal(report.courseCode, "BMEB470"); + assert.equal(report.discussion.id, "28850"); + assert.equal(report.discussion.source, "original-html"); + assert.equal(report.discussion.metadataPartial, true); + assert.equal(report.returned, 1); + assert.equal(report.hasMore, false); + assert.equal(report.messages[0]?.id, "133567"); + assert.equal(report.messages[0]?.discussionId, "28850"); + assert.equal(report.messages[0]?.subject, "第一周课前思考"); + assert.equal(report.messages[0]?.author, "匿名"); + assert.equal(report.messages[0]?.status, "Published"); + assert.equal(report.messages[0]?.isRead, false); + assert.equal(report.messages[0]?.postDate, "2020-12-28T09:30:00.000Z"); + assert.equal(report.messages[0]?.body, "请大家先思考课程目标。"); + assert.equal(report.messages[0]?.source, "original-html"); + assert.equal(report.messages[0]?.metadataPartial, true); + assert.equal(report.messages[0]?.unreadPosts, 1); + assert.equal(report.messages[0]?.totalPosts, 1); + assert.deepEqual( + seen.filter((url) => url.includes("message_frame")), + ["https://bb.sustech.edu.cn/webapps/discussionboard/do/message?action=message_frame&course_id=_8537_1&nav=discussion_board_entry&conf_id=_20961_1&forum_id=_28850_1&message_id=_133567_1&thread_id=_133567_1"], + ); + assert.match(formatBlackboardDiscussion(report), /Original HTML fallback/u); + assert.match(formatBlackboardDiscussion(report), /metadata partial/u); + assert.match(formatBlackboardDiscussion(report), /第一周课前思考/u); +}); + +test("Blackboard discussion fallback can recover conf_id from the final launch URL when the HTML omits it", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8537_1") { + return jsonResponse({ + id: "_8537_1", + name: "专业实习(2026夏)", + courseCode: "BMEB470", + externalId: "BMEB470-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8537_1/discussions/_28850_1") { + return jsonResponse({ status: 400, message: "Original courses are not supported by this API" }, 400); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8537_1/discussions/_28850_1/messages?offset=0&limit=1") { + return jsonResponse({ status: 400, message: "Original courses are not supported by this API" }, 400); + } + if (url === "https://bb.sustech.edu.cn/webapps/blackboard/content/launchLink.jsp?course_id=_8537_1&tool_id=_142_1&tool_type=TOOL&mode=reset") { + return textResponse(` + + +
+ Displaying 1 to 1 of 1 items +
+ + + + +
Choose Topic
+ + + `, "text/html", "https://bb.sustech.edu.cn/webapps/discussionboard/do/conference?action=list_forums&course_id=_8537_1&conf_id=_20961_1&nav=discussion_board_entry"); + } + if (url === "https://bb.sustech.edu.cn/webapps/discussionboard/do/forum?action=list_threads&course_id=_8537_1&nav=discussion_board_entry&conf_id=_20961_1&forum_id=_28850_1&forum_view=list&showAll=true") { + return textResponse(` + + +
+ Displaying 1 to 1 of 1 items +
+ + + + + + + + + + +
第一周课前思考日期: 20-12-28 下午5:30作者: 匿名状态: 已发布未读帖子: 0未读对我的回复: 0帖子总数: 1
+ + + `, "text/html"); + } + if (url === "https://bb.sustech.edu.cn/webapps/discussionboard/do/message?action=message_frame&course_id=_8537_1&nav=discussion_board_entry&conf_id=_20961_1&forum_id=_28850_1&message_id=_133567_1&thread_id=_133567_1") { + return textResponse(`

请大家先思考课程目标。

`, "text/html"); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const report = await getBlackboardDiscussionMessages(adapter, { + courseId: "_8537_1", + discussionId: "28850", + page: 1, + pageSize: 1, + }); + + assert.equal(report.discussion.id, "28850"); + assert.equal(report.discussion.source, "original-html"); + assert.equal(report.messages[0]?.id, "133567"); + assert.equal(report.messages[0]?.body, "请大家先思考课程目标。"); +}); + +test("Blackboard discussion detail read fails closed when Blackboard rejects the REST discussion endpoint for an Original course", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8537_1/discussions/_65_1") { + return jsonResponse({ status: 400, message: "Original courses are not supported by this API" }, 400); + } + throw new Error(`Unexpected URL ${url}`); + }); + + await assert.rejects( + getBlackboardDiscussion(adapter, "_8537_1", "_65_1"), + (error: unknown) => { + assert.ok(error instanceof CliError); + assert.equal(error.code, "BLACKBOARD_DISCUSSIONS_UNSUPPORTED"); + assert.equal(error.details?.courseId, "_8537_1"); + assert.equal(error.details?.discussionId, "_65_1"); + return true; + }, + ); +}); + +test("Blackboard discussion groups expose discoverable group ids for group-scoped threads", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1") { + return jsonResponse({ + id: "_8343_1", + name: "Physical Chemistry", + courseCode: "CHEM201", + externalId: "CHEM201-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/discussions/_66_1") { + return jsonResponse({ + id: "_66_1", + title: "Group project Q&A", + available: true, + gradable: false, + groupDiscussion: true, + createdDate: "2026-08-18T10:00:00.000Z", + modifiedDate: "2026-08-24T10:00:00.000Z", + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/discussions/_66_1/groups?offset=25&limit=25&sort=groupId%28desc%29") { + return jsonResponse({ + results: [ + { groupId: "_88_1", discussionId: "_66_1", threadId: "_701_1" }, + { groupId: "_77_1", discussionId: "_66_1", threadId: "_702_1" }, + ], + paging: { + nextPage: "/learn/api/public/v1/courses/_8343_1/discussions/_66_1/groups?offset=50&limit=25", + }, + }); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const report = await listBlackboardDiscussionGroups(adapter, { + courseId: "8343", + discussionId: "66", + page: 2, + pageSize: 25, + sort: "groupId(desc)", + }); + + assert.equal(report.courseId, "_8343_1"); + assert.equal(report.courseCode, "CHEM201"); + assert.equal(report.discussion.id, "66"); + assert.equal(report.discussion.title, "Group project Q&A"); + assert.equal(report.discussion.groupDiscussion, true); + assert.equal(report.returned, 2); + assert.equal(report.hasMore, true); + assert.equal(report.nextPage, 3); + assert.equal(report.groups[0]?.groupId, "88"); + assert.equal(report.groups[0]?.threadId, "701"); + assert.equal(report.groups[1]?.discussionId, "66"); + assert.match(formatBlackboardDiscussionGroups(report), /Group project Q&A/u); + assert.match(formatBlackboardDiscussionGroups(report), /Next page: 3/u); +}); + +test("Blackboard Original discussion forum fallback respects position(desc) sorting", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8537_1") { + return jsonResponse({ + id: "_8537_1", + name: "专业实习(2026夏)", + courseCode: "BMEB470", + externalId: "BMEB470-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8537_1/discussions?offset=0&limit=25&sort=position%28desc%29") { + return jsonResponse({ + status: 400, + message: ": 1 errors\nField error in object 'discussionSortCriteriaPubV1' on field 'sort': rejected value [position(desc)]", + }, 400); + } + if (url === "https://bb.sustech.edu.cn/webapps/blackboard/content/launchLink.jsp?course_id=_8537_1&tool_id=_142_1&tool_type=TOOL&mode=reset") { + return textResponse(` + + + +
+ Displaying 1 to 2 of 2 items +
+ + + +
Choose Topic
Progress Q&A
+ + + `, "text/html"); + } + if (url === "https://bb.sustech.edu.cn/webapps/discussionboard/do/conference?action=list_forums&course_id=_8537_1&conf_id=_20961_1&nav=discussion_board_entry&toggle_mode=read&mode=view&showAll=true&startIndex=0&sortCol=position&sortDir=ASCENDING") { + return textResponse(` + + + +
+ Displaying 1 to 2 of 2 items +
+ + + +
Choose Topic
Progress Q&A
+ + + `, "text/html"); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const report = await listBlackboardDiscussions(adapter, { + courseId: "_8537_1", + sort: "position(desc)", + }); + + assert.deepEqual(report.discussions.map((discussion) => discussion.id), ["28851", "28850"]); + assert.deepEqual(report.discussions.map((discussion) => discussion.title), ["Progress Q&A", "Choose Topic"]); +}); + +test("Blackboard discussion replies fall back to Original-course HTML thread detail replies", async () => { + const seen: string[] = []; + const adapter = routeAdapter((url) => { + seen.push(url); + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8537_1") { + return jsonResponse({ + id: "_8537_1", + name: "专业实习(2026夏)", + courseCode: "BMEB470", + externalId: "BMEB470-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8537_1/discussions/_28850_1/messages/_133567_1/replies?offset=0&limit=5&status=Published&isRead=false&sort=postDate%28desc%29") { + return jsonResponse({ status: 400, message: "Original courses are not supported by this API" }, 400); + } + if (url === "https://bb.sustech.edu.cn/webapps/blackboard/content/launchLink.jsp?course_id=_8537_1&tool_id=_142_1&tool_type=TOOL&mode=reset") { + return textResponse(` + + + +
+ Displaying 1 to 1 of 1 items +
+ + + + + +
Choose TopicDescription:
Pick a topic and post your report.
+ + + `, "text/html"); + } + if (url === "https://bb.sustech.edu.cn/webapps/discussionboard/do/message?action=list_messages&course_id=_8537_1&nav=discussion_board_entry&conf_id=_20961_1&forum_id=_28850_1&message_id=_133567_1&thread_id=_133567_1") { + return textResponse(` + + +
+ Displaying 1 to 3 of 3 items +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
第一周课前思考日期: 20-12-28 下午5:30作者: 匿名状态: 已发布未读帖子: 0未读对我的回复: 0帖子总数: 3
RE: 第一周课前思考日期: 20-12-29 上午9:15作者: 李四状态: 已发布未读帖子: 1未读对我的回复: 0帖子总数: 1
RE: 第一周课前思考(补充)日期: 20-12-29 上午10:00作者: 王五状态: 草稿未读帖子: 0未读对我的回复: 0帖子总数: 1
+ + + `, "text/html"); + } + if (url === "https://bb.sustech.edu.cn/webapps/discussionboard/do/message?action=message_frame&course_id=_8537_1&nav=discussion_board_entry&conf_id=_20961_1&forum_id=_28850_1&message_id=_133568_1&thread_id=_133567_1") { + return textResponse(`

我认为课程目标应该先聚焦工程实践。

`, "text/html"); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const report = await listBlackboardDiscussionReplies(adapter, { + courseId: "_8537_1", + discussionId: "_28850_1", + messageId: "133567", + status: "Published", + isRead: false, + page: 1, + pageSize: 5, + sort: "postDate(desc)", + }); + + assert.equal(report.courseCode, "BMEB470"); + assert.equal(report.discussionId, "28850"); + assert.equal(report.messageId, "133567"); + assert.equal(report.returned, 1); + assert.equal(report.hasMore, false); + assert.equal(report.replies[0]?.id, "133568"); + assert.equal(report.replies[0]?.parentId, "133567"); + assert.equal(report.replies[0]?.threadId, "133567"); + assert.equal(report.replies[0]?.author, "李四"); + assert.equal(report.replies[0]?.status, "Published"); + assert.equal(report.replies[0]?.isRead, false); + assert.equal(report.replies[0]?.body, "我认为课程目标应该先聚焦工程实践。"); + assert.equal(report.replies[0]?.source, "original-html"); + assert.equal(report.replies[0]?.metadataPartial, true); + assert.equal(report.replies[0]?.unreadPosts, 1); + assert.equal(report.replies[0]?.totalPosts, 1); + assert.match(formatBlackboardDiscussionReplies(report), /Original HTML fallback/u); + assert.match(formatBlackboardDiscussionReplies(report), /original-html/u); + assert.match(formatBlackboardDiscussionReplies(report), /metadata partial/u); + assert.deepEqual( + seen.filter((url) => url.includes("message_frame")), + ["https://bb.sustech.edu.cn/webapps/discussionboard/do/message?action=message_frame&course_id=_8537_1&nav=discussion_board_entry&conf_id=_20961_1&forum_id=_28850_1&message_id=_133568_1&thread_id=_133567_1"], + ); +}); + +test("Blackboard discussion replies normalize reply filters and paging", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1") { + return jsonResponse({ + id: "_8343_1", + name: "Physical Chemistry", + courseCode: "CHEM201", + externalId: "CHEM201-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/discussions/_65_1/messages/_71_1/replies?offset=2&limit=2&groupId=_88_1&userId=_2_1&status=Draft&isRead=true&sort=modifiedDate") { + return jsonResponse({ + results: [{ + id: "_80_1", + discussionId: "_65_1", + parentId: "_71_1", + threadId: "_71_1", + userId: "_2_1", + groupId: "_88_1", + givenName: "TA", + familyName: "Two", + status: "Draft", + body: "

Need one more citation.

", + postDate: "2026-08-26T10:10:00.000Z", + editDate: "2026-08-26T10:12:00.000Z", + createdDate: "2026-08-26T10:10:00.000Z", + modifiedDate: "2026-08-26T10:12:00.000Z", + isRead: true, + }], + paging: { + nextPage: "/learn/api/public/v1/courses/_8343_1/discussions/_65_1/messages/_71_1/replies?offset=4&limit=2", + }, + }); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const report = await listBlackboardDiscussionReplies(adapter, { + courseId: "_8343_1", + discussionId: "_65_1", + messageId: "71", + groupId: "88", + userId: "_2_1", + status: "Draft", + isRead: true, + page: 2, + pageSize: 2, + sort: "modifiedDate", + }); + + assert.equal(report.courseCode, "CHEM201"); + assert.equal(report.discussionId, "65"); + assert.equal(report.messageId, "71"); + assert.equal(report.groupId, "_88_1"); + assert.equal(report.userId, "_2_1"); + assert.equal(report.status, "Draft"); + assert.equal(report.isRead, true); + assert.equal(report.returned, 1); + assert.equal(report.hasMore, true); + assert.equal(report.nextPage, 3); + assert.equal(report.replies[0]?.id, "80"); + assert.equal(report.replies[0]?.author, "TA Two"); + assert.equal(report.replies[0]?.parentId, "71"); + assert.equal(report.replies[0]?.body, "Need one more citation."); + assert.match(formatBlackboardDiscussionReplies(report), /Next page: 3/u); +}); + +test("Blackboard discussion replies preserve fallback filter errors after REST sort rejection", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8537_1") { + return jsonResponse({ + id: "_8537_1", + name: "专业实习(2026夏)", + courseCode: "BMEB470", + externalId: "BMEB470-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8537_1/discussions/_28850_1/messages/_133567_1/replies?offset=0&limit=5&groupId=_88_1&status=Published&sort=position%28desc%29") { + return jsonResponse({ + status: 400, + message: ": 1 errors\nField error in object 'discussionSortCriteriaPubV1' on field 'sort': rejected value [position(desc)]", + }, 400); + } + throw new Error(`Unexpected URL ${url}`); + }); + + await assert.rejects( + () => listBlackboardDiscussionReplies(adapter, { + courseId: "_8537_1", + discussionId: "_28850_1", + messageId: "133567", + groupId: "88", + status: "Published", + page: 1, + pageSize: 5, + sort: "position(desc)", + }), + (error: unknown) => { + assert.ok(error instanceof CliError); + assert.equal(error.code, "BLACKBOARD_DISCUSSIONS_FILTER_UNSUPPORTED"); + assert.equal(error.details?.filter, "groupId"); + return true; + }, + ); +}); + +function routeAdapter(route: (url: string, init?: RequestInit) => Response | Promise): ServiceAdapter { + return { + name: "route-adapter", + fetch: async (input, init) => route(String(input), init), + }; +} + +function jsonResponse(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function textResponse(value: string, contentType = "text/plain", url?: string): Response { + const response = new Response(value, { + headers: { "content-type": `${contentType}; charset=utf-8` }, + }); + if (url) Object.defineProperty(response, "url", { value: url }); + return response; +} diff --git a/src/test/blackboard_message_write.test.ts b/src/test/blackboard_message_write.test.ts new file mode 100644 index 0000000..f055f22 --- /dev/null +++ b/src/test/blackboard_message_write.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createBlackboardCourseMessage } from "../services/blackboard.js"; +import type { ServiceAdapter } from "../services/base.js"; + +test("Blackboard course-message write helper follows the official message-create endpoint", async () => { + const calls: string[] = []; + const adapter = routeAdapter((url, init) => { + calls.push(`${init?.method ?? "GET"} ${url}`); + + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/messages" && init?.method === "POST") { + assert.equal(new Headers(init.headers).get("content-type"), "application/json"); + assert.deepEqual(JSON.parse(String(init.body)), { + subject: "Project update", + body: "Bring the latest draft", + toUsers: [{ id: "_1_1" }, { id: "_2_1" }], + ccUsers: [{ id: "_3_1" }], + }); + return jsonResponse({ + id: "_71_1", + subject: "Project update", + body: "

Bring the latest draft

", + postedDate: "2026-09-03T08:00:00.000Z", + isRead: false, + type: "Normal", + senderId: "_7_1", + toUsers: ["_1_1", "_2_1"], + ccUsers: ["_3_1"], + bccUsers: [], + isExistingAttachment: false, + isReply: false, + }, 201); + } + + throw new Error(`Unexpected URL ${url}`); + }); + + const created = await createBlackboardCourseMessage(adapter, "8343", { + subject: "Project update", + body: "Bring the latest draft", + toUsers: ["1", "_2_1"], + ccUsers: ["3"], + }); + + assert.deepEqual(created, { + id: "71", + subject: "Project update", + body: "Bring the latest draft", + postedDate: "2026-09-03T08:00:00.000Z", + isRead: false, + type: "Normal", + senderId: "_7_1", + toUsers: ["_1_1", "_2_1"], + ccUsers: ["_3_1"], + bccUsers: [], + isExistingAttachment: false, + isReply: false, + }); + + assert.deepEqual(calls, [ + "POST https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/messages", + ]); +}); + +test("Blackboard course-message write helper rejects blank text, missing recipients, and duplicated recipients before network access", async () => { + let called = false; + const adapter = routeAdapter(() => { + called = true; + return jsonResponse({}); + }); + + await assert.rejects( + createBlackboardCourseMessage(adapter, "8343", { body: " \n\t ", toUsers: ["1"] }), + hasCode("BLACKBOARD_MESSAGE_TEXT_EMPTY"), + ); + await assert.rejects( + createBlackboardCourseMessage(adapter, "8343", { body: "hello", toUsers: [] }), + hasCode("BLACKBOARD_MESSAGE_RECIPIENTS_EMPTY"), + ); + await assert.rejects( + createBlackboardCourseMessage(adapter, "8343", { body: "hello", toUsers: ["1"], ccUsers: ["_1_1"] }), + hasCode("BLACKBOARD_MESSAGE_RECIPIENT_DUPLICATE"), + ); + assert.equal(called, false); +}); + +function routeAdapter(route: (url: string, init?: RequestInit) => Response | Promise): ServiceAdapter { + return { + name: "blackboard-message-write", + fetch(input: string, init?: RequestInit): Promise { + return Promise.resolve(route(String(input), init)); + }, + }; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function hasCode(code: string): (error: unknown) => boolean { + return (error: unknown) => Boolean(error && typeof error === "object" && "code" in error && error.code === code); +} diff --git a/src/test/blackboard_messages.test.ts b/src/test/blackboard_messages.test.ts new file mode 100644 index 0000000..12e78a5 --- /dev/null +++ b/src/test/blackboard_messages.test.ts @@ -0,0 +1,255 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + listBlackboardCourseMessageFolders, + listBlackboardCourseMessageParticipants, + listBlackboardCourseMessages, +} from "../services/blackboard.js"; +import type { ServiceAdapter } from "../services/base.js"; +import { + formatBlackboardMessageFolders, + formatBlackboardMessageParticipants, + formatBlackboardMessages, +} from "../services/text.js"; + +test("Blackboard message folders normalize course folder counts and paging", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1") { + return jsonResponse({ + id: "_8343_1", + name: "Physical Chemistry", + courseCode: "CHEM201", + externalId: "CHEM201-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/messages/folders?offset=25&limit=25") { + return jsonResponse({ + results: [ + { + name: "inbox", + label: "Inbox", + type: "Inbox", + courseMessagesCounts: { + courseId: "_8343_1", + unreadCount: 4, + totalCount: 12, + }, + }, + { + name: "project-team", + label: "Project Team", + type: "Custom", + courseMessagesCounts: { + courseId: "_8343_1", + unreadCount: 1, + totalCount: 5, + }, + }, + ], + paging: { + nextPage: "/learn/api/public/v1/courses/_8343_1/messages/folders?offset=50&limit=25", + }, + }); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const report = await listBlackboardCourseMessageFolders(adapter, { + courseId: "8343", + page: 2, + pageSize: 25, + }); + + assert.equal(report.courseId, "_8343_1"); + assert.equal(report.courseCode, "CHEM201"); + assert.equal(report.courseName, "Physical Chemistry"); + assert.equal(report.page, 2); + assert.equal(report.pageSize, 25); + assert.equal(report.returned, 2); + assert.equal(report.hasMore, true); + assert.equal(report.nextPage, 3); + assert.deepEqual(report.folders[0], { + name: "inbox", + label: "Inbox", + type: "Inbox", + unreadCount: 4, + totalCount: 12, + }); + assert.match(formatBlackboardMessageFolders(report), /Project Team/u); + assert.match(formatBlackboardMessageFolders(report), /Next page: 3/u); +}); + +test("Blackboard course messages normalize sender data, attachments, and folder filters", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1") { + return jsonResponse({ + id: "_8343_1", + name: "Physical Chemistry", + courseCode: "CHEM201", + externalId: "CHEM201-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/messages?offset=0&limit=2&folderType=Custom&folderName=Project+Team&sort=postedDate%28desc%29&expand=sender") { + return jsonResponse({ + results: [ + { + id: "_71_1", + subject: "Project update", + body: "

Bring report

", + postedDate: "2026-09-03T08:00:00.000Z", + isRead: false, + type: "Normal", + senderId: "_7_1", + sender: { + id: "_7_1", + userName: "achen", + givenName: { text: "Alice" }, + familyName: { rawText: "Chen" }, + preferredDisplayName: "GivenName", + }, + attachment: { + id: "_91_1", + fileName: "report.pdf", + mimeType: "application/pdf", + fileLocation: "COURSE", + }, + toUsers: ["_1_1", "_2_1"], + ccUsers: ["_3_1"], + bccUsers: [], + isExistingAttachment: false, + isReply: false, + }, + { + id: "_72_1", + subject: "", + body: "
FYI
", + postedDate: "2026-09-03T08:30:00.000Z", + isRead: true, + type: "System", + senderId: "_8_1", + toUsers: [], + ccUsers: [], + bccUsers: ["_9_1"], + isExistingAttachment: true, + isReply: true, + }, + ], + }); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const report = await listBlackboardCourseMessages(adapter, { + courseId: "_8343_1", + folderType: "Custom", + folderName: " Project Team ", + page: 1, + pageSize: 2, + sort: "postedDate(desc)", + }); + + assert.equal(report.courseId, "_8343_1"); + assert.equal(report.courseCode, "CHEM201"); + assert.equal(report.folderType, "Custom"); + assert.equal(report.folderName, "Project Team"); + assert.equal(report.sort, "postedDate(desc)"); + assert.equal(report.returned, 2); + assert.equal(report.hasMore, false); + assert.equal(report.messages[0]?.id, "71"); + assert.equal(report.messages[0]?.subject, "Project update"); + assert.equal(report.messages[0]?.body, "Bring report"); + assert.equal(report.messages[0]?.sender?.displayName, "Alice Chen"); + assert.equal(report.messages[0]?.attachment?.fileName, "report.pdf"); + assert.deepEqual(report.messages[0]?.toUsers, ["_1_1", "_2_1"]); + assert.equal(report.messages[1]?.id, "72"); + assert.equal(report.messages[1]?.subject, ""); + assert.equal(report.messages[1]?.isRead, true); + assert.equal(report.messages[1]?.isReply, true); + assert.deepEqual(report.messages[1]?.bccUsers, ["_9_1"]); + assert.match(formatBlackboardMessages(report), /Project update/u); + assert.match(formatBlackboardMessages(report), /attachment report\.pdf/u); + assert.match(formatBlackboardMessages(report), /unread/u); +}); + +test("Blackboard message participants normalize canonical ids and display-name preferences", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1") { + return jsonResponse({ + id: "_8343_1", + name: "Physical Chemistry", + courseCode: "CHEM201", + externalId: "CHEM201-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/messages/_71_1/participants?offset=0&limit=2&participationType=To&sort=participationType&expand=user") { + return jsonResponse({ + results: [ + { + messageId: "_71_1", + userId: "_1_1", + participationType: "To", + user: { + id: "_1_1", + userName: "alicej", + otherName: { displayText: "AJ" }, + givenName: { text: "Alice" }, + familyName: { plainText: "Jones" }, + preferredDisplayName: "Both", + }, + }, + { + messageId: "_71_1", + userId: "_2_1", + participationType: "To", + }, + ], + paging: { + nextPage: "/learn/api/public/v1/courses/_8343_1/messages/_71_1/participants?offset=2&limit=2", + }, + }); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const report = await listBlackboardCourseMessageParticipants(adapter, { + courseId: "8343", + messageId: "71", + participationType: "To", + page: 1, + pageSize: 2, + sort: "participationType", + }); + + assert.equal(report.courseId, "_8343_1"); + assert.equal(report.courseCode, "CHEM201"); + assert.equal(report.messageId, "71"); + assert.equal(report.participationType, "To"); + assert.equal(report.returned, 2); + assert.equal(report.hasMore, true); + assert.equal(report.nextPage, 2); + assert.equal(report.participants[0]?.messageId, "71"); + assert.equal(report.participants[0]?.displayName, "AJ Alice Jones"); + assert.equal(report.participants[0]?.user?.userName, "alicej"); + assert.equal(report.participants[1]?.displayName, "_2_1"); + assert.match(formatBlackboardMessageParticipants(report), /AJ Alice Jones/u); + assert.match(formatBlackboardMessageParticipants(report), /Next page: 2/u); +}); + +function routeAdapter(route: (url: string, init?: RequestInit) => Response | Promise): ServiceAdapter { + return { + name: "blackboard-message-fixture", + async fetch(input: string, init?: RequestInit): Promise { + return route(input, init); + }, + }; +} + +function jsonResponse(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json" }, + }); +} diff --git a/src/test/blackboard_readflows.test.ts b/src/test/blackboard_readflows.test.ts index 408a04a..9b14bb0 100644 --- a/src/test/blackboard_readflows.test.ts +++ b/src/test/blackboard_readflows.test.ts @@ -4,11 +4,17 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { + listBlackboardAssignmentsAcrossCourses, + filterBlackboardAssignmentsBySubmissionState, + listBlackboardAssignmentsWithAttempts, listBlackboardCalendarItems, listBlackboardCalendars, + listBlackboardContentTree, listBlackboardDeadlines, + listBlackboardGrades, nextBlackboardDeadline, searchBlackboardContentTree, + summarizeBlackboardContentTypes, syncBlackboardAttachments, } from "../services/blackboard.js"; import type { ServiceAdapter } from "../services/base.js"; @@ -233,7 +239,539 @@ test("Blackboard calendar items keep successful chunks when later chunks fail", assert.equal(report.failures[0]?.calendarItemType, "Course"); }); +test("Blackboard assignments can include per-assignment attempt summaries while preserving partial failures", async () => { + let userLookups = 0; + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/me") { + userLookups += 1; + return jsonResponse({ id: "_1_1", userName: "12200000", name: "Student Name" }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v2/courses/_8343_1/gradebook/columns") { + return jsonResponse({ + results: [ + { + id: "_991_1", + name: "Lab Report 1", + contentId: "_490876_1", + availability: { available: "Yes" }, + grading: { + type: "Attempts", + due: "2026-08-27T18:00:00+08:00", + attemptsAllowed: 2, + scoringModel: "Last", + }, + score: { possible: 100 }, + scoreProviderHandle: "resource/x-bb-assignment", + }, + { + id: "_992_1", + name: "Quiz 2", + contentId: "_490877_1", + availability: { available: "Yes" }, + grading: { + type: "Attempts", + scoringModel: "Last", + }, + scoreProviderHandle: "resource/x-bb-assignment", + }, + { + id: "_993_1", + name: "Final Project", + contentId: "_490878_1", + availability: { available: "Yes" }, + grading: { + type: "Attempts", + scoringModel: "Last", + }, + scoreProviderHandle: "resource/x-bb-assignment", + }, + ], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v2/courses/_8343_1/gradebook/columns/_991_1/attempts?userId=_1_1") { + return jsonResponse({ + results: [ + { + id: "_700_1", + userId: "_1_1", + status: "Completed", + readyToPost: false, + displayGrade: { text: "95/100", score: 95 }, + created: "2026-08-26T09:55:00+08:00", + modified: "2026-08-26T10:05:00+08:00", + attemptDate: "2026-08-26T10:00:00+08:00", + attemptReceipt: { + receiptId: "rcpt-1", + submissionDate: "2026-08-26T10:05:00+08:00", + submissionTotalSize: 1024, + courseId: "_8343_1", + gradableItemId: "_991_1", + attemptId: "_700_1", + userId: "_1_1", + responseStatus: "SUCCESS", + submissionType: "file", + }, + }, + ], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v2/courses/_8343_1/gradebook/columns/_992_1/attempts?userId=_1_1") { + return jsonResponse({ results: [] }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v2/courses/_8343_1/gradebook/columns/_993_1/attempts?userId=_1_1") { + return jsonResponse({ message: "upstream unavailable" }, 503); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const report = await listBlackboardAssignmentsWithAttempts(adapter, "_8343_1"); + assert.equal(userLookups, 1); + assert.equal(report.courseId, "_8343_1"); + assert.equal(report.totalAssignments, 3); + assert.equal(report.completedAttemptFetches, 2); + assert.equal(report.attemptedAssignments, 1); + assert.equal(report.partial, true); + assert.equal(report.failures.length, 1); + assert.equal(report.failures[0]?.stage, "attempts"); + assert.equal(report.failures[0]?.columnId, "993"); + assert.equal(report.assignments[0]?.attemptSummary?.state, "completed"); + assert.equal(report.assignments[0]?.attemptSummary?.latestDisplayGradeText, "95/100"); + assert.equal(report.assignments[1]?.attemptSummary?.state, "not_attempted"); + assert.equal(report.assignments[2]?.attemptSummary, undefined); + assert.deepEqual( + filterBlackboardAssignmentsBySubmissionState(report.assignments, "completed").map((item) => item.assignment.id), + ["991"], + ); + assert.deepEqual( + filterBlackboardAssignmentsBySubmissionState(report.assignments, "not_attempted").map((item) => item.assignment.id), + ["992"], + ); + assert.deepEqual( + filterBlackboardAssignmentsBySubmissionState(report.assignments, "mixed"), + [], + ); +}); + +test("Blackboard assignments aggregate across matching courses without attempt lookups when not requested", async () => { + let attemptCalls = 0; + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/me") { + return jsonResponse({ id: "_1_1", userName: "12200000", name: "Student Name" }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/_1_1/courses") { + return jsonResponse({ + results: [ + { courseId: "_8343_1", courseRoleId: "Student" }, + { courseId: "_9000_1", courseRoleId: "Student" }, + { courseId: "_9200_1", courseRoleId: "Student" }, + ], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1") { + return jsonResponse({ + id: "_8343_1", + name: "Physical Chemistry", + courseCode: "CHEM201", + externalId: "CHEM201-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_9000_1") { + return jsonResponse({ + id: "_9000_1", + name: "Algorithms", + courseCode: "CS208", + externalId: "CS208-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_9200_1") { + return jsonResponse({ + id: "_9200_1", + name: "Advanced Systems", + courseCode: "CS302", + externalId: "CS302-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v2/courses/_9000_1/gradebook/columns") { + return jsonResponse({ + results: [ + { + id: "_991_1", + name: "Quiz 1", + contentId: "_490875_1", + availability: { available: "Yes" }, + grading: { type: "Attempts", due: "2026-09-06T18:00:00+08:00", scoringModel: "Last" }, + score: { possible: 100 }, + scoreProviderHandle: "resource/x-bb-assignment", + }, + ], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v2/courses/_9200_1/gradebook/columns") { + return jsonResponse({ + results: [ + { + id: "_992_1", + name: "Project Proposal", + contentId: "_490876_1", + availability: { available: "Yes" }, + grading: { type: "Attempts", scoringModel: "Last" }, + scoreProviderHandle: "resource/x-bb-assignment", + }, + ], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v2/courses/_8343_1/gradebook/columns") { + return jsonResponse({ message: "upstream unavailable" }, 503); + } + if (url.includes("/attempts?userId=")) { + attemptCalls += 1; + throw new Error(`Attempt lookups should not happen without --with-attempts: ${url}`); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const report = await listBlackboardAssignmentsAcrossCourses(adapter, { courseQuery: "CS" }); + assert.equal(report.withAttempts, false); + assert.equal(report.courseQuery, "CS"); + assert.equal(report.coursesMatched, 2); + assert.equal(report.coursesScanned, 2); + assert.equal(report.totalAssignments, 2); + assert.equal(report.completedAttemptFetches, 0); + assert.equal(report.attemptedAssignments, 0); + assert.equal(report.partial, false); + assert.deepEqual(report.assignments.map((item) => item.courseCode), ["CS208", "CS302"]); + assert.equal(report.assignments[0]?.assignment.title, "Quiz 1"); + assert.equal(report.assignments[1]?.assignment.title, "Project Proposal"); + assert.equal(attemptCalls, 0); +}); + +test("Blackboard assignments aggregate can include attempts and filter by submission state", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/me") { + return jsonResponse({ id: "_1_1", userName: "12200000", name: "Student Name" }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/_1_1/courses") { + return jsonResponse({ + results: [ + { courseId: "_8343_1", courseRoleId: "Student" }, + { courseId: "_9000_1", courseRoleId: "Student" }, + ], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1") { + return jsonResponse({ + id: "_8343_1", + name: "Physical Chemistry", + courseCode: "CHEM201", + externalId: "CHEM201-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_9000_1") { + return jsonResponse({ + id: "_9000_1", + name: "Algorithms", + courseCode: "CS208", + externalId: "CS208-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v2/courses/_8343_1/gradebook/columns") { + return jsonResponse({ + results: [ + { + id: "_991_1", + name: "Lab Report 1", + contentId: "_490876_1", + availability: { available: "Yes" }, + grading: { type: "Attempts", due: "2026-09-07T18:00:00+08:00", scoringModel: "Last" }, + score: { possible: 100 }, + scoreProviderHandle: "resource/x-bb-assignment", + }, + { + id: "_992_1", + name: "Quiz 2", + contentId: "_490877_1", + availability: { available: "Yes" }, + grading: { type: "Attempts", due: "2026-09-08T18:00:00+08:00", scoringModel: "Last" }, + scoreProviderHandle: "resource/x-bb-assignment", + }, + ], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v2/courses/_9000_1/gradebook/columns") { + return jsonResponse({ + results: [ + { + id: "_993_1", + name: "Project 1", + contentId: "_490878_1", + availability: { available: "Yes" }, + grading: { type: "Attempts", due: "2026-09-09T18:00:00+08:00", scoringModel: "Last" }, + scoreProviderHandle: "resource/x-bb-assignment", + }, + ], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v2/courses/_8343_1/gradebook/columns/_991_1/attempts?userId=_1_1") { + return jsonResponse({ + results: [{ + id: "_700_1", + userId: "_1_1", + status: "Completed", + displayGrade: { text: "95/100", score: 95 }, + attemptDate: "2026-09-05T11:00:00+08:00", + attemptReceipt: { + receiptId: "rcpt-1", + submissionDate: "2026-09-05T11:05:00+08:00", + submissionTotalSize: 1024, + courseId: "_8343_1", + gradableItemId: "_991_1", + attemptId: "_700_1", + userId: "_1_1", + responseStatus: "SUCCESS", + submissionType: "file", + }, + }], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v2/courses/_8343_1/gradebook/columns/_992_1/attempts?userId=_1_1") { + return jsonResponse({ results: [] }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v2/courses/_9000_1/gradebook/columns/_993_1/attempts?userId=_1_1") { + return jsonResponse({ message: "upstream unavailable" }, 503); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const report = await listBlackboardAssignmentsAcrossCourses(adapter, { + courseQuery: "20", + submissionState: "completed", + }); + assert.equal(report.withAttempts, true); + assert.equal(report.submissionState, "completed"); + assert.equal(report.coursesMatched, 2); + assert.equal(report.coursesScanned, 2); + assert.equal(report.totalAssignments, 3); + assert.equal(report.completedAttemptFetches, 2); + assert.equal(report.attemptedAssignments, 1); + assert.equal(report.partial, true); + assert.equal(report.assignments.length, 1); + assert.equal(report.assignments[0]?.courseCode, "CHEM201"); + assert.equal(report.assignments[0]?.assignment.title, "Lab Report 1"); + assert.equal(report.assignments[0]?.attemptSummary?.latestDisplayGradeText, "95/100"); + assert.equal(report.failures.length, 1); + assert.equal(report.failures[0]?.stage, "attempts"); + assert.equal(report.failures[0]?.courseCode, "CS208"); +}); + +test("Blackboard grades keep attempted items, sort by latest activity, limit output, and preserve partial failures", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/me") { + return jsonResponse({ id: "_1_1", userName: "12200000", name: "Student Name" }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/_1_1/courses") { + return jsonResponse({ + results: [ + { courseId: "_8343_1", courseRoleId: "Student" }, + { courseId: "_9000_1", courseRoleId: "Student" }, + ], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1") { + return jsonResponse({ + id: "_8343_1", + name: "Physical Chemistry", + courseCode: "CHEM201", + externalId: "CHEM201-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_9000_1") { + return jsonResponse({ + id: "_9000_1", + name: "Algorithms", + courseCode: "CS208", + externalId: "CS208-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v2/courses/_8343_1/gradebook/columns") { + return jsonResponse({ + results: [ + { + id: "_991_1", + name: "Lab Report 1", + contentId: "_490876_1", + availability: { available: "Yes" }, + grading: { type: "Attempts", due: "2026-09-07T18:00:00+08:00", scoringModel: "Last" }, + score: { possible: 100 }, + scoreProviderHandle: "resource/x-bb-assignment", + }, + { + id: "_992_1", + name: "Quiz 2", + contentId: "_490877_1", + availability: { available: "Yes" }, + grading: { type: "Attempts", due: "2026-09-08T18:00:00+08:00", scoringModel: "Last" }, + scoreProviderHandle: "resource/x-bb-assignment", + }, + ], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v2/courses/_9000_1/gradebook/columns") { + return jsonResponse({ + results: [ + { + id: "_993_1", + name: "Project 1", + contentId: "_490878_1", + availability: { available: "Yes" }, + grading: { type: "Attempts", due: "2026-09-09T18:00:00+08:00", scoringModel: "Last" }, + score: { possible: 80 }, + scoreProviderHandle: "resource/x-bb-assignment", + }, + ], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v2/courses/_8343_1/gradebook/columns/_991_1/attempts?userId=_1_1") { + return jsonResponse({ + results: [{ + id: "_700_1", + userId: "_1_1", + status: "Completed", + displayGrade: { text: "95/100", score: 95 }, + attemptDate: "2026-09-01T10:00:00+08:00", + attemptReceipt: { + receiptId: "rcpt-1", + submissionDate: "2026-09-01T10:05:00+08:00", + submissionTotalSize: 1024, + courseId: "_8343_1", + gradableItemId: "_991_1", + attemptId: "_700_1", + userId: "_1_1", + responseStatus: "SUCCESS", + submissionType: "file", + }, + }], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v2/courses/_8343_1/gradebook/columns/_992_1/attempts?userId=_1_1") { + return jsonResponse({ results: [] }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v2/courses/_9000_1/gradebook/columns/_993_1/attempts?userId=_1_1") { + return jsonResponse({ + results: [{ + id: "_701_1", + userId: "_1_1", + status: "NeedsGrading", + displayGrade: { text: "Pending", score: 0 }, + attemptDate: "2026-09-02T11:00:00+08:00", + }], + }); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const report = await listBlackboardGrades(adapter, { limit: 1 }); + assert.equal(report.limit, 1); + assert.equal(report.coursesMatched, 2); + assert.equal(report.coursesScanned, 2); + assert.equal(report.totalAssignments, 3); + assert.equal(report.completedAttemptFetches, 3); + assert.equal(report.attemptedAssignments, 2); + assert.equal(report.partial, false); + assert.equal(report.grades.length, 1); + assert.equal(report.grades[0]?.courseCode, "CS208"); + assert.equal(report.grades[0]?.assignment.title, "Project 1"); + assert.equal(report.grades[0]?.attemptSummary.state, "submitted"); + assert.equal(report.grades[0]?.attemptSummary.latestAttemptDate, "2026-09-02T11:00:00+08:00"); +}); + +test("Blackboard grades can filter by submission state and keep per-assignment attempt failures partial", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/me") { + return jsonResponse({ id: "_1_1", userName: "12200000", name: "Student Name" }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/_1_1/courses") { + return jsonResponse({ + results: [{ courseId: "_8343_1", courseRoleId: "Student" }], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1") { + return jsonResponse({ + id: "_8343_1", + name: "Physical Chemistry", + courseCode: "CHEM201", + externalId: "CHEM201-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v2/courses/_8343_1/gradebook/columns") { + return jsonResponse({ + results: [ + { + id: "_991_1", + name: "Lab Report 1", + contentId: "_490876_1", + availability: { available: "Yes" }, + grading: { type: "Attempts", due: "2026-09-07T18:00:00+08:00", scoringModel: "Last" }, + scoreProviderHandle: "resource/x-bb-assignment", + }, + { + id: "_992_1", + name: "Final Project", + contentId: "_490877_1", + availability: { available: "Yes" }, + grading: { type: "Attempts", due: "2026-09-10T18:00:00+08:00", scoringModel: "Last" }, + scoreProviderHandle: "resource/x-bb-assignment", + }, + ], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v2/courses/_8343_1/gradebook/columns/_991_1/attempts?userId=_1_1") { + return jsonResponse({ + results: [{ + id: "_700_1", + userId: "_1_1", + status: "Completed", + displayGrade: { text: "95/100", score: 95 }, + attemptDate: "2026-09-01T10:00:00+08:00", + attemptReceipt: { + receiptId: "rcpt-1", + submissionDate: "2026-09-01T10:05:00+08:00", + submissionTotalSize: 1024, + courseId: "_8343_1", + gradableItemId: "_991_1", + attemptId: "_700_1", + userId: "_1_1", + responseStatus: "SUCCESS", + submissionType: "file", + }, + }], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v2/courses/_8343_1/gradebook/columns/_992_1/attempts?userId=_1_1") { + return jsonResponse({ message: "upstream unavailable" }, 503); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const report = await listBlackboardGrades(adapter, { submissionState: "completed", limit: 10 }); + assert.equal(report.submissionState, "completed"); + assert.equal(report.partial, true); + assert.equal(report.grades.length, 1); + assert.equal(report.grades[0]?.assignment.title, "Lab Report 1"); + assert.equal(report.failures.length, 1); + assert.equal(report.failures[0]?.stage, "attempts"); + assert.equal(report.failures[0]?.columnId, "992"); +}); + test("Blackboard deadlines aggregate future assignments and preserve per-course failures", async () => { + let attemptCalls = 0; const adapter = routeAdapter((url) => { if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/me") { return jsonResponse({ id: "_1_1", userName: "12200000", name: "Student Name" }); @@ -324,6 +862,10 @@ test("Blackboard deadlines aggregate future assignments and preserve per-course if (url === "https://bb.sustech.edu.cn/learn/api/public/v2/courses/_9000_1/gradebook/columns") { return jsonResponse({ message: "upstream unavailable" }, 503); } + if (url.includes("/attempts?userId=")) { + attemptCalls += 1; + throw new Error(`Attempt lookups should not happen without submission-state filtering: ${url}`); + } throw new Error(`Unexpected URL ${url}`); }); @@ -342,6 +884,355 @@ test("Blackboard deadlines aggregate future assignments and preserve per-course assert.ok(report.failures.some((failure) => /unparseable due date/i.test(failure.message))); assert.ok(report.failures.some((failure) => failure.status === 503)); assert.equal(nextBlackboardDeadline(report)?.title, "Quiz 0"); + assert.equal(attemptCalls, 0); +}); + +test("Blackboard deadlines can filter by submission state and keep attempt failures partial", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/me") { + return jsonResponse({ id: "_1_1", userName: "12200000", name: "Student Name" }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/_1_1/courses") { + return jsonResponse({ + results: [{ courseId: "_8343_1", courseRoleId: "Student" }], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1") { + return jsonResponse({ + id: "_8343_1", + name: "Physical Chemistry", + courseCode: "CHEM201", + externalId: "CHEM201-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v2/courses/_8343_1/gradebook/columns") { + return jsonResponse({ + results: [ + { + id: "_991_1", + name: "Quiz 0", + contentId: "_490875_1", + availability: { available: "Yes" }, + grading: { type: "Attempts", due: "2026-08-26T18:00:00+08:00", scoringModel: "Last" }, + scoreProviderHandle: "resource/x-bb-assignment", + }, + { + id: "_992_1", + name: "Lab Report 1", + contentId: "_490876_1", + availability: { available: "Yes" }, + grading: { type: "Attempts", due: "2026-08-27T18:00:00+08:00", scoringModel: "Last", attemptsAllowed: 2 }, + scoreProviderHandle: "resource/x-bb-assignment", + }, + { + id: "_993_1", + name: "Final Project", + contentId: "_490877_1", + availability: { available: "Yes" }, + grading: { type: "Attempts", due: "2026-08-28T18:00:00+08:00", scoringModel: "Last" }, + scoreProviderHandle: "resource/x-bb-assignment", + }, + ], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v2/courses/_8343_1/gradebook/columns/_991_1/attempts?userId=_1_1") { + return jsonResponse({ + results: [{ + id: "_700_1", + status: "Completed", + attemptDate: "2026-08-25T11:00:00+08:00", + displayGradeText: "95/100", + attemptReceipt: { + receiptId: "bb-receipt-1", + submissionDate: "2026-08-25T11:05:00+08:00", + submissionTotalSize: 1024, + courseId: "_8343_1", + gradableItemId: "_991_1", + attemptId: "_700_1", + userId: "_1_1", + responseStatus: "SUCCESS", + submissionType: "file", + }, + }], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v2/courses/_8343_1/gradebook/columns/_992_1/attempts?userId=_1_1") { + return jsonResponse({ results: [] }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v2/courses/_8343_1/gradebook/columns/_993_1/attempts?userId=_1_1") { + return jsonResponse({ message: "upstream unavailable" }, 503); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const report = await listBlackboardDeadlines(adapter, { + now: new Date("2026-08-26T12:00:00+08:00"), + days: 7, + submissionState: "not_attempted", + }); + assert.equal(report.submissionState, "not_attempted"); + assert.equal(report.partial, true); + assert.equal(report.deadlines.length, 1); + assert.equal(report.deadlines[0]?.title, "Lab Report 1"); + assert.equal(report.deadlines[0]?.attemptSummary?.state, "not_attempted"); + assert.equal(report.failures.length, 1); + assert.equal(report.failures[0]?.stage, "attempts"); + assert.equal(report.failures[0]?.columnId, "993"); +}); + +test("Blackboard content types summarize course trees and keep traversal failures partial", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/me") { + return jsonResponse({ id: "_1_1", userName: "12200000", name: "Student Name" }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/_1_1/courses") { + return jsonResponse({ + results: [ + { courseId: "_8343_1", courseRoleId: "Student" }, + { courseId: "_9000_1", courseRoleId: "Student" }, + ], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1") { + return jsonResponse({ + id: "_8343_1", + name: "Physical Chemistry", + courseCode: "CHEM201", + externalId: "CHEM201-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_9000_1") { + return jsonResponse({ + id: "_9000_1", + name: "Algorithms", + courseCode: "CS208", + externalId: "CS208-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/contents") { + return jsonResponse({ + results: [ + { + id: "_100_1", + parentId: "_8343_1", + title: "Week 1", + contentHandler: { id: "resource/x-bb-folder" }, + hasChildren: true, + }, + { + id: "_101_1", + parentId: "_8343_1", + title: "Syllabus", + contentHandler: { id: "resource/x-bb-document" }, + hasChildren: false, + }, + ], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/contents/_100_1/children") { + return jsonResponse({ + results: [ + { + id: "_102_1", + parentId: "_100_1", + title: "Lab Report 1", + contentHandler: { id: "resource/x-bb-assignment" }, + hasChildren: false, + }, + { + id: "_103_1", + parentId: "_100_1", + title: "Slides", + contentHandler: { id: "resource/x-bb-file" }, + hasChildren: false, + }, + ], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_9000_1/contents") { + return jsonResponse({ message: "upstream unavailable" }, 503); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const report = await summarizeBlackboardContentTypes(adapter); + assert.equal(report.coursesMatched, 2); + assert.equal(report.coursesScanned, 2); + assert.equal(report.totalItems, 4); + assert.equal(report.partial, true); + assert.deepEqual(report.totals, [ + { kind: "document", count: 1 }, + { kind: "assignment", count: 1 }, + { kind: "file", count: 1 }, + { kind: "folder", count: 1 }, + ]); + assert.equal(report.courses[0]?.courseCode, "CHEM201"); + assert.equal(report.courses[0]?.totalItems, 4); + assert.deepEqual(report.courses[0]?.kindCounts, [ + { kind: "document", count: 1 }, + { kind: "assignment", count: 1 }, + { kind: "file", count: 1 }, + { kind: "folder", count: 1 }, + ]); + assert.deepEqual(report.courses[0]?.handlerCounts, [ + { handler: "resource/x-bb-assignment", count: 1 }, + { handler: "resource/x-bb-document", count: 1 }, + { handler: "resource/x-bb-file", count: 1 }, + { handler: "resource/x-bb-folder", count: 1 }, + ]); + assert.equal(report.courses[1]?.courseCode, "CS208"); + assert.equal(report.courses[1]?.totalItems, 0); + assert.equal(report.failures.length, 1); + assert.equal(report.failures[0]?.stage, "content"); + assert.equal(report.failures[0]?.courseId, "_9000_1"); + assert.equal(report.failures[0]?.status, 503); +}); + +test("Blackboard content tree traverses one course recursively, supports roots, and truncates safely", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1") { + return jsonResponse({ + id: "_8343_1", + name: "Physical Chemistry", + courseCode: "CHEM201", + externalId: "CHEM201-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/contents/_100_1") { + return jsonResponse({ + id: "_100_1", + parentId: "_8343_1", + title: "Week 1", + contentHandler: { id: "resource/x-bb-folder" }, + hasChildren: true, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/contents/_100_1/children") { + return jsonResponse({ + results: [ + { + id: "_101_1", + parentId: "_100_1", + title: "Slides", + contentHandler: { id: "resource/x-bb-file" }, + hasChildren: false, + }, + { + id: "_102_1", + parentId: "_100_1", + title: "Assignment 1", + contentHandler: { id: "resource/x-bb-assignment" }, + hasChildren: true, + }, + ], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/contents/_102_1/children") { + return jsonResponse({ message: "upstream unavailable" }, 503); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const report = await listBlackboardContentTree(adapter, { + courseId: "_8343_1", + rootContentId: "_100_1", + maxItems: 2, + }); + assert.equal(report.courseId, "_8343_1"); + assert.equal(report.courseCode, "CHEM201"); + assert.equal(report.courseName, "Physical Chemistry"); + assert.equal(report.rootContentId, "100"); + assert.equal(report.maxItems, 2); + assert.equal(report.returnedItems, 2); + assert.equal(report.truncated, true); + assert.equal(report.partial, true); + assert.deepEqual(report.entries.map((entry) => ({ + contentId: entry.contentId, + depth: entry.depth, + kind: entry.kind, + path: entry.path, + })), [ + { + contentId: "100", + depth: 0, + kind: "folder", + path: "CHEM201 · Physical Chemistry / Week 1", + }, + { + contentId: "101", + depth: 1, + kind: "file", + path: "CHEM201 · Physical Chemistry / Week 1 / Slides", + }, + ]); + assert.match(report.failures[0]?.message ?? "", /stopped after 2 content items/u); +}); + +test("Blackboard content tree keeps collected entries when a descendant folder read fails", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1") { + return jsonResponse({ + id: "_8343_1", + name: "Physical Chemistry", + courseCode: "CHEM201", + externalId: "CHEM201-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/contents/_100_1") { + return jsonResponse({ + id: "_100_1", + parentId: "_8343_1", + title: "Week 1", + contentHandler: { id: "resource/x-bb-folder" }, + hasChildren: true, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/contents/_100_1/children") { + return jsonResponse({ + results: [ + { + id: "_101_1", + parentId: "_100_1", + title: "Slides", + contentHandler: { id: "resource/x-bb-file" }, + hasChildren: false, + }, + { + id: "_102_1", + parentId: "_100_1", + title: "Assignment 1", + contentHandler: { id: "resource/x-bb-assignment" }, + hasChildren: true, + }, + ], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/contents/_102_1/children") { + return jsonResponse({ message: "upstream unavailable" }, 503); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const report = await listBlackboardContentTree(adapter, { + courseId: "_8343_1", + rootContentId: "_100_1", + maxItems: 10, + }); + + assert.equal(report.returnedItems, 3); + assert.equal(report.truncated, false); + assert.equal(report.partial, true); + assert.deepEqual(report.entries.map((entry) => entry.contentId), ["100", "101", "102"]); + assert.equal(report.failures.length, 1); + assert.equal(report.failures[0]?.stage, "content"); + assert.equal(report.failures[0]?.contentId, "102"); + assert.equal(report.failures[0]?.status, 503); + assert.match(report.failures[0]?.path ?? "", /Week 1 \/ Assignment 1/u); }); test("Blackboard search defaults to title-only matches and avoids attachment lookups", async () => { diff --git a/src/test/blackboard_roster.test.ts b/src/test/blackboard_roster.test.ts new file mode 100644 index 0000000..428da93 --- /dev/null +++ b/src/test/blackboard_roster.test.ts @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { listBlackboardCourseRoster } from "../services/blackboard.js"; +import type { ServiceAdapter } from "../services/base.js"; +import { formatBlackboardRoster } from "../services/text.js"; + +test("Blackboard roster normalizes course memberships, expanded users, and paging", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1") { + return jsonResponse({ + id: "_8343_1", + name: "Physical Chemistry", + courseCode: "CHEM201", + externalId: "CHEM201-2026", + availability: { available: "Yes" }, + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/users?offset=0&limit=2&role=Student&availability.available=Yes&sort=lastAccessed%28desc%29&expand=user") { + return jsonResponse({ + results: [ + { + id: "_901_1", + userId: "_1_1", + courseId: "_8343_1", + childCourseId: "_8344_1", + created: "2026-08-20T08:00:00.000Z", + modified: "2026-08-21T08:00:00.000Z", + availability: { available: "Yes" }, + courseRoleId: "Student", + lastAccessed: "2026-09-03T09:00:00.000Z", + dueDateExceptionType: "Normal", + timeLimitExceptionType: "150", + displayOrder: 1, + user: { + id: "_1_1", + userName: "alicej", + availability: { available: "Yes" }, + name: { + given: "Alice", + family: "Jones", + other: "AJ", + preferredDisplayName: "Both", + }, + contact: { + email: "alice@example.edu", + institutionEmail: "alice@sustech.edu.cn", + }, + avatar: { + viewUrl: "https://bb.sustech.edu.cn/avatars/alice", + }, + }, + }, + { + id: "_902_1", + userId: "_2_1", + courseId: "_8343_1", + created: "2026-08-20T09:00:00.000Z", + modified: "2026-08-22T09:00:00.000Z", + availability: { available: "Yes" }, + courseRoleId: "Student", + lastAccessed: "", + }, + ], + paging: { + nextPage: "/learn/api/public/v1/courses/_8343_1/users?offset=2&limit=2", + }, + }); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const report = await listBlackboardCourseRoster(adapter, { + courseId: "8343", + role: "Student", + availability: "Yes", + page: 1, + pageSize: 2, + sort: "lastAccessed(desc)", + }); + + assert.equal(report.courseId, "_8343_1"); + assert.equal(report.courseCode, "CHEM201"); + assert.equal(report.courseName, "Physical Chemistry"); + assert.equal(report.role, "Student"); + assert.equal(report.availability, "Yes"); + assert.equal(report.sort, "lastAccessed(desc)"); + assert.equal(report.returned, 2); + assert.equal(report.hasMore, true); + assert.equal(report.nextPage, 2); + assert.equal(report.memberships[0]?.id, "901"); + assert.equal(report.memberships[0]?.courseId, "_8343_1"); + assert.equal(report.memberships[0]?.childCourseId, "_8344_1"); + assert.equal(report.memberships[0]?.displayOrder, 1); + assert.equal(report.memberships[0]?.user?.displayName, "AJ Alice Jones"); + assert.equal(report.memberships[0]?.user?.institutionEmail, "alice@sustech.edu.cn"); + assert.equal(report.memberships[1]?.userId, "_2_1"); + assert.equal(report.memberships[1]?.user, undefined); + assert.match(formatBlackboardRoster(report), /AJ Alice Jones/u); + assert.match(formatBlackboardRoster(report), /Next page: 2/u); +}); + +function routeAdapter(route: (url: string, init?: RequestInit) => Response | Promise): ServiceAdapter { + return { + name: "blackboard-roster-fixture", + async fetch(input: string, init?: RequestInit): Promise { + return route(input, init); + }, + }; +} + +function jsonResponse(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json" }, + }); +} diff --git a/src/test/blackboard_submission.test.ts b/src/test/blackboard_submission.test.ts index 9412388..76cb09b 100644 --- a/src/test/blackboard_submission.test.ts +++ b/src/test/blackboard_submission.test.ts @@ -6,13 +6,17 @@ import test from "node:test"; import { attachBlackboardAttemptFile, createBlackboardAttempt, + downloadBlackboardAttemptFile, evaluateBlackboardSubmissionPreflight, getBlackboardAttempt, getBlackboardUploadSettings, inspectBlackboardSubmissionFile, + inspectBlackboardSubmissionTextFile, listBlackboardAttemptFiles, listBlackboardAttempts, + publicBlackboardAttemptFile, readBlackboardSubmissionPayload, + readBlackboardSubmissionTextPayload, updateBlackboardAttempt, uploadBlackboardTemporaryFile, } from "../services/blackboard.js"; @@ -185,6 +189,182 @@ test("Blackboard submission helpers follow the official attempt/upload/file flow } }); +test("Blackboard attempt-file reads and downloads use the official attempt-files endpoint", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "sustech-bb-attempt-file-")); + const destination = join(tempDir, "submitted-report.pdf"); + try { + const calls: string[] = []; + const adapter = routeAdapter((url) => { + calls.push(url); + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/gradebook/attempts/_2201_1/files") { + return jsonResponse({ + results: [{ + id: "_3301_1", + name: "report.pdf", + viewUrl: "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/gradebook/attempts/_2201_1/files/_3301_1", + downloadUrl: "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/gradebook/attempts/_2201_1/files/_3301_1/download", + }], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/gradebook/attempts/_2201_1/files/_3301_1/download") { + return new Response("%PDF-1.7\nsubmitted", { + status: 200, + headers: { + "content-type": "application/pdf", + "content-length": "18", + }, + }); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const files = await listBlackboardAttemptFiles(adapter, "8343", "2201"); + assert.deepEqual(files, [{ + id: "3301", + name: "report.pdf", + viewUrl: "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/gradebook/attempts/_2201_1/files/_3301_1", + downloadUrl: "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/gradebook/attempts/_2201_1/files/_3301_1/download", + }]); + + const downloaded = await downloadBlackboardAttemptFile(adapter, "8343", "2201", "3301", destination); + assert.equal(downloaded.file.id, "3301"); + assert.equal(downloaded.file.name, "report.pdf"); + assert.equal(downloaded.destination, destination); + assert.equal(downloaded.size, 18); + assert.equal(downloaded.contentType, "application/pdf"); + assert.equal(downloaded.overwritten, false); + assert.match(downloaded.sha256, /^[0-9a-f]{64}$/); + assert.deepEqual(calls, [ + "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/gradebook/attempts/_2201_1/files", + "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/gradebook/attempts/_2201_1/files", + "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/gradebook/attempts/_2201_1/files/_3301_1/download", + ]); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("Blackboard attempt-file downloads synthesize the official download endpoint when metadata omits downloadUrl", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "sustech-bb-attempt-file-fallback-")); + const destination = join(tempDir, "submitted-report.pdf"); + try { + const calls: string[] = []; + const adapter = routeAdapter((url) => { + calls.push(url); + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/gradebook/attempts/_2201_1/files") { + return jsonResponse({ + results: [{ + id: "_3301_1", + name: "report.pdf", + }], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/gradebook/attempts/_2201_1/files/_3301_1/download") { + return new Response("%PDF-1.7\nsubmitted", { + status: 200, + headers: { + "content-type": "application/pdf", + "content-length": "18", + }, + }); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const files = await listBlackboardAttemptFiles(adapter, "8343", "2201"); + assert.deepEqual(files, [{ + id: "3301", + name: "report.pdf", + viewUrl: "", + downloadUrl: "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/gradebook/attempts/_2201_1/files/_3301_1/download", + }]); + + const downloaded = await downloadBlackboardAttemptFile(adapter, "8343", "2201", "3301", destination); + assert.equal(downloaded.file.id, "3301"); + assert.equal(downloaded.file.name, "report.pdf"); + assert.equal(downloaded.destination, destination); + assert.equal(downloaded.size, 18); + assert.equal(downloaded.contentType, "application/pdf"); + assert.equal(downloaded.overwritten, false); + assert.match(downloaded.sha256, /^[0-9a-f]{64}$/); + assert.deepEqual(calls, [ + "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/gradebook/attempts/_2201_1/files", + "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/gradebook/attempts/_2201_1/files", + "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/gradebook/attempts/_2201_1/files/_3301_1/download", + ]); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("Blackboard attempt-file downloads report unavailable when the official download endpoint is missing", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "sustech-bb-attempt-file-unavailable-")); + const destination = join(tempDir, "submitted-report.pdf"); + try { + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/gradebook/attempts/_2201_1/files") { + return jsonResponse({ + results: [{ + id: "_3301_1", + name: "report.pdf", + }], + }); + } + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/gradebook/attempts/_2201_1/files/_3301_1/download") { + return new Response("", { status: 404 }); + } + throw new Error(`Unexpected URL ${url}`); + }); + + await assert.rejects( + () => downloadBlackboardAttemptFile(adapter, "8343", "2201", "3301", destination), + (error: unknown) => Boolean( + error + && typeof error === "object" + && "code" in error + && error.code === "BLACKBOARD_ATTEMPT_FILE_UNAVAILABLE" + ), + ); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("Blackboard attempt-file reads reject unsafe URLs", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/gradebook/attempts/_2201_1/files") { + return jsonResponse({ + results: [{ + id: "_3301_1", + name: "report.pdf", + downloadUrl: "https://evil.example/download", + }], + }); + } + throw new Error(`Unexpected URL ${url}`); + }); + + await assert.rejects( + listBlackboardAttemptFiles(adapter, "8343", "2201"), + (error: unknown) => Boolean(error && typeof error === "object" && "code" in error && error.code === "UNSAFE_SERVICE_URL"), + ); +}); + +test("Blackboard public attempt-file output omits view and download URLs", () => { + assert.deepEqual( + publicBlackboardAttemptFile({ + id: "3301", + name: "report.pdf", + viewUrl: "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/gradebook/attempts/_2201_1/files/_3301_1", + downloadUrl: "https://bb.sustech.edu.cn/learn/api/public/v1/courses/_8343_1/gradebook/attempts/_2201_1/files/_3301_1/download", + }), + { + id: "3301", + name: "report.pdf", + }, + ); +}); + test("Blackboard submission preflight surfaces blockers and late-submission warnings", () => { const preflight = evaluateBlackboardSubmissionPreflight({ assignment: { @@ -219,12 +399,15 @@ test("Blackboard submission preflight surfaces blockers and late-submission warn modified: "2026-08-25T10:00:00.000Z", attemptDate: "2026-08-25T10:00:00.000Z", }], - file: { - path: "report.pdf", - absolutePath: "/tmp/report.pdf", - name: "report.pdf", - size: 2048, - sha256: "0".repeat(64), + submission: { + kind: "file", + file: { + path: "report.pdf", + absolutePath: "/tmp/report.pdf", + name: "report.pdf", + size: 2048, + sha256: "0".repeat(64), + }, }, uploadSettings: { supportsInlineRender: true, @@ -258,22 +441,59 @@ test("Blackboard submission preflight surfaces blockers and late-submission warn id: "629897", parentId: "0", title: "Ultra assessment", - handler: "resource/x-bb-assessment", - kind: "unknown", + handler: "resource/x-bb-asmt-test-link", + kind: "assignment", hasChildren: false, }, attempts: [], - file: { - path: "answer.txt", - absolutePath: "/tmp/answer.txt", - name: "answer.txt", - size: 10, - sha256: "1".repeat(64), + submission: { + kind: "text", + text: { + path: "answer.txt", + absolutePath: "/tmp/answer.txt", + size: 10, + sha256: "1".repeat(64), + charCount: 10, + }, }, uploadSettings: { supportsInlineRender: true, maxUploadSizeInBytes: 1024 }, now: new Date("2026-08-26T00:00:00.000Z"), }); - assert.deepEqual(unsupported.blockers.map((entry) => entry.code), [ + assert.equal(unsupported.ready, true); + assert.deepEqual(unsupported.blockers, []); + + const ultraFile = evaluateBlackboardSubmissionPreflight({ + assignment: { + id: "993", + contentId: "629898", + title: "Ultra file upload", + availability: "Yes", + grading: { type: "Attempts", attemptsAllowed: 1, scoringModel: "Last" }, + scoreProviderHandle: "resource/x-bb-assessment", + }, + content: { + id: "629898", + parentId: "0", + title: "Ultra file upload", + handler: "resource/x-bb-asmt-test-link", + kind: "assignment", + hasChildren: false, + }, + attempts: [], + submission: { + kind: "file", + file: { + path: "answer.txt", + absolutePath: "/tmp/answer.txt", + name: "answer.txt", + size: 10, + sha256: "1".repeat(64), + }, + }, + uploadSettings: { supportsInlineRender: true, maxUploadSizeInBytes: 1024 }, + now: new Date("2026-08-26T00:00:00.000Z"), + }); + assert.deepEqual(ultraFile.blockers.map((entry) => entry.code), [ "UNSUPPORTED_CONTENT_TYPE", "UNSUPPORTED_SCORE_PROVIDER", ]); @@ -308,6 +528,36 @@ test("Blackboard upload binds the exact bytes to the inspected SHA-256 before an } }); +test("Blackboard text submission files must be UTF-8, non-empty regular files", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "sustech-bb-text-")); + const textPath = join(tempDir, "answer.txt"); + const emptyPath = join(tempDir, "empty.txt"); + const invalidPath = join(tempDir, "binary.txt"); + await writeFile(textPath, "第一题答案\nSecond line", "utf8"); + await writeFile(emptyPath, ""); + await writeFile(invalidPath, Buffer.from([0xc3, 0x28])); + + try { + const payload = await readBlackboardSubmissionTextPayload(textPath); + const inspected = await inspectBlackboardSubmissionTextFile(textPath); + assert.equal(payload.text, "第一题答案\nSecond line"); + assert.equal(payload.textFile.absolutePath, textPath); + assert.equal(payload.textFile.charCount, [...payload.text].length); + assert.deepEqual(inspected, payload.textFile); + + await assert.rejects( + readBlackboardSubmissionTextPayload(emptyPath), + (error: unknown) => Boolean(error && typeof error === "object" && "code" in error && error.code === "BLACKBOARD_TEXT_FILE_EMPTY"), + ); + await assert.rejects( + readBlackboardSubmissionTextPayload(invalidPath), + (error: unknown) => Boolean(error && typeof error === "object" && "code" in error && error.code === "BLACKBOARD_TEXT_FILE_NOT_UTF8"), + ); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + function routeAdapter(route: (url: string, init?: RequestInit) => Response | Promise): ServiceAdapter { return { name: "fixture", diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index 6b92f4b..59f593e 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -96,6 +96,274 @@ test("online commands are registered and reject unsafe IDs before network access const described = run(["describe", "online", "talks", "search", "--json"]); assert.equal(described.status, 0); assert.equal(JSON.parse(described.stdout).data.command, "online talks search"); + const describedOnlineSearch = run(["describe", "online", "search", "--json"]); + assert.equal(describedOnlineSearch.status, 0); + assert.ok(JSON.parse(describedOnlineSearch.stdout).data.options.some((entry: { name: string }) => entry.name === "--source")); + + const describedManualList = run(["describe", "online", "manual", "list", "--json"]); + assert.equal(describedManualList.status, 0); + assert.equal(JSON.parse(describedManualList.stdout).data.command, "online manual list"); + + const describedManualGet = run(["describe", "online", "manual", "get", "--json"]); + assert.equal(describedManualGet.status, 0); + assert.equal(JSON.parse(describedManualGet.stdout).data.command, "online manual get"); + + const invalidManualWindow = run(["online", "search", "校园卡", "--section", "manual", "--since", "2026-09-01", "--json"]); + assert.equal(invalidManualWindow.status, 2); + assert.equal(JSON.parse(invalidManualWindow.stdout).error.code, "USAGE"); + + const misplacedManualSource = run(["online", "search", "校园卡", "--source", "service", "--json"]); + assert.equal(misplacedManualSource.status, 2); + assert.equal(JSON.parse(misplacedManualSource.stdout).error.code, "USAGE"); + + const invalidManualSource = run(["online", "manual", "list", "--source", "unknown", "--json"]); + assert.equal(invalidManualSource.status, 2); + assert.equal(JSON.parse(invalidManualSource.stdout).error.code, "USAGE"); +}); + +test("extended NCES and Blackboard announcement commands expose strict metadata and validate locally", () => { + const filterOptions = run(["describe", "nces", "filter-options", "--json"]); + assert.equal(filterOptions.status, 0); + assert.equal(JSON.parse(filterOptions.stdout).data.command, "nces filter-options"); + + const globalStats = run(["describe", "nces", "global-stats", "--json"]); + assert.equal(globalStats.status, 0); + assert.equal(JSON.parse(globalStats.stdout).data.command, "nces global-stats"); + + const described = run(["describe", "nces", "reviews", "--json"]); + assert.equal(described.status, 0); + const envelope = JSON.parse(described.stdout); + assert.equal(envelope.data.command, "nces reviews"); + assert.equal(envelope.data.capability.kind, "read"); + for (const option of ["--page", "--page-size", "--sort", "--term", "--rating"]) { + assert.ok(envelope.data.options.some((entry: { name: string }) => entry.name === option)); + } + + const browse = run(["describe", "nces", "browse", "--json"]); + assert.equal(browse.status, 0); + assert.ok(JSON.parse(browse.stdout).data.options.some((entry: { name: string }) => entry.name === "--offering-unit")); + + const rankings = run(["describe", "nces", "rankings", "--json"]); + assert.equal(rankings.status, 0); + assert.ok(JSON.parse(rankings.stdout).data.options.some((entry: { name: string }) => entry.name === "--limit")); + + const byCode = run(["describe", "nces", "by-code", "--json"]); + assert.equal(byCode.status, 0); + assert.ok(JSON.parse(byCode.stdout).data.options.some((entry: { name: string }) => entry.name === "--all-reviews")); + assert.ok(JSON.parse(byCode.stdout).data.options.some((entry: { name: string }) => entry.name === "--teacher")); + + const course = run(["describe", "nces", "course", "--json"]); + assert.equal(course.status, 0); + assert.ok(JSON.parse(course.stdout).data.options.some((entry: { name: string }) => entry.name === "--all-reviews")); + + const invalidRating = run(["nces", "reviews", "244", "--rating", "11", "--json"]); + assert.equal(invalidRating.status, 2); + assert.equal(JSON.parse(invalidRating.stdout).error.code, "USAGE"); + + const invalidTerm = run(["nces", "by-code", "CS302", "--term", "2025-spring", "--json"]); + assert.equal(invalidTerm.status, 2); + assert.equal(JSON.parse(invalidTerm.stdout).error.code, "USAGE"); + + const invalidRankingCategory = run(["nces", "rankings", "wrong", "--json"]); + assert.equal(invalidRankingCategory.status, 2); + assert.equal(JSON.parse(invalidRankingCategory.stdout).error.code, "USAGE"); + + const invalidRankingLimit = run(["nces", "rankings", "top-users", "--limit", "51", "--json"]); + assert.equal(invalidRankingLimit.status, 2); + assert.equal(JSON.parse(invalidRankingLimit.stdout).error.code, "USAGE"); + + const invalidDays = runWithoutCredentials(["bb", "announcements", "--days", "0", "--json"]); + assert.equal(invalidDays.status, 2); + assert.equal(JSON.parse(invalidDays.stdout).error.code, "USAGE"); + + const describedDeadlines = run(["describe", "bb", "deadlines", "--json"]); + assert.equal(describedDeadlines.status, 0); + assert.ok(JSON.parse(describedDeadlines.stdout).data.options.some((entry: { name: string }) => entry.name === "--submission-state")); + + const describedAssignments = run(["describe", "bb", "assignments", "--json"]); + assert.equal(describedAssignments.status, 0); + assert.ok(JSON.parse(describedAssignments.stdout).data.options.some((entry: { name: string }) => entry.name === "--course")); + assert.ok(JSON.parse(describedAssignments.stdout).data.options.some((entry: { name: string }) => entry.name === "--with-attempts")); + assert.ok(JSON.parse(describedAssignments.stdout).data.options.some((entry: { name: string }) => entry.name === "--submission-state")); + + const describedRoster = run(["describe", "bb", "roster", "--json"]); + assert.equal(describedRoster.status, 0); + assert.equal(JSON.parse(describedRoster.stdout).data.command, "bb roster"); + assert.match(JSON.parse(describedRoster.stdout).data.usage[0], /^sustech bb roster COURSE_ID /); + for (const option of ["--role", "--availability", "--page", "--page-size", "--sort"]) { + assert.ok(JSON.parse(describedRoster.stdout).data.options.some((entry: { name: string }) => entry.name === option)); + } + + const describedMessageFolders = run(["describe", "bb", "message-folders", "--json"]); + assert.equal(describedMessageFolders.status, 0); + assert.equal(JSON.parse(describedMessageFolders.stdout).data.command, "bb message-folders"); + assert.match(JSON.parse(describedMessageFolders.stdout).data.usage[0], /^sustech bb message-folders COURSE_ID /); + for (const option of ["--page", "--page-size"]) { + assert.ok(JSON.parse(describedMessageFolders.stdout).data.options.some((entry: { name: string }) => entry.name === option)); + } + + const describedMessages = run(["describe", "bb", "messages", "--json"]); + assert.equal(describedMessages.status, 0); + assert.equal(JSON.parse(describedMessages.stdout).data.command, "bb messages"); + assert.match(JSON.parse(describedMessages.stdout).data.usage[0], /^sustech bb messages COURSE_ID /); + for (const option of ["--folder-type", "--folder-name", "--page", "--page-size", "--sort"]) { + assert.ok(JSON.parse(describedMessages.stdout).data.options.some((entry: { name: string }) => entry.name === option)); + } + + const describedMessageParticipants = run(["describe", "bb", "message-participants", "--json"]); + assert.equal(describedMessageParticipants.status, 0); + assert.equal(JSON.parse(describedMessageParticipants.stdout).data.command, "bb message-participants"); + assert.match(JSON.parse(describedMessageParticipants.stdout).data.usage[0], /^sustech bb message-participants COURSE_ID MESSAGE_ID /); + for (const option of ["--participation-type", "--page", "--page-size", "--sort"]) { + assert.ok(JSON.parse(describedMessageParticipants.stdout).data.options.some((entry: { name: string }) => entry.name === option)); + } + + const describedMessageSend = run(["describe", "bb", "message-send", "preview", "--json"]); + assert.equal(describedMessageSend.status, 0); + assert.equal(JSON.parse(describedMessageSend.stdout).data.command, "bb message-send preview"); + assert.match(JSON.parse(describedMessageSend.stdout).data.usage[0], /^sustech bb message-send preview COURSE_ID /); + for (const option of ["--subject", "--to-user", "--cc-user", "--bcc-user", "--text-file", "--browser", "--interactive"]) { + assert.ok(JSON.parse(describedMessageSend.stdout).data.options.some((entry: { name: string }) => entry.name === option)); + } + + const describedDiscussions = run(["describe", "bb", "discussions", "--json"]); + assert.equal(describedDiscussions.status, 0); + assert.equal(JSON.parse(describedDiscussions.stdout).data.command, "bb discussions"); + assert.match(JSON.parse(describedDiscussions.stdout).data.usage[0], /^sustech bb discussions COURSE_ID /); + for (const option of ["--title", "--gradable", "--page", "--page-size", "--sort"]) { + assert.ok(JSON.parse(describedDiscussions.stdout).data.options.some((entry: { name: string }) => entry.name === option)); + } + + const describedDiscussionGroups = run(["describe", "bb", "discussion-groups", "--json"]); + assert.equal(describedDiscussionGroups.status, 0); + assert.equal(JSON.parse(describedDiscussionGroups.stdout).data.command, "bb discussion-groups"); + assert.match(JSON.parse(describedDiscussionGroups.stdout).data.usage[0], /^sustech bb discussion-groups COURSE_ID DISCUSSION_ID /); + for (const option of ["--page", "--page-size", "--sort"]) { + assert.ok(JSON.parse(describedDiscussionGroups.stdout).data.options.some((entry: { name: string }) => entry.name === option)); + } + + const describedDiscussion = run(["describe", "bb", "discussion", "--json"]); + assert.equal(describedDiscussion.status, 0); + assert.equal(JSON.parse(describedDiscussion.stdout).data.command, "bb discussion"); + assert.match(JSON.parse(describedDiscussion.stdout).data.usage[0], /^sustech bb discussion COURSE_ID DISCUSSION_ID /); + for (const option of ["--group-id", "--user-id", "--status", "--is-read", "--page", "--page-size", "--sort"]) { + assert.ok(JSON.parse(describedDiscussion.stdout).data.options.some((entry: { name: string }) => entry.name === option)); + } + + const describedDiscussionReplies = run(["describe", "bb", "discussion-replies", "--json"]); + assert.equal(describedDiscussionReplies.status, 0); + assert.equal(JSON.parse(describedDiscussionReplies.stdout).data.command, "bb discussion-replies"); + assert.match(JSON.parse(describedDiscussionReplies.stdout).data.usage[0], /^sustech bb discussion-replies COURSE_ID DISCUSSION_ID MESSAGE_ID /); + for (const option of ["--group-id", "--user-id", "--status", "--is-read", "--page", "--page-size", "--sort"]) { + assert.ok(JSON.parse(describedDiscussionReplies.stdout).data.options.some((entry: { name: string }) => entry.name === option)); + } + + const describedDiscussionPost = run(["describe", "bb", "discussion-post", "preview", "--json"]); + assert.equal(describedDiscussionPost.status, 0); + assert.equal(JSON.parse(describedDiscussionPost.stdout).data.command, "bb discussion-post preview"); + assert.match(JSON.parse(describedDiscussionPost.stdout).data.usage[0], /^sustech bb discussion-post preview COURSE_ID DISCUSSION_ID /); + for (const option of ["--text-file", "--group-id", "--status", "--browser", "--interactive"]) { + assert.ok(JSON.parse(describedDiscussionPost.stdout).data.options.some((entry: { name: string }) => entry.name === option)); + } + + const describedDiscussionReply = run(["describe", "bb", "discussion-reply", "apply", "--json"]); + assert.equal(describedDiscussionReply.status, 0); + assert.equal(JSON.parse(describedDiscussionReply.stdout).data.command, "bb discussion-reply apply"); + assert.match(JSON.parse(describedDiscussionReply.stdout).data.usage[0], /^sustech bb discussion-reply apply COURSE_ID DISCUSSION_ID MESSAGE_ID /); + for (const option of ["--text-file", "--group-id", "--status", "--expected-sha256", "--confirm"]) { + assert.ok(JSON.parse(describedDiscussionReply.stdout).data.options.some((entry: { name: string }) => entry.name === option)); + } + + const describedGrades = run(["describe", "bb", "grades", "--json"]); + assert.equal(describedGrades.status, 0); + assert.equal(JSON.parse(describedGrades.stdout).data.command, "bb grades"); + for (const option of ["--course", "--submission-state", "--limit"]) { + assert.ok(JSON.parse(describedGrades.stdout).data.options.some((entry: { name: string }) => entry.name === option)); + } + + const invalidAssignmentCourse = runWithoutCredentials(["bb", "assignments", "deadbeef;echo", "--with-attempts", "--json"]); + assert.equal(invalidAssignmentCourse.status, 2); + assert.equal(JSON.parse(invalidAssignmentCourse.stdout).error.code, "USAGE"); + + const conflictingAssignmentSelectors = runWithoutCredentials(["bb", "assignments", "_8343_1", "--course", "CS208", "--json"]); + assert.equal(conflictingAssignmentSelectors.status, 2); + assert.equal(JSON.parse(conflictingAssignmentSelectors.stdout).error.code, "USAGE"); + + const invalidSubmissionState = runWithoutCredentials(["bb", "assignments", "_8343_1", "--submission-state", "done", "--json"]); + assert.equal(invalidSubmissionState.status, 2); + assert.equal(JSON.parse(invalidSubmissionState.stdout).error.code, "USAGE"); + + const invalidGradesSubmissionState = runWithoutCredentials(["bb", "grades", "--submission-state", "not_attempted", "--json"]); + assert.equal(invalidGradesSubmissionState.status, 2); + assert.equal(JSON.parse(invalidGradesSubmissionState.stdout).error.code, "USAGE"); + + const invalidGradesLimit = runWithoutCredentials(["bb", "grades", "--limit", "201", "--json"]); + assert.equal(invalidGradesLimit.status, 2); + assert.equal(JSON.parse(invalidGradesLimit.stdout).error.code, "USAGE"); + + const invalidDeadlineSubmissionState = runWithoutCredentials(["bb", "deadlines", "--submission-state", "done", "--json"]); + assert.equal(invalidDeadlineSubmissionState.status, 2); + assert.equal(JSON.parse(invalidDeadlineSubmissionState.stdout).error.code, "USAGE"); + + const invalidRosterAvailability = runWithoutCredentials(["bb", "roster", "_8343_1", "--availability", "maybe", "--json"]); + assert.equal(invalidRosterAvailability.status, 2); + assert.equal(JSON.parse(invalidRosterAvailability.stdout).error.code, "USAGE"); + + const invalidRosterPageSize = runWithoutCredentials(["bb", "roster", "_8343_1", "--page-size", "101", "--json"]); + assert.equal(invalidRosterPageSize.status, 2); + assert.equal(JSON.parse(invalidRosterPageSize.stdout).error.code, "USAGE"); + + const invalidDiscussionWriteStatus = runWithoutCredentials(["bb", "discussion-post", "preview", "_8343_1", "_65_1", "--status", "wrong", "--json"]); + assert.equal(invalidDiscussionWriteStatus.status, 2); + assert.equal(JSON.parse(invalidDiscussionWriteStatus.stdout).error.code, "USAGE"); + + const invalidMessageFolderType = runWithoutCredentials(["bb", "messages", "_8343_1", "--folder-type", "Archive", "--json"]); + assert.equal(invalidMessageFolderType.status, 2); + assert.equal(JSON.parse(invalidMessageFolderType.stdout).error.code, "USAGE"); + + const invalidMessageFolderName = runWithoutCredentials(["bb", "messages", "_8343_1", "--folder-name", "Project Team", "--json"]); + assert.equal(invalidMessageFolderName.status, 2); + assert.equal(JSON.parse(invalidMessageFolderName.stdout).error.code, "USAGE"); + + const invalidCustomMessageFolder = runWithoutCredentials(["bb", "messages", "_8343_1", "--folder-type", "Custom", "--json"]); + assert.equal(invalidCustomMessageFolder.status, 2); + assert.equal(JSON.parse(invalidCustomMessageFolder.stdout).error.code, "USAGE"); + + const invalidMessagePageSize = runWithoutCredentials(["bb", "message-folders", "_8343_1", "--page-size", "101", "--json"]); + assert.equal(invalidMessagePageSize.status, 2); + assert.equal(JSON.parse(invalidMessagePageSize.stdout).error.code, "USAGE"); + + const invalidMessageParticipantType = runWithoutCredentials(["bb", "message-participants", "_8343_1", "_71_1", "--participation-type", "all", "--json"]); + assert.equal(invalidMessageParticipantType.status, 2); + assert.equal(JSON.parse(invalidMessageParticipantType.stdout).error.code, "USAGE"); + + const invalidMessageSendRecipients = runWithoutCredentials(["bb", "message-send", "preview", "_8343_1", "--text-file", "/tmp/msg.txt", "--json"]); + assert.equal(invalidMessageSendRecipients.status, 2); + assert.equal(JSON.parse(invalidMessageSendRecipients.stdout).error.code, "USAGE"); + + const duplicateMessageSendRecipients = runWithoutCredentials(["bb", "message-send", "preview", "_8343_1", "--to-user", "_1_1", "--cc-user", "1", "--text-file", "/tmp/msg.txt", "--json"]); + assert.equal(duplicateMessageSendRecipients.status, 2); + assert.equal(JSON.parse(duplicateMessageSendRecipients.stdout).error.code, "USAGE"); + + const invalidDiscussionGradable = runWithoutCredentials(["bb", "discussions", "_8343_1", "--gradable", "maybe", "--json"]); + assert.equal(invalidDiscussionGradable.status, 2); + assert.equal(JSON.parse(invalidDiscussionGradable.stdout).error.code, "USAGE"); + + const invalidDiscussionStatus = runWithoutCredentials(["bb", "discussion", "_8343_1", "_65_1", "--status", "posted", "--json"]); + assert.equal(invalidDiscussionStatus.status, 2); + assert.equal(JSON.parse(invalidDiscussionStatus.stdout).error.code, "USAGE"); + + const invalidDiscussionRead = runWithoutCredentials(["bb", "discussion", "_8343_1", "_65_1", "--is-read", "yes", "--json"]); + assert.equal(invalidDiscussionRead.status, 2); + assert.equal(JSON.parse(invalidDiscussionRead.stdout).error.code, "USAGE"); + + const invalidDiscussionGroupPageSize = runWithoutCredentials(["bb", "discussion-groups", "_8343_1", "_65_1", "--page-size", "101", "--json"]); + assert.equal(invalidDiscussionGroupPageSize.status, 2); + assert.equal(JSON.parse(invalidDiscussionGroupPageSize.stdout).error.code, "USAGE"); + + const invalidDiscussionPageSize = runWithoutCredentials(["bb", "discussion-replies", "_8343_1", "_65_1", "_71_1", "--page-size", "101", "--json"]); + assert.equal(invalidDiscussionPageSize.status, 2); + assert.equal(JSON.parse(invalidDiscussionPageSize.stdout).error.code, "USAGE"); }); test("enrollment preview is a no-network command with an exact apply handoff", () => { @@ -241,6 +509,59 @@ test("blackboard apply requires both the preview hash and explicit confirmation } }); +test("blackboard text submission preview validates the local text file before requiring Blackboard authentication", () => { + const tempDir = mkdtempSync(join(tmpdir(), "sustech-cli-bb-text-")); + const textPath = join(tempDir, "answer.txt"); + writeFileSync(textPath, "第一题答案\nSecond line", "utf8"); + + try { + const result = runWithoutCredentials([ + "bb", "submit", "preview", + "--course-id", "_8537_1", + "--content-id", "_629896_1", + "--text-file", textPath, + "--comment", "inline essay", + "--json", + ]); + assert.equal(result.status, 2); + const envelope = JSON.parse(result.stdout); + assert.equal(envelope.error.code, "CREDENTIALS_REQUIRED"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test("blackboard text apply requires both the preview hash and explicit confirmation before authentication", () => { + const tempDir = mkdtempSync(join(tmpdir(), "sustech-cli-bb-text-apply-")); + const textPath = join(tempDir, "answer.txt"); + const contents = "reviewed inline answer"; + writeFileSync(textPath, contents, "utf8"); + const sha256 = createHash("sha256").update(contents).digest("hex"); + const baseArgs = [ + "bb", "submit", "apply", + "--course-id", "_8537_1", + "--content-id", "_629896_1", + "--text-file", textPath, + ]; + + try { + const unconfirmed = runWithoutCredentials([...baseArgs, "--expected-sha256", sha256, "--json"]); + assert.equal(unconfirmed.status, 3); + assert.equal(JSON.parse(unconfirmed.stdout).error.code, "CONFIRMATION_REQUIRED"); + + const mismatched = runWithoutCredentials([ + ...baseArgs, + "--expected-sha256", "0".repeat(64), + "--confirm", + "--json", + ]); + assert.equal(mismatched.status, 4); + assert.equal(JSON.parse(mismatched.stdout).error.code, "BLACKBOARD_FILE_HASH_MISMATCH"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + test("context accepts calendar-level and help documents it", () => { const invalid = run(["context", "--calendar-level", "doctoral", "--json"]); assert.equal(invalid.status, 2); @@ -252,11 +573,27 @@ test("context accepts calendar-level and help documents it", () => { const help = run(["--help"]); assert.equal(help.status, 0); assert.match(help.stdout, /sustech describe COMMAND\.\.\. \[--json\|--jsonl\]/); + assert.match(help.stdout, /sustech doctor \[--profile NAME\] \[--credentials-file PATH\] \[--service all\|tis,bb,ws,booking,lib-booking,pms\] \[--live\] \[--browser \[--interactive\]\]/); + assert.match(help.stdout, /sustech auth check \[--profile NAME\] \[--service tis\|bb\|ws\|booking\|lib-booking\|library-booking\|pms\] \[--credentials-file PATH\] \[--browser \[--interactive\]\] \[--json\|\--jsonl\]/); assert.match(help.stdout, /sustech context \[--date YYYY-MM-DD\] \[--calendar-level undergraduate\|graduate\] \[--level terse\|normal\|verbose\] \[--live\] \[--credentials-file PATH\]/); assert.match(help.stdout, /sustech academic changes BEFORE AFTER/); assert.match(help.stdout, /sustech academic watch --state PATH \[--semester YYYY-YYYY-N\] \[--include-blackboard\] \[--overwrite\]/); assert.match(help.stdout, /sustech library search QUERY \[--limit N\] \[--browser \[--interactive\]\]/); assert.match(help.stdout, /sustech library detail CONTEXT:DOC_ID \[--browser \[--interactive\]\]/); + assert.match(help.stdout, /sustech online search QUERY \[--section talks\|contact\|manual\] \[--source SOURCE\]\.\.\. \[--since YYYY-MM-DD\] \[--until YYYY-MM-DD\] \[--limit N\]/); + assert.match(help.stdout, /sustech bb user \[--browser \[--interactive\]\]/); + assert.match(help.stdout, /sustech bb tree COURSE_ID \[--content-id CONTENT_ID\] \[--max N\] \[--browser \[--interactive\]\]/); + assert.match(help.stdout, /sustech bb types \[--course QUERY\] \[--browser \[--interactive\]\]/); + assert.match(help.stdout, /sustech bb roster COURSE_ID \[--role ROLE_ID\] \[--availability Yes\|No\|Disabled\] \[--page N\] \[--page-size N\] \[--sort FIELD\[\(desc\)\]\] \[--browser \[--interactive\]\]/); + assert.match(help.stdout, /sustech bb message-folders COURSE_ID \[--page N\] \[--page-size N\] \[--browser \[--interactive\]\]/); + assert.match(help.stdout, /sustech bb messages COURSE_ID \[--folder-type Inbox\|Sent\|Delete\|Custom\] \[--folder-name NAME\] \[--page N\] \[--page-size N\] \[--sort FIELD\[\(desc\)\]\] \[--browser \[--interactive\]\]/); + assert.match(help.stdout, /sustech bb message-participants COURSE_ID MESSAGE_ID \[--participation-type From\|To\|Cc\|Bcc\] \[--page N\] \[--page-size N\] \[--sort FIELD\[\(desc\)\]\] \[--browser \[--interactive\]\]/); + assert.match(help.stdout, /sustech bb message-send preview COURSE_ID \[--subject TEXT\] \[--to-user USER_ID\]\.\.\. \[--cc-user USER_ID\]\.\.\. \[--bcc-user USER_ID\]\.\.\. --text-file PATH \[--browser \[--interactive\]\]/); + assert.match(help.stdout, /sustech bb message-send apply COURSE_ID \[--subject TEXT\] \[--to-user USER_ID\]\.\.\. \[--cc-user USER_ID\]\.\.\. \[--bcc-user USER_ID\]\.\.\. --text-file PATH --expected-sha256 HEX --confirm/); + assert.match(help.stdout, /sustech bb discussion-groups COURSE_ID DISCUSSION_ID \[--page N\] \[--page-size N\] \[--sort FIELD\[\(desc\)\]\] \[--browser \[--interactive\]\]/); + assert.match(help.stdout, /sustech bb discussion-post preview COURSE_ID DISCUSSION_ID --text-file PATH \[--group-id GROUP_ID\] \[--status Published\|Deleted\|Draft\] \[--browser \[--interactive\]\]/); + assert.match(help.stdout, /sustech bb discussion-reply apply COURSE_ID DISCUSSION_ID MESSAGE_ID --text-file PATH --expected-sha256 HEX \[--group-id GROUP_ID\] \[--status Published\|Deleted\|Draft\] --confirm/); + assert.match(help.stdout, /sustech bb submit preview .* \[--browser \[--interactive\]\]/); assert.match(help.stdout, /sustech online talks search QUERY \[--since YYYY-MM-DD\] \[--until YYYY-MM-DD\] \[--limit N\]/); assert.match(help.stdout, /sustech tis plan explain COURSE_OR_RWH --round ROUND/); assert.match(help.stdout, /sustech tis plan recommend \[CODE\.\.\.\] --round ROUND/); @@ -274,8 +611,77 @@ test("describe exposes structured command metadata without parsing the full help assert.ok(Array.isArray(envelope.data.usage)); assert.ok(envelope.data.usage[0].startsWith("sustech bb submit apply")); assert.ok(envelope.data.options.some((entry: { name: string }) => entry.name === "--expected-sha256")); + assert.ok(envelope.data.options.some((entry: { name: string }) => entry.name === "--text-file")); assert.ok(envelope.data.options.some((entry: { name: string; shared: boolean }) => entry.name === "--json" && entry.shared === true)); assert.ok(envelope.data.consequences.some((entry: { operation: string }) => entry.operation === "blackboard.submit")); + + const discussionPost = run(["describe", "bb", "discussion-post", "apply", "--json"]); + assert.equal(discussionPost.status, 0); + const discussionPostEnvelope = JSON.parse(discussionPost.stdout); + assert.equal(discussionPostEnvelope.data.command, "bb discussion-post apply"); + assert.equal(discussionPostEnvelope.data.capability.kind, "mutation"); + assert.ok(discussionPostEnvelope.data.options.some((entry: { name: string }) => entry.name === "--text-file")); + assert.ok(discussionPostEnvelope.data.options.some((entry: { name: string }) => entry.name === "--expected-sha256")); + assert.ok(discussionPostEnvelope.data.consequences.some((entry: { operation: string }) => entry.operation === "blackboard.discussion-post")); + + const discussionReply = run(["describe", "bb", "discussion-reply", "preview", "--json"]); + assert.equal(discussionReply.status, 0); + const discussionReplyEnvelope = JSON.parse(discussionReply.stdout); + assert.equal(discussionReplyEnvelope.data.command, "bb discussion-reply preview"); + assert.equal(discussionReplyEnvelope.data.capability.kind, "plan"); + assert.ok(discussionReplyEnvelope.data.options.some((entry: { name: string }) => entry.name === "--text-file")); + assert.ok(discussionReplyEnvelope.data.options.some((entry: { name: string }) => entry.name === "--browser")); + + const messageSend = run(["describe", "bb", "message-send", "apply", "--json"]); + assert.equal(messageSend.status, 0); + const messageSendEnvelope = JSON.parse(messageSend.stdout); + assert.equal(messageSendEnvelope.data.command, "bb message-send apply"); + assert.equal(messageSendEnvelope.data.capability.kind, "mutation"); + assert.ok(messageSendEnvelope.data.options.some((entry: { name: string }) => entry.name === "--to-user")); + assert.ok(messageSendEnvelope.data.options.some((entry: { name: string }) => entry.name === "--expected-sha256")); + assert.ok(messageSendEnvelope.data.consequences.some((entry: { operation: string }) => entry.operation === "blackboard.message-send")); + + const authCheck = run(["describe", "auth", "check", "--json"]); + assert.equal(authCheck.status, 0); + const authCheckEnvelope = JSON.parse(authCheck.stdout); + assert.equal(authCheckEnvelope.data.command, "auth check"); + assert.ok(authCheckEnvelope.data.options.some((entry: { name: string }) => entry.name === "--browser")); + assert.ok(authCheckEnvelope.data.options.some((entry: { name: string }) => entry.name === "--interactive")); + + const doctor = run(["describe", "doctor", "--json"]); + assert.equal(doctor.status, 0); + const doctorEnvelope = JSON.parse(doctor.stdout); + assert.equal(doctorEnvelope.data.command, "doctor"); + assert.ok(doctorEnvelope.data.options.some((entry: { name: string }) => entry.name === "--browser")); + assert.ok(doctorEnvelope.data.options.some((entry: { name: string }) => entry.name === "--interactive")); +}); + +test("describe exposes blackboard type summaries as a read-only authenticated command", () => { + const result = run(["describe", "bb", "types", "--json"]); + assert.equal(result.status, 0); + const envelope = JSON.parse(result.stdout); + assert.equal(envelope.command, "describe"); + assert.equal(envelope.data.command, "bb types"); + assert.equal(envelope.data.capability.kind, "read"); + assert.equal(envelope.data.capability.authentication, "bb"); + assert.ok(envelope.data.usage.some((entry: string) => entry.startsWith("sustech bb types"))); + assert.ok(envelope.data.options.some((entry: { name: string }) => entry.name === "--course")); + assert.ok(envelope.data.options.some((entry: { name: string }) => entry.name === "--browser")); + assert.ok(envelope.data.options.some((entry: { name: string }) => entry.name === "--interactive")); +}); + +test("describe exposes blackboard tree traversal as a read-only authenticated command", () => { + const result = run(["describe", "bb", "tree", "--json"]); + assert.equal(result.status, 0); + const envelope = JSON.parse(result.stdout); + assert.equal(envelope.command, "describe"); + assert.equal(envelope.data.command, "bb tree"); + assert.equal(envelope.data.capability.kind, "read"); + assert.equal(envelope.data.capability.authentication, "bb"); + assert.ok(envelope.data.usage.some((entry: string) => entry.startsWith("sustech bb tree COURSE_ID"))); + assert.ok(envelope.data.options.some((entry: { name: string }) => entry.name === "--content-id")); + assert.ok(envelope.data.options.some((entry: { name: string }) => entry.name === "--max")); + assert.ok(envelope.data.options.some((entry: { name: string }) => entry.name === "--browser")); }); test("describe exposes typed public library catalog reads", () => { @@ -318,11 +724,32 @@ test("describe exposes advisory TIS plan decision commands and their live-read o }); test("blackboard content attachment commands keep selection and local writes explicit", () => { + const interactiveNeedsBrowser = runWithoutCredentials(["bb", "courses", "--interactive", "--json"]); + assert.equal(interactiveNeedsBrowser.status, 2); + assert.equal(JSON.parse(interactiveNeedsBrowser.stdout).command, "bb courses"); + assert.equal(JSON.parse(interactiveNeedsBrowser.stdout).error.code, "USAGE"); + assert.match(JSON.parse(interactiveNeedsBrowser.stdout).error.message, /--interactive requires --browser/u); + const list = runWithoutCredentials(["bb", "attachments", "_8537_1", "_629896_1", "--json"]); assert.equal(list.status, 2); assert.equal(JSON.parse(list.stdout).command, "bb attachments"); assert.equal(JSON.parse(list.stdout).error.code, "CREDENTIALS_REQUIRED"); + const attemptFiles = runWithoutCredentials(["bb", "attempt-files", "_8537_1", "_2201_1", "--json"]); + assert.equal(attemptFiles.status, 2); + assert.equal(JSON.parse(attemptFiles.stdout).command, "bb attempt-files"); + assert.equal(JSON.parse(attemptFiles.stdout).error.code, "CREDENTIALS_REQUIRED"); + + const types = runWithoutCredentials(["bb", "types", "--json"]); + assert.equal(types.status, 2); + assert.equal(JSON.parse(types.stdout).command, "bb types"); + assert.equal(JSON.parse(types.stdout).error.code, "CREDENTIALS_REQUIRED"); + + const tree = runWithoutCredentials(["bb", "tree", "_8537_1", "--json"]); + assert.equal(tree.status, 2); + assert.equal(JSON.parse(tree.stdout).command, "bb tree"); + assert.equal(JSON.parse(tree.stdout).error.code, "CREDENTIALS_REQUIRED"); + const deadlines = runWithoutCredentials(["bb", "deadlines", "--json"]); assert.equal(deadlines.status, 2); assert.equal(JSON.parse(deadlines.stdout).command, "bb deadlines"); @@ -336,6 +763,14 @@ test("blackboard content attachment commands keep selection and local writes exp assert.equal(JSON.parse(missingDestination.stdout).error.code, "USAGE"); assert.match(JSON.parse(missingDestination.stdout).error.message, /--destination/); + const attemptDownloadMissingDestination = runWithoutCredentials([ + "bb", "attempt-download", "_8537_1", "_2201_1", "_3301_1", "--json", + ]); + assert.equal(attemptDownloadMissingDestination.status, 2); + assert.equal(JSON.parse(attemptDownloadMissingDestination.stdout).command, "bb attempt-download"); + assert.equal(JSON.parse(attemptDownloadMissingDestination.stdout).error.code, "USAGE"); + assert.match(JSON.parse(attemptDownloadMissingDestination.stdout).error.message, /--destination/); + const irrelevantOverwrite = runWithoutCredentials([ "bb", "attachments", "_8537_1", "_629896_1", "--overwrite", "--json", ]); @@ -354,6 +789,17 @@ test("blackboard content attachment commands keep selection and local writes exp ]); assert.equal(syncNeedsCredentials.status, 2); assert.equal(JSON.parse(syncNeedsCredentials.stdout).error.code, "CREDENTIALS_REQUIRED"); + + const describedAttemptFiles = run(["describe", "bb", "attempt-files", "--json"]); + assert.equal(describedAttemptFiles.status, 0); + assert.equal(JSON.parse(describedAttemptFiles.stdout).data.command, "bb attempt-files"); + assert.ok(JSON.parse(describedAttemptFiles.stdout).data.options.some((entry: { name: string }) => entry.name === "--browser")); + assert.ok(JSON.parse(describedAttemptFiles.stdout).data.options.some((entry: { name: string }) => entry.name === "--interactive")); + + const describedAttemptDownload = run(["describe", "bb", "attempt-download", "--json"]); + assert.equal(describedAttemptDownload.status, 0); + assert.ok(JSON.parse(describedAttemptDownload.stdout).data.options.some((entry: { name: string }) => entry.name === "--destination")); + assert.ok(JSON.parse(describedAttemptDownload.stdout).data.options.some((entry: { name: string }) => entry.name === "--browser")); }); test("Blackboard calendar commands expose strict options without requiring credentials to validate input", () => { @@ -381,6 +827,12 @@ test("Blackboard calendar commands expose strict options without requiring crede assert.equal(wrongMissingOption.status, 2); assert.equal(JSON.parse(wrongMissingOption.stdout).command, "tis degree missing"); assert.equal(JSON.parse(wrongMissingOption.stdout).error.code, "USAGE"); + + const invalidTreeMax = runWithoutCredentials(["bb", "tree", "_8537_1", "--max", "6000", "--json"]); + assert.equal(invalidTreeMax.status, 2); + assert.equal(JSON.parse(invalidTreeMax.stdout).command, "bb tree"); + assert.equal(JSON.parse(invalidTreeMax.stdout).error.code, "USAGE"); + assert.match(JSON.parse(invalidTreeMax.stdout).error.message, /--max cannot exceed 5000/); }); test("capabilities exposes safety metadata without requiring help-text parsing", () => { @@ -398,8 +850,18 @@ test("capabilities exposes safety metadata without requiring help-text parsing", const authLogout = envelope.data.capabilities.find((entry: { command: string }) => entry.command === "auth logout"); const bbApply = envelope.data.capabilities.find((entry: { command: string }) => entry.command === "bb submit apply"); const bbPreview = envelope.data.capabilities.find((entry: { command: string }) => entry.command === "bb submit preview"); + const bbMessageSendPreview = envelope.data.capabilities.find((entry: { command: string }) => entry.command === "bb message-send preview"); + const bbMessageSendApply = envelope.data.capabilities.find((entry: { command: string }) => entry.command === "bb message-send apply"); + const bbDiscussionPostPreview = envelope.data.capabilities.find((entry: { command: string }) => entry.command === "bb discussion-post preview"); + const bbDiscussionPostApply = envelope.data.capabilities.find((entry: { command: string }) => entry.command === "bb discussion-post apply"); + const bbDiscussionReplyPreview = envelope.data.capabilities.find((entry: { command: string }) => entry.command === "bb discussion-reply preview"); + const bbDiscussionReplyApply = envelope.data.capabilities.find((entry: { command: string }) => entry.command === "bb discussion-reply apply"); const bbAttachments = envelope.data.capabilities.find((entry: { command: string }) => entry.command === "bb attachments"); const bbDownload = envelope.data.capabilities.find((entry: { command: string }) => entry.command === "bb download"); + const bbAttemptFiles = envelope.data.capabilities.find((entry: { command: string }) => entry.command === "bb attempt-files"); + const bbAttemptDownload = envelope.data.capabilities.find((entry: { command: string }) => entry.command === "bb attempt-download"); + const bbTree = envelope.data.capabilities.find((entry: { command: string }) => entry.command === "bb tree"); + const bbTypes = envelope.data.capabilities.find((entry: { command: string }) => entry.command === "bb types"); const selectionApply = envelope.data.capabilities.find((entry: { command: string }) => entry.command === "tis selection apply"); const bidApply = envelope.data.capabilities.find((entry: { command: string }) => entry.command === "tis bid apply"); const classroomLive = envelope.data.capabilities.find((entry: { command: string }) => entry.command === "tis classroom live"); @@ -436,10 +898,30 @@ test("capabilities exposes safety metadata without requiring help-text parsing", assert.equal(bbApply.authentication, "bb"); assert.equal(bbApply.confirmation, "required"); assert.equal(bbPreview.network, true); + assert.equal(bbMessageSendPreview.kind, "plan"); + assert.equal(bbMessageSendPreview.authentication, "bb"); + assert.equal(bbMessageSendApply.kind, "mutation"); + assert.equal(bbMessageSendApply.confirmation, "required"); + assert.equal(bbDiscussionPostPreview.kind, "plan"); + assert.equal(bbDiscussionPostPreview.authentication, "bb"); + assert.equal(bbDiscussionPostApply.kind, "mutation"); + assert.equal(bbDiscussionPostApply.confirmation, "required"); + assert.equal(bbDiscussionReplyPreview.kind, "plan"); + assert.equal(bbDiscussionReplyPreview.authentication, "bb"); + assert.equal(bbDiscussionReplyApply.kind, "mutation"); + assert.equal(bbDiscussionReplyApply.confirmation, "required"); assert.equal(bbAttachments.kind, "read"); assert.equal(bbAttachments.authentication, "bb"); assert.equal(bbDownload.kind, "mutation"); assert.equal(bbDownload.confirmation, "none"); + assert.equal(bbAttemptFiles.kind, "read"); + assert.equal(bbAttemptFiles.authentication, "bb"); + assert.equal(bbAttemptDownload.kind, "mutation"); + assert.equal(bbAttemptDownload.authentication, "bb"); + assert.equal(bbTree.kind, "read"); + assert.equal(bbTree.authentication, "bb"); + assert.equal(bbTypes.kind, "read"); + assert.equal(bbTypes.authentication, "bb"); assert.equal(selectionApply.kind, "mutation"); assert.equal(selectionApply.confirmation, "required"); assert.equal(bidApply.kind, "mutation"); @@ -508,6 +990,31 @@ test("auth profile commands are machine-readable without exposing or inventing c assert.equal(check.status, 2); assert.equal(JSON.parse(check.stdout).error.code, "CREDENTIALS_REQUIRED"); + const browserNeedsInteractiveBrowser = runWithoutCredentials(["auth", "check", "--service", "bb", "--interactive", "--json"]); + assert.equal(browserNeedsInteractiveBrowser.status, 2); + assert.equal(JSON.parse(browserNeedsInteractiveBrowser.stdout).error.code, "USAGE"); + assert.match(JSON.parse(browserNeedsInteractiveBrowser.stdout).error.message, /--interactive requires --browser/u); + + const browserWrongService = runWithoutCredentials(["auth", "check", "--service", "tis", "--browser", "--json"]); + assert.equal(browserWrongService.status, 2); + assert.equal(JSON.parse(browserWrongService.stdout).error.code, "USAGE"); + assert.match(JSON.parse(browserWrongService.stdout).error.message, /only for Blackboard auth checks/u); + + const doctorNeedsLive = runWithoutCredentials(["doctor", "--service", "bb", "--browser", "--json"]); + assert.equal(doctorNeedsLive.status, 2); + assert.equal(JSON.parse(doctorNeedsLive.stdout).error.code, "USAGE"); + assert.match(JSON.parse(doctorNeedsLive.stdout).error.message, /--browser requires --live/u); + + const doctorNeedsBrowser = runWithoutCredentials(["doctor", "--service", "bb", "--live", "--interactive", "--json"]); + assert.equal(doctorNeedsBrowser.status, 2); + assert.equal(JSON.parse(doctorNeedsBrowser.stdout).error.code, "USAGE"); + assert.match(JSON.parse(doctorNeedsBrowser.stdout).error.message, /--interactive requires --browser/u); + + const doctorWrongService = runWithoutCredentials(["doctor", "--service", "tis", "--live", "--browser", "--json"]); + assert.equal(doctorWrongService.status, 2); + assert.equal(JSON.parse(doctorWrongService.stdout).error.code, "USAGE"); + assert.match(JSON.parse(doctorWrongService.stdout).error.message, /only when doctor includes Blackboard/u); + const missingSid = runWithoutCredentials(["auth", "login", "--password-stdin", "--json"]); assert.equal(missingSid.status, 2); assert.equal(JSON.parse(missingSid.stdout).error.code, "USAGE"); @@ -873,6 +1380,8 @@ test("new authenticated commands reject invalid inputs before network or credent [["doctor", "--service", "tis,not-a-service", "--json"], "USAGE"], [["papers", "fetch-oa", "not-a-doi", "--destination", "/tmp/paper.pdf", "--json"], "USAGE"], [["bb", "submit", "apply", "--course-id", "_8537_1", "--content-id", "_629896_1", "--file", "/tmp/report.pdf", "--expected-sha256", "not-a-sha", "--confirm", "--json"], "USAGE"], + [["bb", "submit", "preview", "--course-id", "_8537_1", "--content-id", "_629896_1", "--json"], "USAGE"], + [["bb", "submit", "preview", "--course-id", "_8537_1", "--content-id", "_629896_1", "--file", "/tmp/report.pdf", "--text-file", "/tmp/answer.txt", "--json"], "USAGE"], [["bb", "search", "hw", "--attachments", "bad", "--json"], "USAGE"], [["bb", "search", "hw", "--kind", "bad", "--json"], "USAGE"], [["bb", "search", "hw", "--page-size", "0", "--json"], "USAGE"], @@ -909,11 +1418,13 @@ test("context live supports calendar level and degrades gracefully when credenti assert.equal(result.status, 0); const envelope = JSON.parse(result.stdout); assert.equal(envelope.data.sourceStatus.nextDeadline, "missing"); + assert.equal(envelope.data.sourceStatus.recentAnnouncement, "missing"); assert.equal(envelope.data.sourceStatus.schedule, "missing"); assert.equal(envelope.data.sourceStatus.nextExam, "missing"); assert.equal(envelope.data.liveSources.tisSchedule.state, "credentials-missing"); assert.equal(envelope.data.liveSources.tisExams.state, "credentials-missing"); assert.equal(envelope.data.liveSources.blackboardDeadlines.state, "credentials-missing"); + assert.equal(envelope.data.liveSources.blackboardAnnouncements.state, "credentials-missing"); }); test("profile commands remain machine-readable when credentials are unavailable", () => { diff --git a/src/test/consequences.test.ts b/src/test/consequences.test.ts index 762abb5..430fbff 100644 --- a/src/test/consequences.test.ts +++ b/src/test/consequences.test.ts @@ -13,7 +13,11 @@ const MUTATION_CONSEQUENCES: Readonly> = { "bb calendar-link set": ["blackboard.calendar-link.store"], "bb calendar-link fetch": ["blackboard.calendar-link.fetch"], "bb calendar-link delete": ["blackboard.calendar-link.delete"], + "bb attempt-download": ["blackboard.attempt-download"], "bb submit apply": ["blackboard.submit"], + "bb message-send apply": ["blackboard.message-send"], + "bb discussion-post apply": ["blackboard.discussion-post"], + "bb discussion-reply apply": ["blackboard.discussion-reply"], "booking create apply": ["booking.create"], "booking cancel apply": ["booking.cancel"], "lib-booking create apply": ["library-booking.create"], @@ -37,6 +41,9 @@ test("consequence registry has stable unique operation IDs", () => { assert.equal(consequenceByOperation("blackboard.calendar-link.fetch")?.availability, "implemented"); assert.equal(consequenceByOperation("blackboard.calendar-link.delete")?.availability, "implemented"); assert.equal(consequenceByOperation("blackboard.submit")?.availability, "implemented"); + assert.equal(consequenceByOperation("blackboard.message-send")?.availability, "implemented"); + assert.equal(consequenceByOperation("blackboard.discussion-post")?.availability, "implemented"); + assert.equal(consequenceByOperation("blackboard.discussion-reply")?.availability, "implemented"); assert.equal(consequenceByOperation("pms.upload")?.availability, "implemented"); assert.equal(consequenceByOperation("pms.delete")?.availability, "implemented"); }); diff --git a/src/test/context.test.ts b/src/test/context.test.ts index c9216ec..3e55131 100644 --- a/src/test/context.test.ts +++ b/src/test/context.test.ts @@ -67,6 +67,7 @@ test("context service derives academic labels from the calendar and keeps terse assert.equal(snapshot.sourceStatus.academicDay, "derived"); assert.equal(snapshot.sourceStatus.schedule, "provided"); assert.equal(snapshot.sourceStatus.nextDeadline, "missing"); + assert.equal(snapshot.sourceStatus.recentAnnouncement, "missing"); assert.match(service.toText(snapshot), /Week 14 of 2026 Spring/); const record = service.toRecord(snapshot); @@ -74,7 +75,7 @@ test("context service derives academic labels from the calendar and keeps terse assert.equal((record.schedule as { next?: string }).next, "程序设计基础"); }); -test("context service includes deadlines, evaluations, and exams at normal level", () => { +test("context service includes deadlines, announcements, evaluations, and exams at normal level", () => { const service = new ContextService(); const snapshot = service.build({ now: "2026-05-29T14:30:00+08:00", @@ -97,12 +98,24 @@ test("context service includes deadlines, evaluations, and exams at normal level }, }, nextDeadline: { name: "BB HW1", daysLeft: 1 }, + recentAnnouncement: { + title: "Lab slides posted", + source: "course", + course: "CHEM201 Physical Chemistry", + activityAt: "2026-05-29T08:00:00+08:00", + }, nextEvaluation: { course: "线性代数", name: "教学评估", daysLeft: 3 }, nextExam: { name: "高等数学", code: "MA101", date: "2026-06-20", time: "09:00-11:00", building: "主楼", room: "301" }, }, "normal"); const record = service.toRecord(snapshot); assert.deepEqual(record.nextDeadline, { name: "BB HW1", daysLeft: 1 }); + assert.deepEqual(record.recentAnnouncement, { + title: "Lab slides posted", + source: "course", + course: "CHEM201 Physical Chemistry", + activityAt: "2026-05-29T08:00:00+08:00", + }); assert.deepEqual(record.nextEvaluation, { course: "线性代数", name: "教学评估", daysLeft: 3 }); assert.deepEqual(record.nextExam, { name: "高等数学", @@ -112,6 +125,7 @@ test("context service includes deadlines, evaluations, and exams at normal level building: "主楼", room: "301", }); + assert.match(service.toText(snapshot), /Recent Blackboard announcement: \[Lab slides posted\] — CHEM201 Physical Chemistry · 2026-05-29T08:00:00\+08:00/); assert.match(service.toText(snapshot), /Next exam: \[高等数学 \(MA101\)\]/); }); diff --git a/src/test/mcp.test.ts b/src/test/mcp.test.ts index 279ffcc..492db2e 100644 --- a/src/test/mcp.test.ts +++ b/src/test/mcp.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import test from "node:test"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { fileURLToPath } from "node:url"; +import { PUBLIC_MCP_TOOL_BY_COMMAND } from "../mcp/public-tool-names.js"; import { MCP_TOOL_BY_COMMAND } from "../mcp/registry.js"; import { createSustechMcpServer } from "../mcp/server.js"; import { @@ -53,6 +54,15 @@ test("MCP runner reuses CLI JSON envelopes", async () => { assert.equal(description.envelope.command, "describe"); }); +test("public MCP tool names centralize every non-core typed command name", () => { + assert.equal(Object.keys(MCP_TOOL_BY_COMMAND).length, Object.keys(PUBLIC_MCP_TOOL_BY_COMMAND).length + 3); + assert.equal(PUBLIC_MCP_TOOL_BY_COMMAND["calendar day"], "sustech_calendar_day"); + assert.equal(PUBLIC_MCP_TOOL_BY_COMMAND["online search"], "sustech_online_search"); + assert.equal(PUBLIC_MCP_TOOL_BY_COMMAND["online manual list"], "sustech_online_manual_list"); + assert.equal(PUBLIC_MCP_TOOL_BY_COMMAND["online manual get"], "sustech_online_manual_get"); + assert.equal(PUBLIC_MCP_TOOL_BY_COMMAND["online contact get"], "sustech_online_contact_get"); +}); + test("MCP runner terminates the CLI when the client cancels", async () => { const controller = new AbortController(); const slowCliPath = fileURLToPath(new URL("./fixtures/slow-cli.js", import.meta.url)); @@ -142,9 +152,17 @@ test("MCP exposes discovery, description, and a typed public-read allowlist", as ); assert.ok(listed.tools.every((tool) => tool.name !== "sustech_run")); assert.ok(listed.tools.every((tool) => tool.annotations?.readOnlyHint === true)); - assert.equal(listed.tools.length, 33); + assert.equal(listed.tools.length, 42); assert.ok(listed.tools.some((tool) => tool.name === "sustech_library_search_url")); + assert.ok(listed.tools.some((tool) => tool.name === "sustech_online_manual_list")); + assert.ok(listed.tools.some((tool) => tool.name === "sustech_online_manual_get")); + assert.ok(listed.tools.some((tool) => tool.name === "sustech_nces_filter_options")); + assert.ok(listed.tools.some((tool) => tool.name === "sustech_nces_global_stats")); + assert.ok(listed.tools.some((tool) => tool.name === "sustech_nces_rankings")); assert.ok(listed.tools.some((tool) => tool.name === "sustech_transit_live")); + const ncesByCodeTool = listed.tools.find((tool) => tool.name === "sustech_nces_by_code"); + assert.ok(ncesByCodeTool); + assert.match(JSON.stringify(ncesByCodeTool.inputSchema), /teacher/u); const discovered = await client.callTool({ name: "sustech_discover", @@ -158,6 +176,13 @@ test("MCP exposes discovery, description, and a typed public-read allowlist", as assert.equal(run.isError, undefined); assert.equal((run.structuredContent as { ok: boolean }).ok, true); + const invalidManualWindow = await client.callTool({ + name: "sustech_online_search", + arguments: { query: "校园卡", section: "manual", since: "2026-09-01" }, + }); + assert.equal(invalidManualWindow.isError, true); + assert.match(invalidManualWindow.content[0]?.type === "text" ? invalidManualWindow.content[0].text : "", /invalid|argument|unrecognized key/u); + const consequences = await client.callTool({ name: "sustech_consequences", arguments: { operation: "tis.drop" }, diff --git a/src/test/nces-resolution.test.ts b/src/test/nces-resolution.test.ts index 49893eb..d5f61b8 100644 --- a/src/test/nces-resolution.test.ts +++ b/src/test/nces-resolution.test.ts @@ -11,7 +11,7 @@ import type { ServiceAdapter } from "../services/base.js"; test("NCES lookup resolves a concrete section with explicit match confidence", async () => { const adapter = routeAdapter((url) => { - if (url === "https://ncesnext.com/api/v1/search?q=CS109") { + if (url === "https://ncesnext.com/api/v1/search?q=CS109&type=course&per_page=50") { return jsonResponse({ courses: { total: 3, @@ -78,7 +78,7 @@ test("NCES lookup resolves a concrete section with explicit match confidence", a }, }); } - if (url === "https://ncesnext.com/api/v1/course/11/reviews") { + if (url === "https://ncesnext.com/api/v1/course/11/reviews?term=20222") { return jsonResponse({ items: [{ id: 1, author: "Alice", term: "20222", rate: 9, upvote_count: 2, content: "

solid

" }], }); @@ -103,9 +103,88 @@ test("NCES lookup resolves a concrete section with explicit match confidence", a assert.equal(resolved.detail?.reviews[0]?.content, "solid"); }); +test("NCES lookup base-code matching keeps longer same-prefix suffixes but rejects shorter or confusable codes", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://ncesnext.com/api/v1/search?q=CS203B&type=course&per_page=50") { + return jsonResponse({ + courses: { + total: 4, + items: [ + { + id: 8121, + name: "数据结构与算法分析B", + course_code: "CS203B", + teacher_names: "杨鹏", + term_ids: ["20242"], + rate_average: 6.7, + review_count: 7, + difficulty_score: 64.29, + homework_score: 78.57, + grading_score: 57.14, + gain_score: 50, + }, + { + id: 9001, + name: "数据结构与算法分析B Honors", + course_code: "CS203BH", + teacher_names: "杨鹏", + term_ids: ["20242"], + rate_average: 6.5, + review_count: 2, + difficulty_score: 60, + homework_score: 70, + grading_score: 55, + gain_score: 52, + }, + { + id: 1157, + name: "数据结构与算法分析", + course_code: "CS203", + teacher_names: "唐博", + term_ids: ["20261"], + rate_average: 7.8, + review_count: 21, + difficulty_score: 21.43, + homework_score: 16.67, + grading_score: 52.38, + gain_score: 88.1, + }, + { + id: 2132, + name: "人工智能B", + course_code: "CS303B", + teacher_names: "张建国", + term_ids: ["20231"], + rate_average: 7, + review_count: 2, + difficulty_score: 50, + homework_score: 50, + grading_score: 75, + gain_score: 75, + }, + ], + }, + teachers: { total: 0, pages: 0, items: [] }, + reviews: { total: 0, pages: 0, items: [] }, + }); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const resolved = await resolveNcesCourseLookup( + { code: "CS203B", teachers: ["杨鹏"] }, + { adapter }, + ); + + assert.equal(resolved.status, "matched"); + assert.equal(resolved.picked?.ncesId, 8121); + assert.deepEqual(resolved.matchedCandidates.map((item) => item.ncesId), [8121, 9001]); + assert.equal(resolved.matchedCandidates.every((item) => item.code.startsWith("CS203B")), true); +}); + test("NCES batch lookup isolates per-course failures and preserves error versus not_found", async () => { const adapter = routeAdapter((url) => { - if (url === "https://ncesnext.com/api/v1/search?q=CS999") { + if (url === "https://ncesnext.com/api/v1/search?q=CS999&type=course&per_page=50") { return jsonResponse({ courses: { total: 1, @@ -126,7 +205,7 @@ test("NCES batch lookup isolates per-course failures and preserves error versus reviews: { items: [] }, }); } - if (url === "https://ncesnext.com/api/v1/search?q=BAD500") { + if (url === "https://ncesnext.com/api/v1/search?q=BAD500&type=course&per_page=50") { throw new Error("token=secret-cookie"); } throw new Error(`Unexpected URL ${url}`); @@ -146,6 +225,130 @@ test("NCES batch lookup isolates per-course failures and preserves error versus assert.match(batch.items.error?.notes[0] || "", /isolated/i); }); +test("NCES batch lookup reuses identical logical lookups across distinct section keys", async () => { + const calls = { + byCode: 0, + exactDetail: 0, + exactReviews: 0, + search: 0, + pickedDetail: 0, + pickedReviews: 0, + }; + const adapter = routeAdapter((url) => { + if (url === "https://ncesnext.com/api/v1/course/by-code/CS109?term=20261") { + calls.byCode += 1; + return jsonResponse({ course_id: 7103 }); + } + if (url === "https://ncesnext.com/api/v1/course/7103") { + calls.exactDetail += 1; + return jsonResponse({ + id: 7103, + name: "计算机程序设计基础", + course_code: "CS109", + teacher_names: "陶伊达", + dept: "计算机科学与工程系", + term_ids: ["20261"], + review_term_list: [], + rate: { + rate_average: 8.9, + review_count: 10, + difficulty_score: 60, + homework_score: 55, + grading_score: 85, + gain_score: 80, + }, + }); + } + if (url === "https://ncesnext.com/api/v1/course/7103/reviews?term=20261") { + calls.exactReviews += 1; + return jsonResponse({ items: [], total: 0, pages: 0, per_page: 20 }); + } + if (url === "https://ncesnext.com/api/v1/search?q=CS109&type=course&per_page=50") { + calls.search += 1; + return jsonResponse({ + courses: { + total: 2, + pages: 1, + items: [ + { + id: 7103, + name: "计算机程序设计基础", + course_code: "CS109", + teacher_names: "陶伊达", + term_ids: ["20261"], + rate_average: 8.9, + review_count: 10, + difficulty_score: 60, + homework_score: 55, + grading_score: 85, + gain_score: 80, + }, + { + id: 9851, + name: "计算机程序设计基础", + course_code: "CS109", + teacher_names: "赵耀", + term_ids: ["20261"], + rate_average: 0, + review_count: 0, + difficulty_score: 0, + homework_score: 0, + grading_score: 0, + gain_score: 0, + }, + ], + }, + teachers: { total: 0, pages: 0, items: [] }, + reviews: { total: 0, pages: 0, items: [] }, + }); + } + if (url === "https://ncesnext.com/api/v1/course/9851") { + calls.pickedDetail += 1; + return jsonResponse({ + id: 9851, + name: "计算机程序设计基础", + course_code: "CS109", + teacher_names: "赵耀", + dept: "计算机科学与工程系", + term_ids: ["20261"], + review_term_list: [], + rate: { + rate_average: 0, + review_count: 0, + difficulty_score: 0, + homework_score: 0, + grading_score: 0, + gain_score: 0, + }, + }); + } + if (url === "https://ncesnext.com/api/v1/course/9851/reviews?term=20261") { + calls.pickedReviews += 1; + return jsonResponse({ items: [], total: 0, pages: 0, per_page: 20 }); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const batch = await resolveNcesCourseLookups([ + { key: "001B", code: "CS109", name: "计算机程序设计基础", teachers: ["赵耀"] }, + { key: "001C", code: "CS109", name: "计算机程序设计基础", teachers: ["赵耀"] }, + ], { termId: "20261", includeDetail: true, adapter }); + + assert.equal(batch.partial, false); + assert.equal(batch.items["001B"]?.picked?.ncesId, 9851); + assert.equal(batch.items["001C"]?.picked?.ncesId, 9851); + assert.equal(batch.items["001B"]?.detail?.ncesId, 9851); + assert.equal(batch.items["001C"]?.detail?.ncesId, 9851); + assert.deepEqual(calls, { + byCode: 1, + exactDetail: 1, + exactReviews: 1, + search: 1, + pickedDetail: 1, + pickedReviews: 1, + }); +}); + test("NCES lookup returns insufficient_query without making a request", async () => { let called = 0; const adapter = routeAdapter(() => { @@ -161,7 +364,7 @@ test("NCES lookup returns insufficient_query without making a request", async () test("NCES lookup sorts equal-match candidates by numeric rating before stable IDs", async () => { const adapter = routeAdapter((url) => { - if (url === "https://ncesnext.com/api/v1/search?q=CS555") { + if (url === "https://ncesnext.com/api/v1/search?q=CS555&type=course&per_page=50") { return jsonResponse({ courses: { total: 2, @@ -205,6 +408,287 @@ test("NCES lookup sorts equal-match candidates by numeric rating before stable I assert.deepEqual(resolved.matchedCandidates.map((item) => item.ncesId), [21, 20]); }); +test("NCES lookup prefers a teacher-matched search candidate over mismatched exact by-code fallback", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://ncesnext.com/api/v1/course/by-code/CS109?term=20261") { + return jsonResponse({ course_id: 7103 }); + } + if (url === "https://ncesnext.com/api/v1/course/7103") { + return jsonResponse({ + id: 7103, + name: "计算机程序设计基础", + course_code: "CS109", + teacher_names: "陶伊达", + dept: "计算机科学与工程系", + term_ids: ["20261"], + review_term_list: ["20251", "20241", "20231"], + rate: { + rate_average: 8.9, + review_count: 10, + difficulty_score: 60, + homework_score: 55, + grading_score: 85, + gain_score: 80, + }, + }); + } + if (url === "https://ncesnext.com/api/v1/course/7103/reviews?term=20261") { + return jsonResponse({ items: [], total: 0, pages: 0, per_page: 20 }); + } + if (url === "https://ncesnext.com/api/v1/search?q=CS109&type=course&per_page=50") { + return jsonResponse({ + courses: { + total: 3, + pages: 1, + items: [ + { + id: 7101, + name: "计算机程序设计基础", + course_code: "CS109", + teacher_names: "马昱欣", + term_ids: ["20261"], + rate_average: 9.28571, + review_count: 14, + difficulty_score: 64.29, + homework_score: 67.86, + grading_score: 89.29, + gain_score: 71.43, + }, + { + id: 7103, + name: "计算机程序设计基础", + course_code: "CS109", + teacher_names: "陶伊达", + term_ids: ["20261"], + rate_average: 8.9, + review_count: 10, + difficulty_score: 60, + homework_score: 55, + grading_score: 85, + gain_score: 80, + }, + { + id: 9851, + name: "计算机程序设计基础", + course_code: "CS109", + teacher_names: "赵耀", + term_ids: ["20261"], + rate_average: 0, + review_count: 0, + difficulty_score: 0, + homework_score: 0, + grading_score: 0, + gain_score: 0, + }, + ], + }, + teachers: { total: 0, pages: 0, items: [] }, + reviews: { total: 0, pages: 0, items: [] }, + }); + } + if (url === "https://ncesnext.com/api/v1/course/9851") { + return jsonResponse({ + id: 9851, + name: "计算机程序设计基础", + course_code: "CS109", + teacher_names: "赵耀", + dept: "计算机科学与工程系", + term_ids: ["20261"], + review_term_list: [], + rate: { + rate_average: 0, + review_count: 0, + difficulty_score: 0, + homework_score: 0, + grading_score: 0, + gain_score: 0, + }, + }); + } + if (url === "https://ncesnext.com/api/v1/course/9851/reviews?term=20261") { + return jsonResponse({ items: [], total: 0, pages: 0, per_page: 20 }); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const resolved = await resolveNcesCourseLookup( + { code: "CS109", name: "计算机程序设计基础", teachers: ["赵耀"] }, + { termId: "20261", includeDetail: true, adapter }, + ); + + assert.equal(resolved.status, "matched"); + assert.equal(resolved.confidence, "high"); + assert.equal(resolved.picked?.ncesId, 9851); + assert.deepEqual(resolved.matchedCandidates.map((item) => item.ncesId), [9851, 7101, 7103]); + assert.deepEqual(resolved.signals.teacherMatches, ["赵耀"]); + assert.equal(resolved.signals.termMatched, true); + assert.equal(resolved.detail?.ncesId, 9851); + assert.match(resolved.notes[0] ?? "", /teacher-aware ranking selected a different section/i); +}); + +test("NCES lookup prefers exact by-code term resolution over search-only semester mismatches", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://ncesnext.com/api/v1/course/by-code/CS109?term=20222") { + return jsonResponse({ course_id: 7415 }); + } + if (url === "https://ncesnext.com/api/v1/course/7415") { + return jsonResponse({ + id: 7415, + name: "计算机程序设计基础", + course_code: "CS109", + teacher_names: "杨鹏", + dept: "计算机科学与工程系", + term_ids: ["20222"], + review_term_list: ["20222"], + rate: { + rate_average: 8.8, + review_count: 6, + difficulty_score: 60, + homework_score: 55, + grading_score: 72, + gain_score: 85, + }, + }); + } + if (url === "https://ncesnext.com/api/v1/course/7415/reviews?term=20222") { + return jsonResponse({ items: [], total: 0, pages: 0, per_page: 20 }); + } + if (url === "https://ncesnext.com/api/v1/search?q=CS109&type=course&per_page=50") { + return jsonResponse({ + courses: { + total: 1, + pages: 1, + items: [{ + id: 7104, + name: "计算机程序设计基础", + course_code: "CS109", + teacher_names: "朱悦铭", + term_ids: ["20252"], + rate_average: 9.6, + review_count: 18, + difficulty_score: 50, + homework_score: 55, + grading_score: 83, + gain_score: 94, + }], + }, + teachers: { total: 0, pages: 0, items: [] }, + reviews: { total: 0, pages: 0, items: [] }, + }); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const resolved = await resolveNcesCourseLookup( + { code: "CS109", name: "计算机程序设计基础", teachers: ["杨鹏"] }, + { termId: "20222", includeDetail: true, adapter }, + ); + + assert.equal(resolved.status, "matched"); + assert.equal(resolved.confidence, "high"); + assert.equal(resolved.searchTotal, 1); + assert.equal(resolved.picked?.ncesId, 7415); + assert.equal(resolved.signals.termMatched, true); + assert.deepEqual(resolved.signals.teacherMatches, ["杨鹏"]); + assert.equal(resolved.detail?.ncesId, 7415); + assert.equal(resolved.matchedCandidates[0]?.ncesId, 7415); + assert.match(resolved.notes[0] ?? "", /exact code lookup matched the requested semester directly/i); +}); + +test("NCES lookup can still resolve from exact by-code when search is unavailable", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://ncesnext.com/api/v1/course/by-code/CS109?term=20222") { + return jsonResponse({ course_id: 7415 }); + } + if (url === "https://ncesnext.com/api/v1/course/7415") { + return jsonResponse({ + id: 7415, + name: "计算机程序设计基础", + course_code: "CS109", + teacher_names: "杨鹏", + dept: "计算机科学与工程系", + term_ids: ["20222"], + review_term_list: ["20222"], + rate: { + rate_average: 8.8, + review_count: 6, + difficulty_score: 60, + homework_score: 55, + grading_score: 72, + gain_score: 85, + }, + }); + } + if (url === "https://ncesnext.com/api/v1/course/7415/reviews?term=20222") { + return jsonResponse({ items: [], total: 0, pages: 0, per_page: 20 }); + } + if (url === "https://ncesnext.com/api/v1/search?q=CS109&type=course&per_page=50") { + throw new Error("temporary upstream failure"); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const resolved = await resolveNcesCourseLookup( + { code: "CS109", name: "计算机程序设计基础", teachers: ["杨鹏"] }, + { termId: "20222", includeDetail: true, adapter }, + ); + + assert.equal(resolved.status, "matched"); + assert.equal(resolved.confidence, "high"); + assert.equal(resolved.searchTotal, 0); + assert.equal(resolved.items.length, 0); + assert.deepEqual(resolved.matchedCandidates.map((item) => item.ncesId), [7415]); + assert.equal(resolved.detail?.ncesId, 7415); + assert.match(resolved.notes[0] ?? "", /after search was unavailable/i); +}); + +test("NCES exact by-code fallback avoids review pagination when detail is not requested", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://ncesnext.com/api/v1/course/by-code/CS109?term=20222") { + return jsonResponse({ course_id: 7415 }); + } + if (url === "https://ncesnext.com/api/v1/course/7415") { + return jsonResponse({ + id: 7415, + name: "计算机程序设计基础", + course_code: "CS109", + teacher_names: "杨鹏", + dept: "计算机科学与工程系", + term_ids: ["20222"], + review_term_list: ["20222"], + rate: { + rate_average: 8.8, + review_count: 6, + difficulty_score: 60, + homework_score: 55, + grading_score: 72, + gain_score: 85, + }, + }); + } + if (url === "https://ncesnext.com/api/v1/search?q=CS109&type=course&per_page=50") { + return jsonResponse({ + courses: { total: 0, pages: 0, items: [] }, + teachers: { total: 0, pages: 0, items: [] }, + reviews: { total: 0, pages: 0, items: [] }, + }); + } + if (url.includes("/reviews")) { + throw new Error(`Review pagination should not happen without includeDetail: ${url}`); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const resolved = await resolveNcesCourseLookup( + { code: "CS109", name: "计算机程序设计基础", teachers: ["杨鹏"] }, + { termId: "20222", adapter }, + ); + + assert.equal(resolved.status, "matched"); + assert.equal(resolved.picked?.ncesId, 7415); + assert.equal(resolved.detail, undefined); +}); + test("NCES detail turns non-404 HTTP failures into ServiceError", async () => { const adapter = routeAdapter((url) => { if (url === "https://ncesnext.com/api/v1/course/500") { diff --git a/src/test/nces_extended.test.ts b/src/test/nces_extended.test.ts new file mode 100644 index 0000000..0000701 --- /dev/null +++ b/src/test/nces_extended.test.ts @@ -0,0 +1,915 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { setTimeout as delay } from "node:timers/promises"; +import { + browseNces, + getNcesCourseFilterOptions, + getNcesCourseByCode, + getNcesGlobalStats, + getNcesRankings, + getNcesCourseStats, + getNcesTeacherDetail, + listNcesCourseReviews, + searchNces, +} from "../services/nces.js"; +import type { ServiceAdapter } from "../services/base.js"; +import { formatNcesCourseByCode, formatNcesDetail, formatNcesSearch, formatNcesTeacher } from "../services/text.js"; + +test("browseNces maps CLI sort modes onto upstream sort_by", async () => { + const seen: string[] = []; + const adapter = routeAdapter((url) => { + seen.push(url); + return jsonResponse({ items: [], total: 0, pages: 1 }); + }); + + const cases = [ + { sort: "rating", sortBy: "rate" }, + { sort: "reviews", sortBy: "review_count" }, + { sort: "name", sortBy: "name" }, + ] as const; + + for (const entry of cases) { + const result = await browseNces({ page: 2, perPage: 7, sort: entry.sort, adapter }); + assert.equal(result.page, 2); + assert.equal(result.perPage, 7); + } + + assert.equal(seen.length, cases.length); + for (const [index, entry] of cases.entries()) { + const url = new URL(seen[index]!); + assert.equal(url.origin, "https://ncesnext.com"); + assert.equal(url.pathname, "/api/v1/course"); + assert.equal(url.searchParams.get("page"), "2"); + assert.equal(url.searchParams.get("per_page"), "7"); + assert.equal(url.searchParams.get("sort_by"), entry.sortBy); + } +}); + +test("browseNces forwards the live offering_unit filter", async () => { + const adapter = routeAdapter((url) => { + const parsed = new URL(url); + assert.equal(parsed.pathname, "/api/v1/course"); + assert.equal(parsed.searchParams.get("offering_unit"), "计算机科学与工程系"); + return jsonResponse({ + items: [{ + id: 7103, + name: " 程序设计基础 ", + course_code: "CS109", + teacher_names: " 王老师 ", + term_ids: ["20222"], + rate_average: 9.4, + review_count: 27, + difficulty_score: 74, + homework_score: 69, + grading_score: 72, + gain_score: 94, + }], + total: 386, + pages: 20, + }); + }); + + const result = await browseNces({ + page: 1, + perPage: 20, + sort: "rating", + offeringUnit: " 计算机科学与工程系 ", + adapter, + }); + assert.equal(result.total, 386); + assert.equal(result.offeringUnit, "计算机科学与工程系"); + assert.equal(result.items[0]?.name, "程序设计基础"); + assert.equal(result.items[0]?.teacher, "王老师"); +}); + +test("getNcesCourseFilterOptions reads live offering units", async () => { + const adapter = routeAdapter((url) => { + assert.equal(url, "https://ncesnext.com/api/v1/course/filter-options"); + return jsonResponse({ + offering_units: ["计算机科学与工程系", "数学系", " 语言中心 "], + }); + }); + + const result = await getNcesCourseFilterOptions({ adapter }); + assert.deepEqual(result, { + offeringUnits: ["计算机科学与工程系", "数学系", "语言中心"], + }); +}); + +test("getNcesGlobalStats normalises counts and distributions", async () => { + const adapter = routeAdapter((url) => { + assert.equal(url, "https://ncesnext.com/api/v1/stats"); + return jsonResponse({ + user_count: 10, + course_count: 20, + review_count: 30, + teacher_count: 40, + registered_teacher_count: 5, + running_days: 1000, + course_avg_rate: 8.12, + course_avg_rate_count: 4.09, + review_rate_distribution: [{ label: "10", value: 8 }], + course_rate_distribution: [{ label: "9-10", value: 6 }], + course_review_count_distribution: [{ label: "1", value: 4, cumulative: 4 }], + user_review_count_distribution: [{ label: "2", value: 3, cumulative: 7 }], + review_monthly_distribution: [{ label: "2026-09", value: 2, cumulative: 30 }], + user_monthly_distribution: [{ label: "2026-09", value: 1, cumulative: 10 }], + }); + }); + + const stats = await getNcesGlobalStats({ adapter }); + assert.deepEqual(stats, { + userCount: 10, + courseCount: 20, + reviewCount: 30, + teacherCount: 40, + registeredTeacherCount: 5, + runningDays: 1000, + courseAverageRating: 8.12, + averageReviewsPerCourse: 4.09, + reviewRateDistribution: [{ label: "10", value: 8 }], + courseRateDistribution: [{ label: "9-10", value: 6 }], + courseReviewCountDistribution: [{ label: "1", value: 4, cumulative: 4 }], + userReviewCountDistribution: [{ label: "2", value: 3, cumulative: 7 }], + reviewMonthlyDistribution: [{ label: "2026-09", value: 2, cumulative: 30 }], + userMonthlyDistribution: [{ label: "2026-09", value: 1, cumulative: 10 }], + }); +}); + +test("getNcesRankings normalises teacher, course, review, and user ranking lists", async () => { + const adapter = routeAdapter((url) => { + assert.equal(url, "https://ncesnext.com/api/v1/stats/rankings"); + return jsonResponse({ + stats: { + avg_rate: 8.1143, + avg_rate_count: 4.088, + avg_review_upvotes: 0.848, + avg_review_length: 803.2648, + }, + top_teachers: [ + { id: 11, name: "王老师", dept: "计算机科学与工程系", course_count: 7, review_count: 27, normalized_rate: 9.41 }, + ], + top_rated_courses: [ + { + id: 7103, + name: "程序设计基础", + course_code: "CS109", + teacher_names: "王老师", + term_ids: ["20222"], + rate_average: 9.4, + review_count: 27, + difficulty_score: 74, + homework_score: 69, + grading_score: 72, + gain_score: 94, + normalized_rate: 9.36, + }, + ], + popular_courses: [ + { + id: 7104, + name: "数据结构", + course_code: "CS203", + teacher_names: "李老师", + term_ids: ["20231"], + rate_average: 9.1, + review_count: 54, + difficulty_score: 62, + homework_score: 59, + grading_score: 65, + gain_score: 90, + normalized_rate: 9.1, + }, + ], + top_reviews: [ + { course_id: 7103, course_name: "程序设计基础", review_id: 501, author_name: "匿名用户", is_anonymous: true, upvote_count: 12, content_length: 1600 }, + ], + long_reviews: [ + { course_id: 7104, course_name: "数据结构", review_id: 601, author: { username: "Reviewer" }, author_name: "Reviewer", is_anonymous: false, upvote_count: 9, content_length: 5000 }, + ], + top_users: [ + { user: { id: 99, username: "Boltzwell", avatar: "/static/image/user.png", identity: "Student" }, reviews_count: 64, review_upvotes_count: 117, review_length: 121001, score: 121.72 }, + ], + }); + }); + + const rankings = await getNcesRankings({ adapter }); + assert.equal(rankings.stats.averageRating, 8.1143); + assert.equal(rankings.stats.averageReviewCount, 4.088); + assert.equal(rankings.topTeachers[0]?.department, "计算机科学与工程系"); + assert.equal(rankings.topTeachers[0]?.directUrl, "https://ncesnext.com/teacher/11"); + assert.equal(rankings.topRatedCourses[0]?.normalizedRating, 9.36); + assert.equal(rankings.topRatedCourses[0]?.semester, "2022春"); + assert.equal(rankings.popularCourses[0]?.reviewCount, 54); + assert.equal(rankings.topReviews[0]?.courseUrl, "https://ncesnext.com/course/7103/"); + assert.equal(rankings.longReviews[0]?.author, "Reviewer"); + assert.equal(rankings.topUsers[0]?.avatar, "https://ncesnext.com/static/image/user.png"); + assert.equal(rankings.topUsers[0]?.reviewUpvotes, 117); +}); + +test("searchNces forwards pagination and type while retaining course, teacher, and review totals", async () => { + const adapter = routeAdapter((url) => { + const parsed = new URL(url); + assert.equal(parsed.pathname, "/api/v1/search"); + assert.equal(parsed.searchParams.get("q"), "操作系统"); + assert.equal(parsed.searchParams.get("page"), "2"); + assert.equal(parsed.searchParams.get("per_page"), "5"); + assert.equal(parsed.searchParams.get("type"), "all"); + return jsonResponse({ + courses: { items: [], total: 12, pages: 3 }, + teachers: { items: [{ id: 201, name: "张老师" }], total: 1 }, + reviews: { items: [{ id: 1, author_name: "匿名用户", term: "20252", rate: 8, content: "有收获" }], total: 7 }, + }); + }); + + const result = await searchNces("操作系统", { page: 2, perPage: 5, type: "all", adapter }); + assert.equal(result.total, 12); + assert.equal(result.pages, 3); + assert.equal(result.aggregateTotal, 20); + assert.equal(result.aggregateShown, 2); + assert.equal(result.courseTotal, 12); + assert.equal(result.coursePages, 3); + assert.equal(result.selectedBucket, "course"); + assert.equal(result.selectedItems.length, 0); + assert.equal(result.teacherTotal, 1); + assert.equal(result.reviewTotal, 7); + assert.deepEqual(result.aggregateItems.map((entry) => entry.kind), ["teacher", "review"]); + assert.equal(result.teachers[0]?.name, "张老师"); + assert.equal(result.sampleReviews[0]?.author, "匿名用户"); +}); + +test("searchNces keeps aggregate totals truthful when type=all has no course matches", async () => { + const adapter = routeAdapter((url) => { + const parsed = new URL(url); + assert.equal(parsed.searchParams.get("type"), "all"); + return jsonResponse({ + courses: { items: [], total: 0, pages: 0 }, + teachers: { items: [{ id: 1, name: "融亦鸣" }], total: 1, pages: 1 }, + reviews: { items: [{ id: 9, author_name: "匿名用户", term: "20252", rate: 8, content: "有收获" }], total: 15, pages: 15 }, + }); + }); + + const result = await searchNces("rongym", { page: 1, perPage: 1, type: "all", adapter }); + assert.equal(result.total, 0); + assert.equal(result.pages, 0); + assert.equal(result.aggregateTotal, 16); + assert.equal(result.aggregateShown, 2); + assert.equal(result.courseTotal, 0); + assert.equal(result.teacherTotal, 1); + assert.equal(result.reviewTotal, 15); + assert.equal(result.selectedBucket, "course"); + assert.equal(result.selectedItems.length, 0); + assert.deepEqual(result.aggregateItems.map((entry) => entry.kind), ["teacher", "review"]); + assert.equal(result.teachers[0]?.name, "融亦鸣"); + assert.equal(result.sampleReviews[0]?.author, "匿名用户"); +}); + +test("formatNcesSearch surfaces bucket totals for mixed all-bucket output", () => { + const text = formatNcesSearch( + "rongym", + [], + [{ teacherId: 1, name: "融亦鸣", email: "rongym@sustech.edu.cn", title: "", image: "", directUrl: "https://ncesnext.com/teacher/1" }], + [], + { type: "all", courseTotal: 0, teacherTotal: 1, reviewTotal: 0, page: 1, perPage: 1 }, + ); + assert.match(text, /NCES search · rongym/u); + assert.match(text, /Bucket totals · courses 0 · teachers 1 · reviews 0 · page 1 · page size 1/u); + assert.match(text, /NCES teachers · 1/u); +}); + +test("searchNces total and pages follow a teacher-only result bucket", async () => { + const adapter = routeAdapter((url) => { + const parsed = new URL(url); + assert.equal(parsed.searchParams.get("type"), "teacher"); + return jsonResponse({ + courses: null, + teachers: { items: [{ id: 184, name: "王老师" }], total: 117, pages: 24 }, + reviews: null, + }); + }); + const result = await searchNces("王老师", { type: "teacher", page: 1, perPage: 5, adapter }); + assert.equal(result.total, 117); + assert.equal(result.pages, 24); + assert.equal(result.courseTotal, 0); + assert.equal(result.selectedBucket, "teacher"); + assert.equal(result.teacherTotal, 117); + assert.equal(result.teacherPages, 24); + assert.deepEqual(result.items, result.teachers); + assert.deepEqual(result.selectedItems, result.teachers); + assert.deepEqual(result.aggregateItems.map((entry) => entry.kind), ["teacher"]); + assert.equal(result.teachers[0]?.name, "王老师"); +}); + +test("searchNces items follow a review-only result bucket", async () => { + const adapter = routeAdapter((url) => { + const parsed = new URL(url); + assert.equal(parsed.searchParams.get("type"), "review"); + return jsonResponse({ + courses: { items: [{ id: 7103, name: "程序设计基础", course_code: "CS109", teacher_names: "王老师", term_ids: ["20222"], rate_average: 9.4, review_count: 27, difficulty_score: 74, homework_score: 69, grading_score: 72, gain_score: 94 }], total: 1, pages: 1 }, + teachers: { items: [], total: 0, pages: 0 }, + reviews: { items: [{ id: 9, author_name: "匿名用户", term: "20252", rate: 8, upvote_count: 4, content: "有收获" }], total: 31, pages: 7 }, + }); + }); + + const result = await searchNces("程序设计", { type: "review", page: 1, perPage: 5, adapter }); + assert.equal(result.total, 31); + assert.equal(result.pages, 7); + assert.equal(result.selectedBucket, "review"); + assert.equal(result.items[0]?.content, "有收获"); + assert.deepEqual(result.items, result.selectedItems); + assert.deepEqual(result.aggregateItems.map((entry) => entry.kind), ["course", "review"]); + assert.equal(result.courseItems[0]?.code, "CS109"); +}); + +test("listNcesCourseReviews forwards filters and normalises page metadata", async () => { + const adapter = routeAdapter((url) => { + const parsed = new URL(url); + assert.equal(parsed.pathname, "/api/v1/course/244/reviews"); + assert.equal(parsed.searchParams.get("page"), "2"); + assert.equal(parsed.searchParams.get("per_page"), "3"); + assert.equal(parsed.searchParams.get("sort_by"), "score_desc"); + assert.equal(parsed.searchParams.get("term"), "20252"); + assert.equal(parsed.searchParams.get("rating"), "9"); + return jsonResponse({ + items: [{ id: 9, author: { username: "Reviewer" }, term: "20252", rate: 9, upvote_count: 4, content: "

清晰

" }], + total: 10, + page: 2, + per_page: 3, + pages: 4, + }); + }); + + const result = await listNcesCourseReviews(244, { + page: 2, + perPage: 3, + sort: "rating-high", + term: "20252", + rating: 9, + adapter, + }); + assert.equal(result.items[0]?.author, "Reviewer"); + assert.equal(result.items[0]?.content, "清晰"); + assert.deepEqual({ total: result.total, page: result.page, perPage: result.perPage, pages: result.pages }, { + total: 10, + page: 2, + perPage: 3, + pages: 4, + }); +}); + +test("getNcesCourseByCode resolves course_id first, then fetches rich detail and reviews", async () => { + const seen: string[] = []; + const adapter = routeAdapter((url) => { + seen.push(url); + if (url === "https://ncesnext.com/api/v1/course/by-code/CS109?term=20222") { + return jsonResponse({ course_id: 7103 }); + } + if (url === "https://ncesnext.com/api/v1/course/7103") { + return jsonResponse({ + id: 7103, + name: "程序设计基础", + course_code: "cs109", + courseries: "CS109", + course_material_code: "MAT-CS109", + dept: "计算机科学与工程系", + introduction: "

课程介绍

", + homepage: "https://example.edu/cs109", + admin_announcement: "
带实验
", + access_count: 1234, + credit: 3, + hours: 48, + hours_per_week: 4, + description: "

中文描述

", + description_eng: "

English description

", + teaching_material: "

教材 A

", + reference_material: "

参考资料 B

", + student_requirements: "

需要编程基础

", + campus: "SUSTech", + course_major: "计算机类", + course_type: "必修", + grading_type: "百分制", + review_term_list: ["20222", "20231"], + teachers: [ + { + id: 11, + name: "王老师", + email: "teacher@sustech.edu.cn", + title: "副教授", + image: "https://img.example/teacher.png", + }, + ], + terms: [ + { + id: 1002, + term: "20261", + courseries: "CS109", + kcid: "KCID-109", + course_major: "计算机类", + course_type: "必修", + course_level: "本科", + join_type: "正常选课", + teaching_type: "课堂教学", + grading_type: "百分制", + credit: 3, + hours: 48, + hours_per_week: 4, + campus: "SUSTech", + start_week: 1, + end_week: 16, + }, + { + id: 1001, + term: "20222", + courseries: "CS109", + kcid: "KCID-109", + course_major: "计算机类", + course_type: "必修", + course_level: "本科", + join_type: "正常选课", + teaching_type: "课堂教学", + grading_type: "百分制", + credit: 3, + hours: 48, + hours_per_week: 4, + campus: "SUSTech", + start_week: 1, + end_week: 16, + }, + ], + related_courses: [ + { + id: 7104, + name: "数据结构", + course_code: "CS203", + teacher_names: "李老师", + term_ids: ["20231"], + rate_average: 9.1, + review_count: 7, + difficulty_score: 62, + homework_score: 59, + grading_score: 65, + gain_score: 90, + }, + ], + same_teacher_courses: [ + { + teacher: { + id: 11, + name: "王老师", + email: "teacher@sustech.edu.cn", + title: "副教授", + image: "https://img.example/teacher.png", + }, + courses: [ + { + id: 8101, + name: "编译原理", + course_code: "CS308", + teacher_names: "王老师", + term_ids: ["20231"], + rate_average: 8.8, + review_count: 10, + difficulty_score: 80, + homework_score: 70, + grading_score: 60, + gain_score: 88, + }, + ], + }, + ], + ai_summary: { + overview: "

Rigorous and rewarding.

", + strengths: ["讲得清楚", "练习充足"], + caveats: ["节奏快"], + assessment: ["适合愿意写代码的学生"], + source_review_count: 27, + generated_at: "2026-09-01T00:00:00Z", + }, + rate: { + average_rate: 9.4, + review_count: 27, + difficulty_score: 74, + homework_score: 69, + grading_score: 72, + gain_score: 94, + }, + }); + } + if (url === "https://ncesnext.com/api/v1/course/7103/reviews?term=20222") { + return jsonResponse({ + total: 3, + page: 1, + per_page: 2, + pages: 2, + items: [ + { + id: 501, + author: "Alice", + term: "20222", + rate: 9, + upvote_count: 5, + content: "

收获很大

", + }, + { + id: 502, + author: "Bob", + term: "20222", + rate: 8, + upvote_count: 2, + content: "

作业不少

", + }, + ], + }); + } + if (url === "https://ncesnext.com/api/v1/course/7103/reviews?page=2&per_page=2&term=20222") { + return jsonResponse({ + total: 3, + page: 2, + per_page: 2, + pages: 2, + items: [ + { + id: 502, + author: "Bob", + term: "20222", + rate: 8, + upvote_count: 2, + content: "

作业不少

", + }, + { + id: 503, + author: "Carol", + term: "20222", + rate: 10, + upvote_count: 8, + content: "

值得一上

", + }, + ], + }); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const course = await getNcesCourseByCode(" cs109 ", { term: "20222", adapter }); + + assert.deepEqual(seen, [ + "https://ncesnext.com/api/v1/course/by-code/CS109?term=20222", + "https://ncesnext.com/api/v1/course/7103", + "https://ncesnext.com/api/v1/course/7103/reviews?term=20222", + ]); + assert.equal(course?.ncesId, 7103); + assert.equal(course?.code, "CS109"); + assert.equal(course?.teacher, "王老师"); + assert.equal(course?.department, "计算机科学与工程系"); + assert.equal(course?.semester, "2022春"); + assert.deepEqual(course?.semesters, ["2022春", "2026秋"]); + assert.equal(course?.courseMaterialCode, "MAT-CS109"); + assert.equal(course?.descriptionEng, "English description"); + assert.deepEqual(course?.reviewTerms, ["20222", "20231"]); + assert.equal(course?.teachers[0]?.directUrl, "https://ncesnext.com/teacher/11"); + assert.equal(course?.terms[0]?.term, "2022春"); + assert.equal(course?.relatedCourses[0]?.code, "CS203"); + assert.equal(course?.sameTeacherCourses[0]?.courses[0]?.code, "CS308"); + assert.equal(course?.aiSummary?.overview, "Rigorous and rewarding."); + assert.deepEqual(course?.aiSummary?.strengths, ["讲得清楚", "练习充足"]); + assert.deepEqual(course?.aiSummary?.caveats, ["节奏快"]); + assert.deepEqual(course?.aiSummary?.assessment, ["适合愿意写代码的学生"]); + assert.equal(course?.aiSummary?.sourceReviewCount, 27); + assert.equal(course?.aiSummary?.authority, "community"); + assert.equal(course?.aiSummary?.generatedBy, "NCES"); + assert.match(course?.aiSummary?.advisory ?? "", /AI-generated/u); + assert.equal(course?.reviews[0]?.author, "Alice"); + assert.equal(course?.reviews[0]?.term, "2022春"); + assert.equal(course?.reviews[0]?.content, "收获很大"); + assert.equal(course?.reviews[1]?.author, "Bob"); + assert.equal(course?.reviews.length, 2); + assert.equal(course?.reviewResultsTotal, 3); + assert.equal(course?.reviewResultsPages, 2); + assert.equal(course?.reviewResultsPerPage, 2); + assert.equal(course?.reviewFilterTerm, "20222"); + assert.match(formatNcesCourseByCode("CS109", "20222", course), /Reviews loaded · 2\/3 .* course total 27/u); +}); + +test("getNcesCourseByCode loads every review page only when allReviews is enabled", async () => { + let inFlight = 0; + let maxInFlight = 0; + const seen: string[] = []; + const adapter = routeAdapter(async (url) => { + seen.push(url); + if (url === "https://ncesnext.com/api/v1/course/by-code/CS208?term=20252") { + return jsonResponse({ course_id: 8208 }); + } + if (url === "https://ncesnext.com/api/v1/course/8208") { + return jsonResponse({ + id: 8208, + name: "算法设计", + course_code: "CS208", + courseries: "CS208", + review_term_list: ["20252"], + teachers: [], + terms: [{ id: 1, term: "20252", courseries: "CS208" }], + related_courses: [], + same_teacher_courses: [], + rate: { + average_rate: 9.1, + review_count: 16, + difficulty_score: 70, + homework_score: 68, + grading_score: 72, + gain_score: 92, + }, + }); + } + const parsed = new URL(url); + if (parsed.pathname === "/api/v1/course/8208/reviews") { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + try { + await delay(5); + const page = Number(parsed.searchParams.get("page") ?? "1"); + if (page === 1) { + return jsonResponse({ + total: 16, + page: 1, + per_page: 2, + pages: 8, + items: [ + { id: 1, author: "A", term: "20252", rate: 9, content: "

1

" }, + { id: 2, author: "B", term: "20252", rate: 9, content: "

2

" }, + ], + }); + } + const firstId = (page - 1) * 2 + 1; + return jsonResponse({ + total: 16, + page, + per_page: 2, + pages: 8, + items: [ + { id: firstId, author: `R${firstId}`, term: "20252", rate: 9, content: `

${firstId}

` }, + { id: firstId + 1, author: `R${firstId + 1}`, term: "20252", rate: 9, content: `

${firstId + 1}

` }, + ], + }); + } finally { + inFlight -= 1; + } + } + throw new Error(`Unexpected URL ${url}`); + }); + + const course = await getNcesCourseByCode("CS208", { term: "20252", allReviews: true, adapter }); + + assert.equal(course?.reviews.length, 16); + assert.equal(course?.reviewResultsPages, 8); + assert.ok(maxInFlight <= 5, `expected review-page fan-out <= 5, got ${maxInFlight}`); + assert.deepEqual( + seen.filter((url) => url.includes("/course/8208/reviews")), + [ + "https://ncesnext.com/api/v1/course/8208/reviews?term=20252", + "https://ncesnext.com/api/v1/course/8208/reviews?page=2&per_page=2&term=20252", + "https://ncesnext.com/api/v1/course/8208/reviews?page=3&per_page=2&term=20252", + "https://ncesnext.com/api/v1/course/8208/reviews?page=4&per_page=2&term=20252", + "https://ncesnext.com/api/v1/course/8208/reviews?page=5&per_page=2&term=20252", + "https://ncesnext.com/api/v1/course/8208/reviews?page=6&per_page=2&term=20252", + "https://ncesnext.com/api/v1/course/8208/reviews?page=7&per_page=2&term=20252", + "https://ncesnext.com/api/v1/course/8208/reviews?page=8&per_page=2&term=20252", + ], + ); +}); + +test("NCES detail text distinguishes review-endpoint totals from the course headline review count", () => { + const text = formatNcesDetail({ + ncesId: 244, + code: "CS302", + name: "计算机操作系统", + teacher: "王老师", + semester: "2025秋", + semesters: ["2025秋"], + rating: 9.2, + reviewCount: 20, + difficulty: { label: "Hard", pct: 80 }, + workload: { label: "Heavy", pct: 75 }, + grading: { label: "Fair", pct: 68 }, + takeaways: { label: "High", pct: 95 }, + directUrl: "https://ncesnext.com/course/244/", + department: "计算机科学与工程系", + courseries: "CS302", + courseMaterialCode: "", + introduction: "", + homepage: "", + adminAnnouncement: "", + accessCount: 0, + description: "", + descriptionEng: "", + teachingMaterial: "", + referenceMaterial: "", + studentRequirements: "", + campus: "SUSTech", + courseMajor: "计算机类", + courseType: "必修", + gradingType: "百分制", + reviewTerms: ["20252"], + reviewResultsTotal: 19, + reviewResultsPages: 1, + reviewResultsPerPage: 50, + teachers: [], + terms: [], + relatedCourses: [], + sameTeacherCourses: [], + reviews: [], + }); + assert.match(text, /Reviews loaded · 0\/19 across 1 page\(s\) from NCES · course total 20; use `nces reviews 244` for paginated inspection or rerun with `--all-reviews` to load every current page\./u); +}); + +test("getNcesCourseStats normalises distributions and term averages", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://ncesnext.com/api/v1/course/7103/stats") { + return jsonResponse({ + review_count: 5, + rating_distribution: { "10": 3, "9": 2 }, + term_distribution: { "20222": 4, "20231": 1 }, + term_stats: [ + { term: "20222", review_count: 4, rate_average: 8.8 }, + { term: "20231", review_count: 1, rate_average: null }, + ], + }); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const stats = await getNcesCourseStats(7103, { adapter }); + assert.deepEqual(stats, { + reviewCount: 5, + ratingDistribution: { "9": 2, "10": 3 }, + termDistribution: { "20222": 4, "20231": 1 }, + termStats: [ + { term: "2022春", reviewCount: 4, ratingAverage: 8.8 }, + { term: "2023秋", reviewCount: 1 }, + ], + }); +}); + +test("getNcesTeacherDetail normalises teacher profile and course list", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://ncesnext.com/api/v1/teacher/11") { + return jsonResponse({ + id: 11, + name: "

王老师

", + email: "teacher@sustech.edu.cn", + title: "副教授", + image: "https://img.example/teacher.png", + access_count: 345, + review_count: 27, + average_rate: 9.4, + normalized_rate: 96, + gender: "F", + description: "

研究编程语言。

", + homepage: "https://example.edu/~teacher", + research_interest: "

编译器,程序分析

", + office_phone: "0755-12345678", + courses: [ + { + id: 7103, + name: "程序设计基础", + course_code: "CS109", + teacher_names: "王老师", + term_ids: ["20222"], + rate_average: 9.4, + review_count: 27, + difficulty_score: 74, + homework_score: 69, + grading_score: 72, + gain_score: 94, + }, + ], + }); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const teacher = await getNcesTeacherDetail(11, { adapter }); + assert.equal(teacher?.teacherId, 11); + assert.equal(teacher?.name, "王老师"); + assert.equal(teacher?.title, "副教授"); + assert.equal(teacher?.directUrl, "https://ncesnext.com/teacher/11"); + assert.equal(teacher?.averageRate, 9.4); + assert.equal(teacher?.normalizedRate, 96); + assert.equal(teacher?.description, "研究编程语言。"); + assert.equal(teacher?.researchInterest, "编译器,程序分析"); + assert.equal(teacher?.courses.length, 1); + assert.equal(teacher?.courses[0]?.code, "CS109"); + assert.equal(teacher?.courses[0]?.semester, "2022春"); +}); + +test("NCES teacher detail preserves missing public rating metrics instead of zeroes", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://ncesnext.com/api/v1/teacher/356") { + return jsonResponse({ + id: 356, + name: "刘珂廷", + email: "liukt@sustech.edu.cn", + review_count: 23, + average_rate: null, + normalized_rate: null, + courses: [], + }); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const teacher = await getNcesTeacherDetail(356, { adapter }); + assert.equal(teacher?.averageRate, null); + assert.equal(teacher?.normalizedRate, null); + assert.match(formatNcesTeacher(teacher ?? null), /Community rating unavailable · 23 review\(s\)/u); + assert.doesNotMatch(formatNcesTeacher(teacher ?? null), /Community rating 0 · 23 review\(s\)/u); +}); + +test("NCES preserves missing course ratings and score dimensions instead of synthesizing zeroes", async () => { + const adapter = routeAdapter((url) => { + if (url === "https://ncesnext.com/api/v1/course/by-code/CS302?term=20252") { + return jsonResponse({ course_id: 243 }); + } + if (url === "https://ncesnext.com/api/v1/course/243") { + return jsonResponse({ + id: 243, + name: "计算机操作系统", + course_code: "CS302", + courseries: "CS302", + dept: "计算机科学与工程系", + teachers: [{ id: 200, name: "沈昀", image: "/static/image/teacher.jpg" }], + review_term_list: [], + terms: [{ id: 13453, term: "20252", courseries: "CS302" }], + related_courses: [], + same_teacher_courses: [], + rate: { + rate_average: null, + review_count: 0, + difficulty_score: null, + homework_score: null, + grading_score: null, + gain_score: null, + }, + }); + } + if (url === "https://ncesnext.com/api/v1/course/243/reviews?term=20252") { + return jsonResponse({ items: [], total: 0, page: 1, per_page: 20, pages: 0 }); + } + if (url === "https://ncesnext.com/api/v1/teacher/356") { + return jsonResponse({ + id: 356, + name: "刘珂廷", + email: "liukt@sustech.edu.cn", + review_count: 23, + average_rate: 10, + normalized_rate: 9.71, + courses: [{ + id: 8381, + name: "艺术与科学大讲堂", + course_code: "GEM029", + teacher_names: "毕宝仪, 刘珂廷", + term_ids: ["20232"], + review_count: 0, + rate_average: null, + difficulty_score: null, + homework_score: null, + grading_score: null, + gain_score: null, + }], + }); + } + throw new Error(`Unexpected URL ${url}`); + }); + + const course = await getNcesCourseByCode("CS302", { term: "20252", adapter }); + assert.equal(course?.rating, null); + assert.equal(course?.difficulty, null); + assert.equal(course?.workload, null); + assert.equal(course?.grading, null); + assert.equal(course?.takeaways, null); + assert.match(formatNcesCourseByCode("CS302", "20252", course), /Community rating unavailable \/ reviews 0/u); + assert.doesNotMatch(formatNcesCourseByCode("CS302", "20252", course), /Community rating 0 \/ reviews 0/u); + assert.doesNotMatch(formatNcesDetail(course), /difficulty Hard · workload Heavy · grading Poor · takeaways Low/u); + + const teacher = await getNcesTeacherDetail(356, { adapter }); + assert.equal(teacher?.courses[0]?.rating, null); + assert.equal(teacher?.courses[0]?.difficulty, null); + assert.equal(teacher?.courses[0]?.workload, null); + assert.equal(teacher?.courses[0]?.grading, null); + assert.equal(teacher?.courses[0]?.takeaways, null); +}); + +function routeAdapter(route: (url: string, init?: RequestInit) => Response | Promise): ServiceAdapter { + return { + name: "fixture", + fetch(input: string, init?: RequestInit): Promise { + return Promise.resolve(route(String(input), init)); + }, + }; +} + +function jsonResponse(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json" }, + }); +} diff --git a/src/test/online-manual.test.ts b/src/test/online-manual.test.ts new file mode 100644 index 0000000..78d1933 --- /dev/null +++ b/src/test/online-manual.test.ts @@ -0,0 +1,481 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { ServiceAdapter } from "../services/base.js"; +import { + ONLINE_CONTACT_REPO_PATH, + ONLINE_CONTACT_SITE_PATH, + ONLINE_MAX_DOCUMENT_BYTES, + ONLINE_TALKS_INDEX_REPO_PATH, + ONLINE_TALKS_INDEX_SITE_PATH, + onlineRawUrl, + onlineSiteUrl, +} from "../online/shared.js"; +import { + getOnlineManualRecord, + listOnlineManualRecords, + listOnlineManualRecordsWithStatus, + loadOnlineManualCorpus, + onlineManualRawUrl, + onlineManualSiteUrl, + searchOnlineManual, + searchOnlineManualWithStatus, +} from "../online/manual.js"; +import { formatOnlineManualRecord, formatOnlineManualRecords } from "../online/manual-text.js"; +import { searchOnlineWithStatus } from "../online/search.js"; + +const FETCHED_AT = "2026-09-04T00:00:00.000Z"; +const UPDATED_AT = "2026-09-01T09:45:32.000Z"; + +const SERVICE_REPO_PATH = "docs/service/README.md"; +const SERVICE_SITE_PATH = "/service/"; +const LIFE_REPO_PATH = "docs/life/README.md"; +const LIFE_SITE_PATH = "/life/"; +const CALENDAR_REPO_PATH = "docs/calendar/README.md"; +const CALENDAR_SITE_PATH = "/calendar/"; + +const SERVICE_MARKDOWN = ` +# 服务与技巧 + +## SID (Student ID) 相关 + +### 🆔学号 + +- [学号的含义](./sid) + +### 💳校园卡&学生证 + +- [校园卡](./campus-card) +- [火车票学生优惠使用指南](./student-train-ticket/) +- [恶意入口](javascript:alert(1)) +- 使用校园卡可进入宿舍、校门和图书馆等场所。 + +## 信息服务 + +### 校园网络 + +- [校园网络介绍与连接指南](./network) +- [eduroam(学术网路漫游)](./network/eduroam) + +### Ehall + +1. [SUSTech ehall | 成绩查询](http://ehall.sustech.edu.cn/publicapp/sys/cjcxapp/index.do) + +## 教学相关 + +### 👨‍🏫Sakai + +- [Sakai | 文件分享](./sakai) + +### 计算机研究协会(CRA) + +1. [镜像站](https://mirrors.sustech.edu.cn/) +2. [Markdown](https://md.cra.moe/) + +## 退税 + +- [如何申报退税?](/service/tax/) + +## 软件授权 + +### 教育邮箱福利 + +1. [Office 365](https://signup.microsoft.com/signup?sku=Education) +2. [Jetbrains 全家桶](https://www.jetbrains.com/zh/student/) + +### 非官方Windows套件激活服务 + +1. [KMS](https://example.invalid/kms) +`; + +const LIFE_MARKDOWN = ` +# 生活在南科 + +## 住宿 + +::: tip 宿舍房型图 + +宿舍房型图可至[此页面](/life/dormitory/dorm-floor-plan.html)查看。 + +::: + +- [🏠住在南科](./dormitory) + + 包含宿舍概况,位置,房型图等。 + +- 新生宿舍楼下有超市,晚归进入宿舍需要登记。 + +## 餐饮 + +- [☕️校园餐饮](./catering) + +## Tips + +- 出入校门、食堂买饭买水果买饮品、进出宿舍楼等都需要刷校园卡。 +- 关于校园卡,请参考“[校园卡](/service/campus-card)”一节。 +`; + +const CALENDAR_MARKDOWN = ` +# 校历 + +校历暂时缺失结构。 +`; + +test("manual search returns bounded records for full content and short linked sections", async () => { + const adapter = manualAdapter(); + + const campusCard = await searchOnlineManual("校园卡", { + adapter, + fetchedAt: FETCHED_AT, + source: ["service", "life"], + limit: 1, + }); + assert.equal(campusCard.length, 1); + assert.equal(campusCard[0]?.sourceKey, "service"); + assert.equal(campusCard[0]?.title, "校园卡&学生证"); + assert.equal(campusCard[0]?.provenance.sourceUrl, onlineManualSiteUrl(SERVICE_SITE_PATH)); + assert.ok(campusCard[0]?.summary.includes("校园卡")); + assert.deepEqual( + campusCard[0]?.links.map((link) => link.url), + [ + "https://sustech.online/service/campus-card", + "https://sustech.online/service/student-train-ticket/", + ], + ); + + const sakai = await searchOnlineManual("Sakai", { + adapter, + fetchedAt: FETCHED_AT, + source: "service", + limit: 1, + }); + assert.equal(sakai[0]?.title, "Sakai"); + assert.deepEqual(sakai[0]?.links, [{ text: "Sakai | 文件分享", url: "https://sustech.online/service/sakai" }]); + + const dormitory = await searchOnlineManual("宿舍", { + adapter, + fetchedAt: FETCHED_AT, + source: ["service", "life"], + limit: 1, + }); + assert.equal(dormitory.length, 1); + assert.equal(dormitory[0]?.sourceKey, "life"); + assert.equal(dormitory[0]?.title, "住宿"); + assert.ok(dormitory[0]?.content.includes("宿舍")); + + const exact = await getOnlineManualRecord(dormitory[0]!.id, { + adapter, + fetchedAt: FETCHED_AT, + source: ["service", "life"], + }); + assert.equal(exact.title, "住宿"); + + const ehall = await getOnlineManualRecord("Ehall", { + adapter, + fetchedAt: FETCHED_AT, + source: "service", + }); + assert.deepEqual(ehall.links, [{ + text: "SUSTech ehall | 成绩查询", + url: "http://ehall.sustech.edu.cn/publicapp/sys/cjcxapp/index.do", + }]); + + const benefits = await getOnlineManualRecord("教育邮箱福利", { + adapter, + fetchedAt: FETCHED_AT, + source: "service", + }); + assert.deepEqual(benefits.links, []); +}); + +test("manual list excludes non-allowlisted sections and keeps community provenance", async () => { + const records = await listOnlineManualRecords({ + adapter: manualAdapter(), + fetchedAt: FETCHED_AT, + source: ["service", "life"], + }); + const titles = records.map((record) => record.title); + assert.ok(titles.includes("校园卡&学生证")); + assert.ok(titles.includes("住宿")); + assert.ok(!titles.includes("退税")); + assert.ok(!titles.includes("餐饮")); + assert.ok(!titles.includes("非官方Windows套件激活服务")); + + const campusCard = records.find((record) => record.title === "校园卡&学生证"); + assert.equal(campusCard?.pageRepoPath, SERVICE_REPO_PATH); + assert.equal(campusCard?.pageUrl, onlineManualSiteUrl(SERVICE_SITE_PATH)); + assert.deepEqual(campusCard?.provenance.advisories, ["COMMUNITY_MAINTAINED"]); +}); + +test("manual corpus reports invalid and partial sources without fabricating records", async () => { + const corpus = await loadOnlineManualCorpus({ + adapter: manualAdapter({ calendarMarkdown: CALENDAR_MARKDOWN }), + fetchedAt: FETCHED_AT, + source: ["service", "life", "calendar"], + }); + assert.equal(corpus.records.some((record) => record.sourceKey === "calendar"), false); + + const calendarStatus = corpus.sourceStatuses.find((status) => status.sourceKey === "calendar"); + assert.equal(calendarStatus?.status, "invalid"); + assert.equal(calendarStatus?.recordCount, 0); + assert.match(calendarStatus?.message ?? "", /No allowlisted manual sections/u); + + const lifeStatus = corpus.sourceStatuses.find((status) => status.sourceKey === "life"); + assert.equal(lifeStatus?.status, "ok"); + assert.ok(corpus.records.some((record) => record.sourceKey === "life")); +}); + +test("manual searches expose partial sources while unified manual search respects source filters", async () => { + const adapter = manualAdapter({ calendarMarkdown: CALENDAR_MARKDOWN }); + const manual = await searchOnlineManualWithStatus("校园卡", { + adapter, + fetchedAt: FETCHED_AT, + source: ["service", "calendar"], + }); + assert.equal(manual.partial, true); + assert.equal(manual.matchedTotal, 1); + assert.equal(manual.records[0]?.title, "校园卡&学生证"); + assert.equal(manual.sourceStatuses.find((status) => status.sourceKey === "calendar")?.status, "invalid"); + + const unified = await searchOnlineWithStatus("校园卡", { + adapter, + fetchedAt: FETCHED_AT, + section: "manual", + source: ["service"], + limit: 2, + }); + assert.equal(unified.partial, false); + assert.equal(unified.manualMatchedTotal, 1); + assert.equal(unified.hits[0]?.kind, "manual"); + assert.equal(unified.hits[0]?.title, "校园卡&学生证"); + assert.equal(unified.hits[0]?.url, "https://sustech.online/service/campus-card"); + assert.deepEqual(unified.manualSourceStatuses.map((status) => status.sourceKey), ["service"]); + + await assert.rejects( + searchOnlineManual("校历", { + adapter, + fetchedAt: FETCHED_AT, + source: "calendar", + }), + /No allowlisted SUSTech Online manual source/u, + ); +}); + +test("manual text output surfaces partial source status in list and detail modes", async () => { + const adapter = manualAdapter({ calendarMarkdown: CALENDAR_MARKDOWN }); + const listed = await listOnlineManualRecords({ + adapter, + fetchedAt: FETCHED_AT, + source: "service", + limit: 1, + }); + const withStatus = await searchOnlineManualWithStatus("校园卡", { + adapter, + fetchedAt: FETCHED_AT, + source: ["service", "calendar"], + limit: 1, + }); + const detailReport = await getOnlineManualRecord("校园卡&学生证", { + adapter, + fetchedAt: FETCHED_AT, + source: ["service", "calendar"], + }); + const listText = formatOnlineManualRecords( + listed, + "SUSTech Online manual", + { partial: withStatus.partial, sourceStatuses: withStatus.sourceStatuses }, + ); + const detailText = formatOnlineManualRecord(detailReport, { + partial: withStatus.partial, + sourceStatuses: withStatus.sourceStatuses, + }); + assert.match(listText, /Partial result: 1 manual source\(s\)/u); + assert.match(detailText, /Partial result: 1 manual source\(s\)/u); +}); + +test("unscoped unified search keeps manual opt-in and preserves talk/contact behavior", async () => { + const requested: string[] = []; + const adapter: ServiceAdapter = { + name: "unscoped-online-fixture", + async fetch(input: string): Promise { + requested.push(input); + if (input === onlineRawUrl(ONLINE_TALKS_INDEX_REPO_PATH)) { + return textResponse(` +# 讲座信息 +## 2026-09-10 周四 +- 10:00 - [Alice Professor:Quantum Widgets](2026-09-10T10-00-00_Alice.md) +`); + } + if (input === onlineSiteUrl(ONLINE_TALKS_INDEX_SITE_PATH)) return htmlResponse(UPDATED_AT); + if (input === onlineRawUrl(ONLINE_CONTACT_REPO_PATH)) { + return textResponse(` +# 黄页 +## 电话与邮件 +### 行政 +- 党政办公室: 88010229 +`); + } + if (input === onlineSiteUrl(ONLINE_CONTACT_SITE_PATH)) return htmlResponse(UPDATED_AT); + throw new Error(`Unexpected fixture URL: ${input}`); + }, + }; + + const report = await searchOnlineWithStatus("党政办公室", { adapter, fetchedAt: FETCHED_AT }); + assert.deepEqual(report.hits.map((hit) => hit.kind), ["contact"]); + assert.equal(report.partial, false); + assert.deepEqual(report.manualSourceStatuses, []); + assert.equal(requested.includes(onlineManualRawUrl(SERVICE_REPO_PATH)), false); +}); + +test("manual reads tolerate missing freshness HTML and mark it unknown", async () => { + const records = await searchOnlineManual("校园卡", { + adapter: manualAdapter({ failSiteFor: new Set(["service"]) }), + fetchedAt: FETCHED_AT, + source: "service", + }); + assert.ok(records[0]?.provenance.advisories.includes("SOURCE_UPDATE_UNKNOWN")); +}); + +test("manual get fails closed on ambiguous exact titles but accepts deterministic ids and section paths", async () => { + const duplicateServiceMarkdown = ` +# 服务与技巧 + +## SID (Student ID) 相关 + +### 💳校园卡&学生证 + +- [校园卡](./campus-card) + +## 软件授权 + +### 校园卡&学生证 + +- [备用校园卡说明](./campus-card-backup) +`; + const adapter = manualAdapter({ serviceMarkdown: duplicateServiceMarkdown }); + + await assert.rejects( + getOnlineManualRecord("校园卡&学生证", { + adapter, + fetchedAt: FETCHED_AT, + source: "service", + }), + hasCode("ONLINE_MANUAL_LOOKUP_AMBIGUOUS"), + ); + + const byPath = await getOnlineManualRecord("软件授权 / 校园卡&学生证", { + adapter, + fetchedAt: FETCHED_AT, + source: "service", + }); + assert.equal(byPath.sourceKey, "service"); + assert.equal(byPath.title, "校园卡&学生证"); + + const byId = await getOnlineManualRecord(byPath.id, { + adapter, + fetchedAt: FETCHED_AT, + source: "service", + }); + assert.equal(byId.id, byPath.id); + assert.equal(byId.sectionPath, "软件授权 / 校园卡&学生证"); +}); + +test("manual list reports matchedTotal before limit truncation", async () => { + const report = await listOnlineManualRecordsWithStatus({ + adapter: manualAdapter(), + fetchedAt: FETCHED_AT, + source: "service", + limit: 2, + }); + assert.equal(report.records.length, 2); + assert.ok(report.matchedTotal > report.records.length); +}); + +test("manual allowlist rejects path escape and fetched-url escape", async () => { + assert.throws(() => onlineManualRawUrl("docs/../secret.md"), hasCode("ONLINE_SOURCE_NOT_ALLOWED")); + assert.throws(() => onlineManualSiteUrl("/service/../secret/"), hasCode("ONLINE_SOURCE_NOT_ALLOWED")); + + const escapedAdapter: ServiceAdapter = { + name: "escaped-source", + async fetch(input: string): Promise { + if (input === onlineManualRawUrl(SERVICE_REPO_PATH)) { + const response = textResponse(SERVICE_MARKDOWN); + Object.defineProperty(response, "url", { + value: "https://raw.githubusercontent.com/SUSTech-CRA/sustech-online-ng/master/docs/secret.md", + }); + return response; + } + if (input === onlineManualSiteUrl(SERVICE_SITE_PATH)) return htmlResponse(UPDATED_AT); + throw new Error(`Unexpected fixture URL: ${input}`); + }, + }; + const corpus = await loadOnlineManualCorpus({ + adapter: escapedAdapter, + fetchedAt: FETCHED_AT, + source: "service", + }); + assert.equal(corpus.records.length, 0); + assert.equal(corpus.sourceStatuses[0]?.status, "error"); + assert.match(corpus.sourceStatuses[0]?.message ?? "", /allowlist target/u); +}); + +test("manual corpus bounds oversized sources as errors", async () => { + const oversizedAdapter: ServiceAdapter = { + name: "oversized-manual", + async fetch(input: string): Promise { + if (input === onlineManualRawUrl(SERVICE_REPO_PATH)) { + return new Response(new Uint8Array(ONLINE_MAX_DOCUMENT_BYTES + 1), { status: 200 }); + } + if (input === onlineManualSiteUrl(SERVICE_SITE_PATH)) return htmlResponse(UPDATED_AT); + throw new Error(`Unexpected fixture URL: ${input}`); + }, + }; + const corpus = await loadOnlineManualCorpus({ + adapter: oversizedAdapter, + fetchedAt: FETCHED_AT, + source: "service", + }); + assert.equal(corpus.records.length, 0); + assert.equal(corpus.sourceStatuses[0]?.status, "error"); + assert.match(corpus.sourceStatuses[0]?.message ?? "", /oversized document/u); +}); + +function manualAdapter(options: { + calendarMarkdown?: string; + failSiteFor?: ReadonlySet; + lifeMarkdown?: string; + serviceMarkdown?: string; +} = {}): ServiceAdapter { + return { + name: "manual-fixture", + async fetch(input: string): Promise { + if (input === onlineManualRawUrl(SERVICE_REPO_PATH)) return textResponse(options.serviceMarkdown ?? SERVICE_MARKDOWN); + if (input === onlineManualRawUrl(LIFE_REPO_PATH)) return textResponse(options.lifeMarkdown ?? LIFE_MARKDOWN); + if (input === onlineManualRawUrl(CALENDAR_REPO_PATH)) return textResponse(options.calendarMarkdown ?? CALENDAR_MARKDOWN); + if (input === onlineManualSiteUrl(SERVICE_SITE_PATH)) { + if (options.failSiteFor?.has("service")) throw new Error("service metadata unavailable"); + return htmlResponse(UPDATED_AT); + } + if (input === onlineManualSiteUrl(LIFE_SITE_PATH)) { + if (options.failSiteFor?.has("life")) throw new Error("life metadata unavailable"); + return htmlResponse(UPDATED_AT); + } + if (input === onlineManualSiteUrl(CALENDAR_SITE_PATH)) { + if (options.failSiteFor?.has("calendar")) throw new Error("calendar metadata unavailable"); + return htmlResponse(UPDATED_AT); + } + throw new Error(`Unexpected fixture URL: ${input}`); + }, + }; +} + +function textResponse(body: string, contentType = "text/markdown"): Response { + return new Response(body, { status: 200, headers: { "content-type": contentType } }); +} + +function htmlResponse(updatedAt: string): Response { + return textResponse(``, "text/html"); +} + +function hasCode(code: string): (error: unknown) => boolean { + return (error: unknown) => Boolean(error && typeof error === "object" && "code" in error && error.code === code); +} diff --git a/src/test/profile.test.ts b/src/test/profile.test.ts index 3b9348c..b63f1e3 100644 --- a/src/test/profile.test.ts +++ b/src/test/profile.test.ts @@ -37,6 +37,7 @@ test("profile collection keeps only whitelisted identity fields and masks the st generatedAt: "2026-08-26T10:00:00.000Z", coursesMatched: 0, coursesScanned: 0, + partial: false, deadlines: [], failures: [], }), @@ -138,6 +139,7 @@ test("profile collection chooses the next future exam and omits same-day or futu generatedAt: "2026-08-26T10:00:00.000Z", coursesMatched: 0, coursesScanned: 0, + partial: false, deadlines: [], failures: [], }), @@ -160,6 +162,7 @@ test("profile source failures redact secrets before surfacing them", async () => generatedAt: "2026-08-26T10:00:00.000Z", coursesMatched: 0, coursesScanned: 0, + partial: false, deadlines: [], failures: [], }), diff --git a/src/test/services_auth.test.ts b/src/test/services_auth.test.ts index 90b8bfa..a5cd1a7 100644 --- a/src/test/services_auth.test.ts +++ b/src/test/services_auth.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + getBlackboardUser, listBlackboardAssignments, listBlackboardCourses, normaliseBlackboardContentItem, @@ -20,7 +21,14 @@ import type { ServiceAdapter } from "../services/base.js"; test("Blackboard adapter resolves enrolled courses and assignment metadata through REST endpoints", async () => { const adapter = routeAdapter((url) => { if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/me") { - return jsonResponse({ id: "_1_1", userName: "12200000", name: "Student Name" }); + return jsonResponse({ + id: "_1_1", + userName: "12200000", + name: { + given: "Student Name", + family: "Engineering", + }, + }); } if (url === "https://bb.sustech.edu.cn/learn/api/public/v1/users/_1_1/courses") { return jsonResponse({ @@ -46,8 +54,7 @@ test("Blackboard adapter resolves enrolled courses and assignment metadata throu return jsonResponse({ id: "_9000_1", name: "Algorithms", - courseCode: "CS208", - externalId: "CS208-2026", + courseId: "CS208-30003435-2025SP", availability: { available: "Yes" }, }); } @@ -81,6 +88,13 @@ test("Blackboard adapter resolves enrolled courses and assignment metadata throu throw new Error(`Unexpected URL ${url}`); }); + const user = await getBlackboardUser(adapter); + assert.deepEqual(user, { + id: "_1_1", + userName: "12200000", + displayName: "Student Name", + }); + const courses = await listBlackboardCourses(adapter); assert.deepEqual(courses, [ { @@ -97,7 +111,7 @@ test("Blackboard adapter resolves enrolled courses and assignment metadata throu numericId: "9000", name: "Algorithms", courseCode: "CS208", - externalId: "CS208-2026", + externalId: "CS208-30003435-2025SP", roleId: "Student", availability: "Yes", }, diff --git a/src/test/services_public.test.ts b/src/test/services_public.test.ts index 8c7454b..906669c 100644 --- a/src/test/services_public.test.ts +++ b/src/test/services_public.test.ts @@ -579,12 +579,14 @@ test("NCES search and detail normalize public course and review JSON", async () const search = await searchNces("cs101", { adapter }); assert.equal(search.total, 1); assert.equal(search.items[0]?.code, "CS101B"); - assert.equal(search.items[0]?.difficulty.label, "Easy"); + assert.equal(search.items[0]?.difficulty?.label, "Easy"); + assert.equal(search.sampleReviews[0]?.author, "Alice"); assert.equal(search.sampleReviews[0]?.content, "Great course"); const detail = await getNcesCourseDetail(212, { adapter }); assert.ok(detail); assert.equal(detail?.department, "计算机科学与工程系"); + assert.equal(detail?.reviews[0]?.author, "Alice"); assert.equal(detail?.reviews[0]?.term, "2022春"); assert.equal(tisToNcesTerm("2025-2026", "2"), "20262"); diff --git a/src/tis/course-decision.ts b/src/tis/course-decision.ts index 6fa1991..8824c7d 100644 --- a/src/tis/course-decision.ts +++ b/src/tis/course-decision.ts @@ -776,6 +776,14 @@ function analyseNcesFit(nces: NcesResolvedCourse | null | undefined): NcesAnalys const detail = nces.detail ?? undefined; const target = detail ?? nces.picked; const reviewCount = Math.max(target.reviewCount, 0); + if (target.rating === null || target.takeaways === null) { + reasons.push({ + kind: "data", + impact: "caution", + message: "NCES matched this course, but the selected entry does not have enough community rating data yet.", + }); + return { fit, score: 0, reasons, warnings }; + } const evidenceWeight = ncesEvidenceWeight(nces.confidence, reviewCount); const ratingScore = round((target.rating - 7) * 4 * evidenceWeight); const takeawaysScore = round(((target.takeaways.pct - 50) / 25) * evidenceWeight); From 8d2919d772a5e6cd0dea1a61f352bf7a9092e283 Mon Sep 17 00:00:00 2001 From: Apryle Wu Date: Mon, 7 Sep 2026 14:52:48 +0800 Subject: [PATCH 2/2] fix: use portable file URLs for offline test imports --- src/test/cli.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index 18497de..5c6b0a5 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -5,7 +5,7 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { academicSnapshotSource, buildAcademicSnapshot } from "../academic/snapshot.js"; import { buildBookingCreateApplyConfirmation, @@ -1433,7 +1433,7 @@ test("selection reconciliation is bounded, read-only, and validates locally befo test("context live degrades gracefully when public sources and credentials are unavailable", () => { const result = runWithoutCredentials(["context", "--calendar-level", "graduate", "--live", "--json"], { offline: true }); - assert.equal(result.status, 0); + assert.equal(result.status, 0, result.stderr || result.stdout); const envelope = JSON.parse(result.stdout); assert.equal(envelope.data.calendarSource.state, "error"); assert.equal(envelope.data.liveSources.weather.state, "error"); @@ -1560,7 +1560,7 @@ function runWithoutCredentials(args: string[], options: { offline?: boolean } = if (options.offline) { const fixturePath = join(configRoot, "offline.mjs"); writeFileSync(fixturePath, 'globalThis.fetch = async () => { throw new Error("Synthetic public source outage"); };'); - imports.push("--import", fixturePath); + imports.push("--import", pathToFileURL(fixturePath).href); } const result = spawnSync(process.execPath, [...imports, CLI_PATH, ...args], { encoding: "utf8",