diff --git a/CHANGELOG.md b/CHANGELOG.md index 8242727..478c5fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,20 @@ All notable changes to `sustech-cli` are documented in this file. headless servers, containers, and CI environments without requiring a desktop D-Bus session or `secret-tool`. The encrypted store requires a master password on first use and never stores credentials in plaintext. +- `tis schedule` now supports `--date YYYY-MM-DD` to query a specific date's + schedule, resolving the teaching week from the academic calendar automatically. + The `today` behavior uses `--date` with the current Shanghai date internally. +- Personal schedule entries from week-specific queries (`tis schedule --week N`, + `--date YYYY-MM-DD`, or current-week default) now include full ISO-8601 + timestamps: `startAt` / `endAt` in Asia/Shanghai time (e.g. + `2026-09-15T14:00:00+08:00`) combining class date with period-based clock + times. The existing `periodStart` / `periodEnd` fields remain for + compatibility. Catalog `schedule[]` slots lack concrete dates and retain + period fields only. The official SUSTech period→clock mapping is documented in + `docs/ARCHITECTURE.md` so agents and humans share one source of truth. +- Schedule entries with multiple rooms (e.g. "505, 506") now populate a + structured `rooms` array when parseable, while keeping the primary `room` + field for compatibility. ### Changed @@ -22,6 +36,11 @@ All notable changes to `sustech-cli` are documented in this file. Secret Service is unavailable. Instead, it automatically uses the encrypted file backend at `~/.config/sustech-cli/encrypted-credentials/` with file mode `0600`. +- Credential unlock failures now use stable, machine-readable error codes: + `MASTER_PASSWORD_REQUIRED` (missing master password for encrypted-file backend) + and `MASTER_PASSWORD_INVALID` (decryption failed). Remediation messages + consistently mention `SUSTECH_MASTER_PASSWORD` or interactive unlock across + `auth status`, `doctor`, and actual credential reads. ## [0.12.1] - 2026-09-12 diff --git a/FEATURE_DEMO.md b/FEATURE_DEMO.md new file mode 100644 index 0000000..65aa489 --- /dev/null +++ b/FEATURE_DEMO.md @@ -0,0 +1,146 @@ +# Feature Demo: Schedule UX Improvements + +This document demonstrates the new schedule UX improvements in `sustech-cli`. + +## 1. Full ISO-8601 Datetime Timestamps + +When querying a specific week (`--week`, `--date`, or current-week default), +personal schedule entries now include full ISO-8601 timestamps combining date +and clock time: + +```json +{ + "rwh": "2026-2027-1-CS101-001", + "courseCode": "CS101", + "courseName": "Programming", + "teacher": "Prof. Zhang", + "room": "一教101", + "day": 1, + "periodStart": 1, + "periodEnd": 2, + "startAt": "2026-09-07T08:00:00+08:00", + "endAt": "2026-09-07T09:50:00+08:00", + "weeks": [1, 2, 3, ...] +} +``` + +### Benefits for Agents +- **Direct datetime comparisons**: "Is there class this afternoon?" → Compare + current time against `startAt` / `endAt` directly +- **No date reassembly needed**: Timestamps are complete Asia/Shanghai ISO-8601 + strings ready for parsing +- **Natural language queries**: "What time does CS101 start on Monday?" → + `startAt` field contains both date and time + +### Catalog vs Personal Schedule + +- **Personal schedule** (week-specific queries): Full `startAt` / `endAt` timestamps +- **Catalog search** (`tis courses search`): `schedule[]` slots lack concrete + dates, so only `periodStart` / `periodEnd` are provided + +## 2. Date-Based Schedule Queries + +Query schedules by specific date instead of week number: + +```bash +# Old way (required knowing the teaching week) +sustech tis schedule --week 5 + +# New way (natural date query) +sustech tis schedule --date 2026-09-15 + +# Still works: query by week +sustech tis schedule --week 5 + +# Default behavior: show current week +sustech tis schedule +``` + +### Benefits +- More intuitive for "where is class on Friday?" questions +- Automatically resolves teaching week from academic calendar +- Validates date is within semester teaching period + +## 3. Structured Room Fields + +Multiple rooms are now parsed into a structured array: + +```json +{ + "room": "505, 506", + "rooms": ["505", "506"] +} +``` + +Single rooms remain as-is without the `rooms` array: + +```json +{ + "room": "一教101" +} +``` + +### Benefits +- Easy to detect multiple room assignments +- Structured data for route planning or resource allocation +- Backward compatible: existing `room` field unchanged + +## 4. Credential Error Consistency + +Master password errors are now clearly identified: + +```bash +# Missing master password +Error: Encrypted credential store requires a master password. + Set SUSTECH_MASTER_PASSWORD or run interactively. +Code: MASTER_PASSWORD_REQUIRED + +# Incorrect master password +Error: Encrypted store decryption failed; the master password may be incorrect. +Code: MASTER_PASSWORD_INVALID +``` + +### Benefits +- Clear distinction between missing vs incorrect password +- Consistent error codes across `auth status`, `doctor`, and credential reads +- Remediation always mentions `SUSTECH_MASTER_PASSWORD` when relevant + +## 5. Official Period Mapping Documentation + +The SUSTech period→clock mapping is now documented in `docs/ARCHITECTURE.md`: + +| Period | Start | End | Duration | +|--------|--------|--------|----------| +| 1 | 08:00 | 08:50 | 50min | +| 2 | 09:00 | 09:50 | 50min | +| 3 | 10:20 | 11:10 | 50min | +| ... | ... | ... | ... | + +### Benefits +- Single source of truth for humans and agents +- ISO timestamps use this mapping automatically +- Automatic handling of legacy vs current schedules + +**Note**: When a specific week is queried, the CLI automatically combines this +mapping with the class date to produce full ISO-8601 timestamps. No manual +date arithmetic needed. + +## Backward Compatibility + +All changes are backward compatible: +- Period fields (`periodStart`, `periodEnd`) remain unchanged +- New fields (`startAt`, `endAt`, `rooms`) are optional and additive +- `startAt` / `endAt` are only added for week-specific personal schedule queries +- Catalog `schedule[]` slots continue to use period fields only +- Existing JSON consumers continue to work +- `--week` option still works alongside new `--date` option + +## Testing + +All 474 tests pass, including 6 new tests for: +- Schedule entry normalization (periods, rooms) +- ISO timestamp enrichment with full datetimes +- Multiple room parsing +- Single room behavior +- Missing period data handling +- Week filtering for timestamp enrichment diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3acefe0..bf7e3a9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,6 +4,43 @@ The CLI is organized around one rule: service logic returns typed values and never prints. Commands turn those values into a `CommandResult`; the output layer renders text, JSON, or JSONL. +## Teaching period to clock time mapping + +SUSTech schedules courses by period number (节次). The CLI converts periods to +Asia/Shanghai clock times when day and period data are available. + +**Current schedule** (effective 2026-09-07): + +| Period | Start | End | Duration | +|--------|--------|--------|----------| +| 1 | 08:00 | 08:50 | 50min | +| 2 | 09:00 | 09:50 | 50min | +| 3 | 10:20 | 11:10 | 50min | +| 4 | 11:20 | 12:10 | 50min | +| 5 | 14:00 | 14:50 | 50min | +| 6 | 15:00 | 15:50 | 50min | +| 7 | 16:20 | 17:10 | 50min | +| 8 | 17:20 | 18:10 | 50min | +| 9 | 19:00 | 19:50 | 50min | +| 10 | 20:00 | 20:50 | 50min | +| 11 | 21:00 | 21:50 | 50min | + +All periods are 50 minutes. When `tis schedule` queries a specific week +(via `--week`, `--date`, or current-week resolution), personal schedule entries +are enriched with full ISO-8601 timestamps: `startAt` / `endAt` fields in +Asia/Shanghai time (e.g. `2026-09-15T14:00:00+08:00`) that combine the class +date with period-based clock times. The existing `periodStart` / `periodEnd` +fields remain for compatibility. + +Catalog `schedule[]` slots span many weeks and lack a concrete date, so they +retain period fields only without `startAt` / `endAt`. Agents answering +"where is class this afternoon?" can use the ISO timestamps from personal +schedule queries without reassembling date + clock themselves. + +Legacy schedules (pre-2026-09-07) used different afternoon/evening times and +additional periods 12-13; the CLI recognizes dates and selects the correct +mapping automatically. + ```text command parser ↓ diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index 7c51e33..72f2906 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -115,9 +115,17 @@ profile metadata or assume the password expired merely because the collection is locked. For the encrypted-file backend, decryption failures indicate an incorrect -master password. The backend does not impose a retry limit or lockout; protect -the master password accordingly. Each encrypted credential entry uses a unique -salt and initialization vector to prevent cross-entry attacks. +master password and produce `MASTER_PASSWORD_INVALID`. Missing master passwords +produce `MASTER_PASSWORD_REQUIRED` with remediation mentioning +`SUSTECH_MASTER_PASSWORD` or interactive unlock. The backend does not impose a +retry limit or lockout; protect the master password accordingly. Each encrypted +credential entry uses a unique salt and initialization vector to prevent +cross-entry attacks. + +`auth status`, `doctor`, and credential read paths now consistently distinguish: +backend available vs profile metadata present vs secret unlockable vs remote +auth OK. When the linux-encrypted-file backend is active, missing or incorrect +master passwords fail with stable error codes rather than generic store errors. ## Profiles diff --git a/src/cli.ts b/src/cli.ts index b1ebf1a..c14182b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -76,7 +76,7 @@ import { import { parseSemester, type Semester } from "./core/semester.js"; import { CLI_VERSION } from "./core/version.js"; import { checkForUpdate, installLatest, shouldAutomaticallyCheck } from "./core/update.js"; -import { AcademicCalendar, CalendarClient } from "./calendar/client.js"; +import { AcademicCalendar, CalendarClient, CalendarTerm } from "./calendar/client.js"; import { formatCalendarDay, formatCalendarTerms } from "./calendar/text.js"; import type { CalendarLevel } from "./calendar/types.js"; import { @@ -548,7 +548,7 @@ Usage: sustech tis courses available [KEYWORD] --round ROUND [--semester YYYY-YYYY-N] [--limit N] sustech tis courses detail CODE [--rwh RWH] [--round ROUND] [--semester YYYY-YYYY-N] sustech tis enrolled [--semester YYYY-YYYY-N] - sustech tis schedule [--semester YYYY-YYYY-N] [--week N|--all] + sustech tis schedule [--semester YYYY-YYYY-N] [--week N|--date YYYY-MM-DD|--all] sustech tis grades [--semester YYYY-YYYY-N] sustech tis exams sustech tis timetable CODE... [--semester YYYY-YYYY-N] [--block MON:1-4] [--max N] [--refresh] @@ -1044,22 +1044,66 @@ async function main(argv: string[]): Promise { } if (command === "schedule" && operation === undefined) { if (values.all && values.week !== undefined) throw usageError("Choose either --week or --all, not both."); + if (values.all && values.date !== undefined) throw usageError("Choose either --date or --all, not both."); + if (values.date !== undefined && values.week !== undefined) throw usageError("Choose either --date or --week, not both."); const semester = parseSemester(values.semester); const client = await tisClient(values); - const week = values.all - ? undefined - : values.week === undefined - ? await client.currentWeek() - : parsePositiveInteger(values.week, 1, "--week"); + let week: number | undefined; + let resolvedDate: string | undefined; + if (values.all) { + week = undefined; + } else if (values.date !== undefined) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(values.date)) { + throw usageError("--date must be in YYYY-MM-DD format."); + } + resolvedDate = values.date; + const calendar = await new CalendarClient().loadYear(Number(semester.xn.split("-")[0]), "undergraduate"); + const term = calendar.terms().find((t: CalendarTerm) => t.snapshot.semester.value === semester.value); + if (!term) { + throw new CliError(`Calendar term not found for semester ${semester.value}.`, "CALENDAR_TERM_NOT_FOUND", 2); + } + week = term.weekOf(resolvedDate); + if (week === 0) { + throw new CliError(`Date ${resolvedDate} is not within the teaching period of ${semester.value}.`, "DATE_OUT_OF_SEMESTER", 2); + } + } else if (values.week === undefined) { + week = await client.currentWeek(); + } else { + week = parsePositiveInteger(values.week, 1, "--week"); + } if (week !== undefined && week > 36) throw usageError("--week must be between 1 and 36."); - const entries = await client.schedule(semester, week); - const data = { semester, ...(week !== undefined ? { week } : {}), entries, total: entries.length }; + let entries = await client.schedule(semester, week); + + if (week !== undefined) { + const calendar = await new CalendarClient().loadYear(Number(semester.xn.split("-")[0]), "undergraduate"); + const term = calendar.terms().find((t: CalendarTerm) => t.snapshot.semester.value === semester.value); + if (term) { + const { enrichScheduleEntriesWithDatetimes } = await import("./tis/client.js"); + entries = enrichScheduleEntriesWithDatetimes(entries, { + teachingStartDate: term.snapshot.teachingStart, + week, + }); + } + } + + const data = { + semester, + ...(week !== undefined ? { week } : {}), + ...(resolvedDate ? { date: resolvedDate } : {}), + entries, + total: entries.length, + }; writeSuccess({ command: "tis schedule", data, text: formatScheduleEntries(semester, entries, week), items: entries, - summary: { semester: semester.value, ...(week !== undefined ? { week } : {}), total: entries.length }, + summary: { + semester: semester.value, + ...(week !== undefined ? { week } : {}), + ...(resolvedDate ? { date: resolvedDate } : {}), + total: entries.length, + }, }, output); return; } diff --git a/src/core/encrypted-store.ts b/src/core/encrypted-store.ts index dff3a50..f390c26 100644 --- a/src/core/encrypted-store.ts +++ b/src/core/encrypted-store.ts @@ -3,6 +3,7 @@ import { constants } from "node:fs"; import { access, mkdir, readFile, writeFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; +import { CliError } from "./errors.js"; const pbkdf2Async = promisify(pbkdf2); @@ -77,7 +78,12 @@ export class EncryptedStore { if (error && typeof error === "object" && "message" in error) { const message = String(error.message); if (/Unsupported state|bad decrypt/i.test(message)) { - throw new Error("Encrypted store decryption failed; the master password may be incorrect."); + throw new CliError( + "Encrypted store decryption failed; the master password may be incorrect.", + "MASTER_PASSWORD_INVALID", + 2, + { backend: "linux-encrypted-file" }, + ); } } throw error; diff --git a/src/core/keyring.ts b/src/core/keyring.ts index ba14580..eaa949e 100644 --- a/src/core/keyring.ts +++ b/src/core/keyring.ts @@ -67,7 +67,7 @@ export interface CredentialProfileStatus { persistent: boolean; storedAt?: string; profiles: string[]; - reasonCode?: "CREDENTIAL_STORE_ERROR" | "CREDENTIAL_STORE_TIMEOUT"; + reasonCode?: "CREDENTIAL_STORE_ERROR" | "CREDENTIAL_STORE_TIMEOUT" | "MASTER_PASSWORD_REQUIRED" | "MASTER_PASSWORD_INVALID"; reason?: string; remediation?: string; } @@ -638,7 +638,12 @@ async function resolveLinuxEncryptedFile( if (options.promptForMasterPassword) { return await options.promptForMasterPassword(); } - throw new Error("Encrypted credential store requires a master password, but no password provider was configured."); + throw new CliError( + "Encrypted credential store requires a master password. Set SUSTECH_MASTER_PASSWORD or run interactively.", + "MASTER_PASSWORD_REQUIRED", + 2, + { backend: "linux-encrypted-file" }, + ); }; const encryptedStore = new EncryptedStore({ storePath, getMasterPassword }); @@ -779,6 +784,9 @@ function requireMatchingStore(resolution: BackendResolution, expected: Credentia } function storeAccessError(subject: string, operation: string, backend: CredentialBackend, error: unknown): CliError { + if (error instanceof CliError && (error.code === "MASTER_PASSWORD_REQUIRED" || error.code === "MASTER_PASSWORD_INVALID")) { + return error; + } return new CliError( `Could not ${operation} ${subject} using ${backend}.`, "CREDENTIAL_STORE_ERROR", @@ -882,10 +890,16 @@ function safeStoreReason(error: unknown): string { : "The operating-system credential store rejected or could not complete the request."; } -function credentialStatusReasonCode(error: unknown): "CREDENTIAL_STORE_ERROR" | "CREDENTIAL_STORE_TIMEOUT" { - return error && typeof error === "object" && "code" in error && error.code === "CREDENTIAL_STORE_TIMEOUT" - ? "CREDENTIAL_STORE_TIMEOUT" - : "CREDENTIAL_STORE_ERROR"; +function credentialStatusReasonCode( + error: unknown, +): "CREDENTIAL_STORE_ERROR" | "CREDENTIAL_STORE_TIMEOUT" | "MASTER_PASSWORD_REQUIRED" | "MASTER_PASSWORD_INVALID" { + if (error && typeof error === "object" && "code" in error) { + const code = error.code; + if (code === "CREDENTIAL_STORE_TIMEOUT") return "CREDENTIAL_STORE_TIMEOUT"; + if (code === "MASTER_PASSWORD_REQUIRED") return "MASTER_PASSWORD_REQUIRED"; + if (code === "MASTER_PASSWORD_INVALID") return "MASTER_PASSWORD_INVALID"; + } + return "CREDENTIAL_STORE_ERROR"; } function storeRemediation(error: unknown): string | undefined { diff --git a/src/test/schedule-enrichment.test.ts b/src/test/schedule-enrichment.test.ts new file mode 100644 index 0000000..3a26a36 --- /dev/null +++ b/src/test/schedule-enrichment.test.ts @@ -0,0 +1,146 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { normalisePersonalScheduleEntry } from "../tis/normalise.js"; +import { enrichScheduleEntriesWithDatetimes } from "../tis/client.js"; + +test("schedule entry normalisation extracts periods correctly", () => { + const raw = { + RWH: "2026-2027-1-CS101-001", + KEY: "xq1_jc1", + KCDM: "CS101", + KCMC: "Programming", + SKJS: "Prof. Zhang", + SKDD: "一教101", + SKSJ: "Programming\n[Prof. Zhang]\n[1-8周]\n[一教101]\n[1-2节]", + SKSJ_EN: "", + KSJC: 1, + JSJC: 2, + ZC: "011111111", + }; + + const entry = normalisePersonalScheduleEntry(raw); + + assert.equal(entry.courseCode, "CS101"); + assert.equal(entry.periodStart, 1); + assert.equal(entry.periodEnd, 2); + assert.equal(entry.room, "一教101"); + assert.equal(entry.day, 1); + assert.deepEqual(entry.weeks, [1, 2, 3, 4, 5, 6, 7, 8]); +}); + +test("schedule enrichment adds full ISO datetime timestamps", () => { + const entry: import("../tis/types.js").PersonalScheduleEntry = { + rwh: "2026-2027-1-CS101-001", + key: "xq1_jc1", + courseCode: "CS101", + courseName: "Programming", + teacher: "Prof. Zhang", + room: "一教101", + description: "", + descriptionEn: "", + day: 1, + periodStart: 1, + periodEnd: 2, + weeks: [1, 2, 3], + }; + + const enriched = enrichScheduleEntriesWithDatetimes([entry], { + teachingStartDate: "2026-09-07", + week: 1, + }); + + assert.equal(enriched.length, 1); + assert.equal(enriched[0]?.startAt, "2026-09-07T08:00:00+08:00"); + assert.equal(enriched[0]?.endAt, "2026-09-07T09:50:00+08:00"); +}); + +test("multiple rooms are parsed into rooms array", () => { + const raw = { + RWH: "2026-2027-1-PHY201-001", + KEY: "xq3_jc5", + KCDM: "PHY201", + KCMC: "Physics Lab", + SKJS: "Prof. Li", + SKDD: "505, 506", + SKSJ: "Physics Lab\n[Prof. Li]\n[1-8周]\n[505, 506]\n[5-6节]", + SKSJ_EN: "", + KSJC: 5, + JSJC: 6, + ZC: "11111111", + }; + + const entry = normalisePersonalScheduleEntry(raw); + + assert.equal(entry.room, "505, 506"); + assert.deepEqual(entry.rooms, ["505", "506"]); +}); + +test("single room does not populate rooms array", () => { + const raw = { + RWH: "2026-2027-1-CS101-001", + KEY: "xq1_jc1", + KCDM: "CS101", + KCMC: "Programming", + SKJS: "Prof. Zhang", + SKDD: "一教101", + SKSJ: "Programming\n[Prof. Zhang]\n[1-16周]\n[一教101]\n[1-2节]", + SKSJ_EN: "", + KSJC: 1, + JSJC: 2, + ZC: "1111111111111111", + }; + + const entry = normalisePersonalScheduleEntry(raw); + + assert.equal(entry.room, "一教101"); + assert.equal(entry.rooms, undefined); +}); + +test("entries without period data are not enriched", () => { + const entry: import("../tis/types.js").PersonalScheduleEntry = { + rwh: "2026-2027-1-CS101-001", + key: "unknown", + courseCode: "CS101", + courseName: "Programming", + teacher: "Prof. Zhang", + room: "一教101", + description: "", + descriptionEn: "", + weeks: [1, 2, 3], + }; + + const enriched = enrichScheduleEntriesWithDatetimes([entry], { + teachingStartDate: "2026-09-07", + week: 1, + }); + + assert.equal(enriched.length, 1); + assert.equal(enriched[0]?.startAt, undefined); + assert.equal(enriched[0]?.endAt, undefined); +}); + +test("entries not scheduled for the query week are not enriched", () => { + const entry: import("../tis/types.js").PersonalScheduleEntry = { + rwh: "2026-2027-1-CS101-001", + key: "xq1_jc1", + courseCode: "CS101", + courseName: "Programming", + teacher: "Prof. Zhang", + room: "一教101", + description: "", + descriptionEn: "", + day: 1, + periodStart: 1, + periodEnd: 2, + weeks: [5, 6, 7], + }; + + const enriched = enrichScheduleEntriesWithDatetimes([entry], { + teachingStartDate: "2026-09-07", + week: 1, + }); + + assert.equal(enriched.length, 1); + assert.equal(enriched[0]?.startAt, undefined); + assert.equal(enriched[0]?.endAt, undefined); +}); diff --git a/src/tis/client.ts b/src/tis/client.ts index 76cdf4c..9af08b1 100644 --- a/src/tis/client.ts +++ b/src/tis/client.ts @@ -27,6 +27,7 @@ import { type EvaluationCourseStatus, type EvaluationStatusFilter, } from "./remaining-evaluation.js"; +import { PERIOD_START_TIMES, PERIOD_DURATION_MINUTES } from "./remaining-calendar.js"; import type { SelectionPreview } from "./remaining-selection.js"; import { bundleSelectionCourses, type SelectionCourseBundle } from "./selection-bundles.js"; import type { @@ -661,3 +662,50 @@ function mutationTransportError( }, ); } + +export function enrichScheduleEntriesWithDatetimes( + entries: PersonalScheduleEntry[], + options: { teachingStartDate: string; week?: number }, +): PersonalScheduleEntry[] { + return entries.map((entry) => enrichScheduleEntryWithDatetime(entry, options)); +} + +function enrichScheduleEntryWithDatetime( + entry: PersonalScheduleEntry, + options: { teachingStartDate: string; week?: number }, +): PersonalScheduleEntry { + if (entry.periodStart === undefined || entry.periodEnd === undefined || entry.day === undefined) { + return entry; + } + + const startSlot = PERIOD_START_TIMES[entry.periodStart]; + const endSlot = PERIOD_START_TIMES[entry.periodEnd]; + if (!startSlot || !endSlot) { + return entry; + } + + if (options.week === undefined || !entry.weeks.includes(options.week)) { + return entry; + } + + const teachingStart = new Date(options.teachingStartDate); + const mondayOfWeek = new Date(teachingStart); + mondayOfWeek.setUTCDate(teachingStart.getUTCDate() + (options.week - 1) * 7); + + const classDate = new Date(mondayOfWeek); + classDate.setUTCDate(mondayOfWeek.getUTCDate() + (entry.day - 1)); + + const dateStr = classDate.toISOString().slice(0, 10); + + const startHour = String(startSlot[0]).padStart(2, "0"); + const startMinute = String(startSlot[1]).padStart(2, "0"); + const endMinutes = endSlot[0] * 60 + endSlot[1] + PERIOD_DURATION_MINUTES; + const endHour = String(Math.floor(endMinutes / 60)).padStart(2, "0"); + const endMinute = String(endMinutes % 60).padStart(2, "0"); + + return { + ...entry, + startAt: `${dateStr}T${startHour}:${startMinute}:00+08:00`, + endAt: `${dateStr}T${endHour}:${endMinute}:00+08:00`, + }; +} diff --git a/src/tis/normalise.ts b/src/tis/normalise.ts index f8fa257..11c3a42 100644 --- a/src/tis/normalise.ts +++ b/src/tis/normalise.ts @@ -94,13 +94,17 @@ export function parseScheduleLine(line: string): ScheduleSlot | undefined { if (match.groups.parity === "双") weeks = weeks.filter((week) => week % 2 === 0); const day = DAY_CHARS.indexOf(match.groups.day) + 1; const periodStart = Number(match.groups.start); + const periodEnd = Number(match.groups.end ?? match.groups.start); + const room = match.groups.room.trim(); + const rooms = parseRoomList(room); return { weeks, day, dayName: DAY_NAMES[day] ?? `day${day}`, periodStart, - periodEnd: Number(match.groups.end ?? match.groups.start), - room: match.groups.room.trim(), + periodEnd, + room, + ...(rooms && rooms.length > 1 ? { rooms } : {}), }; } @@ -119,6 +123,10 @@ export function normalisePersonalScheduleEntry(raw: Record): Pe ?? (keyMatch ? Number(keyMatch[2]) : descriptionMeeting ? Number(descriptionMeeting[4]) : undefined); const periodEnd = numberValue(raw.JSJC ?? raw.jsjc) ?? (descriptionMeeting ? Number(descriptionMeeting[5] ?? descriptionMeeting[4]) : periodStart); + + const room = firstString(raw, ["SKDD", "JXDD", "JXCDMC", "room"]) || descriptionMeeting?.[3]?.trim() || ""; + const rooms = room ? parseRoomList(room) : undefined; + return { rwh: firstString(raw, ["RWH", "rwh"]), key, @@ -127,7 +135,8 @@ export function normalisePersonalScheduleEntry(raw: Record): Pe || description.split("\n")[0]?.trim() || "", teacher: firstString(raw, ["SKJS", "DGJSMC", "dgjsmc", "teacher"]) || descriptionTeacher, - room: firstString(raw, ["SKDD", "JXDD", "JXCDMC", "room"]) || descriptionMeeting?.[3]?.trim() || "", + room, + ...(rooms && rooms.length > 1 ? { rooms } : {}), description, descriptionEn: firstString(raw, ["SKSJ_EN", "sksj_en"]), ...(keyMatch ? { day: Number(keyMatch[1]) } : {}), @@ -190,6 +199,12 @@ export function gradePoints(letterGrade: string, numericScore?: number): number return 1; } +function parseRoomList(room: string): string[] | undefined { + if (!room) return undefined; + const parts = room.split(/[,,、;;]/).map((part) => part.trim()).filter(Boolean); + return parts.length > 0 ? parts : undefined; +} + function expandWeeks(value: string): number[] { const weeks = new Set(); for (const part of value.split(",")) { diff --git a/src/tis/types.ts b/src/tis/types.ts index ceee032..05d9532 100644 --- a/src/tis/types.ts +++ b/src/tis/types.ts @@ -5,6 +5,7 @@ export interface ScheduleSlot { periodStart: number; periodEnd: number; room: string; + rooms?: string[]; } export interface Course { @@ -57,11 +58,14 @@ export interface PersonalScheduleEntry { courseName: string; teacher: string; room: string; + rooms?: string[]; description: string; descriptionEn: string; day?: number; periodStart?: number; periodEnd?: number; + startAt?: string; + endAt?: string; weeks: number[]; }