@robertying/thu-learn-lib 5.0.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +94 -227
  2. package/lib/auth.d.ts +15 -4
  3. package/lib/auth.d.ts.map +1 -1
  4. package/lib/auth.js +65 -55
  5. package/lib/auth.js.map +1 -1
  6. package/lib/calendar.d.ts +12 -5
  7. package/lib/calendar.d.ts.map +1 -1
  8. package/lib/calendar.js +118 -20
  9. package/lib/calendar.js.map +1 -1
  10. package/lib/contents.d.ts.map +1 -1
  11. package/lib/contents.js +10 -6
  12. package/lib/contents.js.map +1 -1
  13. package/lib/courses.d.ts.map +1 -1
  14. package/lib/courses.js +15 -29
  15. package/lib/courses.js.map +1 -1
  16. package/lib/discussions.d.ts.map +1 -1
  17. package/lib/discussions.js +8 -19
  18. package/lib/discussions.js.map +1 -1
  19. package/lib/favorites.d.ts +1 -0
  20. package/lib/favorites.d.ts.map +1 -1
  21. package/lib/favorites.js +25 -60
  22. package/lib/favorites.js.map +1 -1
  23. package/lib/files.d.ts +4 -4
  24. package/lib/files.d.ts.map +1 -1
  25. package/lib/files.js +112 -134
  26. package/lib/files.js.map +1 -1
  27. package/lib/homework.d.ts +35 -5
  28. package/lib/homework.d.ts.map +1 -1
  29. package/lib/homework.js +150 -92
  30. package/lib/homework.js.map +1 -1
  31. package/lib/index.d.ts +45 -20
  32. package/lib/index.d.ts.map +1 -1
  33. package/lib/index.js +52 -17
  34. package/lib/index.js.map +1 -1
  35. package/lib/notices.d.ts +4 -4
  36. package/lib/notices.d.ts.map +1 -1
  37. package/lib/notices.js +13 -33
  38. package/lib/notices.js.map +1 -1
  39. package/lib/questionnaires.d.ts +6 -3
  40. package/lib/questionnaires.d.ts.map +1 -1
  41. package/lib/questionnaires.js +26 -24
  42. package/lib/questionnaires.js.map +1 -1
  43. package/lib/request.d.ts +64 -20
  44. package/lib/request.d.ts.map +1 -1
  45. package/lib/request.js +174 -64
  46. package/lib/request.js.map +1 -1
  47. package/lib/timetable.d.ts +26 -0
  48. package/lib/timetable.d.ts.map +1 -0
  49. package/lib/timetable.js +110 -0
  50. package/lib/timetable.js.map +1 -0
  51. package/lib/types.d.ts +117 -76
  52. package/lib/types.d.ts.map +1 -1
  53. package/lib/types.js +3 -0
  54. package/lib/types.js.map +1 -1
  55. package/lib/urls.d.ts +10 -20
  56. package/lib/urls.d.ts.map +1 -1
  57. package/lib/urls.js +11 -56
  58. package/lib/urls.js.map +1 -1
  59. package/lib/user.d.ts +7 -2
  60. package/lib/user.d.ts.map +1 -1
  61. package/lib/user.js +20 -6
  62. package/lib/user.js.map +1 -1
  63. package/lib/utils.d.ts +42 -4
  64. package/lib/utils.d.ts.map +1 -1
  65. package/lib/utils.js +80 -29
  66. package/lib/utils.js.map +1 -1
  67. package/package.json +9 -8
  68. package/src/auth.ts +72 -70
  69. package/src/calendar.ts +136 -24
  70. package/src/contents.ts +13 -6
  71. package/src/courses.ts +21 -37
  72. package/src/discussions.ts +10 -21
  73. package/src/favorites.ts +44 -80
  74. package/src/files.ts +124 -142
  75. package/src/homework.ts +207 -135
  76. package/src/index.ts +79 -48
  77. package/src/notices.ts +29 -42
  78. package/src/questionnaires.ts +62 -45
  79. package/src/request.ts +192 -69
  80. package/src/timetable.ts +121 -0
  81. package/src/types.ts +123 -87
  82. package/src/urls.ts +13 -64
  83. package/src/user.ts +22 -7
  84. package/src/utils.ts +97 -29
