diff --git a/CHANGELOG.md b/CHANGELOG.md index 3706383..b5effef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,44 @@ All notable changes to `sustech-cli` are documented in this file. ## [Unreleased] +### Added + +- Added public `talks list` and `talks search` commands for official SUSTech + homepage lectures, showing upcoming events by default and all currently + displayed events with `--all`, with source metadata and text/JSON/JSONL output. + +- Added normalized lecture/lab selection bundles with explicit component, + credit-bearing, mutation-ID, task-RWH, and read-back contracts. +- Added bounded `tis selection reconcile` reads for uncertain enrollment, + cart, drop, and bid outcomes. + +### Changed + +- Made planning-oriented availability, enrollment, degree-progress, and + degree-missing JSON use documented minimum-data projections; grade-free + output is the default. + +- Refined `context` into a Shanghai-time daily snapshot with structured current, + next and today's classes, teaching-week parity and makeup details, explicit + empty/unavailable sources, and weather/AQI at normal detail level. Environmental + observations retain source timestamps and freshness; public source failures + no longer prevent a partial snapshot. Non-today live snapshots are rejected. + ### Fixed +- Corrected terminal-table alignment for Unicode Roman numerals, ellipses, + and combining characters, and kept grapheme clusters intact during truncation. - Made `auth status` use a metadata-only macOS Keychain lookup instead of reading the stored password, and bounded credential-helper subprocesses to five seconds with a structured `CREDENTIAL_STORE_TIMEOUT` status. +- Linux Secret Service writes now require an immediate verified read-back and + report actionable locked-collection, D-Bus-session, and access-denied states. + +### Security + +- Selection transport ambiguity now returns an explicit non-retriable outcome + with a local correlation ID, while raw upstream mutation and personal + selection envelopes are excluded from default CLI output. ## [0.10.0] - 2026-08-29 diff --git a/README.md b/README.md index 3e657aa..20bf5c7 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Public data does not require an account: sustech calendar day 2026-09-01 sustech faculty search "computer vision" sustech online talks list --limit 10 +sustech talks list sustech online contact search "教学" sustech transit lines sustech library search "graph neural networks" --limit 5 @@ -166,6 +167,32 @@ Local state can also change through credential login/logout, persistent and do not overwrite an existing target unless the command explicitly permits and requests it. +## Official campus lectures + +Official university lectures are available without login: + +```bash +sustech talks list +sustech talks list --all +sustech talks list --json --pretty +sustech talks search "物理" --jsonl +sustech talks search "物理" --all +``` + +These commands read the lecture section of the official +[homepage events page](https://www.sustech.edu.cn/zh/home-events.html), excluding +notices. The default view shows lectures whose Beijing start time is still in +the future, ordered from nearest to furthest. Records with an unparseable time +remain visible under “time to confirm” rather than being silently omitted. +`--all` adds lectures whose advertised start time has passed; it means all +lectures currently displayed on the homepage, not the complete historical +archive. Search matches titles, speakers, venues, and time text, and follows the +same upcoming-by-default behavior. Results include title, speaker, venue, +original time text, a normalized Beijing start time when parseable, timing +classification, detail URL, reference time, and official source/fetch metadata. +The existing `online talks` commands continue to use the community-maintained +SUSTech Online source. The new official commands are CLI-only at present. + ## Output contract ```bash @@ -210,7 +237,10 @@ in the operating system's native credential store: The password is entered through a hidden prompt, is never accepted as a normal command-line argument, and is never written to the CLI config. If no safe backend is available, the CLI returns `CREDENTIAL_STORE_UNAVAILABLE` instead of -falling back to plaintext. +falling back to plaintext. Linux writes are verified by immediate read-back; +locked collections and broken desktop D-Bus sessions produce distinct safe +remediation in `auth status --json` instead of being reported as an expired +password. On macOS, `auth status` checks Keychain item metadata without reading the password. Credential-helper commands are bounded to five seconds and report @@ -261,6 +291,19 @@ sustech tis enroll apply \ --course-id TIS_INTERNAL_ID --rwh TASK_ID --round bxxk --bid 2 --confirm ``` +Availability JSON groups lecture/lab rows into credit-deduplicated bundles and +labels the exact `courseId` (`p_id`) and component `rwh` roles. If apply returns +`TIS_SELECTION_OUTCOME_UNKNOWN`, preserve that exact pair and reconcile without +repeating the write: + +```bash +sustech tis selection reconcile enroll \ + --course-id TIS_INTERNAL_ID --rwh TASK_ID --round bxxk --attempts 3 --json +``` + +See [docs/SELECTION_CONTRACTS.md](docs/SELECTION_CONTRACTS.md) for bundle, +identifier, bounded reconciliation, and grade-free planning-output contracts. + Blackboard attachment and submission example: ```bash @@ -301,23 +344,45 @@ academic state once, compares it against the existing local state file when present, reports the changes, and updates that local file. It does not poll, it does not loop in the background, and it does not write any remote campus state. -## Context v2 +## Daily context for AI assistants ```bash sustech context --level terse sustech context --live --level normal +sustech context --live --json 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 -- `verbose`: adds public weather, AQI, and library-status observations when - `--live` is enabled - -`--live` keeps source status explicit. Missing or failed live sources stay -marked as missing or partial; they are not silently backfilled. +- `terse`: date, teaching week and parity, holiday/makeup timetable, and current/next class; only the timetable is requested with `--live` +- `normal` (default): adds the next assignment deadline, evaluation, exam, weather and AQI with `--live` +- `verbose`: also retrieves library opening status + +All dates and display times use **Asia/Shanghai**, including on overseas machines. +JSON includes `generatedAt` (snapshot creation), `referenceAt` (the instant used +for class/deadline selection), `timezone`, and the full public `academicDay`. +`schedule.currentClass`, `nextClass`, and `todayClasses` expose ISO timestamps, +periods, locations when available, and `makeupFor` dates. Current and next classes +can appear together; holiday/makeup dates use the same rules as ICS exports. + +`sourceStatus` distinguishes a successful empty result (`empty`) from unavailable +data (`missing`). `liveSources` adds errors, missing credentials, partial coverage, +and intentionally skipped requests (`not-requested`). An empty result describes +only the successfully retrieved sources; it is not a claim about all university +systems. A failed public calendar fetch does not prevent other available sources +from being returned. + +Weather and air quality include source URLs and upstream `observedAt` timestamps +when supplied. Observations older than three hours are labeled `stale`; absent +timestamps are `unknown`. AQI uses **US EPA** categories, not China's AQI scale. +Public environmental requests time out after eight seconds, and TIS, Blackboard, +and environmental reads run concurrently. + +Without `--live`, only the date/calendar snapshot is requested. Use +`context --date YYYY-MM-DD` for a calendar preview (reference time: noon in +Shanghai); combining a non-today date with `--live` is rejected so today's +observations cannot be mistaken for historical data or forecasts. ## Library catalog diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 53b2e3c..d03d4f5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -145,6 +145,11 @@ commands for them. - Mutation commands use explicit preview/build phases and post-action verification. Any ambiguous remote result returns exit code 5 plus `DO_NOT_RETRY_AUTOMATICALLY` when write state cannot be determined safely. +- TIS selection previews carry a local correlation ID but never claim upstream + idempotency. Transport ambiguity is reconciled through bounded exact + `{courseId, rwh, round}` reads rather than by repeating a mutation. +- Planning-facing TIS output passes through field-allowlisted projections; + broad upstream rows and raw mutation responses remain outside CLI JSON. - Consequence metadata lives in `src/core/consequences.ts` so agents can inspect risks and follow-up checks without scraping prose. - New authenticated campus-service wrappers are validated with protocol fixtures diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index c0b42cc..e0835bc 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -75,6 +75,13 @@ retried automatically. If a helper exceeds that deadline, structured status sets `reasonCode` to `CREDENTIAL_STORE_TIMEOUT`, marks the backend unavailable for that probe, and leaves the credential and profile metadata unchanged. +Credential writes are verified by an immediate read-back before profile +metadata is committed. Linux errors distinguish a locked collection, a missing +desktop D-Bus/Secret Service session, an access denial, and an unclassified +`secret-tool` failure. Run `sustech auth status --json` in the same unlocked +graphical session and follow its `remediation`; do not delete profile metadata +or assume the password expired merely because the collection is locked. + ## Profiles The default profile is named `default`. Multiple accounts use explicit names: diff --git a/docs/OUTPUT.md b/docs/OUTPUT.md index dfade5c..8333243 100644 --- a/docs/OUTPUT.md +++ b/docs/OUTPUT.md @@ -72,6 +72,12 @@ Credential commands return backend, profile, availability, and masked account metadata only. Passwords, cookies, bearer tokens, and keyring values are never part of text, JSON, JSONL, error details, or capability output. +Planning-oriented TIS commands additionally use field allowlists. Available +courses are emitted as normalized bundles, enrolled rows omit broad description +fields, and `tis degree missing` omits letter grades and numeric scores. Course +grades in `tis degree progress` require the explicit `--details` option. See +`SELECTION_CONTRACTS.md` for exact bundle, identifier, and projection semantics. + 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. diff --git a/docs/SELECTION_CONTRACTS.md b/docs/SELECTION_CONTRACTS.md new file mode 100644 index 0000000..4d0dc8b --- /dev/null +++ b/docs/SELECTION_CONTRACTS.md @@ -0,0 +1,60 @@ +# TIS selection contracts + +The selection surface separates catalog rows, selectable bundles, mutation identifiers, and read-back identifiers. Consumers must not infer one identifier's meaning from its spelling. + +## Bundled availability + +`sustech tis courses available ... --json` returns `data.bundles`. A bundle contains: + +- `bundleId`: an explicit source bundle ID when TIS exposes one; otherwise a stable selection/task-scoped identity. +- `components`: lecture, lab, tutorial, other, or unknown rows. Every component states whether it is required and identifies its task `rwh`. +- `credits` and `creditStatus`: equal repeated component credits are counted once. Conflicting component credits produce `creditStatus: "ambiguous"` and omit `credits` instead of guessing or summing. +- `teachingTeam` and `meetings`: unions across all components, retaining parity-week schedules. +- `operationTargets`: exact component-level mutation `courseId`, task `rwh`, payload field, and read-back identity. +- `selectableWithoutGuessing`: true only when every required component has the explicit identifier pair needed for mutation and verification, and source course identity and credit evidence do not conflict. + +Duplicate source rows for the same component are merged and reported in `warnings`. Default CLI output never contains the upstream selection envelope, enrolled/cart raw rows, credentials, cookies, tokens, or unrelated student fields. `retainCourseSourceRecord` is a library-level diagnostics-only escape hatch and is not called by CLI commands. + +## Identifier meanings + +| Name | Meaning | Accepted by | +| --- | --- | --- | +| `bundleId` | normalized course bundle identity | display, planning, grouping only | +| `componentId` / `taskId` / `rwh` | exact teaching-task component | required together with `courseId` for apply and reconciliation read-back | +| `courseId` | opaque selection mutation identifier | CLI `--course-id`; serialized as upstream `p_id` | +| `clientRequestId` | local correlation identifier | output/errors only; it is not an upstream idempotency key | + +Do not pass `bundleId`, course code, or `rwh` as `--course-id`. Do not treat `courseId` alone as a unique lecture/lab component: exact verification keys on `{courseId, rwh}`. + +## Uncertain writes and reconciliation + +TIS does not currently expose a verified idempotency-key facility for these endpoints. Every preview therefore says `upstreamKeySupported: false` and `automaticRetry: "forbidden"`. The generated `clientRequestId` is not added to the upstream payload. + +If a request is known to fail before submission, the CLI returns `TIS_SELECTION_NOT_SUBMITTED`, exit 4, and `NO_MUTATION_PERFORMED`. If submission may have started but no conclusive response arrives, it returns `TIS_SELECTION_OUTCOME_UNKNOWN`, exit 5, and `DO_NOT_RETRY_AUTOMATICALLY`. + +Use the exact target from the error: + +```bash +sustech tis selection reconcile cart.add \ + --course-id SELECTION_ID --rwh TASK_ID --round bxxk \ + --attempts 3 --json +``` + +Reconciliation performs two to five bounded read-only queries and reports: + +- `applied`: the final bounded observation reached the requested exact state; +- `not_applied`: at least two consistent exact observations retained the inverse state and no desired/conflicting observation appeared; +- `still_uncertain`: a query failed, identifiers conflicted, round metadata was missing or mismatched, observations regressed, or evidence remained incomplete (including a missing bid value). + +None of these states authorizes an automatic mutation retry. `not_applied` means a human or higher-level workflow may review a new preview; it does not reuse the uncertain request. + +## Privacy-minimized planning projections + +Planning commands use documented allowlists: + +- `tis courses available`: normalized bundles, components, exact identifiers, teaching teams, meetings, capacity/credit context, and report time; +- `tis enrolled`: course identity, exact `rwh`, teaching team, and meeting coordinates; +- `tis degree progress`: summary/category/module data by default; course grades appear only with explicit `--details`; +- `tis degree missing`: completion classification may guide gap reasoning, but letter grades and numeric scores are removed from both text and JSON. + +The projection guard rejects credential, cookie, token, raw-envelope, SID, and unrelated student-identifier keys before output. diff --git a/package-lock.json b/package-lock.json index 8236a65..db4ec20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,9 @@ "dependencies": { "@modelcontextprotocol/server": "^2.0.0", "@napi-rs/keyring": "1.3.0", + "cheerio": "1.0.0", "playwright-core": "^1.62.1", + "string-width": "^7.2.0", "zod": "^4.5.2" }, "bin": { @@ -300,6 +302,66 @@ "undici-types": "~6.21.0" } }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/cheerio": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz", + "integrity": "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "encoding-sniffer": "^0.2.0", + "htmlparser2": "^9.1.0", + "parse5": "^7.1.2", + "parse5-htmlparser2-tree-adapter": "^7.0.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^6.19.5", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=18.17" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -315,6 +377,120 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -338,6 +514,49 @@ "node": ">=18.0.0" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -355,6 +574,67 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -387,6 +667,12 @@ "node": ">=20" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -410,6 +696,38 @@ "node": ">=8" } }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -424,6 +742,15 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "6.28.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz", + "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -431,6 +758,28 @@ "dev": true, "license": "MIT" }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index d909e19..4491488 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "dist/services", "dist/sso", "dist/tis", + "dist/talks", "dist/transit", "dist/wifi", "docs", @@ -75,7 +76,9 @@ "dependencies": { "@modelcontextprotocol/server": "^2.0.0", "@napi-rs/keyring": "1.3.0", + "cheerio": "1.0.0", "playwright-core": "^1.62.1", + "string-width": "^7.2.0", "zod": "^4.5.2" } } diff --git a/skills/sustech-cli/SKILL.md b/skills/sustech-cli/SKILL.md index aa78dc2..56a6e83 100644 --- a/skills/sustech-cli/SKILL.md +++ b/skills/sustech-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: sustech-cli -description: Use the installed sustech CLI for SUSTech campus reads, planning, guarded exports, and confirm-gated workflows across TIS, Blackboard, profile/context, booking, library booking, PMS, transit, faculty, papers, NCES, and selected SUSTech Online public information. Do not use for unrelated universities or invent commands the installed CLI does not report. +description: Use the installed sustech CLI for SUSTech campus reads, planning, guarded exports, and confirm-gated workflows across TIS, Blackboard, profile/context, booking, library booking, PMS, transit, faculty, official talks, papers, NCES, and selected SUSTech Online public information. Do not use for unrelated universities or invent commands the installed CLI does not report. --- # SUSTech CLI @@ -35,8 +35,9 @@ includes these high-value areas: `resources list`, `resources search`, `wifi status`, `wifi events`, `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`. + `transit schedule`, `transit stops`, `transit live`, `talks list`, + `talks search`, `online search`, `online talks list/search/get`, + `online contact search/get`. - 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`, @@ -52,8 +53,8 @@ includes these high-value areas: `tis ical`, `tis degree progress`, `tis degree missing`, `tis degree audit`. - TIS writes: `tis selection preview/apply` for `cart`, `drop`, and `bid` - style operations, `tis bid plan`, `tis bid apply`, `tis enroll preview`, - `tis enroll apply`. + style operations, read-only `tis selection reconcile`, `tis bid plan`, `tis + bid apply`, `tis enroll preview`, `tis enroll apply`. - Other authenticated campus services: `ws programs`, `ws detail`, `library search`, `library detail`, `booking whoami`, `booking rooms`, `booking my-meetings`, `booking create preview/apply`, @@ -72,6 +73,12 @@ Some useful routing hints: - 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. +- For current official university lectures, use the direct CLI commands `talks + list` and `talks search`. They show future lectures by default using Beijing + start times; add `--all` for every lecture currently displayed on the + official homepage. Keep unknown-time records visible and preserve the + returned official source metadata. These commands are not exposed through + the local MCP surface. - For timetable exploration, prefer `tis timetable` for one-off solving and `tis plan *` for persistent local planning. - `tis plan explain` and `tis plan recommend` are read-only planning helpers. @@ -132,6 +139,14 @@ direct CLI and the approval workflow below when state must change. `meta`, and `schemaVersion` in the output envelope. - Do not parse human-readable text when a JSON mode is available. - Preserve IDs exactly as returned; Blackboard and TIS IDs are opaque strings. +- For `tis courses available`, consume `data.bundles`, count bundle credits + once, include every required component, and use only its documented + `operationTargets`. `courseId` becomes upstream `p_id`; `rwh` identifies the + exact component for read-back. Never guess one from the other. +- Planning output is minimum-data by default. `tis degree missing` is + grade-free, and course grades in `tis degree progress` require an explicit + `--details`; do not request details when summary/category/module evidence is + sufficient. ## Handle credentials @@ -147,6 +162,10 @@ direct CLI and the approval workflow below when state must change. mechanism. Never create a plaintext fallback. - Use `sustech auth status --json` and the appropriate read-only `sustech auth check --service ... --json` before a workflow that needs login. +- On Linux, a locked Secret Service collection or missing desktop D-Bus session + is not evidence that credentials expired. Follow the structured + `remediation`, keep profile metadata intact, and retry only after the same + graphical login collection is unlocked. - Use `--profile` when the task depends on a specific account identity. - If CAS returns `CAS_INTERACTIVE_CHALLENGE_REQUIRED`, report that the password was not submitted and stop. Do not bypass, solve, or repeatedly retry the @@ -201,7 +220,10 @@ Important command-specific rules: - For file-bound applies such as Blackboard submission and PMS upload, preserve the exact previewed SHA-256 into apply. - If a result is ambiguous or contains `DO_NOT_RETRY_AUTOMATICALLY`, stop and - report it; do not retry the mutation automatically. + report it; do not retry the mutation automatically. For a TIS selection + timeout, run bounded `tis selection reconcile OP` with the exact + `courseId`/`rwh`/round from the error. Its `applied`, `not_applied`, or + `still_uncertain` result is read-only and never authorizes an automatic retry. ## Guard local writes and exports diff --git a/src/cli.ts b/src/cli.ts index d6dbb0e..2f5b588 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -72,7 +72,8 @@ import { fetchContextWeather, } from "./context/live.js"; import { ContextService } from "./context/service.js"; -import type { ContextLevel, DeadlineSummary } from "./context/types.js"; +import { buildContextSchedule } from "./context/schedule.js"; +import type { ContextInput, ContextLevel, DeadlineSummary } from "./context/types.js"; import { DOCTOR_SERVICES, buildDoctorReport, @@ -96,6 +97,8 @@ import { searchOnlineContacts, searchOnlineTalks, } from "./online/index.js"; +import { OfficialTalksClient } from "./talks/client.js"; +import { formatOfficialTalks } from "./talks/text.js"; import { searchResources, type ResourceCategory } from "./resources/catalog.js"; import { formatResources } from "./resources/text.js"; import { @@ -128,6 +131,14 @@ import { } from "./tis/course-decision.js"; import { formatCourseRecommendationReport } from "./tis/course-decision-text.js"; import { deriveTisDegreeMissing } from "./tis/degree-missing.js"; +import { + PLANNING_PROJECTION_FIELDS, + assertPlanningProjection, + projectDegreeMissingForPlanning, + projectDegreeProgressForPlanning, + projectEnrollmentForPlanning, + projectSelectionRoundForPlanning, +} from "./tis/planning-projection.js"; import { parseBlockedTime, solveTimetables } from "./tis/planner.js"; import { addPlanEntries, createPlanDocument, loadPlan, removePlanEntries, savePlan } from "./tis/plan.js"; import { @@ -146,11 +157,11 @@ import { parseShenzhenExamTimeRange, planBidUpdates, projectBidTotal, + reconcileSelectionSnapshots, revalidateSelectionWrite, resolveLiveRoom, scheduleIcsEvents, summariseEvaluationStatuses, - summariseCurrentOrNextClass, summariseLiveOccupancy, teachingPeriodAtShenzhenTime, verifySelectionWrite, @@ -348,6 +359,8 @@ Usage: 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 talks list [--all] + sustech talks search QUERY [--all] 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 @@ -442,6 +455,7 @@ Usage: sustech tis degree missing [--semester YYYY-YYYY-N] sustech tis degree audit --requirements FILE [--semester YYYY-YYYY-N] sustech tis selection preview OP --course-id ID [--rwh RWH] [--semester YYYY-YYYY-N] [--round ROUND] [--bid N] [--where cart|enrolled] [--cultivation 1|2] + sustech tis selection reconcile OP --course-id ID --rwh RWH [--semester YYYY-YYYY-N] [--round ROUND] [--bid N] [--where cart|enrolled] [--cultivation 1|2] [--attempts 2-5] sustech tis selection apply OP --course-id ID --rwh RWH [--semester YYYY-YYYY-N] [--round ROUND] [--bid N] [--where cart|enrolled] [--cultivation 1|2] --confirm sustech tis bid plan --pick COURSE_ID:BID|RWH:COURSE_ID:BID [--pick ...] [--semester YYYY-YYYY-N] [--bid-limit N] [--where cart|enrolled] [--round ROUND] [--cultivation 1|2] sustech tis bid apply --pick RWH:COURSE_ID:BID [--pick ...] [--semester YYYY-YYYY-N] [--where cart|enrolled] [--round ROUND] [--cultivation 1|2] --confirm @@ -562,6 +576,7 @@ type Values = OutputFlags & { path?: string; requirements?: string; details?: boolean; + attempts?: string; since?: string; until?: string; "url-stdin"?: boolean; @@ -694,6 +709,33 @@ async function main(argv: string[]): Promise { await runOnline(parsed.positionals, values, output); return; } + if (group === "talks") { + const query = parsed.positionals.slice(2).join(" ").trim(); + if (command !== "list" && command !== "search") throw usageError(`Unknown command: ${parsed.positionals.join(" ")}`); + if (command === "list" && parsed.positionals.length !== 2) throw usageError("talks list does not accept positional arguments."); + if (command === "search" && !query) throw usageError("A talk search query is required."); + const result = await new OfficialTalksClient().list({ + ...(command === "search" ? { query } : {}), + all: values.all, + }); + writeSuccess({ + command: `talks ${command}`, + data: result, + text: formatOfficialTalks(result), + items: result.talks, + summary: { + total: result.total, + sourceTotal: result.sourceTotal, + scope: result.scope, + referenceTime: result.referenceTime, + unknownTimeCount: result.unknownTimeCount, + provenance: result.provenance, + warnings: result.warnings, + }, + meta: result.provenance, + }, output); + return; + } if (group === "context") { await runContext(parsed.positionals, values, output); return; @@ -773,13 +815,21 @@ async function main(argv: string[]): Promise { if (limit > 500) throw usageError("--limit cannot exceed 500 for selectable-course queries."); const keyword = parsed.positionals.slice(3).join(" ") || undefined; const result = await client.searchAvailable(semester, { keyword, round, limit }); - const data = { semester, ...result }; + const data = { + semester, + round: projectSelectionRoundForPlanning(result.round), + bundles: result.bundles, + total: result.total, + reportedAt: result.reportedAt, + projection: { mode:"planning-minimum", fieldAllowlist:PLANNING_PROJECTION_FIELDS.availability }, + }; + assertPlanningProjection(data); writeSuccess({ command: "tis courses available", data, text: formatAvailableCourses({ semester, courses: result.courses, total: result.total, round }), - items: result.courses, - summary: { semester: semester.value, round, total: result.total, shown: result.courses.length }, + items: result.bundles, + summary: { semester: semester.value, round, total: result.total, shown: result.bundles.length }, meta: { enrolledCount: result.enrolled.length, cartCount: result.cart.length }, }, output); return; @@ -788,13 +838,20 @@ async function main(argv: string[]): Promise { const semester = parseSemester(values.semester); const client = await tisClient(values); const courses = await client.enrolled(semester); - const data = { semester, courses, total: courses.length }; + const projectedCourses = projectEnrollmentForPlanning(courses); + const data = { + semester, + courses: projectedCourses, + total: projectedCourses.length, + reportedAt: new Date().toISOString(), + projection: { mode:"planning-minimum", fieldAllowlist:PLANNING_PROJECTION_FIELDS.enrollment }, + }; writeSuccess({ command: "tis enrolled", data, text: formatEnrolledCourses(semester, courses), - items: courses, - summary: { semester: semester.value, total: courses.length }, + items: projectedCourses, + summary: { semester: semester.value, total: projectedCourses.length }, }, output); return; } @@ -1365,6 +1422,7 @@ async function main(argv: string[]): Promise { { operation: selectionOperation, courseId, + ...(rwh ? { rwh } : {}), ...(values.round ? { round: opaqueToken(values.round, "--round") } : {}), bid, where, @@ -1407,6 +1465,54 @@ async function main(argv: string[]): Promise { }, output); return; } + if (command === "selection" && operation === "reconcile" && parsed.positionals.length === 4) { + const selectionOperation = selectionOperationValue(required(parsed.positionals[3], "selection operation")); + const semester = parseSemester(values.semester); + const cultivation = selectionCultivation(values.cultivation); + const target = selectionApplyTarget(values, selectionOperation); + const attempts = parsePositiveInteger(values.attempts, 3, "--attempts"); + if (attempts < 2 || attempts > 5) throw usageError("--attempts must be between 2 and 5."); + const client = await tisClient(values); + const states: TisSelectionState[] = []; + const readErrors: string[] = []; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + states.push(await client.selectionState(semester, { + keyword: "", + round: target.round, + limit: 500, + cultivation, + })); + } catch (error) { + readErrors.push(error instanceof CliError ? error.code : "UNKNOWN_READ_ERROR"); + } + if (attempt < attempts) await boundedWait(750); + } + const base = reconcileSelectionSnapshots(states, target); + const reconciliation = readErrors.length > 0 + ? { + ...base, + status: "still_uncertain" as const, + readErrors, + message: "At least one bounded read-back failed; the outcome remains uncertain and must not be retried automatically.", + } + : base; + writeSuccess({ + command: "tis selection reconcile", + data: { semester, cultivation, reconciliation }, + text: [ + `Reconciliation: ${reconciliation.status}`, + `Exact target: ${target.courseId} / ${target.rwh} / ${target.round}`, + `Read-back attempts: ${attempts}`, + reconciliation.message, + "Automatic retry: forbidden", + ].join("\n"), + items: reconciliation.observations, + summary: { status: reconciliation.status, attempts, readErrors: readErrors.length }, + meta: { warning: "DO_NOT_RETRY_AUTOMATICALLY" }, + }, output); + return; + } if (command === "selection" && operation === "apply" && parsed.positionals.length === 4) { const selectionOperation = selectionOperationValue(required(parsed.positionals[3], "selection operation")); if (selectionOperation === "enroll") { @@ -1444,6 +1550,7 @@ async function main(argv: string[]): Promise { { operation: target.operation, courseId: target.courseId, + rwh: target.rwh, round: target.round, bid: target.bid, where: target.where, @@ -1454,6 +1561,7 @@ async function main(argv: string[]): Promise { throw new CliError(result.message || "TIS rejected the selection mutation.", "TIS_WRITE_REJECTED", 4, { target, tisCode: result.jg, + clientRequestId: result.clientRequestId, }); } let verification; @@ -1608,6 +1716,7 @@ async function main(argv: string[]): Promise { { operation: "bid.update", courseId: pick.courseId, + rwh: pick.rwh!, round, bid: pick.bid, where, @@ -1621,6 +1730,7 @@ async function main(argv: string[]): Promise { confirmed, unchanged, tisCode: result.jg, + clientRequestId: result.clientRequestId, result, warning: "DO_NOT_RETRY_AUTOMATICALLY", }); @@ -1630,6 +1740,7 @@ async function main(argv: string[]): Promise { confirmed, unchanged, tisCode: result.jg, + clientRequestId: result.clientRequestId, }); } try { @@ -1724,11 +1835,12 @@ async function main(argv: string[]): Promise { if (command === "degree" && operation === "progress" && parsed.positionals.length === 3) { const client = await tisClient(values); const progress = await client.degreeProgress({ details: values.details === true }); + const projectedProgress = projectDegreeProgressForPlanning(progress, { includeGrades: values.details === true }); writeSuccess({ command: "tis degree progress", - data: progress, + data: projectedProgress, text: formatDegreeProgress(progress), - ...(progress.detailsIncluded && progress.courses ? { items: progress.courses } : {}), + ...(projectedProgress.detailsIncluded && projectedProgress.courses ? { items: projectedProgress.courses } : {}), summary: { dataAvailable: progress.dataAvailable, detailsRequested: progress.detailsRequested, @@ -1749,10 +1861,11 @@ async function main(argv: string[]): Promise { const semester = values.semester ? parseSemester(values.semester) : undefined; const client = await tisClient(values); const report = await deriveTisDegreeMissing(client, { semester }); + const projectedReport = projectDegreeMissingForPlanning(report); writeSuccess({ command: "tis degree missing", - data: report, - text: formatDegreeMissing(report), + data: projectedReport, + text: formatDegreeMissing(projectedReport), summary: { definiteMissingRequiredCourses: report.counts.definiteMissingRequiredCourses, inProgressRequiredCourses: report.counts.inProgressRequiredCourses, @@ -1797,6 +1910,7 @@ async function main(argv: string[]): Promise { action: "enroll", rwh: target.rwh, tisCode: result.jg, + clientRequestId: result.clientRequestId, }); } let verification: { status: "confirmed" | "not_observed" | "unavailable"; message: string }; @@ -2858,36 +2972,40 @@ async function runContext( ): Promise { if (positionals.length !== 1) throw usageError(`Unknown command: ${positionals.join(" ")}`); const date = isoDate(values.date ?? todayInShenzhen(), "--date"); + if (values.live && date !== todayInShenzhen()) throw usageError("--live is only available for today's date in Asia/Shanghai. Omit --live for a calendar date preview."); const year = Number(date.slice(0, 4)); - const calendar = await new CalendarClient().loadYear(year, calendarLevel(values["calendar-level"])); const level = contextLevel(values.level); + const requestedCalendarLevel = calendarLevel(values["calendar-level"]); + let calendar: AcademicCalendar | undefined; + let calendarError: string | undefined; + try { + calendar = await new CalendarClient().loadYear(year, requestedCalendarLevel); + } catch (error) { + calendarError = errorMessage(error); + } const service = new ContextService(); - const now = contextReferenceTime(date, values.live); + const now = contextReferenceTime(date); const live = values.live ? await loadLiveContext(date, now, calendar, values, level) : undefined; const snapshot = service.build({ now, calendar, - ...(live?.schedule ? { schedule: live.schedule } : {}), - ...(live?.nextDeadline ? { nextDeadline: live.nextDeadline } : {}), - ...(live?.nextEvaluation ? { nextEvaluation: live.nextEvaluation } : {}), - ...(live?.nextExam ? { nextExam: live.nextExam } : {}), - ...(live?.weather ? { weather: live.weather } : {}), - ...(live?.airQuality ? { airQuality: live.airQuality } : {}), - ...(live?.libraryStatus ? { libraryStatus: live.libraryStatus } : {}), + ...live, }, level); const liveText = live ? formatContextLiveSources(live.liveSources) : []; writeSuccess({ command: "context", data: { ...service.toRecord(snapshot), + mode: date === todayInShenzhen() ? "daily" : "date-preview", + calendarSource: { state: calendar ? "provided" : "error", ...(calendarError ? { message: calendarError } : {}) }, ...(live ? { liveSources: live.liveSources } : {}), }, - text: [...snapshot.lines, ...liveText].join("\n"), + text: [...snapshot.lines, ...liveText, ...(calendarError ? [`Calendar unavailable: ${calendarError}`] : []), ...(!live ? ["Personal and environmental sources not requested; use --live for a daily snapshot."] : [])].join("\n"), ...(live ? { meta: { liveSources: live.liveSources } } : {}), }, output); } -type ContextLiveSourceState = "provided" | "missing" | "partial" | "credentials-missing" | "error"; +type ContextLiveSourceState = "provided" | "empty" | "not-requested" | "missing" | "partial" | "credentials-missing" | "error"; interface ContextLiveSourceStatus { state: ContextLiveSourceState; @@ -2897,17 +3015,7 @@ interface ContextLiveSourceStatus { message?: string; } -async function loadLiveContext( - date: string, - now: Date, - calendar: AcademicCalendar, - values: Values, - level: ContextLevel, -): Promise<{ - schedule?: { now?: string; next?: string; nextDetail?: string; tomorrowMorning?: string }; - nextDeadline?: DeadlineSummary; - nextEvaluation?: { course: string; name: string; daysLeft?: number; dueAt?: string }; - nextExam?: { name: string; code: string; date: string; time?: string; building?: string; room?: string; campus?: string }; +interface ContextLiveResult extends ContextInput { liveSources: { tisSchedule: ContextLiveSourceStatus; tisExams: ContextLiveSourceStatus; @@ -2917,180 +3025,176 @@ async function loadLiveContext( airQuality?: ContextLiveSourceStatus; libraryStatus?: ContextLiveSourceStatus; }; - weather?: { condition: string; icon?: string; tempC?: number; feelsLikeC?: number; humidity?: number; windKmh?: number; precipitationMm?: number }; - airQuality?: { aqi: number; level?: string; pm25?: number; pm10?: number; ozone?: number }; - libraryStatus?: string; -}> { - const liveSources: { - tisSchedule: ContextLiveSourceStatus; - tisExams: ContextLiveSourceStatus; - blackboardDeadlines: ContextLiveSourceStatus; - tisEvaluations?: ContextLiveSourceStatus; - weather?: ContextLiveSourceStatus; - airQuality?: ContextLiveSourceStatus; - libraryStatus?: ContextLiveSourceStatus; - } = { +} + +async function loadLiveContext( + date: string, + now: Date, + calendar: AcademicCalendar | undefined, + values: Values, + level: ContextLevel, +): Promise { + const liveSources: ContextLiveResult["liveSources"] = { tisSchedule: { state: "missing" }, - tisExams: { state: "missing" }, - blackboardDeadlines: { state: "missing" }, + tisExams: { state: contextLoadsNormalFields(level) ? "missing" : "not-requested" }, + blackboardDeadlines: { state: contextLoadsNormalFields(level) ? "missing" : "not-requested" }, ...(contextLoadsNormalFields(level) ? { tisEvaluations: { state: "missing" as const } } : {}), - ...(contextLoadsVerboseFields(level) + ...(contextLoadsNormalFields(level) ? { weather: { state: "missing" as const }, airQuality: { state: "missing" as const }, - libraryStatus: { state: "missing" as const }, } : {}), + ...(contextLoadsVerboseFields(level) ? { libraryStatus: { state: "missing" as const } } : {}), }; - const result: { - schedule?: { now?: string; next?: string; nextDetail?: string; tomorrowMorning?: string }; - nextDeadline?: DeadlineSummary; - 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; - tisEvaluations?: ContextLiveSourceStatus; - weather?: ContextLiveSourceStatus; - airQuality?: ContextLiveSourceStatus; - libraryStatus?: ContextLiveSourceStatus; - }; - weather?: { condition: string; icon?: string; tempC?: number; feelsLikeC?: number; humidity?: number; windKmh?: number; precipitationMm?: number }; - airQuality?: { aqi: number; level?: string; pm25?: number; pm10?: number; ozone?: number }; - libraryStatus?: string; - } = { liveSources }; + const result: ContextLiveResult = { liveSources }; - const calendarDay = calendar.day(date); - const termSemester = calendarDay.semester; + const calendarDay = calendar?.day(date); + const termSemester = calendarDay?.semester; const semester = termSemester ? parseSemester(termSemester.semester.value) : parseSemester(undefined); - const currentWeek = calendarDay.week; + const calendarTerm = calendar?.terms().find((candidate) => candidate.snapshot.semester.value === semester.value); - let tis: TisClient | undefined; - try { - tis = await tisClient(values); - } catch (error) { - const message = errorMessage(error); - const state = error instanceof CliError && error.code === "CREDENTIALS_REQUIRED" ? "credentials-missing" : "error"; - liveSources.tisSchedule = { state, message }; - liveSources.tisExams = { state, message }; - if (liveSources.tisEvaluations) liveSources.tisEvaluations = { state, message }; - } - - if (tis) { - const [scheduleResult, examsResult, evaluationsResult] = await Promise.allSettled([ - currentWeek > 0 ? tis.schedule(semester) : Promise.resolve([] as PersonalScheduleEntry[]), - tis.exams(), - contextLoadsNormalFields(level) - ? tis.evaluations(semester.value, "all") - : Promise.resolve(undefined), - ]); + const loadTis = async () => { + let tis: TisClient | undefined; + try { + tis = await tisClient(values); + } catch (error) { + const message = errorMessage(error); + const state = error instanceof CliError && error.code === "CREDENTIALS_REQUIRED" ? "credentials-missing" : "error"; + liveSources.tisSchedule = { state, message }; + if (contextLoadsNormalFields(level)) liveSources.tisExams = { state, message }; + if (liveSources.tisEvaluations) liveSources.tisEvaluations = { state, message }; + } - if (scheduleResult.status === "fulfilled") { - if (currentWeek > 0) { - const calendarTerm = calendar.terms().find((candidate) => ( - candidate.snapshot.semester.value === semester.value - )); - const schedule = summariseCurrentOrNextClass(scheduleResult.value, { currentWeek, now, calendarTerm }); - result.schedule = schedule; - liveSources.tisSchedule = { - state: schedule.now || schedule.next || schedule.tomorrowMorning ? "provided" : "missing", - }; + if (tis) { + const [scheduleResult, examsResult, evaluationsResult] = await Promise.allSettled([ + calendarTerm ? tis.schedule(semester).then((entries) => buildContextSchedule(entries, calendarTerm, now)) : Promise.resolve(undefined), + contextLoadsNormalFields(level) ? tis.exams() : Promise.resolve([] as ExamRecord[]), + contextLoadsNormalFields(level) + ? tis.evaluations(semester.value, "all") + : Promise.resolve(undefined), + ]); + + if (scheduleResult.status === "fulfilled") { + if (scheduleResult.value) { + const schedule = scheduleResult.value; + result.schedule = schedule; + liveSources.tisSchedule = { + state: schedule.omissionCount ? "partial" : schedule.now || schedule.next || schedule.todayClasses?.length ? "provided" : "empty", + omissionCount: schedule.omissionCount, + ...(schedule.omissionCount ? { message: `${schedule.omissionCount} timetable row(s) lacked usable weeks or periods.` } : {}), + }; + } else { + liveSources.tisSchedule = { + state: "missing", + message: `Date ${date} is outside the loaded academic teaching weeks.`, + }; + } } else { liveSources.tisSchedule = { - state: "missing", - message: `Date ${date} is outside the loaded academic teaching weeks.`, + state: "error", + message: errorMessage(scheduleResult.reason), }; } - } else { - liveSources.tisSchedule = { - state: "error", - message: errorMessage(scheduleResult.reason), - }; - } - - if (examsResult.status === "fulfilled") { - const semesterOmissions = examsResult.value - .filter((exam) => !matchesSemesterLabel(exam.semester, semester)) - .map((exam) => ({ - code: exam.code || exam.name || "exam", - message: exam.semester - ? `Skipped ${exam.code || exam.name || "exam"}: exam semester "${exam.semester}" did not match ${semester.value}.` - : `Skipped ${exam.code || exam.name || "exam"}: exam semester was missing.`, - })); - const selection = nearestUpcomingExam( - examsResult.value.filter((exam) => matchesSemesterLabel(exam.semester, semester)), - { now }, - ); - if (selection.exam) result.nextExam = contextExamSummary(selection.exam); - const omissionCount = selection.omissions.length + semesterOmissions.length; - liveSources.tisExams = { - state: omissionCount > 0 ? "partial" : selection.exam ? "provided" : "missing", - omissionCount, - ...((selection.omissions[0] ?? semesterOmissions[0]) ? { message: (selection.omissions[0] ?? semesterOmissions[0])?.message } : {}), - }; - } else { - liveSources.tisExams = { - state: "error", - message: errorMessage(examsResult.reason), - }; - } - if (liveSources.tisEvaluations) { - if (evaluationsResult.status === "fulfilled") { - const selection = nextPendingEvaluationSummary(evaluationsResult.value ?? [], now); - if (selection.evaluation) result.nextEvaluation = selection.evaluation; - liveSources.tisEvaluations = { - state: selection.state, - omissionCount: selection.omissionCount, - ...(selection.message ? { message: selection.message } : {}), + if (contextLoadsNormalFields(level) && examsResult.status === "fulfilled") { + const semesterOmissions = examsResult.value + .filter((exam) => !matchesSemesterLabel(exam.semester, semester)) + .map((exam) => ({ + code: exam.code || exam.name || "exam", + message: exam.semester + ? `Skipped ${exam.code || exam.name || "exam"}: exam semester "${exam.semester}" did not match ${semester.value}.` + : `Skipped ${exam.code || exam.name || "exam"}: exam semester was missing.`, + })); + const selection = nearestUpcomingExam( + examsResult.value.filter((exam) => matchesSemesterLabel(exam.semester, semester)), + { now }, + ); + if (selection.exam) result.nextExam = contextExamSummary(selection.exam); + const omissionCount = selection.omissions.length + semesterOmissions.length; + if (!selection.exam && omissionCount === 0) result.nextExam = null; + liveSources.tisExams = { + state: omissionCount > 0 ? "partial" : selection.exam ? "provided" : "empty", + omissionCount, + ...((selection.omissions[0] ?? semesterOmissions[0]) ? { message: (selection.omissions[0] ?? semesterOmissions[0])?.message } : {}), }; - } else { - liveSources.tisEvaluations = { + } else if (examsResult.status === "rejected") { + liveSources.tisExams = { state: "error", - message: errorMessage(evaluationsResult.reason), + message: errorMessage(examsResult.reason), }; } + + if (liveSources.tisEvaluations) { + if (evaluationsResult.status === "fulfilled") { + const selection = nextPendingEvaluationSummary(evaluationsResult.value ?? [], now); + if (selection.evaluation) result.nextEvaluation = selection.evaluation; + else if (selection.state === "empty") result.nextEvaluation = null; + liveSources.tisEvaluations = { + state: selection.state, + omissionCount: selection.omissionCount, + ...(selection.message ? { message: selection.message } : {}), + }; + } else { + liveSources.tisEvaluations = { + state: "error", + message: errorMessage(evaluationsResult.reason), + }; + } + } } - } - 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 } : {}), - }; - } catch (error) { - const message = errorMessage(error); - liveSources.blackboardDeadlines = { - state: error instanceof CliError && error.code === "CREDENTIALS_REQUIRED" ? "credentials-missing" : "error", - message, - }; - } + }; - if (contextLoadsVerboseFields(level)) { - const [weatherResult, airQualityResult, libraryStatusResult] = await Promise.allSettled([ - fetchContextWeather(), - fetchContextAirQuality(), - fetchContextLibraryStatus(), - ]); + const loadDeadlines = async () => { + if (!contextLoadsNormalFields(level)) return; + try { + const adapter = await casServiceAdapter(values, "bb"); + const report = await listBlackboardDeadlines(adapter, { now }); + const deadline = nextBlackboardDeadline(report); + if (deadline) result.nextDeadline = contextDeadlineSummary(deadline); + else if (report.failures.length === 0) result.nextDeadline = null; + liveSources.blackboardDeadlines = { + state: report.failures.length > 0 ? "partial" : deadline ? "provided" : "empty", + generatedAt: report.generatedAt, + failureCount: report.failures.length, + ...(report.failures[0]?.message ? { message: report.failures[0].message } : {}), + }; + } catch (error) { + const message = errorMessage(error); + liveSources.blackboardDeadlines = { + state: error instanceof CliError && error.code === "CREDENTIALS_REQUIRED" ? "credentials-missing" : "error", + message, + }; + } + + }; + + const loadEnvironment = async () => { + if (contextLoadsNormalFields(level)) { + const [weatherResult, airQualityResult, libraryStatusResult] = await Promise.allSettled([ + fetchContextWeather(), + fetchContextAirQuality(), + contextLoadsVerboseFields(level) ? fetchContextLibraryStatus() : Promise.resolve(null), + ]); - liveSources.weather = settledContextPublicSource(weatherResult); - if (weatherResult.status === "fulfilled" && weatherResult.value) result.weather = weatherResult.value; + liveSources.weather = settledContextPublicSource(weatherResult); + if (weatherResult.status === "fulfilled" && weatherResult.value) result.weather = weatherResult.value; - liveSources.airQuality = settledContextPublicSource(airQualityResult); - if (airQualityResult.status === "fulfilled" && airQualityResult.value) result.airQuality = airQualityResult.value; + liveSources.airQuality = settledContextPublicSource(airQualityResult); + if (airQualityResult.status === "fulfilled" && airQualityResult.value) result.airQuality = airQualityResult.value; - liveSources.libraryStatus = settledContextPublicSource(libraryStatusResult); - if (libraryStatusResult.status === "fulfilled" && libraryStatusResult.value) result.libraryStatus = libraryStatusResult.value; + if (contextLoadsVerboseFields(level)) liveSources.libraryStatus = settledContextPublicSource(libraryStatusResult); + if (libraryStatusResult.status === "fulfilled" && libraryStatusResult.value) result.libraryStatus = libraryStatusResult.value; + } + }; + + await Promise.all([loadTis(), loadDeadlines(), loadEnvironment()]); + for (const source of Object.values(liveSources)) { + if (source.state !== "not-requested") source.generatedAt ??= new Date().toISOString(); } return result; @@ -3152,7 +3256,7 @@ function nextPendingEvaluationSummary( evaluation?: { course: string; name: string; daysLeft?: number; dueAt?: string }; } { const actionable = (rows ?? []).filter((row) => !row.submitted); - if (actionable.length === 0) return { state: "missing", omissionCount: 0 }; + if (actionable.length === 0) return { state: "empty", omissionCount: 0 }; const dated = actionable .map((row) => ({ row, due: parseContextDueAt(row.deadline) })) @@ -3430,8 +3534,8 @@ function formatTisIcalSourceStatus(status: TisIcalSourceStatus): string { return `${status.state}${extras ? ` (${extras})` : ""}`; } -function contextReferenceTime(date: string, live: boolean | undefined): Date { - if (live && date === todayInShenzhen()) return new Date(); +function contextReferenceTime(date: string): Date { + if (date === todayInShenzhen()) return new Date(); return new Date(`${date}T12:00:00+08:00`); } @@ -5906,6 +6010,10 @@ function defaultSelectionBid(operation: SelectionOperation): number { return operation === "drop" || operation === "cart.remove" ? 1 : 1; } +function boundedWait(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + function buildEnrollApplyCommand(target: { semester: ReturnType; courseId: string; diff --git a/src/context/live.ts b/src/context/live.ts index df7f169..69cfcd0 100644 --- a/src/context/live.ts +++ b/src/context/live.ts @@ -39,7 +39,7 @@ export async function loadWeatherSummary( export async function fetchContextWeather( adapter: ServiceAdapter = createFetchAdapter(), ): Promise { - const raw = await fetchJson(adapter, "https://api.sustech.online/weather"); + const raw = await fetchJson(adapter, "https://api.sustech.online/weather", { signal: AbortSignal.timeout(8000) }); return normaliseContextWeather(raw); } @@ -82,14 +82,14 @@ export async function fetchContextAirQuality( "ozone", ], timezone: "Asia/Shanghai", - })); + }), { signal: AbortSignal.timeout(8000) }); return normaliseContextAirQuality(raw); } export async function fetchContextLibraryStatus( adapter: ServiceAdapter = createFetchAdapter(), ): Promise { - const html = await fetchText(adapter, "https://lib.sustech.edu.cn/"); + const html = await fetchText(adapter, "https://lib.sustech.edu.cn/", { signal: AbortSignal.timeout(8000) }); return parseContextLibraryStatus(html); } @@ -184,6 +184,8 @@ export function normaliseContextWeather(raw: unknown): WeatherSummary | null { const temp = /气温\s*(-?\d+(?:\.\d+)?)\s*℃/.exec(text); const feelsLike = /体感\s*(-?\d+(?:\.\d+)?)\s*℃/.exec(text); return { + source: "https://api.sustech.online/weather", + ...observationTime(recordValue(raw).update_time), condition, ...(temp ? { tempC: Math.round(Number(temp[1])) } : {}), ...(feelsLike ? { feelsLikeC: Math.round(Number(feelsLike[1])) } : {}), @@ -195,6 +197,9 @@ export function normaliseContextAirQuality(raw: unknown): AirQualitySummary | nu const aqi = optionalNumber(current.us_aqi); if (aqi === undefined) return null; return { + standard: "US EPA", + source: "https://air-quality-api.open-meteo.com", + ...observationTime(current.time), aqi, level: aqiLevel(aqi), pm25: optionalNumber(current.pm2_5), @@ -203,6 +208,13 @@ export function normaliseContextAirQuality(raw: unknown): AirQualitySummary | nu }; } +function observationTime(value: unknown): { observedAt?: string } { + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(value)) return {}; + const normalized = value.replace(" ", "T"); + const timestamp = new Date(/[zZ]|[+-]\d{2}:\d{2}$/.test(normalized) ? normalized : `${normalized}+08:00`); + return Number.isNaN(timestamp.getTime()) ? {} : { observedAt: timestamp.toISOString() }; +} + export function parseContextLibraryStatus(html: string): string | null { const spanBody = String.raw`((?:(?!<\/span>)[\s\S])*)`; const matches = [...html.matchAll(new RegExp( diff --git a/src/context/schedule.ts b/src/context/schedule.ts new file mode 100644 index 0000000..8eda930 --- /dev/null +++ b/src/context/schedule.ts @@ -0,0 +1,75 @@ +import type { CalendarTerm } from "../calendar/client.js"; +import { CliError } from "../core/errors.js"; +import { scheduleOccurrences } from "../tis/remaining-calendar.js"; +import type { PersonalScheduleEntry } from "../tis/types.js"; +import type { ContextClass, ScheduleReminder } from "./types.js"; + +/** Use the same holiday/makeup instances as ICS, with explicit timestamps for agents. */ +export function buildContextSchedule(entries: readonly PersonalScheduleEntry[], term: CalendarTerm, now: Date): ScheduleReminder { + const date = new Intl.DateTimeFormat("sv-SE", { timeZone: "Asia/Shanghai" }).format(now); + let omissionCount = 0; + const occurrences = entries.flatMap((entry) => { + if (entry.day === undefined || entry.periodStart === undefined || entry.periodEnd === undefined || entry.weeks.length === 0) { + omissionCount++; + return []; + } + try { + return scheduleOccurrences([entry], { teachingStartDate: term.snapshot.teachingStart }, term); + } catch (error) { + if (!(error instanceof CliError) || error.code !== "UNSUPPORTED_PERIOD") throw error; + omissionCount++; + return []; + } + }).sort((left, right) => left.startUtc.localeCompare(right.startUtc) || left.uid.localeCompare(right.uid)); + const classes = occurrences + .map((occurrence): ContextClass => { + const startAt = expandedUtc(occurrence.startUtc); + const endAt = expandedUtc(occurrence.endUtc); + return { + name: occurrence.summary, + startAt, + endAt, + ...(occurrence.location ? { location: occurrence.location } : {}), + week: occurrence.week, + periodStart: occurrence.periodStart, + periodEnd: occurrence.periodEnd, + status: now.getTime() >= Date.parse(endAt) ? "completed" + : now.getTime() >= Date.parse(startAt) ? "in-progress" : "upcoming", + ...(occurrence.sourceDate ? { makeupFor: occurrence.sourceDate } : {}), + }; + }); + const currentClass = classes.find((item) => item.status === "in-progress"); + const nextClass = classes.find((item) => item.status === "upcoming"); + const todayClasses = classes.filter((item) => shanghaiDate(item.startAt) === date); + const tomorrow = shanghaiDate(new Date(Date.parse(`${date}T00:00:00+08:00`) + 86400000).toISOString()); + return { + todayClasses, + omissionCount, + ...(currentClass ? { currentClass, now: `${currentClass.name} — ${classDetail(currentClass)}` } : {}), + ...(nextClass ? { + nextClass, + next: nextClass.name, + nextDetail: classDetail(nextClass), + ...(shanghaiDate(nextClass.startAt) === tomorrow && shanghaiTime(nextClass.startAt) < "12:00" + ? { tomorrowMorning: `${nextClass.name} — ${classDetail(nextClass)}` } : {}), + } : {}), + }; +} + +function expandedUtc(value: string): string { + return value.replace(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/, "$1-$2-$3T$4:$5:$6Z"); +} + +function shanghaiDate(value: string): string { + return new Intl.DateTimeFormat("sv-SE", { timeZone: "Asia/Shanghai" }).format(new Date(value)); +} + +function shanghaiTime(value: string): string { + return new Intl.DateTimeFormat("en-GB", { timeZone: "Asia/Shanghai", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(new Date(value)); +} + +function classDetail(item: ContextClass): string { + return `${shanghaiDate(item.startAt)} ${shanghaiTime(item.startAt)}–${shanghaiTime(item.endAt)}` + + (item.location ? ` @ ${item.location}` : "") + + (item.makeupFor ? ` (makeup for ${item.makeupFor})` : ""); +} diff --git a/src/context/service.ts b/src/context/service.ts index 68cfa58..4dc4d39 100644 --- a/src/context/service.ts +++ b/src/context/service.ts @@ -23,9 +23,9 @@ export class ContextService { const sourceStatus: ContextSourceStatus = { academicDay: academic.state, schedule: input.schedule ? "provided" : "missing", - nextDeadline: input.nextDeadline === undefined ? "missing" : "provided", - nextEvaluation: input.nextEvaluation === undefined ? "missing" : "provided", - nextExam: input.nextExam === undefined ? "missing" : "provided", + nextDeadline: sourceState(input.nextDeadline), + nextEvaluation: sourceState(input.nextEvaluation), + nextExam: sourceState(input.nextExam), weather: input.weather === undefined ? "missing" : "provided", airQuality: input.airQuality === undefined ? "missing" : "provided", libraryStatus: input.libraryStatus === undefined ? "missing" : "provided", @@ -33,10 +33,14 @@ export class ContextService { const snapshot: ContextSnapshot = { level, - generatedAt: now.toISOString(), + generatedAt: (input.generatedAt ?? new Date()).toISOString(), + referenceAt: now.toISOString(), + timezone: "Asia/Shanghai", date: formatDate(now), time: formatTime(now), - weekday: WEEKDAY_NAMES[now.getDay()], + weekday: new Intl.DateTimeFormat("en-US", { timeZone: "Asia/Shanghai", weekday: "long" }).format(now), + ...(academic.day ? { academicDay: academic.day } : {}), + ...(academic.day && academic.day.week > 0 ? { weekParity: academic.day.week % 2 ? "odd" as const : "even" as const } : {}), ...(academic.day?.week ? { week: academic.day.week } : {}), ...(academic.day?.label ? { label: academic.day.label } : {}), ...(academic.day?.phase ? { phase: academic.day.phase } : {}), @@ -46,10 +50,10 @@ export class ContextService { nextDeadline: input.nextDeadline ?? null, nextEvaluation: input.nextEvaluation ?? null, nextExam: input.nextExam ?? null, + weather: environmentFreshness(input.weather, now), + airQuality: environmentFreshness(input.airQuality, now), } : {}), ...(LEVEL_ORDER[level] >= LEVEL_ORDER.verbose ? { - weather: input.weather ?? null, - airQuality: input.airQuality ?? null, libraryStatus: input.libraryStatus ?? null, } : {}), sourceStatus, @@ -61,6 +65,9 @@ export class ContextService { public toRecord(snapshot: ContextSnapshot): Record { const record: Record = { + generatedAt: snapshot.generatedAt, + referenceAt: snapshot.referenceAt, + timezone: snapshot.timezone, date: snapshot.date, time: snapshot.time, weekday: snapshot.weekday, @@ -71,14 +78,16 @@ export class ContextService { if (snapshot.label) record.label = snapshot.label; if (snapshot.phase) record.phase = snapshot.phase; if (snapshot.holiday) record.holiday = snapshot.holiday; + if (snapshot.academicDay) record.academicDay = snapshot.academicDay; + if (snapshot.weekParity) record.weekParity = snapshot.weekParity; if (LEVEL_ORDER[snapshot.level] >= LEVEL_ORDER.normal) { record.nextDeadline = snapshot.nextDeadline ?? null; record.nextEvaluation = snapshot.nextEvaluation ?? null; record.nextExam = snapshot.nextExam ?? null; - } - if (LEVEL_ORDER[snapshot.level] >= LEVEL_ORDER.verbose) { record.weather = snapshot.weather ?? null; record.airQuality = snapshot.airQuality ?? null; + } + if (LEVEL_ORDER[snapshot.level] >= LEVEL_ORDER.verbose) { record.libraryStatus = snapshot.libraryStatus ?? null; } return record; @@ -90,29 +99,33 @@ export class ContextService { } function renderLines(snapshot: ContextSnapshot): string[] { + const isToday = snapshot.date === formatDate(new Date(snapshot.generatedAt)); const lines = [ - `Today is [${snapshot.date}], [${snapshot.weekday}]`, + `${isToday ? "Today is" : "Date preview:"} [${snapshot.date}], [${snapshot.weekday}]`, ...(snapshot.label ? [`According to SUSTech academic calendar, this is [${snapshot.label}]`] : []), - `Current time is [${snapshot.time}]`, + `${isToday ? "Current time is" : "Reference time:"} [${snapshot.time}] (Asia/Shanghai)`, ...(snapshot.holiday ? [`Today is [${snapshot.holiday}]`] : []), + ...(snapshot.academicDay?.compensatory ? [`Makeup timetable: ${snapshot.academicDay.compensatory.weekType} ${snapshot.academicDay.compensatory.workday}`] : []), ]; 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.verbose) appendVerbose(lines, snapshot.weather ?? null, snapshot.airQuality ?? null, snapshot.libraryStatus ?? null); + if (LEVEL_ORDER[snapshot.level] >= LEVEL_ORDER.normal) appendVerbose(lines, snapshot.weather ?? null, snapshot.airQuality ?? null, snapshot.libraryStatus ?? null); + if (snapshot.sourceStatus.nextDeadline === "empty" && LEVEL_ORDER[snapshot.level] >= LEVEL_ORDER.normal) lines.push("No upcoming assignments in the retrieved Blackboard deadlines."); + if (snapshot.sourceStatus.nextExam === "empty" && LEVEL_ORDER[snapshot.level] >= LEVEL_ORDER.normal) lines.push("No upcoming exams in the retrieved TIS records."); return lines; } function appendSchedule(lines: string[], schedule: ScheduleReminder): void { + if (schedule.todayClasses?.length === 0 && !schedule.omissionCount) lines.push("No classes scheduled today in the retrieved timetable."); if (schedule.now) { lines.push(`Now: [${schedule.now}]`); - return; } if (schedule.next) { const detail = schedule.nextDetail ? ` — ${schedule.nextDetail}` : ""; lines.push(`Next: [${schedule.next}]${detail}`); return; } - if (schedule.tomorrowMorning) { + if (!schedule.next && schedule.tomorrowMorning) { lines.push(`Tomorrow morning: [${schedule.tomorrowMorning}]`); } } @@ -123,7 +136,7 @@ function appendNormal( nextEvaluation: EvaluationSummary | null, nextExam: ExamSummary | null, ): void { - if (nextDeadline) lines.push(`Next deadline: [${nextDeadline.name}] — ${deadlineStatus(nextDeadline.daysLeft, nextDeadline.dueAt)}`); + if (nextDeadline) lines.push(`Next deadline: [${nextDeadline.name}] — ${deadlineStatus(nextDeadline.daysLeft, nextDeadline.dueAt)}${nextDeadline.dueAt ? ` (${nextDeadline.dueAt})` : ""}`); 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 || ""; @@ -139,10 +152,10 @@ function appendVerbose( ): void { if (weather?.condition) { const temperature = weather.tempC !== undefined ? ` ${weather.tempC}C` : ""; - lines.push(`Weather at SUSTech: [${weather.condition}]${temperature}`); + lines.push(`Weather at SUSTech: [${weather.condition}]${temperature}${observationLabel(weather)}`); } if (airQuality) { - lines.push(`Air quality: [AQI ${airQuality.aqi}]${airQuality.level ? ` ${airQuality.level}` : ""}`); + lines.push(`Air quality: [${airQuality.standard ? `${airQuality.standard} ` : ""}AQI ${airQuality.aqi}]${airQuality.level ? ` ${airQuality.level}` : ""}${observationLabel(airQuality)}`); } if (libraryStatus) { lines.push(`Library: [${libraryStatus}]`); @@ -176,23 +189,24 @@ function normaliseNow(value: Date | string | undefined): Date { } function formatDate(now: Date): string { - return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; + return new Intl.DateTimeFormat("sv-SE", { timeZone: "Asia/Shanghai" }).format(now); } function formatTime(now: Date): string { - return `${pad(now.getHours())}:${pad(now.getMinutes())}`; + return new Intl.DateTimeFormat("en-GB", { timeZone: "Asia/Shanghai", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(now); +} + +function sourceState(value: unknown): SourceState { + return value === undefined ? "missing" : value === null ? "empty" : "provided"; } -function pad(value: number): string { - return String(value).padStart(2, "0"); +function environmentFreshness(value: T | null | undefined, now: Date): T | null { + if (!value) return null; + const observed = value.observedAt ? Date.parse(value.observedAt) : NaN; + return { ...value, freshness: !Number.isFinite(observed) ? "unknown" : now.getTime() - observed > 3 * 3600000 ? "stale" : "fresh" }; } -const WEEKDAY_NAMES = [ - "Sunday", - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", -]; +function observationLabel(value: WeatherSummary | AirQualitySummary): string { + return value.freshness === "stale" ? ` (stale; observed ${value.observedAt})` + : value.observedAt ? ` (observed ${value.observedAt})` : " (observation time unavailable)"; +} diff --git a/src/context/types.ts b/src/context/types.ts index 55bba7d..5f75b39 100644 --- a/src/context/types.ts +++ b/src/context/types.ts @@ -2,13 +2,29 @@ import type { AcademicCalendar } from "../calendar/client.js"; import type { CalendarDayInfo } from "../calendar/types.js"; export type ContextLevel = "terse" | "normal" | "verbose"; -export type SourceState = "provided" | "derived" | "missing"; +export type SourceState = "provided" | "derived" | "empty" | "missing"; + +export interface ContextClass { + name: string; + startAt: string; + endAt: string; + location?: string; + week: number; + periodStart: number; + periodEnd: number; + status: "completed" | "in-progress" | "upcoming"; + makeupFor?: string; +} export interface ScheduleReminder { now?: string; next?: string; nextDetail?: string; tomorrowMorning?: string; + currentClass?: ContextClass; + nextClass?: ContextClass; + todayClasses?: ContextClass[]; + omissionCount?: number; } export interface DeadlineSummary { @@ -36,6 +52,9 @@ export interface ExamSummary { } export interface WeatherSummary { + source?: string; + observedAt?: string; + freshness?: "fresh" | "stale" | "unknown"; condition: string; icon?: string; tempC?: number; @@ -46,6 +65,10 @@ export interface WeatherSummary { } export interface AirQualitySummary { + standard?: "US EPA"; + source?: string; + observedAt?: string; + freshness?: "fresh" | "stale" | "unknown"; aqi: number; level?: string; pm25?: number; @@ -55,6 +78,7 @@ export interface AirQualitySummary { export interface ContextInput { now?: Date | string; + generatedAt?: Date; calendar?: AcademicCalendar; academicDay?: CalendarDayInfo; schedule?: ScheduleReminder; @@ -80,6 +104,8 @@ export interface ContextSourceStatus { export interface ContextSnapshot { level: ContextLevel; generatedAt: string; + referenceAt: string; + timezone: "Asia/Shanghai"; date: string; time: string; weekday: string; @@ -87,6 +113,8 @@ export interface ContextSnapshot { label?: string; phase?: string; holiday?: string; + academicDay?: CalendarDayInfo; + weekParity?: "odd" | "even"; schedule: ScheduleReminder; nextDeadline?: DeadlineSummary | null; nextEvaluation?: EvaluationSummary | null; diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index deb89d9..e3fa49b 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -23,6 +23,8 @@ 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("talks list", "List upcoming official SUSTech homepage lectures, or all currently displayed lectures with --all.", "read", { status: "preview" }), + capability("talks search", "Search upcoming official SUSTech homepage lectures by title, speaker, venue, and time; include started lectures with --all.", "read", { status: "preview" }), capability("online search", "Search selected public community-maintained SUSTech Online content 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" }), @@ -125,6 +127,7 @@ export const CAPABILITIES: readonly Capability[] = [ capability("tis degree audit", "Audit live grade records against a local JSON requirement file without auto-resolving ambiguous matches.", "plan", { authentication: "tis", status: "preview" }), capability("tis enroll preview", "Build an exact enrollment action without network or mutation.", "plan", { network: false, status: "preview" }), capability("tis selection preview", "Build typed enroll, drop, cart, or bid payloads without sending them, with optional exact RWH handoff for apply.", "plan", { network: false, status: "preview" }), + capability("tis selection reconcile", "Poll an exact courseId/RWH/round target after an uncertain selection write without repeating the mutation.", "read", { authentication: "tis", status: "preview" }), capability("tis selection apply", "Submit an exact cart, drop, or bid mutation to TIS after live revalidation.", "mutation", { authentication: "tis", confirmation: "required", status: "preview" }), capability("tis bid plan", "Validate a multi-course bid budget and build local write previews.", "plan", { network: false, status: "preview" }), capability("tis bid apply", "Submit exact multi-course bid updates to TIS after live revalidation.", "mutation", { authentication: "tis", confirmation: "required", status: "preview" }), diff --git a/src/core/command-metadata.ts b/src/core/command-metadata.ts index 1f7f931..b169b12 100644 --- a/src/core/command-metadata.ts +++ b/src/core/command-metadata.ts @@ -91,6 +91,7 @@ export const CLI_PARSE_OPTIONS = { path: { type: "string" }, requirements: { type: "string" }, details: { type: "boolean", default: false }, + attempts: { type: "string" }, since: { type: "string" }, until: { type: "string" }, "url-stdin": { type: "boolean", default: false }, @@ -131,6 +132,8 @@ export const COMMAND_OPTIONS: Readonly> "faculty list": ["full", "limit"], "faculty search": ["department", "limit"], "online search": ["section", "since", "until", "limit"], + "talks list": ["all"], + "talks search": ["all"], "online talks list": ["since", "until", "limit"], "online talks search": ["since", "until", "limit"], "online talks get": [], @@ -239,6 +242,7 @@ export const COMMAND_OPTIONS: Readonly> "tis degree missing": ["credentials-file", "semester"], "tis enroll preview": ["semester", "course-id", "rwh", "round", "bid"], "tis selection preview": ["semester", "course-id", "rwh", "round", "bid", "where", "cultivation"], + "tis selection reconcile": ["credentials-file", "semester", "course-id", "rwh", "round", "bid", "where", "cultivation", "attempts"], "tis selection apply": ["credentials-file", "semester", "course-id", "rwh", "round", "bid", "where", "cultivation", "confirm"], "tis bid plan": ["semester", "pick", "bid-limit", "where", "round", "cultivation"], "tis bid apply": ["credentials-file", "semester", "pick", "where", "round", "cultivation", "confirm"], diff --git a/src/core/keyring.ts b/src/core/keyring.ts index fefc7da..25a5e17 100644 --- a/src/core/keyring.ts +++ b/src/core/keyring.ts @@ -161,10 +161,25 @@ export async function saveStoredCredentials( const account = existing?.account ?? credentialAccount(profile, sid); let previousPassword: string | undefined; + let secretTouched = false; try { previousPassword = await store.get(account); await store.set(account, password); + secretTouched = true; + const verifiedPassword = await store.get(account); + if (verifiedPassword !== password) { + throw credentialStoreDiagnostic( + "Credential store write could not be verified by an immediate read-back.", + "CREDENTIAL_WRITE_NOT_VERIFIED", + store.backend === "linux-secret-service" + ? "Unlock the desktop login keyring, then run auth login again in the same graphical session." + : "Unlock the operating-system credential store, then run auth login again.", + ); + } } catch (error) { + if (secretTouched && !await restoreSecret(store, account, previousPassword)) { + throw credentialRollbackError("save", store.backend); + } throw storeAccessError("credentials", "write", store.backend, error); } @@ -302,6 +317,7 @@ export async function getCredentialStatus( profiles, reasonCode: credentialStatusReasonCode(error), reason: safeStoreReason(error), + ...(storeRemediation(error) ? { remediation: storeRemediation(error) } : {}), }; } } @@ -586,7 +602,7 @@ async function resolveLinuxSecretService( "lookup", "service", namespace.service, "account", account, ], undefined, env, timeoutMs); if (result.code === 1 && !result.stdout.trim() && !result.stderr.trim()) return undefined; - if (result.code !== 0) throw new Error("Secret Service lookup failed."); + if (result.code !== 0) throw secretServiceCommandError("lookup", result); return result.stdout.replace(/\r?\n$/, "") || undefined; }, async set(account, password) { @@ -596,14 +612,14 @@ async function resolveLinuxSecretService( "service", namespace.service, "account", account, ], `${password}\n`, env, timeoutMs); - if (result.code !== 0) throw new Error("Secret Service write failed."); + if (result.code !== 0) throw secretServiceCommandError("write", result); }, async delete(account) { const result = await runCredentialCommand(executable, [ "clear", "service", namespace.service, "account", account, ], undefined, env, timeoutMs); if (result.code === 1 && !result.stderr.trim()) return false; - if (result.code !== 0) throw new Error("Secret Service delete failed."); + if (result.code !== 0) throw secretServiceCommandError("delete", result); return true; }, }; @@ -726,10 +742,52 @@ function storeAccessError(subject: string, operation: string, backend: Credentia `Could not ${operation} ${subject} using ${backend}.`, "CREDENTIAL_STORE_ERROR", 2, - { backend, operation, reason: safeStoreReason(error) }, + { + backend, + operation, + reason: safeStoreReason(error), + ...(storeRemediation(error) ? { remediation: storeRemediation(error) } : {}), + }, ); } +function secretServiceCommandError( + operation: "lookup" | "write" | "delete", + result: { code: number; stderr: string }, +): Error { + const stderr = result.stderr.slice(0, 4096); + if (/locked|is locked|collection.*lock/i.test(stderr)) { + return credentialStoreDiagnostic( + "The Secret Service collection is locked.", + "SECRET_SERVICE_LOCKED", + "Unlock the desktop login keyring and retry in the same graphical session; do not delete the profile metadata.", + ); + } + if (/D-Bus|dbus|cannot autolaunch|serviceunknown|connection (?:refused|closed)|no such file/i.test(stderr)) { + return credentialStoreDiagnostic( + "The desktop Secret Service session is unavailable.", + "SECRET_SERVICE_SESSION_UNAVAILABLE", + "Run the command inside the same unlocked desktop session that owns DBUS_SESSION_BUS_ADDRESS, or inject process-scoped credentials from an external secret manager.", + ); + } + if (/denied|permission|dismissed|cancelled/i.test(stderr)) { + return credentialStoreDiagnostic( + "The Secret Service request was denied.", + "SECRET_SERVICE_ACCESS_DENIED", + "Allow the keyring prompt and verify that the login collection is unlocked before retrying.", + ); + } + return credentialStoreDiagnostic( + `Secret Service ${operation} failed with exit code ${result.code}.`, + "SECRET_SERVICE_COMMAND_FAILED", + "Run `sustech auth status --json` in the desktop session and verify secret-tool can access the unlocked login collection.", + ); +} + +function credentialStoreDiagnostic(message: string, code: string, remediation: string): Error { + return Object.assign(new Error(message), { code, remediation }); +} + function validateStoredSecret(value: string, subject: string, code: string): string { if (!value || /[\r\n]/.test(value) || Buffer.byteLength(value, "utf8") > 16 * 1024) { throw new CliError( @@ -774,6 +832,9 @@ function credentialRollbackError(operation: "save" | "delete", backend: Credenti } function safeStoreReason(error: unknown): string { + if (error && typeof error === "object" && "message" in error && typeof error.message === "string") { + if (/^(?:The Secret Service|Secret Service|Credential store write)/.test(error.message)) return error.message; + } if (error && typeof error === "object" && "code" in error) return `Credential store error ${String(error.code)}.`; return error instanceof Error && /^Secret Service /.test(error.message) ? error.message @@ -786,6 +847,12 @@ function credentialStatusReasonCode(error: unknown): "CREDENTIAL_STORE_ERROR" | : "CREDENTIAL_STORE_ERROR"; } +function storeRemediation(error: unknown): string | undefined { + return error && typeof error === "object" && "remediation" in error && typeof error.remediation === "string" + ? error.remediation + : undefined; +} + async function readCredentialConfig(options: CredentialStoreOptions): Promise { const path = credentialConfigPath(options); let raw: string; diff --git a/src/core/text.ts b/src/core/text.ts index 1901f30..68a82ab 100644 --- a/src/core/text.ts +++ b/src/core/text.ts @@ -1,3 +1,4 @@ +import stringWidth from "string-width"; import type { Semester } from "./semester.js"; import type { StudentProfileReport } from "../profile/report.js"; import type { TimetableResult } from "../tis/planner.js"; @@ -66,10 +67,12 @@ export function formatEnrolledCourses(semester: Semester, courses: PersonalSched const header = `Enrolled courses · ${semester.value}`; if (courses.length === 0) return `${header}\n\nNo enrolled courses returned by TIS.`; const blocks = courses.map((course, index) => { - const name = course.courseName || course.description || course.descriptionEn || "Unnamed course"; + const name = course.courseName || course.rwh || "Unnamed course"; const details = [ course.teacher && `Teacher: ${course.teacher}`, - course.description && `Time: ${course.description}`, + course.day !== undefined && course.periodStart !== undefined + ? `Day ${course.day}, period ${course.periodStart}-${course.periodEnd ?? course.periodStart}` + : "", course.room && `Room: ${course.room}`, ].filter(Boolean).join(" · "); return `${index + 1}. ${course.courseCode ? `${course.courseCode} — ` : ""}${name}${details ? `\n ${details}` : ""}`; @@ -581,10 +584,12 @@ function padCell(value: string, width: number): string { return `${truncated}${" ".repeat(Math.max(0, width - displayWidth(truncated)))}`; } +const graphemes = new Intl.Segmenter("en", { granularity: "grapheme" }); + function truncateDisplay(value: string, width: number): string { if (displayWidth(value) <= width) return value; let output = ""; - for (const character of value) { + for (const { segment: character } of graphemes.segment(value)) { if (displayWidth(`${output}${character}…`) > width) break; output += character; } @@ -592,5 +597,6 @@ function truncateDisplay(value: string, width: number): string { } function displayWidth(value: string): number { - return [...value].reduce((width, character) => width + (/[^\u0000-\u00ff]/.test(character) ? 2 : 1), 0); + // Terminals normally render ambiguous-width characters such as Ⅴ and … in one cell. + return stringWidth(value, { ambiguousIsNarrow: true }); } diff --git a/src/talks/client.ts b/src/talks/client.ts new file mode 100644 index 0000000..f1db3ad --- /dev/null +++ b/src/talks/client.ts @@ -0,0 +1,197 @@ +import { load } from "cheerio"; +import { CliError } from "../core/errors.js"; +import { USER_AGENT } from "../core/version.js"; +import { createFetchAdapter, type ServiceAdapter } from "../services/base.js"; + +export const OFFICIAL_TALKS_URL = "https://www.sustech.edu.cn/zh/home-events.html"; +const MAX_BYTES = 2 * 1024 * 1024; + +export interface OfficialTalk { + id: string; + title: string; + speaker?: string; + venue?: string; + timeText: string; + date?: string; + startAt?: string; + detailUrl: string; + timing: "upcoming" | "started" | "unknown"; +} + +export interface OfficialTalksResult { + talks: OfficialTalk[]; + total: number; + sourceTotal: number; + provenance: { + authority: "official"; + sourceUrl: string; + fetchedAt: string; + coverage: "homepage"; + }; + scope: "upcoming" | "all"; + referenceTime: string; + unknownTimeCount: number; + warnings: string[]; +} + +export interface OfficialTalksOptions { + query?: string; + all?: boolean; + now?: Date; +} + +export class OfficialTalksClient { + constructor(private readonly adapter: ServiceAdapter = createFetchAdapter()) {} + + async list(options: OfficialTalksOptions = {}): Promise { + validateOptions(options); + let html: string; + try { + const response = await this.adapter.fetch(OFFICIAL_TALKS_URL, { + headers: { "user-agent": USER_AGENT, accept: "text/html" }, + signal: AbortSignal.timeout(15_000), + redirect: "error", + }); + if (!response.ok) { + await response.body?.cancel(); + throw new CliError("The official talks page returned an HTTP error.", "UPSTREAM_HTTP_ERROR", 1, { status: response.status }); + } + const type = response.headers.get("content-type"); + if (type && !/\b(?:text\/html|application\/xhtml\+xml)\b/i.test(type)) { + await response.body?.cancel(); + throw protocolError("The official talks page did not return HTML."); + } + const reader = response.body?.getReader(); + if (!reader) throw protocolError("The official talks page returned an empty body."); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > MAX_BYTES) { + await reader.cancel(); + throw protocolError("The official talks page exceeds the 2 MiB limit."); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + html = Buffer.concat(chunks).toString("utf8"); + } catch (error) { + if (error instanceof CliError) throw error; + throw new CliError("Could not fetch the official talks page.", "NETWORK_ERROR", 1, { sourceUrl: OFFICIAL_TALKS_URL }); + } + const now = options.now ?? new Date(); + return queryOfficialTalks(parseOfficialTalks(html, now.toISOString()), { ...options, now }); + } +} + +export function parseOfficialTalks(html: string, fetchedAt: string): OfficialTalksResult { + const $ = load(html); + const section = $(".item6_slider > ul > li.one"); + if (section.length !== 1 || section.find(".swiper-wrapper").length !== 1) { + throw protocolError("The official talks section was not found; the page structure may have changed."); + } + const talks = new Map(); + const warnings: string[] = []; + section.find(".swiper-slide").each((_, element) => { + const card = $(element); + const href = card.find("a[href]").first().attr("href"); + let url: URL; + try { + url = new URL(href ?? "", OFFICIAL_TALKS_URL); + } catch { + throw protocolError("A lecture card has an invalid detail URL."); + } + const id = /^\/zh\/events\/(\d+)\.html$/.exec(url.pathname)?.[1]; + if (url.origin !== "https://www.sustech.edu.cn" || url.username || url.password || url.search || url.hash || !id) { + throw protocolError("A lecture card has an unsupported detail URL."); + } + const text = (selector: string) => card.find(selector).first().text().replace(/\s+/gu, " ").trim(); + const title = text(".evt_ext p"); + if (!title) throw protocolError("A lecture card is missing its title."); + const timeText = text(".evt_time"); + const match = /^(\d{4})年(\d{1,2})月(\d{1,2})日\s+(\d{1,2}):(\d{2})$/.exec(timeText); + const date = match ? `${match[1]}-${match[2]!.padStart(2, "0")}-${match[3]!.padStart(2, "0")}` : undefined; + const validTime = match && date && validDate(date) && Number(match[4]) < 24 && Number(match[5]) < 60; + if (!validTime) warnings.push(timeWarning(id)); + const talk: OfficialTalk = { + id, title, + speaker: text(".evt_peo") || undefined, + venue: text(".evt_adrr") || undefined, + timeText, + ...(validTime ? { date, startAt: `${date}T${match[4]!.padStart(2, "0")}:${match[5]}:00+08:00` } : {}), + detailUrl: url.href, + timing: "unknown", + }; + if (!talks.has(id)) talks.set(id, talk); + }); + return { + talks: [...talks.values()], total: talks.size, sourceTotal: talks.size, + provenance: { authority: "official", sourceUrl: OFFICIAL_TALKS_URL, fetchedAt, coverage: "homepage" }, + scope: "all", + referenceTime: fetchedAt, + unknownTimeCount: warnings.length, + warnings: [...new Set(warnings)], + }; +} + +export function queryOfficialTalks(result: OfficialTalksResult, options: OfficialTalksOptions): OfficialTalksResult { + validateOptions(options); + const now = options.now ?? new Date(result.provenance.fetchedAt); + if (!Number.isFinite(now.getTime())) throw new CliError("The lecture reference time is invalid.", "USAGE", 2); + const referenceTime = now.toISOString(); + const needle = options.query?.trim().toLocaleLowerCase("en-US"); + const classified = result.talks.map((talk) => ({ + ...talk, + timing: talk.startAt === undefined ? "unknown" as const : Date.parse(talk.startAt) > now.getTime() ? "upcoming" as const : "started" as const, + })); + const matches = classified + .filter((talk) => (options.all || talk.timing !== "started") + && (!needle || [talk.title, talk.speaker, talk.venue, talk.timeText].some((field) => field?.toLocaleLowerCase("en-US").includes(needle)))) + .sort((a, b) => compareTalks(a, b, Boolean(options.all))); + return { + ...result, + talks: matches, + total: matches.length, + scope: options.all ? "all" : "upcoming", + referenceTime, + unknownTimeCount: matches.filter((talk) => talk.timing === "unknown").length, + warnings: matches + .filter((talk) => talk.timing === "unknown") + .map((talk) => timeWarning(talk.id)), + }; +} + +function validDate(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const time = Date.parse(`${value}T00:00:00Z`); + return Number.isFinite(time) && new Date(time).toISOString().slice(0, 10) === value; +} + +function validateOptions(options: OfficialTalksOptions): void { + if (options.query !== undefined && !options.query.trim()) throw new CliError("A talk search query is required.", "USAGE", 2); +} + +function compareTalks(a: OfficialTalk, b: OfficialTalk, all: boolean): number { + const rank = (talk: OfficialTalk) => talk.timing === "upcoming" ? 0 : talk.timing === "unknown" ? 1 : 2; + const rankDifference = rank(a) - rank(b); + if (rankDifference !== 0) return rankDifference; + if (!a.startAt || !b.startAt) return a.id.localeCompare(b.id, "en"); + const difference = Date.parse(a.startAt) - Date.parse(b.startAt); + if (difference !== 0) { + return all && a.timing === "started" ? -difference : difference; + } + return a.id.localeCompare(b.id, "en"); +} + +function protocolError(message: string): CliError { + return new CliError(message, "UPSTREAM_PROTOCOL_ERROR", 1, { sourceUrl: OFFICIAL_TALKS_URL }); +} + +function timeWarning(id: string): string { + return `Talk ${id}: date/time could not be normalized; see timeText and the source page.`; +} diff --git a/src/talks/text.ts b/src/talks/text.ts new file mode 100644 index 0000000..07b7470 --- /dev/null +++ b/src/talks/text.ts @@ -0,0 +1,52 @@ +import type { OfficialTalksResult } from "./client.js"; + +export function formatOfficialTalks(result: OfficialTalksResult): string { + const sections = result.scope === "all" + ? [ + ["尚未开始", result.talks.filter((talk) => talk.timing === "upcoming")], + ["时间待确认", result.talks.filter((talk) => talk.timing === "unknown")], + ["已经开始", result.talks.filter((talk) => talk.timing === "started")], + ] as const + : [ + ["尚未开始", result.talks.filter((talk) => talk.timing === "upcoming")], + ["时间待确认", result.talks.filter((talk) => talk.timing === "unknown")], + ] as const; + const body = sections.flatMap(([heading, talks]) => talks.length === 0 ? [] : [ + `${heading}(${talks.length} 场)`, + "", + ...talks.flatMap((talk) => [ + talk.startAt ? formatStart(talk.startAt) : talk.timeText || "时间待定", + talk.title, + `主讲:${talk.speaker || "未提供"}`, + `地点:${talk.venue || "未提供"}`, + `详情:${talk.detailUrl}`, + "", + ]), + ]); + return [ + `南科大官网学术讲座 · ${result.scope === "all" ? `当前展示 ${result.total} 场` : `尚未开始 ${result.talks.filter((talk) => talk.timing === "upcoming").length} 场`}`, + `北京时间 · ${result.scope === "all" ? "未开始的从近到远,已开始的从新到旧" : "按开始时间从近到远"}`, + "", + ...(body.length ? body : ["没有匹配的讲座。", ""]), + `范围:官网首页当前展示的 ${result.sourceTotal} 条讲座`, + `来源:${result.provenance.sourceUrl}`, + `抓取时间:${result.provenance.fetchedAt}`, + ...(result.scope === "all" ? [] : ["使用 --all 查看包含已开始讲座的全部记录。"]), + ...result.warnings.map((warning) => `注意:${warning}`), + ].join("\n"); +} + +function formatStart(startAt: string): string { + const date = new Date(startAt); + const parts = new Intl.DateTimeFormat("zh-CN", { + timeZone: "Asia/Shanghai", + month: "2-digit", + day: "2-digit", + weekday: "short", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).formatToParts(date); + const value = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? ""; + return `${value("month")}-${value("day")} ${value("weekday")} ${value("hour")}:${value("minute")}`; +} diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index 6b92f4b..0ca35db 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -904,6 +904,24 @@ test("selection and bid apply require --confirm before any credential lookup or assert.equal(JSON.parse(bid.stdout).error.code, "CONFIRMATION_REQUIRED"); }); +test("selection reconciliation is bounded, read-only, and validates locally before credentials", () => { + const invalid = runWithoutCredentials([ + "tis", "selection", "reconcile", "cart.add", + "--course-id", "selection-id", + "--rwh", "task-id", + "--round", "bxxk", + "--attempts", "1", + "--json", + ]); + assert.equal(invalid.status, 2); + assert.equal(JSON.parse(invalid.stdout).error.code, "USAGE"); + + const capabilities = JSON.parse(run(["capabilities", "--json"]).stdout).data.capabilities; + const reconcile = capabilities.find((entry: { command:string }) => entry.command === "tis selection reconcile"); + assert.equal(reconcile.kind, "read"); + assert.equal(reconcile.confirmation, "none"); +}); + test("context live supports calendar level and degrades gracefully when credentials are unavailable", () => { const result = runWithoutCredentials(["context", "--calendar-level", "graduate", "--live", "--json"]); assert.equal(result.status, 0); @@ -916,6 +934,12 @@ test("context live supports calendar level and degrades gracefully when credenti assert.equal(envelope.data.liveSources.blackboardDeadlines.state, "credentials-missing"); }); +test("context rejects mixing live observations with a historical date before accessing sources", () => { + const result = runWithoutCredentials(["context", "--date", "2020-01-01", "--live", "--json"]); + assert.equal(result.status, 2); + assert.match(JSON.parse(result.stdout).error.message, /only available for today's date/); +}); + test("profile commands remain machine-readable when credentials are unavailable", () => { const show = runWithoutCredentials(["profile", "show", "--json"]); assert.equal(show.status, 0); diff --git a/src/test/client.test.ts b/src/test/client.test.ts index c839ea5..d4eddc3 100644 --- a/src/test/client.test.ts +++ b/src/test/client.test.ts @@ -3,9 +3,11 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; +import { CliError } from "../core/errors.js"; import type { Semester } from "../core/semester.js"; import type { TisSession } from "../tis/auth.js"; import { TisClient } from "../tis/client.js"; +import { buildSelectionPreview } from "../tis/remaining-selection.js"; const SEMESTER: Semester = { xn: "2025-2026", xq: "1", value: "2025-2026-1" }; @@ -60,6 +62,49 @@ test("available-course parsing preserves nested round metadata", async () => { assert.deepEqual(result.round, { xkfsdm: "bxxk", lcmc: "通识必修" }); }); +test("selection mutation transport failures distinguish known pre-send failure from uncertain submission", async () => { + const preview = buildSelectionPreview({ semester:SEMESTER, cultivation:"1", currentTerm:{} }, { + operation:"cart.add", + courseId:"selection-id", + rwh:"task-id", + round:"bxxk", + bid:5, + where:"cart", + clientRequestId:"00000000-0000-4000-8000-000000000001", + }); + const beforeSend = new TisClient({ + async postForm(): Promise { + throw new CliError("synthetic", "NETWORK_ERROR", 1, { requestPhase:"before-send" }); + }, + } as unknown as TisSession); + await assert.rejects( + beforeSend.selectionWrite(preview), + (error: unknown) => error instanceof CliError + && error.code === "TIS_SELECTION_NOT_SUBMITTED" + && error.exitCode === 4 + && error.details?.warning === "NO_MUTATION_PERFORMED", + ); + + const afterSend = new TisClient({ + async postForm(): Promise { + throw new CliError("synthetic", "NETWORK_TIMEOUT", 1); + }, + } as unknown as TisSession); + await assert.rejects( + afterSend.selectionWrite(preview), + (error: unknown) => error instanceof CliError + && error.code === "TIS_SELECTION_OUTCOME_UNKNOWN" + && error.exitCode === 5 + && error.details?.warning === "DO_NOT_RETRY_AUTOMATICALLY" + && (error.details.target as { clientRequestId?: string; rwh?: string })?.clientRequestId === preview.clientRequestId + && (error.details.target as { rwh?: string })?.rwh === "task-id" + && (error.details.target as { round?: string })?.round === "bxxk" + && (error.details.target as { bid?: number })?.bid === 5 + && (error.details.target as { semester?: string })?.semester === SEMESTER.value + && (error.details.target as { where?: string })?.where === "cart", + ); +}); + test("grade filtering accepts both compact and descriptive TIS semester labels", async () => { const session = { async postJson(): Promise { diff --git a/src/test/context-live.test.ts b/src/test/context-live.test.ts index d8f2f0d..831a089 100644 --- a/src/test/context-live.test.ts +++ b/src/test/context-live.test.ts @@ -14,6 +14,8 @@ test("context weather parser extracts concise condition and rounded temperatures update_time: "2026-08-28T12:00:00+08:00", }), { + source: "https://api.sustech.online/weather", + observedAt: "2026-08-28T04:00:00.000Z", condition: "气温26.8℃,体感29.1℃,近两个小时内无降雨。", tempC: 27, feelsLikeC: 29, @@ -26,6 +28,7 @@ test("context AQI parser preserves particles and maps standard levels", () => { assert.deepEqual( normaliseContextAirQuality({ current: { + time: "2026-08-28T12:00", us_aqi: 88, pm2_5: 18.4, pm10: 26.1, @@ -33,6 +36,9 @@ test("context AQI parser preserves particles and maps standard levels", () => { }, }), { + standard: "US EPA", + source: "https://air-quality-api.open-meteo.com", + observedAt: "2026-08-28T04:00:00.000Z", aqi: 88, level: "Moderate", pm25: 18.4, diff --git a/src/test/context.test.ts b/src/test/context.test.ts index c9216ec..efc3e8b 100644 --- a/src/test/context.test.ts +++ b/src/test/context.test.ts @@ -131,7 +131,33 @@ test("context service exposes verbose environmental fields and explicit partial assert.match(service.toText(snapshot), /Library: \[Main Hall: Open\]/); const record = service.toRecord(snapshot); - assert.deepEqual(record.weather, { condition: "晴", tempC: 26, feelsLikeC: 29 }); - assert.deepEqual(record.airQuality, { aqi: 48, level: "Good" }); + assert.deepEqual(record.weather, { condition: "晴", tempC: 26, feelsLikeC: 29, freshness: "unknown" }); + assert.deepEqual(record.airQuality, { aqi: 48, level: "Good", freshness: "unknown" }); assert.equal(record.libraryStatus, "Main Hall: Open"); }); + +test("daily context uses Shanghai midnight and distinguishes empty sources from unavailable ones", () => { + const service = new ContextService(); + const snapshot = service.build({ + now: "2026-09-06T16:05:00Z", + generatedAt: new Date("2026-09-06T16:06:00Z"), + nextDeadline: null, + weather: { condition: "Clear", observedAt: "2026-09-06T12:00:00Z" }, + airQuality: { aqi: 30, standard: "US EPA", observedAt: "2026-09-06T16:00:00Z" }, + schedule: { now: "Synthetic A", next: "Synthetic B", nextDetail: "09:00" }, + }); + assert.equal(snapshot.date, "2026-09-07"); + assert.equal(snapshot.time, "00:05"); + assert.equal(snapshot.weekday, "Monday"); + assert.equal(snapshot.sourceStatus.nextDeadline, "empty"); + assert.equal(snapshot.sourceStatus.nextExam, "missing"); + assert.equal(snapshot.weather?.freshness, "stale"); + assert.equal(snapshot.airQuality?.freshness, "fresh"); + assert.match(service.toText(snapshot), /Now:.*Synthetic A/); + assert.match(service.toText(snapshot), /Next:.*Synthetic B/); + assert.match(service.toText(snapshot), /US EPA AQI/); + const record = service.toRecord(snapshot); + assert.equal(record.timezone, "Asia/Shanghai"); + assert.equal(record.referenceAt, "2026-09-06T16:05:00.000Z"); + assert.equal(record.generatedAt, "2026-09-06T16:06:00.000Z"); +}); diff --git a/src/test/keyring.test.ts b/src/test/keyring.test.ts index d47997f..5278e13 100644 --- a/src/test/keyring.test.ts +++ b/src/test/keyring.test.ts @@ -101,6 +101,40 @@ test("saving a named profile never changes the implicit default profile", async } }); +test("an unverified credential write is rolled back before metadata is committed", async () => { + const configDir = await mkdtemp(join(tmpdir(), "sustech-cli-keyring-verify-")); + const store = new MemoryStore(); + const normalGet = store.get.bind(store); + const normalSet = store.set.bind(store); + let hideNextRead = false; + store.set = async (account: string, password: string) => { + await normalSet(account, password); + hideNextRead = true; + }; + store.get = async (account: string) => { + if (hideNextRead) { + hideNextRead = false; + return undefined; + } + return normalGet(account); + }; + try { + await assert.rejects( + saveStoredCredentials({ sid:"12410000", password:"secret" }, { configDir, store }), + (error: unknown) => error instanceof CliError + && error.code === "CREDENTIAL_STORE_ERROR" + && error.details?.reason === "Credential store write could not be verified by an immediate read-back.", + ); + assert.equal(store.values.size, 0); + await assert.rejects(readFile(join(configDir, "credentials.json"), "utf8"), (error: unknown) => { + assert.equal((error as NodeJS.ErrnoException).code, "ENOENT"); + return true; + }); + } finally { + await rm(configDir, { recursive:true, force:true }); + } +}); + test("Blackboard calendar links are stored by profile without on-disk metadata", async () => { const configDir = await mkdtemp(join(tmpdir(), "sustech-cli-bb-calendar-keyring-")); const store = new MemoryStore(); @@ -274,6 +308,10 @@ case "$1" in if [ "$FAKE_SECRET_LOOKUP_HANG" = "1" ]; then exec /bin/sleep 60 fi + if [ "$FAKE_SECRET_LOOKUP_LOCKED" = "1" ]; then + printf 'The collection is locked\\n' >&2 + exit 1 + fi if [ -s "$FAKE_SECRET_STATE" ]; then /bin/cat "$FAKE_SECRET_STATE" printf '\\n' @@ -314,6 +352,17 @@ esac assert.match(timedOut.reason ?? "", /CREDENTIAL_STORE_TIMEOUT/); delete fakeEnv.FAKE_SECRET_LOOKUP_HANG; + fakeEnv.FAKE_SECRET_LOOKUP_LOCKED = "1"; + await assert.rejects( + loadStoredCredentials(undefined, storeOptions), + (error: unknown) => error instanceof CliError + && error.code === "CREDENTIAL_STORE_ERROR" + && error.details?.reason === "The Secret Service collection is locked." + && typeof error.details.remediation === "string" + && /Unlock/.test(error.details.remediation), + ); + delete fakeEnv.FAKE_SECRET_LOOKUP_LOCKED; + fakeEnv.FAKE_SECRET_CLEAR_ERROR = "1"; await assert.rejects( deleteStoredCredentials(undefined, storeOptions), diff --git a/src/test/official-talks.test.ts b/src/test/official-talks.test.ts new file mode 100644 index 0000000..3c661d9 --- /dev/null +++ b/src/test/official-talks.test.ts @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { OfficialTalksClient, OFFICIAL_TALKS_URL, parseOfficialTalks, queryOfficialTalks } from "../talks/client.js"; + +const NOW = new Date("2026-09-05T08:00:00.000Z"); + +function card(id: string, title: string, time: string, speaker = "Alice 教授", venue = "理学院 101"): string { + return ``; +} + +function page(cards: string): string { + const notice = card("999", "通知公告", "2026年09月20日 10:00"); + return `
  • ${cards}
  • ${notice}