package/src/index.ts CHANGED
@@ -11,38 +11,45 @@ import * as questionnaires from './questionnaires.ts';
11
11
  import { Session } from './request.ts';
12
12
  import type {
13
13
  AllContentsOptions,
14
+ Credential,
14
15
  AllContentsResult,
15
16
  CalendarOptions,
16
17
  CalendarResult,
17
- CourseListOptions,
18
- ExcellentHomework,
19
- HomeworkListOptions,
20
- IHomeworkDetail,
21
- NotificationListOptions,
22
- RemoteFile,
23
18
  CommentItem,
19
+ ContentType,
20
+ CourseFile,
24
21
  CourseInfo,
22
+ CourseListOptions,
23
+ CourseNotification,
25
24
  Discussion,
25
+ ExcellentHomework,
26
26
  FavoriteItem,
27
- File,
28
27
  FileCategory,
29
28
  HelperConfig,
30
29
  Homework,
30
+ HomeworkDetail,
31
+ HomeworkListOptions,
32
+ HomeworkSubmitAttachment,
33
+ HomeworkSubmitRequest,
31
34
  HomeworkTA,
32
- IHomeworkSubmitAttachment,
33
35
  Language,
34
- Notification,
36
+ NotificationListOptions,
35
37
  Question,
36
38
  Questionnaire,
39
+ QuestionnaireDetail,
40
+ QuestionnaireListOptions,
41
+ RemoteFile,
37
42
  SemesterInfo,
38
43
  UserInfo,
39
- ContentType,
40
44
  } from './types.ts';
41
- import { CourseType } from './types.ts';
45
+ import { CourseType, FailReason } from './types.ts';
46
+ import { apiError } from './utils.ts';
42
47
  import * as URLS from './urls.ts';
43
48
  import * as user from './user.ts';
44
49
 
45
- export { addCSRFTokenToUrl } from './request.ts';
50
+ export { addCSRFTokenToUrl, isConnectionError } from './request.ts';
51
+ export { checkHomeworkSubmitResponse, homeworkContentHtml } from './homework.ts';
52
+ export { CLASS_PERIOD_TIMES, parseCourseMeeting, parseWeeks, semesterWeek } from './timetable.ts';
46
53
  export * from './types.ts';
47
54
 
48
55
  /** the main helper class */
@@ -51,7 +58,7 @@ export class Learn2018Helper {
51
58
 
52
59
  /** you can provide a CookieJar and / or CredentialProvider in the configuration */
53
60
  constructor(config?: HelperConfig) {
54
- this.#session = new Session(config, () => auth.login(this.#session));
61
+ this.#session = new Session(config, () => auth.loginWithProvider(this.#session));
55
62
  }
56
63
 
57
64
  /** whether preview URLs open the first page only (`HelperConfig.generatePreviewUrlForFirstPage`) */
@@ -74,32 +81,25 @@ export class Learn2018Helper {
74
81
  }
75
82
 
76
83
  /**
77
- * If using alternative cookie management systems,
78
- * be sure to clear id.tsinghua.edu.cn cookies before calling this function
84
+ * Sign in to id.tsinghua.edu.cn and return the Web Learning roaming ticket, without logging in to Web Learning.
85
+ * Rejects with `NO_CREDENTIAL` unless `credential` has a username, a password and a fingerprint. If using
86
+ * alternative cookie management systems, be sure to clear id.tsinghua.edu.cn cookies before calling this function.
79
87
  */
80
- public async getRoamingTicket(
81
- username: string,
82
- password: string,
83
- fingerPrint: string,
84
- fingerGenPrint: string = '',
85
- fingerGenPrint3: string = '',
86
- ): Promise<string> {
87
- return auth.getRoamingTicket(this.#session, username, password, fingerPrint, fingerGenPrint, fingerGenPrint3);
88
+ public async getRoamingTicket(credential: Credential): Promise<string> {
89
+ if (!auth.isComplete(credential)) throw apiError(FailReason.NO_CREDENTIAL);
90
+ return auth.getRoamingTicket(this.#session, credential);
88
91
  }
89
92
 
90
93
  /**
91
94
  * Login is necessary if you do not provide a `CredentialProvider`.
92
- * Without credentials it uses the provider, and concurrent calls share one SSO login.
95
+ * Without a credential it uses the provider, and concurrent calls share one SSO login; a credential without a
96
+ * username, a password and a fingerprint rejects with `NO_CREDENTIAL`. Logins never overlap: one with a credential
97
+ * waits for a login in progress, and requests that find the session expired meanwhile wait for it.
93
98
  */
94
- public async login(
95
- username?: string,
96
- password?: string,
97
- fingerPrint?: string,
98
- fingerGenPrint?: string,
99
- fingerGenPrint3?: string,
100
- ): Promise<void> {
101
- if (!username || !password || !fingerPrint) return this.#session.relogin();
102
- return auth.login(this.#session, username, password, fingerPrint, fingerGenPrint, fingerGenPrint3);
99
+ public async login(credential?: Credential): Promise<void> {
100
+ if (credential === undefined) return this.#session.relogin();
101
+ if (!auth.isComplete(credential)) throw apiError(FailReason.NO_CREDENTIAL);
102
+ return this.#session.runLogin(() => auth.login(this.#session, credential));
103
103
  }
104
104
 
105
105
  /**
@@ -108,7 +108,7 @@ export class Learn2018Helper {
108
108
  * Use it when resuming an app instead of `login()`.
109
109
  */
110
110
  public async ensureSession(): Promise<void> {
111
- return this.#session.ensureSession(URLS.LEARN_CURRENT_SEMESTER());
111
+ return this.#session.ensureSession(URLS.LEARN_CURRENT_SEMESTER);
112
112
  }
113
113
 
114
114
  /** logout (to make everyone happy) */
@@ -123,8 +123,11 @@ export class Learn2018Helper {
123
123
 
124
124
  /**
125
125
  * Get calendar items during the specified period (in yyyymmdd format, both inclusive).
126
- * Ranges longer than 28 days are fetched in windows and merged; windows that fail even after a retry are listed in
127
- * `failedRanges`. Rejects only when every window fails (usually with `FailReason.INVALID_RESPONSE`).
126
+ * Ranges longer than 28 days are fetched in windows and merged; windows that still fail are listed in `failedRanges`
127
+ * (a timed-out window is not retried). Rejects when every window fails (usually with `FailReason.INVALID_RESPONSE`),
128
+ * with `FailReason.ERROR_REGISTRAR_AUTH` when signing in to the registrar fails, or with a `RangeError` for a date
129
+ * that is not a valid yyyymmdd. With a `CredentialProvider`, a sign-in that id.tsinghua.edu.cn turns down (its session
130
+ * from the last login expired) logs in again and is tried once more.
128
131
  */
129
132
  public async getCalendar(startDate: string, endDate: string, options?: CalendarOptions): Promise<CalendarResult> {
130
133
  return calendar.getCalendar(this.#session, startDate, endDate, options);
@@ -140,7 +143,8 @@ export class Learn2018Helper {
140
143
 
141
144
  /**
142
145
  * Get all courses in the specified semester.
143
- * `timeAndLocation` is only fetched (one request per course) with `options.timeAndLocation`.
146
+ * `timeAndLocation` is only fetched (one request per course) with `options.timeAndLocation`, and left out for a
147
+ * course whose request failed.
144
148
  */
145
149
  public async getCourseList(
146
150
  semesterID: string,
@@ -178,20 +182,20 @@ export class Learn2018Helper {
178
182
  courseID: string,
179
183
  courseType: CourseType = CourseType.STUDENT,
180
184
  options?: NotificationListOptions,
181
- ): Promise<Notification[]> {
185
+ ): Promise<CourseNotification[]> {
182
186
  return notices.getNotificationList(this.#session, courseID, courseType, options);
183
187
  }
184
188
 
185
189
  /** Get the attachment of a notice (one request), `undefined` if it has none. */
186
190
  public async getNotificationAttachment(
187
- notification: Pick<Notification, 'url' | 'attachmentName'>,
191
+ notification: Pick<CourseNotification, 'url' | 'attachmentName'>,
188
192
  courseType: CourseType = CourseType.STUDENT,
189
193
  ): Promise<RemoteFile | undefined> {
190
194
  return notices.getNotificationAttachment(this.#session, notification, courseType);
191
195
  }
192
196
 
193
197
  /** Get all files (课程文件) of the specified course. */
194
- public async getFileList(courseID: string, courseType: CourseType = CourseType.STUDENT): Promise<File[]> {
198
+ public async getFileList(courseID: string, courseType: CourseType = CourseType.STUDENT): Promise<CourseFile[]> {
195
199
  return files.getFileList(this.#session, courseID, courseType);
196
200
  }
197
201
 
@@ -211,7 +215,7 @@ export class Learn2018Helper {
211
215
  courseID: string,
212
216
  categoryId: string,
213
217
  courseType: CourseType = CourseType.STUDENT,
214
- ): Promise<File[]> {
218
+ ): Promise<CourseFile[]> {
215
219
  return files.getFileListByCategory(this.#session, courseID, categoryId, courseType);
216
220
  }
217
221
 
@@ -220,10 +224,9 @@ export class Learn2018Helper {
220
224
  * Details (page + description, two requests per homework) are only fetched for the homework `options.details`
221
225
  * selects; use `getHomeworkDetail` for the others.
222
226
  */
223
- public async getHomeworkList(courseID: string): Promise<Homework[]>;
224
227
  public async getHomeworkList(
225
228
  courseID: string,
226
- courseType: CourseType.STUDENT,
229
+ courseType?: CourseType.STUDENT,
227
230
  options?: HomeworkListOptions,
228
231
  ): Promise<Homework[]>;
229
232
  public async getHomeworkList(courseID: string, courseType: CourseType.TEACHER): Promise<HomeworkTA[]>;
@@ -238,12 +241,12 @@ export class Learn2018Helper {
238
241
  }
239
242
 
240
243
  /** Get the detail (page content, attachments and description) of one homework. */
241
- public async getHomeworkDetail(item: Pick<Homework, 'url' | 'baseId'>): Promise<IHomeworkDetail> {
244
+ public async getHomeworkDetail(item: Pick<Homework, 'url' | 'baseId'>): Promise<HomeworkDetail> {
242
245
  return homework.getHomeworkDetail(this.#session, item);
243
246
  }
244
247
 
245
248
  /** Get the detail (page content, attachments and description) of one excellent homework. */
246
- public async getExcellentHomeworkDetail(item: Pick<ExcellentHomework, 'url' | 'baseId'>): Promise<IHomeworkDetail> {
249
+ public async getExcellentHomeworkDetail(item: Pick<ExcellentHomework, 'url' | 'baseId'>): Promise<HomeworkDetail> {
247
250
  return homework.getHomeworkDetail(this.#session, item);
248
251
  }
249
252
 
@@ -265,9 +268,16 @@ export class Learn2018Helper {
265
268
 
266
269
  /**
267
270
  * Get all questionnaires (课程问卷/QNR) of the specified course.
271
+ * Questions (`detail`, one request per questionnaire) for the questionnaires `options.details` selects, by default
272
+ * all; use `getQuestionnaireDetail` for the others.
268
273
  */
269
- public async getQuestionnaireList(courseID: string): Promise<Questionnaire[]> {
270
- return questionnaires.getQuestionnaireList(this.#session, courseID);
274
+ public async getQuestionnaireList(courseID: string, options?: QuestionnaireListOptions): Promise<Questionnaire[]> {
275
+ return questionnaires.getQuestionnaireList(this.#session, courseID, options);
276
+ }
277
+
278
+ /** Get the questions of one questionnaire (one request). */
279
+ public async getQuestionnaireDetail(courseID: string, questionnaireID: string): Promise<QuestionnaireDetail[]> {
280
+ return questionnaires.getQuestionnaireDetail(this.#session, courseID, questionnaireID);
271
281
  }
272
282
 
273
283
  /**
@@ -326,15 +336,36 @@ export class Learn2018Helper {
326
336
  return courses.sortCourses(this.#session, courseIDs);
327
337
  }
328
338
 
339
+ /**
340
+ * Submit homework `id` (the `studentHomeworkId`), the way the website's submit page does. `content` is the whole
341
+ * answer as HTML (the website sends its editor's HTML) and replaces the submitted one, so pass the current answer
342
+ * when only changing the attachment (`homeworkContentHtml` turns plain text into the editor's HTML). With no
343
+ * `attachment` the existing one is kept, unless `removeAttachment`: that only deletes the attachment, and `content` is
344
+ * ignored. In React Native, pass the attachment's `content` as `{ uri, type }`.
345
+ */
329
346
  public async submitHomework(
330
347
  id: string,
331
348
  content = '',
332
- attachment?: IHomeworkSubmitAttachment,
349
+ attachment?: HomeworkSubmitAttachment,
333
350
  removeAttachment = false,
334
351
  ): Promise<void> {
335
352
  return homework.submitHomework(this.#session, id, content, attachment, removeAttachment);
336
353
  }
337
354
 
355
+ /**
356
+ * The request `submitHomework` would send, for sending with another HTTP client (e.g. to show upload progress, which
357
+ * `fetch` can't): POST `body` to `url`, then pass the answer (parsed JSON or text) to `checkHomeworkSubmitResponse`.
358
+ * It checks the session first (`ensureSession`), since that request doesn't log in again and retry.
359
+ */
360
+ public async getHomeworkSubmitRequest(
361
+ id: string,
362
+ content = '',
363
+ attachment?: HomeworkSubmitAttachment,
364
+ removeAttachment = false,
365
+ ): Promise<HomeworkSubmitRequest> {
366
+ return homework.getHomeworkSubmitRequest(this.#session, id, content, attachment, removeAttachment);
367
+ }
368
+
338
369
  public async setLanguage(lang: Language): Promise<void> {
339
370
  return user.setLanguage(this.#session, lang);
340
371
  }
package/src/notices.ts CHANGED
@@ -3,11 +3,19 @@
3
3
  import { Base64 } from 'js-base64';
4
4
 
5
5
  import type { Session } from './request.ts';
6
- import { $, YES } from './request.ts';
7
- import type { INotification, Notification, NotificationListOptions, RemoteFile } from './types.ts';
6
+ import type { CourseNotification, CourseNotificationBase, NotificationListOptions, RemoteFile } from './types.ts';
8
7
  import { ContentType, CourseType, FailReason } from './types.ts';
9
8
  import * as URLS from './urls.ts';
10
- import { decodeHTML, isSelected, trimAndDefine } from './utils.ts';
9
+ import {
10
+ $,
11
+ YES,
12
+ decodeHTML,
13
+ expectSuccess,
14
+ isSelected,
15
+ toRequiredServerTime,
16
+ toServerTime,
17
+ trimAndDefine,
18
+ } from './utils.ts';
11
19
 
12
20
  /**
13
21
  * Get all notifications (课程公告) of the specified course: current and expired lists.
@@ -18,13 +26,13 @@ export async function getNotificationList(
18
26
  courseID: string,
19
27
  courseType: CourseType = CourseType.STUDENT,
20
28
  options: NotificationListOptions = {},
21
- ): Promise<Notification[]> {
29
+ ): Promise<CourseNotification[]> {
22
30
  const lists = await Promise.all([
23
31
  getNotificationListKind(s, courseID, courseType, false),
24
32
  getNotificationListKind(s, courseID, courseType, true),
25
33
  ]);
26
34
  return Promise.all(
27
- lists.flat().map(async (n): Promise<Notification> => {
35
+ lists.flat().map(async (n): Promise<CourseNotification> => {
28
36
  if (!n.attachmentName || !isSelected(options.attachments, n)) return n;
29
37
  return { ...n, attachment: await getNotificationAttachment(s, n, courseType) };
30
38
  }),
@@ -36,19 +44,12 @@ async function getNotificationListKind(
36
44
  courseID: string,
37
45
  courseType: CourseType,
38
46
  expired: boolean,
39
- ): Promise<INotification[]> {
40
- const json = await (
41
- await s.fetchWithToken(URLS.LEARN_NOTIFICATION_LIST(courseType, expired), {
42
- method: 'POST',
43
- body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
44
- })
45
- ).json();
46
- if (json.result !== 'success') {
47
- return Promise.reject({
48
- reason: FailReason.INVALID_RESPONSE,
49
- extra: json,
50
- });
51
- }
47
+ ): Promise<CourseNotificationBase[]> {
48
+ const json = await s.fetchJsonWithToken(URLS.LEARN_NOTIFICATION_LIST(courseType, expired), {
49
+ method: 'POST',
50
+ body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
51
+ });
52
+ expectSuccess(json, FailReason.INVALID_RESPONSE);
52
53
 
53
54
  const result = (json.object?.aaData ?? json.object?.resultsList ?? []) as any[];
54
55
 
@@ -62,44 +63,30 @@ async function getNotificationListKind(
62
63
  publisher: n.fbrxm,
63
64
  hasRead: n.sfyd === YES,
64
65
  markedImportant: Number(n.sfqd) === 1, // n.sfqd could be string '1' (teacher mode) or number 1 (student mode)
65
- publishTime: n.fbsj && typeof n.fbsj === 'string' ? n.fbsj : n.fbsjStr,
66
- expireTime: n.jzsj ?? undefined,
66
+ publishTime: toRequiredServerTime(n.fbsj && typeof n.fbsj === 'string' ? n.fbsj : n.fbsjStr),
67
+ expireTime: toServerTime(n.jzsj),
67
68
  isFavorite: n.sfsc === YES,
68
69
  comment: n.bznr ?? undefined,
69
70
  attachmentName: typeof attachmentName === 'string' && attachmentName ? attachmentName : undefined,
70
- } satisfies INotification;
71
+ } satisfies CourseNotificationBase;
71
72
  });
72
73
  }
73
74
 
74
75
  /**
75
76
  * Get the attachment of a notice from its detail page (1 request), or `undefined` without a request if
76
- * `attachmentName` says it has none.
77
+ * `attachmentName` says it has none, or if the page has no attachment link.
77
78
  */
78
79
  export async function getNotificationAttachment(
79
80
  s: Session,
80
- notification: Pick<INotification, 'url' | 'attachmentName'>,
81
+ notification: Pick<CourseNotificationBase, 'url' | 'attachmentName'>,
81
82
  courseType: CourseType = CourseType.STUDENT,
82
83
  ): Promise<RemoteFile | undefined> {
83
84
  if (!notification.attachmentName) return undefined;
84
- /// from JSON (backup, currently not used)
85
- // const postParams = new FormData();
86
- // postParams.append('id', id);
87
- // postParams.append('wlkcid', courseID);
88
- // const metadata = await (await s.fetchWithToken(URL.LEARN_NOTIFICATION_EDIT(courseType), {
89
- // 'method': 'POST',
90
- // 'body': postParams,
91
- // })).json();
92
- // const attachmentId = metadata.ggfjid as string;
93
- /// parsed from HTML
94
- const response = await s.fetchWithToken(notification.url);
95
- const result = $(await response.text());
96
- let path = '';
97
- if (courseType === CourseType.STUDENT) {
98
- path = result('.ml-10').attr('href')!;
99
- } else {
100
- path = result('#wjid').attr('href')!;
101
- }
102
- const size = trimAndDefine(result('div#attachment > div.fl > span[class^="color"]').first().text())!;
85
+ const result = $(await s.fetchTextWithToken(notification.url));
86
+ let path = (courseType === CourseType.STUDENT ? result('.ml-10') : result('#wjid')).attr('href');
87
+ // the page has no attachment link (removed since the list was fetched, or the page changed)
88
+ if (!path) return undefined;
89
+ const size = trimAndDefine(result('div#attachment > div.fl > span[class^="color"]').first().text()) ?? '';
103
90
  const params = new URLSearchParams(path.split('?').slice(-1)[0]);
104
91
  const attachmentId = params.get('wjid')!;
105
92
  if (!path.startsWith(URLS.LEARN_PREFIX)) {
@@ -1,63 +1,80 @@
1
1
  // Questionnaires (课程问卷).
2
2
 
3
3
  import type { Session } from './request.ts';
4
- import { YES } from './request.ts';
5
- import type { Questionnaire, QuestionnaireDetail } from './types.ts';
4
+ import type { Questionnaire, QuestionnaireDetail, QuestionnaireListOptions } from './types.ts';
6
5
  import { FailReason, QuestionnaireType } from './types.ts';
7
6
  import * as URLS from './urls.ts';
8
- import { QNR_TYPE_MAP, decodeHTML } from './utils.ts';
7
+ import {
8
+ QNR_TYPE_MAP,
9
+ YES,
10
+ apiError,
11
+ decodeHTML,
12
+ expectSuccess,
13
+ isSelected,
14
+ toRequiredServerTime,
15
+ toServerTime,
16
+ } from './utils.ts';
9
17
 
10
18
  /**
11
- * Get all questionnaires (课程问卷/QNR) of the specified course.
19
+ * Get all questionnaires (课程问卷/QNR) of the specified course: ongoing and ended lists.
20
+ * Questions (`detail`, 1 request each) only for the questionnaires `options.details` selects (default `'all'`).
12
21
  */
13
- export async function getQuestionnaireList(s: Session, courseID: string): Promise<Questionnaire[]> {
14
- return Promise.all([
22
+ export async function getQuestionnaireList(
23
+ s: Session,
24
+ courseID: string,
25
+ options: QuestionnaireListOptions = {},
26
+ ): Promise<Questionnaire[]> {
27
+ const details = options.details ?? 'all';
28
+ const lists = await Promise.all([
15
29
  getQuestionnaireListAtUrl(s, courseID, URLS.LEARN_QNR_LIST_ONGOING),
16
30
  getQuestionnaireListAtUrl(s, courseID, URLS.LEARN_QNR_LIST_ENDED),
17
- ]).then((r) => r.flat());
31
+ ]);
32
+ return Promise.all(
33
+ lists
34
+ .flat()
35
+ .map(async (q) =>
36
+ isSelected(details, q) ? { ...q, detail: await getQuestionnaireDetail(s, courseID, q.id) } : q,
37
+ ),
38
+ );
18
39
  }
19
40
 
20
41
  async function getQuestionnaireListAtUrl(s: Session, courseID: string, url: string): Promise<Questionnaire[]> {
21
- const json = await (
22
- await s.fetchWithToken(url, { method: 'POST', body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID) })
23
- ).json();
24
- if (json.result !== 'success') {
25
- return Promise.reject({
26
- reason: FailReason.INVALID_RESPONSE,
27
- extra: json,
28
- });
29
- }
42
+ const json = await s.fetchJsonWithToken(url, { method: 'POST', body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID) });
43
+ expectSuccess(json, FailReason.INVALID_RESPONSE);
30
44
  const result = (json.object?.aaData ?? []) as any[];
31
- return Promise.all(
32
- result.map(async (e) => {
33
- const type = QNR_TYPE_MAP.get(e.wjlx) ?? QuestionnaireType.SURVEY;
34
- return {
35
- id: e.wjid,
36
- type,
37
- title: decodeHTML(e.wjbt),
38
- startTime: e.kssj,
39
- endTime: e.jssj,
40
- uploadTime: e.scsj,
41
- uploaderId: e.scr,
42
- uploaderName: e.scrxm,
43
- submitTime: e.tjsj ? e.tjsj : undefined,
44
- isFavorite: e.sfsc === YES,
45
- comment: e.bznr ?? undefined,
46
- url: URLS.LEARN_QNR_SUBMIT_PAGE(e.wlkcid, e.wjid, type),
47
- detail: await getQuestionnaireDetail(s, courseID, e.wjid),
48
- } satisfies Questionnaire;
49
- }),
50
- );
45
+ return result.map((e) => {
46
+ const type = QNR_TYPE_MAP.get(e.wjlx) ?? QuestionnaireType.SURVEY;
47
+ return {
48
+ id: e.wjid,
49
+ type,
50
+ title: decodeHTML(e.wjbt),
51
+ startTime: toRequiredServerTime(e.kssj),
52
+ endTime: toRequiredServerTime(e.jssj),
53
+ uploadTime: toRequiredServerTime(e.scsj),
54
+ uploaderId: e.scr,
55
+ uploaderName: e.scrxm,
56
+ submitTime: toServerTime(e.tjsj),
57
+ isFavorite: e.sfsc === YES,
58
+ comment: e.bznr ?? undefined,
59
+ url: URLS.LEARN_QNR_SUBMIT_PAGE(e.wlkcid, e.wjid, type),
60
+ } satisfies Questionnaire;
61
+ });
51
62
  }
52
63
 
53
- async function getQuestionnaireDetail(s: Session, courseID: string, qnrID: string): Promise<QuestionnaireDetail[]> {
54
- const json = await (
55
- await s.fetchWithToken(URLS.LEARN_QNR_DETAIL, {
56
- method: 'POST',
57
- body: URLS.LEARN_QNR_DETAIL_FORM(courseID, qnrID),
58
- })
59
- ).json();
60
- return (json as any[]).map(
64
+ /** Get the questions of one questionnaire (1 request). */
65
+ export async function getQuestionnaireDetail(
66
+ s: Session,
67
+ courseID: string,
68
+ qnrID: string,
69
+ ): Promise<QuestionnaireDetail[]> {
70
+ const json = await s.fetchJsonWithToken(URLS.LEARN_QNR_DETAIL, {
71
+ method: 'POST',
72
+ body: URLS.LEARN_QNR_DETAIL_FORM(courseID, qnrID),
73
+ });
74
+ if (!Array.isArray(json)) {
75
+ throw apiError(FailReason.INVALID_RESPONSE, json);
76
+ }
77
+ return json.map(
61
78
  (e) =>
62
79
  ({
63
80
  id: e.wtid,
@@ -65,7 +82,7 @@ async function getQuestionnaireDetail(s: Session, courseID: string, qnrID: strin
65
82
  type: e.type,
66
83
  required: e.require === YES,
67
84
  title: decodeHTML(e.wtbt),
68
- score: e.wtfz ? Number(e.wtfz) : undefined, // unsure about original type
85
+ score: e.wtfz ? Number(e.wtfz) : undefined, // a number or a string of digits; none when missing or 0
69
86
  options: (e.list as any[])?.map((o) => ({
70
87
  id: o.xxid,
71
88
  index: Number(o.xxbh),