`; +} + +const HTML = page([ + card("101", "Past", "2026年09月04日 14:00"), + card("102", "Quantum & Materials", "2026年09月08日 09:05"), + card("103", "Soon", "2026年09月06日 14:00", "Bob", "会议中心"), + card("104", "Unknown", "待定"), + card("102", "Duplicate", "2026年09月08日 09:05"), +].join("")); + +test("homepage parser extracts stable records, normalizes Beijing time, deduplicates, and excludes notices", () => { + const result = parseOfficialTalks(HTML, NOW.toISOString()); + assert.deepEqual(result.talks.map((talk) => talk.id), ["101", "102", "103", "104"]); + assert.equal(result.talks[1]!.title, "Quantum & Materials"); + assert.equal(result.talks[1]!.venue, "理学院 101"); + assert.equal(result.talks[1]!.startAt, "2026-09-08T09:05:00+08:00"); + assert.equal(result.talks[1]!.detailUrl, "https://www.sustech.edu.cn/zh/events/102.html"); + assert.deepEqual(result.provenance, { authority: "official", sourceUrl: OFFICIAL_TALKS_URL, fetchedAt: NOW.toISOString(), coverage: "homepage" }); + assert.equal(result.sourceTotal, 4); +}); + +test("default view keeps upcoming and unknown-time talks and sorts upcoming talks nearest first", () => { + const result = queryOfficialTalks(parseOfficialTalks(HTML, NOW.toISOString()), { now: NOW }); + assert.deepEqual(result.talks.map((talk) => [talk.id, talk.timing]), [["103", "upcoming"], ["102", "upcoming"], ["104", "unknown"]]); + assert.equal(result.scope, "upcoming"); + assert.equal(result.total, 3); + assert.equal(result.sourceTotal, 4); + assert.equal(result.unknownTimeCount, 1); + assert.equal(result.referenceTime, NOW.toISOString()); +}); + +test("--all includes started talks after upcoming and unknown records", () => { + const result = queryOfficialTalks(parseOfficialTalks(HTML, NOW.toISOString()), { all: true, now: NOW }); + assert.deepEqual(result.talks.map((talk) => [talk.id, talk.timing]), [["103", "upcoming"], ["102", "upcoming"], ["104", "unknown"], ["101", "started"]]); + assert.equal(result.scope, "all"); + assert.equal(result.total, 4); +}); + +test("search uses title, speaker, venue, and time and follows the same temporal scope", () => { + const source = parseOfficialTalks(HTML, NOW.toISOString()); + assert.deepEqual(queryOfficialTalks(source, { query: "quantum", now: NOW }).talks.map((talk) => talk.id), ["102"]); + assert.deepEqual(queryOfficialTalks(source, { query: "bob", now: NOW }).talks.map((talk) => talk.id), ["103"]); + assert.deepEqual(queryOfficialTalks(source, { query: "会议中心", now: NOW }).talks.map((talk) => talk.id), ["103"]); + assert.equal(queryOfficialTalks(source, { query: "past", now: NOW }).total, 0); + assert.deepEqual(queryOfficialTalks(source, { query: "past", all: true, now: NOW }).talks.map((talk) => talk.id), ["101"]); + assert.deepEqual(queryOfficialTalks(source, { query: "quantum", now: NOW }).warnings, []); +}); + +test("a talk at the reference instant has already started", () => { + const source = parseOfficialTalks(page(card("101", "Exact", "2026年09月05日 16:00")), NOW.toISOString()); + assert.equal(queryOfficialTalks(source, { now: NOW }).total, 0); + assert.equal(queryOfficialTalks(source, { now: NOW, all: true }).talks[0]!.timing, "started"); +}); + +test("invalid lecture times remain visible with warnings and are never guessed", () => { + for (const time of ["待定", "2026年2月30日 10:00", "2026年9月8日 25:00", "2026年9月8日 10:99"]) { + const source = parseOfficialTalks(page(card("101", "Example", time)), NOW.toISOString()); + const result = queryOfficialTalks(source, { now: NOW }); + assert.equal(result.talks[0]!.startAt, undefined); + assert.equal(result.talks[0]!.date, undefined); + assert.equal(result.talks[0]!.timeText, time); + assert.equal(result.talks[0]!.timing, "unknown"); + assert.equal(result.warnings.length, 1); + } +}); + +test("empty lecture sections differ from broken pages and unsafe detail links", () => { + assert.equal(parseOfficialTalks(page(""), NOW.toISOString()).total, 0); + for (const html of ["

Access denied

", HTML.replaceAll("evt_ext", "changed"), HTML.replace("/zh/events/101.html", "https://example.com/zh/events/101.html")]) { + assert.throws(() => parseOfficialTalks(html, NOW.toISOString()), { code: "UPSTREAM_PROTOCOL_ERROR" }); + } +}); + +test("official lecture transport is bounded and reports HTTP, protocol, and network failures", async () => { + const client = new OfficialTalksClient({ name: "fixture", fetch: async (url, init) => { + assert.equal(url, OFFICIAL_TALKS_URL); + assert.equal(init?.redirect, "error"); + assert.ok(init?.signal); + return new Response(HTML, { headers: { "content-type": "text/html; charset=utf-8" } }); + } }); + assert.equal((await client.list({ all: true, now: NOW })).sourceTotal, 4); + for (const [response, code] of [ + [new Response("unavailable", { status: 503 }), "UPSTREAM_HTTP_ERROR"], + [new Response("{}", { headers: { "content-type": "application/json" } }), "UPSTREAM_PROTOCOL_ERROR"], + [new Response("x".repeat(2 * 1024 * 1024 + 1), { headers: { "content-type": "text/html" } }), "UPSTREAM_PROTOCOL_ERROR"], + ] as const) { + await assert.rejects(new OfficialTalksClient({ name: "fixture", fetch: async () => response }).list(), { code }); + } + await assert.rejects(new OfficialTalksClient({ name: "fixture", fetch: async () => { throw new Error("offline"); } }).list(), { code: "NETWORK_ERROR" }); +}); + +const CLI = fileURLToPath(new URL("../cli.js", import.meta.url)); + +function run(args: string[]) { + const mock = `Date = class extends Date { constructor(...args) { super(...(args.length ? args : [${JSON.stringify(NOW.toISOString())}])); } static now() { return ${NOW.getTime()}; } }; globalThis.fetch = async () => new Response(${JSON.stringify(HTML)}, {headers:{"content-type":"text/html"}});`; + return spawnSync(process.execPath, ["--import", `data:text/javascript,${encodeURIComponent(mock)}`, CLI, ...args], { encoding: "utf8", timeout: 10_000 }); +} + +test("CLI supports upcoming text, --all, search, JSONL, discovery, and strict options", () => { + const text = run(["talks", "list"]); + assert.equal(text.status, 0, text.stderr); + assert.match(text.stdout, /南科大官网学术讲座 · 尚未开始 2 场/); + assert.ok(text.stdout.indexOf("Soon") < text.stdout.indexOf("Quantum & Materials")); + assert.doesNotMatch(text.stdout, /\nPast\n/); + assert.match(text.stdout, /时间待确认(1 场)/); + assert.match(text.stdout, /使用 --all/); + + const all = run(["talks", "list", "--all", "--json"]); + assert.equal(all.status, 0, all.stderr); + const envelope = JSON.parse(all.stdout); + assert.equal(envelope.command, "talks list"); + assert.equal(envelope.data.scope, "all"); + assert.equal(envelope.data.total, 4); + assert.equal(envelope.meta.authority, "official"); + + assert.equal(JSON.parse(run(["talks", "search", "past", "--json"]).stdout).data.total, 0); + assert.equal(JSON.parse(run(["talks", "search", "past", "--all", "--json"]).stdout).data.talks[0].id, "101"); + + const jsonl = run(["talks", "list", "--jsonl"]); + assert.equal(jsonl.status, 0, jsonl.stderr); + assert.deepEqual(jsonl.stdout.trim().split("\n").map((line) => JSON.parse(line).type), ["item", "item", "item", "summary"]); + + const described = run(["describe", "talks", "list", "--json"]); + assert.equal(described.status, 0, described.stderr); + assert.deepEqual(JSON.parse(described.stdout).data.options.map((option: { name: string }) => option.name), ["--all", "--output", "--json", "--jsonl", "--pretty"]); + + for (const args of [["list", "extra"], ["search"], ["list", "--page", "2"], ["list", "--source", "homepage"], ["list", "--limit", "1"], ["list", "--confirm"]]) { + const invalid = run(["talks", ...args, "--json"]); + assert.equal(invalid.status, 2, invalid.stdout); + assert.equal(JSON.parse(invalid.stdout).ok, false); + } +}); diff --git a/src/test/planning-projection.test.ts b/src/test/planning-projection.test.ts new file mode 100644 index 0000000..d37f8ae --- /dev/null +++ b/src/test/planning-projection.test.ts @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + assertPlanningProjection, + projectDegreeMissingForPlanning, + projectDegreeProgressForPlanning, + projectEnrollmentForPlanning, + projectSelectionRoundForPlanning, +} from "../tis/planning-projection.js"; +import type { TisDegreeMissing } from "../tis/degree-missing.js"; +import type { TisDegreeProgress } from "../tis/degree-progress.js"; +import { normalisePersonalScheduleEntry } from "../tis/normalise.js"; + +test("SKSJ-only enrollment retains teaching and meeting fields through the planning projection", () => { + const source = { + KCDM: "CS101", RWH: "task-1", KEY: "xq3_jc7", + SKSJ: "Synthetic course\n[Example Teacher]\n[Section A]\n[1-3周][Room 101][7-8节]", + }; + const result = projectEnrollmentForPlanning([normalisePersonalScheduleEntry(source)])[0]!; + assert.deepEqual(result.teachingTeam, ["Example Teacher"]); + assert.deepEqual(result.meetings, [{ day: 3, periodStart: 7, periodEnd: 8, weeks: [1, 2, 3], room: "Room 101" }]); + assert.equal("description" in result, false); + const explicit = normalisePersonalScheduleEntry({ ...source, SKJS: "Explicit Teacher", SKDD: "Room 202", ZC: "00101", KSJC: 1, JSJC: 2 }); + assert.equal(explicit.teacher, "Explicit Teacher"); + assert.equal(explicit.room, "Room 202"); + assert.deepEqual(explicit.weeks, [2, 4]); + assert.equal(explicit.periodEnd, 2); +}); + +test("planning degree projections are grade-free unless details were explicit", () => { + const progress = { + schemaVersion:"1", kind:"tis-degree-progress", reportedAt:"2026-09-02T00:00:00.000Z", + context:{ major:"Synthetic" }, summary:{ remainingCredits:3 }, creditCategories:[], moduleRequirements:[], moduleGaps:[], + dataAvailable:true, detailsRequested:true, detailsIncluded:true, + courses:[{ code:"CS101", name:"Synthetic", credits:3, letterGrade:"A", numericScore:95 }], courseCount:1, + sourceStatuses:{ + graduationRequirements:{ state:"available", count:1 }, requirementSummary:{ state:"available", count:1 }, + creditCategories:{ state:"empty", count:0 }, moduleRequirements:{ state:"empty", count:0 }, courses:{ state:"available", count:1 }, + }, warnings:[], + } satisfies TisDegreeProgress; + const safe = projectDegreeProgressForPlanning(progress); + assert.equal("letterGrade" in safe.courses![0]!, false); + assert.equal("numericScore" in safe.courses![0]!, false); + const explicit = projectDegreeProgressForPlanning(progress, { includeGrades:true }); + assert.equal(explicit.courses![0]!.numericScore, 95); +}); + +test("degree-missing and enrollment projections expose planning fields without raw grades or descriptions", () => { + const report = { + schemaVersion:"1", kind:"tis-degree-missing", generatedAt:"2026-09-02T00:00:00.000Z", reportedAt:"2026-09-02T00:00:00.000Z", + context:{}, officialSummary:{}, summary:{}, + advisory:{ primaryReference:"applicable-official-cultivation-plan", message:"Synthetic", contact:"Synthetic" }, + definiteMissingRequiredCourses:[{ + code:"CS101", name:"Synthetic", groups:[], categories:[], reason:"not complete", + latestAttempt:{ semester:"2025-2026-1", letterGrade:"F", numericScore:40, completion:"failed" }, + }], + inProgressRequiredCourses:[], choiceGaps:[], manualReview:[], + counts:{ definiteMissingRequiredCourses:1, inProgressRequiredCourses:0, choiceGaps:0, manualReview:0 }, + sourceStatuses:{ + progressDetails:{ state:"available", count:1 }, + progress:{ graduationRequirements:{state:"available"}, requirementSummary:{state:"available"}, creditCategories:{state:"empty"}, moduleRequirements:{state:"empty"}, courses:{state:"available"} }, + grades:{ state:"available", count:1 }, enrolled:{ state:"available", count:1 }, + }, warnings:[], + } as TisDegreeMissing; + const projected = projectDegreeMissingForPlanning(report); + assert.deepEqual(projected.definiteMissingRequiredCourses[0]?.latestAttempt, { + semester:"2025-2026-1", completion:"failed", + }); + + const enrollment = projectEnrollmentForPlanning([{ + rwh:"task-1", key:"xq1_jc1", courseCode:"CS101", courseName:"Synthetic", teacher:"Example Teacher", room:"Room 1", + description:"unrelated broad text", descriptionEn:"unrelated broad text", day:1, periodStart:1, periodEnd:2, weeks:[1, 2], + }]); + assert.deepEqual(Object.keys(enrollment[0]!).sort(), ["courseCode", "courseName", "meetings", "rwh", "teachingTeam"]); + assert.equal(JSON.stringify(enrollment).includes("unrelated broad text"), false); +}); + +test("planning projection guard rejects secret and unrelated identity fields", () => { + assert.throws(() => assertPlanningProjection({ courseCode:"CS101", token:"secret" }), /forbidden field/); + assert.throws(() => assertPlanningProjection({ studentId:"123" }), /forbidden field/); +}); + +test("selection-round projection keeps only planning metadata", () => { + assert.deepEqual(projectSelectionRoundForPlanning({ + xkfsdm:"bxxk", lcmc:"Synthetic round", jffs:"20", studentId:"not-for-output", unrelated:"drop", + }), { code:"bxxk", name:"Synthetic round", bidLimit:20 }); +}); diff --git a/src/test/selection-bundles.test.ts b/src/test/selection-bundles.test.ts new file mode 100644 index 0000000..064765a --- /dev/null +++ b/src/test/selection-bundles.test.ts @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { normaliseCourse } from "../tis/normalise.js"; +import { bundleSelectionCourses, retainCourseSourceRecord } from "../tis/selection-bundles.js"; + +test("lecture/lab rows become one selectable bundle with credits counted once", () => { + const lecture = normaliseCourse({ + bundleId: "CS101-A", + componentType: "lecture", + componentRequired: true, + id: "selection-lecture", + rwh: "task-lecture", + kcdm: "CS101", + kcmc: "Synthetic Systems", + kxh: "A", + rwmc: "Lecture A", + xf: 3, + dgjsmc: "Example Lecturer", + kcxx: '

1-16周,星期一第1-2节 Room 101

', + }); + const lab = normaliseCourse({ + bundleId: "CS101-A", + componentType: "lab", + componentRequired: true, + id: "selection-lab", + rwh: "task-lab", + kcdm: "CS101", + kcmc: "Synthetic Systems", + kxh: "A", + rwmc: "Lab A", + xf: 3, + dgjsmc: "Example Lab Teacher", + kcxx: '

1-16双周,星期三第5-6节 Lab 201

', + }); + + const bundles = bundleSelectionCourses([lab, lecture, structuredClone(lab)]); + assert.equal(bundles.length, 1); + const bundle = bundles[0]!; + assert.equal(bundle.credits, 3); + assert.equal(bundle.components.length, 2); + assert.equal(bundle.components.filter((component) => component.creditBearing).length, 1); + assert.deepEqual(bundle.requiredComponentIds, ["task-lecture", "task-lab"]); + assert.deepEqual(bundle.teachingTeam, ["Example Lab Teacher", "Example Lecturer"]); + assert.deepEqual(bundle.meetings.find((meeting) => meeting.componentType === "lab")?.weeks, [2, 4, 6, 8, 10, 12, 14, 16]); + assert.deepEqual(bundle.operationTargets.map((target) => ({ + componentId: target.componentId, + courseId: target.mutationCourseId, + rwh: target.taskId, + payload: target.mutationPayloadField, + })), [ + { componentId: "task-lecture", courseId: "selection-lecture", rwh: "task-lecture", payload: "p_id" }, + { componentId: "task-lab", courseId: "selection-lab", rwh: "task-lab", payload: "p_id" }, + ]); + assert.equal(bundle.selectableWithoutGuessing, true); + assert.ok(bundle.warnings.some((warning) => /Duplicate source row/.test(warning))); +}); + +test("conflicting component credits fail closed instead of being summed or guessed", () => { + const first = normaliseCourse({ bundleId:"X", id:"id-a", rwh:"task-a", kcdm:"X", kcmc:"X", xf:2 }); + const second = normaliseCourse({ bundleId:"X", id:"id-b", rwh:"task-b", kcdm:"X", kcmc:"X", xf:3 }); + const bundle = bundleSelectionCourses([first, second])[0]!; + assert.equal(bundle.credits, undefined); + assert.equal(bundle.creditStatus, "ambiguous"); + assert.equal(bundle.components.some((component) => component.creditBearing), false); +}); + +test("raw selection records require the diagnostics-only envelope", () => { + const source = { id:"selection-id", rwh:"task-id", unknownPersonalField:"not-for-default-json" }; + const diagnostic = retainCourseSourceRecord(source); + source.unknownPersonalField = "changed"; + assert.equal(diagnostic.kind, "tis-selection-source-record"); + assert.equal(diagnostic.raw.unknownPersonalField, "not-for-default-json"); +}); + +test("duplicate-component credit conflicts remain ambiguous in either source order", () => { + for (const creditBearing of [undefined, true]) { + const first = normaliseCourse({ bundleId: "X", id: "id-a", rwh: "task-a", kcdm: "X", kcmc: "X", xf: 2, creditBearing }); + const second = normaliseCourse({ bundleId: "X", id: "id-a", rwh: "task-a", kcdm: "X", kcmc: "X", xf: 3, creditBearing }); + for (const rows of [[first, second], [second, first]]) { + const bundle = bundleSelectionCourses(rows)[0]!; + assert.equal(bundle.components.length, 1); + assert.equal(bundle.credits, undefined); + assert.equal(bundle.creditStatus, "ambiguous"); + assert.equal(bundle.components.some((component) => component.creditBearing), false); + assert.equal(bundle.selectableWithoutGuessing, false); + } + } +}); diff --git a/src/test/text.test.ts b/src/test/text.test.ts new file mode 100644 index 0000000..7efb6b0 --- /dev/null +++ b/src/test/text.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { formatGrades } from "../core/text.js"; + +function gradeRow(name: string): string { + const output = formatGrades([{ + code: "TEST101", + name, + nameEn: "", + semester: "2026秋季", + credits: 3, + letterGrade: "B", + numericScore: 80, + nature: "", + department: "", + }], { gpa: 0, credits: 0, courseCount: 0 }); + return output.split("\n").find((line) => line.includes("TEST101"))!; +} + +// Expected padding uses known terminal-cell widths, independent of the renderer. +function expectedRow(name: string, nameWidth: number): string { + return `2026秋季${" ".repeat(8)}TEST101${" ".repeat(7)}${name}${" ".repeat(32 - nameWidth)}B${" ".repeat(8)}80${" ".repeat(7)}3${" ".repeat(7)}`; +} + +test("grade columns align for ASCII and Unicode Roman numerals", () => { + assert.equal(gradeRow("体育V"), expectedRow("体育V", 5)); + assert.equal(gradeRow("体育Ⅴ"), expectedRow("体育Ⅴ", 5)); + assert.equal(gradeRow("体育Ⅳ"), expectedRow("体育Ⅳ", 5)); +}); + +test("truncated grade names reserve one cell for the ellipsis", () => { + assert.equal(gradeRow("中".repeat(16)), expectedRow(`${"中".repeat(14)}…`, 29)); + assert.equal(gradeRow("A".repeat(31)), expectedRow(`${"A".repeat(29)}…`, 30)); + assert.equal(gradeRow("中".repeat(15)), expectedRow("中".repeat(15), 30)); +}); + +test("grade names preserve combining and emoji graphemes when truncating", () => { + assert.equal(gradeRow("Cafe\u0301"), expectedRow("Cafe\u0301", 4)); + assert.equal(gradeRow("AB"), expectedRow("AB", 4)); + assert.equal(gradeRow(`${"A".repeat(28)}👩‍💻BC`), expectedRow(`${"A".repeat(28)}…`, 29)); + assert.equal(gradeRow(`${"A".repeat(27)}👩‍💻BC`), expectedRow(`${"A".repeat(27)}👩‍💻…`, 30)); +}); diff --git a/src/test/tis-remaining-calendar.test.ts b/src/test/tis-remaining-calendar.test.ts index 85fd24a..18a2ff0 100644 --- a/src/test/tis-remaining-calendar.test.ts +++ b/src/test/tis-remaining-calendar.test.ts @@ -3,6 +3,7 @@ import { mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promise import { join } from "node:path"; import test from "node:test"; import { CalendarTerm } from "../calendar/client.js"; +import { buildContextSchedule } from "../context/schedule.js"; import { buildIcsContent, buildScheduleIcs, @@ -63,6 +64,26 @@ test("week-one inference backtracks from today's week index to the semester anch assert.equal(inferWeekOneMonday("2026-03-04", 2), "2026-02-23"); }); +test("daily snapshot exposes current and next classes together on the adjusted makeup date", () => { + const morning = { ...ENTRIES[0], day: 5, weeks: [3], periodStart: 5, periodEnd: 6 }; + const afternoon = { ...morning, rwh: "R2", periodStart: 7, periodEnd: 8 }; + const snapshot = buildContextSchedule([morning, afternoon], FALL_2026, new Date("2026-09-20T14:30:00+08:00")); + assert.equal(snapshot.todayClasses?.length, 2); + assert.equal(snapshot.currentClass?.startAt, "2026-09-20T06:00:00Z"); + assert.equal(snapshot.nextClass?.startAt, "2026-09-20T08:20:00Z"); + assert.equal(snapshot.currentClass?.makeupFor, "2026-09-25"); + assert.ok(snapshot.now && snapshot.next); + const holiday = buildContextSchedule([morning], FALL_2026, new Date("2026-09-25T14:30:00+08:00")); + assert.deepEqual(holiday.todayClasses, []); + assert.equal(holiday.nextClass, undefined); + const beforeTerm = buildContextSchedule([{ ...ENTRIES[0], weeks: [1] }], FALL_2026, new Date("2026-09-05T10:00:00+08:00")); + assert.equal(beforeTerm.nextClass?.startAt, "2026-09-07T02:20:00Z"); + const priorDay = buildContextSchedule([morning], FALL_2026, new Date("2026-09-19T10:00:00+08:00")); + assert.equal(priorDay.tomorrowMorning, undefined); + const unknownTime = buildContextSchedule([{ ...morning, weeks: [] }], FALL_2026, new Date("2026-09-20T10:00:00+08:00")); + assert.equal(unknownTime.omissionCount, 1); +}); + test("ICS export expands schedule entries into dated UTC events", () => { const occurrences = scheduleOccurrences(ENTRIES, { weekOneMonday: "2026-02-23" }); assert.equal(occurrences.length, 2); diff --git a/src/test/tis-remaining-selection.test.ts b/src/test/tis-remaining-selection.test.ts index a4c221c..493d609 100644 --- a/src/test/tis-remaining-selection.test.ts +++ b/src/test/tis-remaining-selection.test.ts @@ -7,6 +7,7 @@ import { ensureSelectionVerified, planBidUpdates, projectBidTotal, + reconcileSelectionSnapshots, revalidateSelectionWrite, selectionEndpoint, verifySelectionWrite, @@ -29,6 +30,10 @@ test("selection previews keep write payloads typed and operation-specific", () = assert.equal(enroll.endpoint, "/Xsxk/addXuanke"); assert.equal(enroll.payload.p_xktjz, "gwctjzyx"); assert.equal(enroll.payload.p_xkxs, "3"); + assert.match(enroll.clientRequestId, /^[0-9a-f-]{36}$/); + assert.deepEqual(enroll.identifierContract.readbackIdentity, ["courseId", "rwh"]); + assert.equal(enroll.idempotency.upstreamKeySupported, false); + assert.equal(enroll.idempotency.automaticRetry, "forbidden"); const cartAdd = buildSelectionPreview(CONTEXT, { operation: "cart.add", @@ -48,6 +53,38 @@ test("selection previews keep write payloads typed and operation-specific", () = assert.match(cartBid.successHeuristic, /cart bid value/i); }); +test("bounded reconciliation handles delayed visibility without repeating a mutation", () => { + const target = { + operation: "cart.add" as const, + courseId: "hex-id", + rwh: "RWH-1", + round: "bxxk", + bid: 1, + where: "cart" as const, + }; + const absent = { courses:[{ id:"hex-id", rwh:"RWH-1" }], cart:[], enrolled:[], round:{ xkfsdm:"bxxk" } }; + const present = { courses:[{ id:"hex-id", rwh:"RWH-1" }], cart:[{ id:"hex-id", rwh:"RWH-1", xkxs:"1" }], enrolled:[], round:{ xkfsdm:"bxxk" } }; + const result = reconcileSelectionSnapshots([absent, absent, present], target); + assert.equal(result.status, "applied"); + assert.equal(result.automaticRetryAllowed, false); +}); + +test("bounded reconciliation distinguishes stable not-applied from conflicting readback", () => { + const target = { + operation: "bid.update" as const, + courseId: "hex-id", + rwh: "RWH-1", + round: "yixuan", + bid: 5, + where: "cart" as const, + }; + const unchanged = { courses:[{ id:"hex-id", rwh:"RWH-1" }], cart:[{ id:"hex-id", rwh:"RWH-1", xkxs:"2" }], enrolled:[], round:{ xkfsdm:"yixuan" } }; + assert.equal(reconcileSelectionSnapshots([unchanged, structuredClone(unchanged)], target).status, "not_applied"); + + const conflicting = { courses:[{ id:"hex-id", rwh:"RWH-1" }], cart:[{ id:"other-id", rwh:"RWH-1", xkxs:"5" }], enrolled:[], round:{ xkfsdm:"yixuan" } }; + assert.equal(reconcileSelectionSnapshots([unchanged, conflicting], target).status, "still_uncertain"); +}); + test("bid planning short-circuits when the round budget would be exceeded", () => { const plan = planBidUpdates(CONTEXT, { A: 5, B: 4 }, { where: "cart", round: "yixuan", limit: 8 }); assert.equal(plan.overLimit, true); @@ -55,6 +92,24 @@ test("bid planning short-circuits when the round budget would be exceeded", () = assert.equal(plan.previews.length, 0); }); +test("reconciliation requires matching round evidence for every operation", () => { + for (const operation of ["cart.add", "cart.remove", "enroll", "drop", "bid.update"] as const) { + const target = { operation, courseId: "hex-id", rwh: "RWH-1", round: "bxxk", bid: 5, where: "cart" as const }; + for (const round of [{ xkfsdm: "yixuan" }, {}]) { + const entries = [{ id: "hex-id", rwh: "RWH-1", xkxs: "5" }]; + const state = { cart: entries, enrolled: entries, round }; + assert.equal(reconcileSelectionSnapshots([state, structuredClone(state)], target).status, "still_uncertain"); + assert.equal(verifySelectionWrite(state, target).status, "not_observed"); + } + } +}); + +test("a missing bid value cannot establish the inverse mutation state", () => { + const target = { operation: "bid.update" as const, courseId: "hex-id", rwh: "RWH-1", round: "bxxk", bid: 5, where: "cart" as const }; + const state = { cart: [{ id: "hex-id", rwh: "RWH-1" }], enrolled: [], round: { xkfsdm: "bxxk" } }; + assert.equal(reconcileSelectionSnapshots([state, state], target).status, "still_uncertain"); +}); + test("bid planning validates per-course bids before generating previews", () => { const plan = planBidUpdates(CONTEXT, { A: 0, B: 2 }, { where: "enrolled", round: "yixuan" }); assert.deepEqual(plan.errors, ["A: bid must be >= 1"]); diff --git a/src/tis/client.ts b/src/tis/client.ts index fb31bf5..163a45c 100644 --- a/src/tis/client.ts +++ b/src/tis/client.ts @@ -1,4 +1,5 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { CliError } from "../core/errors.js"; @@ -26,6 +27,7 @@ import { type EvaluationStatusFilter, } from "./remaining-evaluation.js"; import type { SelectionPreview } from "./remaining-selection.js"; +import { bundleSelectionCourses, type SelectionCourseBundle } from "./selection-bundles.js"; import type { Course, ExamRecord, @@ -82,14 +84,24 @@ export class TisClient { public async searchAvailable( semester: Semester, options: { keyword?: string; round: string; limit: number }, - ): Promise<{ courses: Course[]; total: number; enrolled: unknown[]; cart: unknown[]; round: Record }> { + ): Promise<{ + courses: Course[]; + bundles: SelectionCourseBundle[]; + total: number; + enrolled: unknown[]; + cart: unknown[]; + round: Record; + reportedAt: string; + }> { const state = await this.selectionState(semester, options); return { courses: state.courses, + bundles: bundleSelectionCourses(state.courses), total: state.total, enrolled: state.enrolled, cart: state.cart, round: state.round, + reportedAt: new Date().toISOString(), }; } @@ -293,18 +305,33 @@ export class TisClient { public async addCourse(input: { semester: Semester; courseId: string; + rwh: string; round: string; bid: number; cultivation: "1" | "2"; }): Promise { + const clientRequestId = randomUUID(); const dq = asRecord(await this.session.postForm("/Xsxk/queryXkdqXnxq", {})); - const response = asRecord( - await this.session.postForm( + let response: Record; + try { + response = asRecord(await this.session.postForm( "/Xsxk/addXuanke", buildWritePayload(input, dq), - ), - ); - return { jg: stringValue(response.jg), message: stringValue(response.message), raw: response }; + )); + } catch (error) { + throw mutationTransportError(error, { + clientRequestId, + operation: "enroll", + courseId: input.courseId, + rwh: input.rwh, + round: input.round, + bid: input.bid, + semester: input.semester.value, + cultivation: input.cultivation, + where: "enrolled", + }); + } + return { clientRequestId, jg: stringValue(response.jg), message: stringValue(response.message) }; } public async selectionWrite(preview: SelectionPreview): Promise { @@ -314,8 +341,21 @@ export class TisClient { endpoint: preview.endpoint, }); } - const response = asRecord(await this.session.postForm(preview.endpoint, preview.payload)); - return { jg: stringValue(response.jg), message: stringValue(response.message), raw: response }; + let response: Record; + try { + response = asRecord(await this.session.postForm(preview.endpoint, preview.payload)); + } catch (error) { + throw mutationTransportError(error, { + clientRequestId: preview.clientRequestId, + operation: preview.operation, + ...preview.exactTarget, + }); + } + return { + clientRequestId: preview.clientRequestId, + jg: stringValue(response.jg), + message: stringValue(response.message), + }; } private async fetchCatalog(semester: Semester): Promise { @@ -567,3 +607,37 @@ function selectionWriteAllowed(preview: SelectionPreview): boolean { } return false; } + +function mutationTransportError( + error: unknown, + target: Record & { clientRequestId: string; operation: string }, +): CliError { + const requestPhase = error instanceof CliError && error.details?.requestPhase === "before-send" + ? "before-send" + : "submission-may-have-started"; + if (requestPhase === "before-send") { + return new CliError( + "The selection request failed before submission; no mutation was performed.", + "TIS_SELECTION_NOT_SUBMITTED", + 4, + { + target, + upstreamCode: error instanceof CliError ? error.code : "UNKNOWN", + requestPhase, + warning: "NO_MUTATION_PERFORMED", + }, + ); + } + return new CliError( + "The selection request lost a conclusive response after submission may have started.", + "TIS_SELECTION_OUTCOME_UNKNOWN", + 5, + { + target, + upstreamCode: error instanceof CliError ? error.code : "UNKNOWN", + requestPhase, + warning: "DO_NOT_RETRY_AUTOMATICALLY", + next: "Run `sustech tis selection reconcile` for this exact courseId/rwh/round target; do not repeat the mutation.", + }, + ); +} diff --git a/src/tis/normalise.ts b/src/tis/normalise.ts index a1d81e6..f8fa257 100644 --- a/src/tis/normalise.ts +++ b/src/tis/normalise.ts @@ -1,4 +1,12 @@ -import type { Course, ExamRecord, GradeRecord, PersonalScheduleEntry, ScheduleSlot } from "./types.js"; +import type { + Course, + CourseComponentType, + CourseSelectionContract, + ExamRecord, + GradeRecord, + PersonalScheduleEntry, + ScheduleSlot, +} from "./types.js"; const DAY_CHARS = "一二三四五六日"; const DAY_NAMES = ["", "周一", "周二", "周三", "周四", "周五", "周六", "周日"]; @@ -9,6 +17,7 @@ export function normaliseCourse(raw: Record): Course { const rwh = stringValue(raw.rwh); const classGroup = stringValue(raw.kxh) || rwh.split("-").at(-1) || ""; const teachers = splitTeachers(stringValue(raw.dgjsmc)); + const selection = normaliseCourseSelectionContract(raw, rwh); return { code: stringValue(raw.kcdm), @@ -30,6 +39,36 @@ export function normaliseCourse(raw: Record): Course { language: stringValue(raw.skyymc), teachers, schedule, + ...(selection ? { selection } : {}), + }; +} + +export function normaliseCourseSelectionContract( + raw: Record, + taskId = stringValue(raw.rwh), +): CourseSelectionContract | undefined { + if (!taskId) return undefined; + const mutationId = firstString(raw, ["id", "courseId", "course_id"]); + const explicitBundleId = firstString(raw, [ + "bundleId", "bundle_id", "kczid", "KCZID", "zhrwid", "ZHRWID", "parentRwh", "PARENT_RWH", + ]); + const taskType = firstString(raw, ["componentType", "component_type", "rwlxmc", "rwlx"]); + const required = firstBoolean(raw, ["componentRequired", "component_required", "required", "sfbd"]); + const creditBearing = firstBoolean(raw, ["creditBearing", "credit_bearing", "sfxfjl"]); + return { + bundleId: explicitBundleId + ? `tis-bundle:${explicitBundleId}` + : mutationId + ? `tis-selection:${mutationId}` + : `tis-task:${taskId}`, + componentId: taskId, + componentType: componentType(taskType), + required: required ?? true, + ...(creditBearing !== undefined ? { creditBearing } : {}), + identifiers: { + task: { field: "rwh", value: taskId }, + ...(mutationId ? { mutation: { field: "courseId" as const, payloadField: "p_id" as const, value: mutationId } } : {}), + }, }; } @@ -70,8 +109,16 @@ export function normalisePersonalScheduleEntry(raw: Record): Pe const keyMatch = /^xq(\d+)_jc(\d+)/i.exec(key); const weekBitmap = firstString(raw, ["ZC", "zc"]); const description = firstString(raw, ["SKSJ", "sksj"]); - const periodStart = numberValue(raw.KSJC ?? raw.ksjc) ?? (keyMatch ? Number(keyMatch[2]) : undefined); - const periodEnd = numberValue(raw.JSJC ?? raw.jsjc) ?? periodStart; + const descriptionLines = description.split(/\r?\n/).map((line) => line.trim()); + const descriptionTeacher = /^\[([^\[\]]+)\]$/.exec(descriptionLines[1] ?? "")?.[1] ?? ""; + const descriptionMeeting = /\[([\d,,、\s-]+)(单|双)?周\]\s*\[([^\[\]]*)\]\s*\[(\d+)(?:-(\d+))?节\]/.exec(description); + let descriptionWeeks = descriptionMeeting ? expandWeeks(descriptionMeeting[1].replace(/[,、]/g, ",")) : []; + if (descriptionMeeting?.[2] === "单") descriptionWeeks = descriptionWeeks.filter((week) => week % 2 === 1); + if (descriptionMeeting?.[2] === "双") descriptionWeeks = descriptionWeeks.filter((week) => week % 2 === 0); + const periodStart = numberValue(raw.KSJC ?? raw.ksjc) + ?? (keyMatch ? Number(keyMatch[2]) : descriptionMeeting ? Number(descriptionMeeting[4]) : undefined); + const periodEnd = numberValue(raw.JSJC ?? raw.jsjc) + ?? (descriptionMeeting ? Number(descriptionMeeting[5] ?? descriptionMeeting[4]) : periodStart); return { rwh: firstString(raw, ["RWH", "rwh"]), key, @@ -79,14 +126,14 @@ export function normalisePersonalScheduleEntry(raw: Record): Pe courseName: firstString(raw, ["KCMC", "kcmc", "KCWZSM", "kcwzsm", "name"]) || description.split("\n")[0]?.trim() || "", - teacher: firstString(raw, ["SKJS", "DGJSMC", "dgjsmc", "teacher"]), - room: firstString(raw, ["SKDD", "JXDD", "JXCDMC", "room"]), + teacher: firstString(raw, ["SKJS", "DGJSMC", "dgjsmc", "teacher"]) || descriptionTeacher, + room: firstString(raw, ["SKDD", "JXDD", "JXCDMC", "room"]) || descriptionMeeting?.[3]?.trim() || "", description, descriptionEn: firstString(raw, ["SKSJ_EN", "sksj_en"]), ...(keyMatch ? { day: Number(keyMatch[1]) } : {}), ...(periodStart !== undefined ? { periodStart } : {}), ...(periodEnd !== undefined ? { periodEnd } : {}), - weeks: bitmapWeeks(weekBitmap), + weeks: weekBitmap ? bitmapWeeks(weekBitmap) : descriptionWeeks, }; } @@ -196,6 +243,28 @@ function firstString(record: Record, keys: string[]): string { return ""; } +function firstBoolean(record: Record, keys: string[]): boolean | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "boolean") return value; + if (typeof value === "number" && (value === 0 || value === 1)) return value === 1; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (["1", "true", "yes", "required", "是", "必修"].includes(normalized)) return true; + if (["0", "false", "no", "optional", "否", "选修"].includes(normalized)) return false; + } + } + return undefined; +} + +function componentType(value: string): CourseComponentType { + const normalized = value.toLowerCase(); + if (/实验|lab/.test(normalized)) return "lab"; + if (/习题|辅导|tutorial/.test(normalized)) return "tutorial"; + if (/讲授|理论|lecture/.test(normalized)) return "lecture"; + return normalized ? "other" : "unknown"; +} + function bitmapWeeks(bitmap: string): number[] { return [...bitmap] .map((enabled, index) => enabled === "1" && index > 0 ? index : undefined) diff --git a/src/tis/planning-projection.ts b/src/tis/planning-projection.ts new file mode 100644 index 0000000..5840586 --- /dev/null +++ b/src/tis/planning-projection.ts @@ -0,0 +1,167 @@ +import type { + DegreeMissingAttempt, + DegreeMissingRequiredCourse, + TisDegreeMissing, +} from "./degree-missing.js"; +import type { DegreeProgressCourse, TisDegreeProgress } from "./degree-progress.js"; +import type { PersonalScheduleEntry } from "./types.js"; + +export const PLANNING_PROJECTION_FIELDS = Object.freeze({ + degreeProgress: ["context", "summary", "creditCategories", "moduleRequirements", "moduleGaps", "sourceStatuses", "reportedAt"], + degreeCourse: ["code", "name", "group", "college", "semester", "required", "credits", "hours", "courseNature", "category", "majorTrack"], + enrollment: ["courseCode", "courseName", "rwh", "teachingTeam", "meetings"], + availability: ["bundleId", "courseCode", "courseName", "credits", "components", "teachingTeam", "meetings", "operationTargets", "reportedAt"], +} as const); + +export type GradeFreeDegreeAttempt = Omit; +export type GradeFreeMissingCourse = Omit & { + latestAttempt?: GradeFreeDegreeAttempt; + previousAttempt?: GradeFreeDegreeAttempt; +}; + +export type PlanningDegreeMissing = Omit & { + definiteMissingRequiredCourses: GradeFreeMissingCourse[]; + inProgressRequiredCourses: GradeFreeMissingCourse[]; + projection: { mode: "planning-grade-free"; fieldAllowlist: typeof PLANNING_PROJECTION_FIELDS }; +}; + +export interface PlanningEnrollment { + courseCode: string; + courseName: string; + rwh: string; + teachingTeam: string[]; + meetings: Array<{ + day?: number; + periodStart?: number; + periodEnd?: number; + weeks: number[]; + room: string; + }>; +} + +export interface PlanningSelectionRound { + code?: string; + name?: string; + bidLimit?: number; +} + +export function projectSelectionRoundForPlanning(raw: Record): PlanningSelectionRound { + const code = text(raw.xkfsdm); + const name = text(raw.lcmc ?? raw.xkfslxmc ?? raw.name); + const bidLimit = numeric(raw.jffs); + const projected = { + ...(code ? { code } : {}), + ...(name ? { name } : {}), + ...(bidLimit !== undefined ? { bidLimit } : {}), + }; + assertPlanningProjection(projected); + return projected; +} + +export function projectDegreeProgressForPlanning( + progress: TisDegreeProgress, + options: { includeGrades?: boolean } = {}, +): TisDegreeProgress & { projection: { mode: "planning-grade-free" | "explicit-details"; fieldAllowlist: typeof PLANNING_PROJECTION_FIELDS } } { + const courses = progress.courses?.map((course) => options.includeGrades ? { ...course } : gradeFreeCourse(course)); + const projected = { + ...progress, + ...(courses ? { courses } : {}), + projection: { + mode: options.includeGrades ? "explicit-details" as const : "planning-grade-free" as const, + fieldAllowlist: PLANNING_PROJECTION_FIELDS, + }, + }; + assertPlanningProjection(projected); + return projected; +} + +export function projectDegreeMissingForPlanning(report: TisDegreeMissing): PlanningDegreeMissing { + const projected: PlanningDegreeMissing = { + ...report, + definiteMissingRequiredCourses: report.definiteMissingRequiredCourses.map(gradeFreeMissingCourse), + inProgressRequiredCourses: report.inProgressRequiredCourses.map(gradeFreeMissingCourse), + projection: { mode:"planning-grade-free", fieldAllowlist:PLANNING_PROJECTION_FIELDS }, + }; + assertPlanningProjection(projected); + return projected; +} + +export function projectEnrollmentForPlanning(entries: readonly PersonalScheduleEntry[]): PlanningEnrollment[] { + const grouped = new Map(); + for (const entry of entries) { + const key = `${entry.courseCode}\u0000${entry.rwh}`; + const current = grouped.get(key) ?? { + courseCode: entry.courseCode, + courseName: entry.courseName, + rwh: entry.rwh, + teachingTeam: [], + meetings: [], + }; + current.teachingTeam = unique([...current.teachingTeam, entry.teacher]); + current.meetings.push({ + ...(entry.day !== undefined ? { day:entry.day } : {}), + ...(entry.periodStart !== undefined ? { periodStart:entry.periodStart } : {}), + ...(entry.periodEnd !== undefined ? { periodEnd:entry.periodEnd } : {}), + weeks: [...entry.weeks], + room: entry.room, + }); + grouped.set(key, current); + } + const projected = [...grouped.values()].sort((left, right) => left.courseCode.localeCompare(right.courseCode) || left.rwh.localeCompare(right.rwh)); + assertPlanningProjection(projected); + return projected; +} + +export function assertPlanningProjection(value: unknown): void { + visit(value, "$", new Set()); +} + +function visit(value: unknown, path: string, seen: Set): void { + if (value === null || typeof value !== "object") return; + if (seen.has(value)) return; + seen.add(value); + if (Array.isArray(value)) { + value.forEach((entry, index) => visit(entry, `${path}[${index}]`, seen)); + return; + } + for (const [key, entry] of Object.entries(value)) { + if (/^(?:password|passwd|authorization|cookie|cookies|token|accessToken|refreshToken|sid|studentId|studentNumber|raw)$/i.test(key)) { + throw new Error(`Planning projection contains forbidden field ${path}.${key}.`); + } + visit(entry, `${path}.${key}`, seen); + } +} + +function gradeFreeCourse(course: DegreeProgressCourse): DegreeProgressCourse { + const { letterGrade: _letterGrade, numericScore: _numericScore, ...projected } = course; + return projected; +} + +function gradeFreeMissingCourse(course: DegreeMissingRequiredCourse): GradeFreeMissingCourse { + const { latestAttempt, previousAttempt, ...projected } = course; + return { + ...projected, + ...(latestAttempt ? { latestAttempt:gradeFreeAttempt(latestAttempt) } : {}), + ...(previousAttempt ? { previousAttempt:gradeFreeAttempt(previousAttempt) } : {}), + }; +} + +function gradeFreeAttempt(attempt: DegreeMissingAttempt): GradeFreeDegreeAttempt { + const { letterGrade: _letterGrade, numericScore: _numericScore, ...projected } = attempt; + return projected; +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort((left, right) => left.localeCompare(right)); +} + +function text(value: unknown): string | undefined { + const result = typeof value === "string" || typeof value === "number" ? String(value).trim() : ""; + return result || undefined; +} + +function numeric(value: unknown): number | undefined { + if (value === undefined || value === null || value === "") return undefined; + const result = Number(value); + return Number.isFinite(result) ? result : undefined; +} diff --git a/src/tis/remaining-selection.ts b/src/tis/remaining-selection.ts index acfa310..e3ccb0b 100644 --- a/src/tis/remaining-selection.ts +++ b/src/tis/remaining-selection.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import type { Semester } from "../core/semester.js"; import { CliError } from "../core/errors.js"; import { asRecord, numberValue, stringValue } from "./remaining-shared.js"; @@ -33,11 +34,13 @@ export interface SelectionContext { export interface SelectionPreviewInput { operation: SelectionOperation; courseId: string; + rwh?: string; round?: string; bid?: number; where?: SelectionBidWhere; ignoreConflicts?: boolean; ignoreZeroCapacity?: boolean; + clientRequestId?: string; } export interface SelectionVerificationStep { @@ -46,12 +49,32 @@ export interface SelectionVerificationStep { } export interface SelectionPreview { + clientRequestId: string; + exactTarget: { + courseId: string; + rwh?: string; + semester: string; + cultivation: "1" | "2"; + round: string; + bid: number; + where: SelectionBidWhere; + }; operation: SelectionOperation; endpoint: string; payload: Record; requiresExplicitConfirm: true; successHeuristic: string; verification: SelectionVerificationStep[]; + identifierContract: { + mutationInput: "courseId"; + mutationPayloadField: "p_id"; + readbackIdentity: readonly ["courseId", "rwh"]; + }; + idempotency: { + upstreamKeySupported: false; + automaticRetry: "forbidden"; + note: string; + }; } export interface BidPlan { @@ -79,7 +102,6 @@ export interface SelectionObservedEntry { bid?: number; courseIdObserved: boolean; courseIdMatches: boolean; - raw: Record; } export interface SelectionStateObservation { @@ -104,6 +126,20 @@ export interface SelectionVerificationResult { observation: SelectionStateObservation; } +export interface SelectionReconciliationResult { + schemaVersion: "1"; + status: "applied" | "not_applied" | "still_uncertain"; + target: SelectionApplyTarget; + attempts: number; + observations: Array<{ + attempt: number; + state: "desired" | "inverse" | "conflicting"; + message: string; + }>; + automaticRetryAllowed: false; + message: string; +} + export interface BidProjection { previousTotalBid: number; totalBid: number; @@ -138,12 +174,32 @@ export function buildSelectionPreview(context: SelectionContext, input: Selectio const endpoint = selectionEndpoint(input.operation, input.where); const payload = buildSelectionPayload(context, input); return { + clientRequestId: input.clientRequestId?.trim() || randomUUID(), + exactTarget: { + courseId: input.courseId, + ...(input.rwh?.trim() ? { rwh: input.rwh.trim() } : {}), + semester: context.semester.value, + cultivation: context.cultivation, + round: stringValue(payload.p_xkfsdm), + bid: input.bid ?? 1, + where: input.where ?? "enrolled", + }, operation: input.operation, endpoint, payload, requiresExplicitConfirm: true, successHeuristic: successHeuristic(input.operation, input.where), verification: verificationSteps(input.operation, input.where), + identifierContract: { + mutationInput: "courseId", + mutationPayloadField: "p_id", + readbackIdentity: ["courseId", "rwh"], + }, + idempotency: { + upstreamKeySupported: false, + automaticRetry: "forbidden", + note: "The client request ID is correlation metadata only and is not sent as an upstream idempotency key.", + }, }; } @@ -240,6 +296,7 @@ export function planBidUpdates( .map((pick) => buildSelectionPreview(context, { operation: "bid.update", courseId: pick.courseId, + ...(pick.rwh ? { rwh:pick.rwh } : {}), round: options.round, bid: pick.bid, where: options.where, @@ -353,6 +410,9 @@ export function verifySelectionWrite( target: SelectionApplyTarget, ): SelectionVerificationResult { const observation = observeSelectionState(state, target); + if (!observation.roundCode || observation.roundCode !== target.round) { + return { status: "not_observed", message: "Read-back did not establish the requested selection round.", observation }; + } if (target.operation === "cart.add") { const cart = observation.cart; if (!cart) return { status: "not_observed", message: "The exact RWH was not observed in cart after the write.", observation }; @@ -400,6 +460,73 @@ export function verifySelectionWrite( return { status: "not_observed", message: "Unsupported selection operation.", observation }; } +export function reconcileSelectionSnapshots( + states: readonly SelectionStateSnapshot[], + target: SelectionApplyTarget, +): SelectionReconciliationResult { + const observations = states.map((state, index) => ({ + attempt: index + 1, + ...classifyReconciliationState(state, target), + })); + const hasConflict = observations.some((observation) => observation.state === "conflicting"); + const desired = observations.filter((observation) => observation.state === "desired").length; + const inverse = observations.filter((observation) => observation.state === "inverse").length; + const finalState = observations.at(-1)?.state; + const status: SelectionReconciliationResult["status"] = hasConflict + ? "still_uncertain" + : finalState === "desired" + ? "applied" + : desired === 0 && inverse >= 2 + ? "not_applied" + : "still_uncertain"; + return { + schemaVersion: "1", + status, + target, + attempts: states.length, + observations, + automaticRetryAllowed: false, + message: status === "applied" + ? "The exact target reached the requested final state during bounded read-back." + : status === "not_applied" + ? "Two or more consistent exact read-backs retained the pre-mutation state. Review before issuing any new mutation." + : "Read-back was missing, changed across attempts, or conflicted on exact identity; do not retry automatically.", + }; +} + +function classifyReconciliationState( + state: SelectionStateSnapshot, + target: SelectionApplyTarget, +): { state: "desired" | "inverse" | "conflicting"; message: string } { + const observation = observeSelectionState(state, target); + if (!observation.roundCode || observation.roundCode !== target.round) { + return { state: "conflicting", message: "Read-back did not establish the requested selection round." }; + } + const candidates = [observation.cart, observation.enrolled].filter((entry) => entry !== undefined); + if (candidates.some((entry) => entry.courseIdObserved && !entry.courseIdMatches)) { + return { state: "conflicting", message: "The RWH was observed with a different course ID." }; + } + const verification = verifySelectionWrite(state, target); + if (verification.status === "confirmed") return { state: "desired", message: verification.message }; + + const exact = target.operation === "cart.add" + ? observation.cart + : target.operation === "enroll" || target.operation === "drop" + ? observation.enrolled + : target.operation === "cart.remove" + ? observation.cart + : target.where === "cart" + ? observation.cart + : observation.enrolled; + if (target.operation === "bid.update" && exact?.bid === undefined) { + return { state: "conflicting", message: "Read-back omitted the bid value needed to establish the final state." }; + } + if (exact?.courseIdMatches || (!exact && (target.operation === "cart.add" || target.operation === "enroll"))) { + return { state: "inverse", message: verification.message }; + } + return { state: "conflicting", message: verification.message }; +} + export function projectBidTotal( state: SelectionStateSnapshot, picks: readonly BidPick[], @@ -534,7 +661,6 @@ function observedEntry( bid: numberValue(item.xkxs ?? item.XKXS), courseIdObserved: courseId !== undefined, courseIdMatches: courseId !== undefined && courseId === target.courseId, - raw: item, }; } return undefined; diff --git a/src/tis/remaining-text.ts b/src/tis/remaining-text.ts index 01885eb..ddb0a74 100644 --- a/src/tis/remaining-text.ts +++ b/src/tis/remaining-text.ts @@ -97,6 +97,8 @@ export function formatSelectionPreview( return [ `TIS selection preview · ${preview.operation}`, "No network request or mutation was performed.", + `Client request ID: ${preview.clientRequestId}`, + "Upstream idempotency key: unsupported; automatic retry forbidden.", ...(options.exactTarget ? [ "Exact target", diff --git a/src/tis/selection-bundles.ts b/src/tis/selection-bundles.ts new file mode 100644 index 0000000..dc4a523 --- /dev/null +++ b/src/tis/selection-bundles.ts @@ -0,0 +1,207 @@ +import type { Course, CourseComponentType, ScheduleSlot } from "./types.js"; + +export interface SelectionIdentifierTarget { + componentId: string; + taskId: string; + mutationCourseId?: string; + mutationPayloadField: "p_id"; + readbackIdentity: readonly ["courseId", "rwh"]; +} + +export interface SelectionCourseComponent { + componentId: string; + type: CourseComponentType; + required: boolean; + creditBearing: boolean; + taskId: string; + mutationCourseId?: string; + sectionName: string; + capacity?: number; + enrolled?: number; + teachingTeam: string[]; + meetings: ScheduleSlot[]; +} + +export interface SelectionCourseBundle { + schemaVersion: "1"; + bundleId: string; + courseCode: string; + courseName: string; + classGroup: string; + credits?: number; + creditStatus: "explicit" | "deduplicated" | "ambiguous"; + components: SelectionCourseComponent[]; + requiredComponentIds: string[]; + teachingTeam: string[]; + meetings: Array; + operationTargets: SelectionIdentifierTarget[]; + selectableWithoutGuessing: boolean; + warnings: string[]; +} + +export interface CourseDiagnosticRecord { + schemaVersion: "1"; + kind: "tis-selection-source-record"; + raw: Readonly>; +} + +/** Explicit diagnostics-only escape hatch. This envelope is never returned by a CLI command. */ +export function retainCourseSourceRecord(raw: Record): CourseDiagnosticRecord { + return { + schemaVersion: "1", + kind: "tis-selection-source-record", + raw: structuredClone(raw), + }; +} + +export function bundleSelectionCourses(courses: readonly Course[]): SelectionCourseBundle[] { + const groups = new Map(); + for (const course of courses) { + const bundleId = course.selection?.bundleId ?? `tis-task:${course.rwh}`; + const group = groups.get(bundleId) ?? []; + group.push(course); + groups.set(bundleId, group); + } + return [...groups.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([bundleId, rows]) => buildBundle(bundleId, rows)); +} + +function buildBundle(bundleId: string, rows: readonly Course[]): SelectionCourseBundle { + const warnings: string[] = []; + const byComponent = new Map(); + let duplicateCreditConflict = false; + for (const course of [...rows].sort(componentOrder)) { + const componentId = course.selection?.componentId ?? course.rwh; + const existing = byComponent.get(componentId); + if (existing) { + if (existing.credits !== course.credits || existing.selection?.creditBearing !== course.selection?.creditBearing) { + duplicateCreditConflict = true; + } + warnings.push(`Duplicate source row for component ${componentId} was merged.`); + byComponent.set(componentId, { + ...existing, + teachers: unique([...existing.teachers, ...course.teachers]), + schedule: uniqueMeetings([...existing.schedule, ...course.schedule]), + }); + continue; + } + byComponent.set(componentId, course); + } + const ordered = [...byComponent.values()].sort(componentOrder); + const identities = new Set(rows.map((course) => `${course.code.trim().toUpperCase()}\u0000${course.name.trim()}`)); + if (identities.size > 1) warnings.push("Bundle source rows disagree on course identity; manual review is required."); + + const credit: ReturnType = duplicateCreditConflict + ? { status: "ambiguous" } + : resolveCreditCarrier(ordered); + if (credit.status === "ambiguous") warnings.push("Bundle source rows disagree on credits; no credit value was projected."); + const components = ordered.map((course, index): SelectionCourseComponent => ({ + componentId: course.selection?.componentId ?? course.rwh, + type: course.selection?.componentType ?? "unknown", + required: course.selection?.required ?? true, + creditBearing: credit.index === index, + taskId: course.rwh, + ...(course.selection?.identifiers.mutation?.value + ? { mutationCourseId: course.selection.identifiers.mutation.value } + : course.id + ? { mutationCourseId: course.id } + : {}), + sectionName: course.sectionName, + ...(course.capacity !== undefined ? { capacity:course.capacity } : {}), + ...(course.enrolled !== undefined ? { enrolled:course.enrolled } : {}), + teachingTeam: [...course.teachers], + meetings: [...course.schedule], + })); + const operationTargets = components.map((component): SelectionIdentifierTarget => ({ + componentId: component.componentId, + taskId: component.taskId, + ...(component.mutationCourseId ? { mutationCourseId: component.mutationCourseId } : {}), + mutationPayloadField: "p_id", + readbackIdentity: ["courseId", "rwh"], + })); + const requiredComponents = components.filter((component) => component.required); + const selectableWithoutGuessing = requiredComponents.length > 0 + && credit.status !== "ambiguous" + && identities.size === 1 + && requiredComponents.every((component) => Boolean(component.mutationCourseId && component.taskId)); + if (!requiredComponents.length || requiredComponents.some((component) => !component.mutationCourseId || !component.taskId)) { + warnings.push("At least one required component lacks an explicit mutation courseId/task rwh pair."); + } + + return { + schemaVersion: "1", + bundleId, + courseCode: ordered[0]?.code ?? "", + courseName: ordered[0]?.name ?? "", + classGroup: ordered[0]?.classGroup ?? "", + ...(credit.credits !== undefined ? { credits: credit.credits } : {}), + creditStatus: credit.status, + components, + requiredComponentIds: requiredComponents.map((component) => component.componentId), + teachingTeam: unique(components.flatMap((component) => component.teachingTeam)), + meetings: components.flatMap((component) => component.meetings.map((meeting) => ({ + ...meeting, + componentId: component.componentId, + componentType: component.type, + }))), + operationTargets, + selectableWithoutGuessing, + warnings, + }; +} + +function resolveCreditCarrier(courses: readonly Course[]): { + index?: number; + credits?: number; + status: SelectionCourseBundle["creditStatus"]; +} { + const explicit = courses + .map((course, index) => course.selection?.creditBearing === true ? index : undefined) + .filter((index): index is number => index !== undefined); + if (explicit.length === 1) return { index: explicit[0], credits: courses[explicit[0]]?.credits ?? 0, status: "explicit" }; + if (explicit.length > 1) return { status: "ambiguous" }; + + const positiveValues = uniqueNumbers(courses.map((course) => course.credits).filter((credits) => credits > 0)); + if (positiveValues.length > 1) return { status: "ambiguous" }; + const index = courses.findIndex((course) => course.credits === (positiveValues[0] ?? 0)); + return { + index: index >= 0 ? index : 0, + credits: positiveValues[0] ?? 0, + status: "deduplicated", + }; +} + +function componentOrder(left: Course, right: Course): number { + return componentRank(left.selection?.componentType ?? "unknown") + - componentRank(right.selection?.componentType ?? "unknown") + || left.rwh.localeCompare(right.rwh); +} + +function componentRank(type: CourseComponentType): number { + return ({ lecture: 0, lab: 1, tutorial: 2, other: 3, unknown: 4 })[type]; +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values.filter(Boolean))].sort((left, right) => left.localeCompare(right)); +} + +function uniqueNumbers(values: readonly number[]): number[] { + return [...new Set(values)].sort((left, right) => left - right); +} + +function uniqueMeetings(values: readonly ScheduleSlot[]): ScheduleSlot[] { + const seen = new Set(); + return values.filter((meeting) => { + const key = JSON.stringify([ + meeting.weeks, + meeting.day, + meeting.periodStart, + meeting.periodEnd, + meeting.room, + ]); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} diff --git a/src/tis/types.ts b/src/tis/types.ts index 1d5ebc7..ceee032 100644 --- a/src/tis/types.ts +++ b/src/tis/types.ts @@ -27,12 +27,27 @@ export interface Course { language: string; teachers: string[]; schedule: ScheduleSlot[]; + selection?: CourseSelectionContract; +} + +export type CourseComponentType = "lecture" | "lab" | "tutorial" | "other" | "unknown"; + +export interface CourseSelectionContract { + bundleId: string; + componentId: string; + componentType: CourseComponentType; + required: boolean; + creditBearing?: boolean; + identifiers: { + task: { field: "rwh"; value: string }; + mutation?: { field: "courseId"; payloadField: "p_id"; value: string }; + }; } export interface TisWriteResult { + clientRequestId: string; jg: string; message: string; - raw: Record; } export interface PersonalScheduleEntry {