@robertying/thu-learn-lib 5.1.0 → 6.0.1

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 (83) hide show
  1. package/README.md +81 -248
  2. package/lib/auth.d.ts +14 -3
  3. package/lib/auth.d.ts.map +1 -1
  4. package/lib/auth.js +56 -58
  5. package/lib/auth.js.map +1 -1
  6. package/lib/calendar.d.ts +7 -2
  7. package/lib/calendar.d.ts.map +1 -1
  8. package/lib/calendar.js +99 -14
  9. package/lib/calendar.js.map +1 -1
  10. package/lib/contents.d.ts.map +1 -1
  11. package/lib/contents.js +4 -9
  12. package/lib/contents.js.map +1 -1
  13. package/lib/courses.d.ts.map +1 -1
  14. package/lib/courses.js +11 -25
  15. package/lib/courses.js.map +1 -1
  16. package/lib/discussions.d.ts.map +1 -1
  17. package/lib/discussions.js +6 -17
  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 +13 -48
  22. package/lib/favorites.js.map +1 -1
  23. package/lib/files.d.ts +3 -3
  24. package/lib/files.d.ts.map +1 -1
  25. package/lib/files.js +109 -133
  26. package/lib/files.js.map +1 -1
  27. package/lib/homework.d.ts +34 -3
  28. package/lib/homework.d.ts.map +1 -1
  29. package/lib/homework.js +135 -79
  30. package/lib/homework.js.map +1 -1
  31. package/lib/index.d.ts +39 -19
  32. package/lib/index.d.ts.map +1 -1
  33. package/lib/index.js +44 -17
  34. package/lib/index.js.map +1 -1
  35. package/lib/notices.d.ts +3 -3
  36. package/lib/notices.d.ts.map +1 -1
  37. package/lib/notices.js +4 -20
  38. package/lib/notices.js.map +1 -1
  39. package/lib/questionnaires.d.ts.map +1 -1
  40. package/lib/questionnaires.js +8 -14
  41. package/lib/questionnaires.js.map +1 -1
  42. package/lib/request.d.ts +27 -13
  43. package/lib/request.d.ts.map +1 -1
  44. package/lib/request.js +84 -51
  45. package/lib/request.js.map +1 -1
  46. package/lib/timetable.d.ts +26 -0
  47. package/lib/timetable.d.ts.map +1 -0
  48. package/lib/timetable.js +110 -0
  49. package/lib/timetable.js.map +1 -0
  50. package/lib/types.d.ts +108 -75
  51. package/lib/types.d.ts.map +1 -1
  52. package/lib/types.js +3 -0
  53. package/lib/types.js.map +1 -1
  54. package/lib/urls.d.ts +10 -20
  55. package/lib/urls.d.ts.map +1 -1
  56. package/lib/urls.js +11 -56
  57. package/lib/urls.js.map +1 -1
  58. package/lib/user.d.ts +7 -2
  59. package/lib/user.d.ts.map +1 -1
  60. package/lib/user.js +19 -5
  61. package/lib/user.js.map +1 -1
  62. package/lib/utils.d.ts +42 -4
  63. package/lib/utils.d.ts.map +1 -1
  64. package/lib/utils.js +78 -31
  65. package/lib/utils.js.map +1 -1
  66. package/package.json +8 -7
  67. package/src/auth.ts +62 -73
  68. package/src/calendar.ts +110 -16
  69. package/src/contents.ts +7 -9
  70. package/src/courses.ts +12 -26
  71. package/src/discussions.ts +8 -19
  72. package/src/favorites.ts +22 -48
  73. package/src/files.ts +121 -139
  74. package/src/homework.ts +184 -102
  75. package/src/index.ts +67 -48
  76. package/src/notices.ts +19 -26
  77. package/src/questionnaires.ts +18 -15
  78. package/src/request.ts +86 -62
  79. package/src/timetable.ts +121 -0
  80. package/src/types.ts +111 -86
  81. package/src/urls.ts +13 -64
  82. package/src/user.ts +21 -6
  83. package/src/utils.ts +96 -32
package/src/homework.ts CHANGED
@@ -1,26 +1,45 @@
1
1
  // Homework (课程作业), excellent homework and homework submission.
2
2
 
3
- import type { Element } from 'cheerio';
4
3
  import { Base64 } from 'js-base64';
5
4
 
6
5
  import type { Session } from './request.ts';
7
- import { $, YES } from './request.ts';
6
+ import { addCSRFTokenToUrl } from './request.ts';
8
7
  import type {
9
- ApiError,
10
8
  ExcellentHomework,
9
+ ExcellentHomeworkBase,
11
10
  Homework,
11
+ HomeworkBase,
12
+ HomeworkDetail,
12
13
  HomeworkListOptions,
14
+ HomeworkStatus,
15
+ HomeworkSubmitAttachment,
16
+ HomeworkSubmitRequest,
13
17
  HomeworkTA,
14
- IExcellentHomework,
15
- IHomework,
16
- IHomeworkDetail,
17
- IHomeworkStatus,
18
- IHomeworkSubmitAttachment,
19
18
  RemoteFile,
20
19
  } from './types.ts';
21
20
  import { ContentType, CourseType, FailReason } from './types.ts';
22
21
  import * as URLS from './urls.ts';
23
- import { GRADE_LEVEL_MAP, decodeHTML, isSelected, trimAndDefine } from './utils.ts';
22
+ import {
23
+ $,
24
+ GRADE_LEVEL_MAP,
25
+ YES,
26
+ apiError,
27
+ decodeHTML,
28
+ expectSuccess,
29
+ isSelected,
30
+ looksLikeLoginPage,
31
+ toRequiredServerTime,
32
+ toServerTime,
33
+ trimAndDefine,
34
+ } from './utils.ts';
35
+ import type { Element } from './utils.ts';
36
+
37
+ /** the student homework lists, by submission status */
38
+ export const HOMEWORK_LIST_SOURCES: readonly { url: string; status: HomeworkStatus }[] = [
39
+ { url: URLS.LEARN_HOMEWORK_LIST_NEW, status: { submitted: false, graded: false } },
40
+ { url: URLS.LEARN_HOMEWORK_LIST_SUBMITTED, status: { submitted: true, graded: false } },
41
+ { url: URLS.LEARN_HOMEWORK_LIST_GRADED, status: { submitted: true, graded: true } },
42
+ ];
24
43
 
25
44
  /**
26
45
  * Get all homeworks (课程作业) of the specified course: 3 status lists and the excellent homework list.
@@ -41,73 +60,70 @@ export async function getHomeworkList(
41
60
  courseType: CourseType = CourseType.STUDENT,
42
61
  options: HomeworkListOptions = {},
43
62
  ): Promise<Homework[] | HomeworkTA[]> {
44
- if (courseType === CourseType.TEACHER) {
45
- const json = await s.fetchJsonWithToken(URLS.LEARN_HOMEWORK_LIST_TEACHER, {
46
- method: 'POST',
47
- body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
48
- });
49
- if (json.result !== 'success') {
50
- return Promise.reject({
51
- reason: FailReason.INVALID_RESPONSE,
52
- extra: json,
53
- });
54
- }
63
+ return courseType === CourseType.TEACHER
64
+ ? getTeacherHomeworkList(s, courseID)
65
+ : getStudentHomeworkList(s, courseID, options);
66
+ }
55
67
 
56
- const result = (json.object?.aaData ?? []) as any[];
68
+ async function getTeacherHomeworkList(s: Session, courseID: string): Promise<HomeworkTA[]> {
69
+ const json = await s.fetchJsonWithToken(URLS.LEARN_HOMEWORK_LIST_TEACHER, {
70
+ method: 'POST',
71
+ body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
72
+ });
73
+ expectSuccess(json, FailReason.INVALID_RESPONSE);
57
74
 
58
- return result.map(
59
- (d) =>
60
- ({
61
- id: d.zyid,
62
- index: d.wz,
63
- title: decodeHTML(d.bt),
64
- description: decodeHTML(Base64.decode(d.nr)),
65
- publisherId: d.fbr,
66
- publishTime: d.fbsj,
67
- startTime: d.kssj,
68
- deadline: d.jzsj,
69
- lateSubmissionDeadline: d.bjjzsj ?? undefined,
70
- url: URLS.LEARN_HOMEWORK_DETAIL_TEACHER(courseID, d.zyid),
71
- completionType: d.zywcfs,
72
- submissionType: d.zytjfs,
73
- gradedCount: d.ypys,
74
- submittedCount: d.yjs,
75
- unsubmittedCount: d.wjs,
76
- }) satisfies HomeworkTA,
77
- );
78
- } else {
79
- const [lists, excellentHomeworkListByHomework] = await Promise.all([
80
- Promise.all(
81
- URLS.LEARN_HOMEWORK_LIST_SOURCE.map((source) => getHomeworkListAtUrl(s, courseID, source.url, source.status)),
82
- ),
83
- // Don't block the whole process if excellent homework list cannot be fetched
84
- getExcellentHomeworkListByHomework(s, courseID).catch((): { [id: string]: ExcellentHomework[] } => ({})),
85
- ]);
86
- return Promise.all(
87
- lists.flat().map(async (h) => {
88
- const homework: Homework = { ...h, excellentHomeworkList: excellentHomeworkListByHomework[h.baseId] };
89
- return isSelected(options.details, homework) ? { ...homework, ...(await getHomeworkDetail(s, h)) } : homework;
90
- }),
91
- );
92
- }
75
+ const result = (json.object?.aaData ?? []) as any[];
76
+
77
+ return result.map(
78
+ (d) =>
79
+ ({
80
+ id: d.zyid,
81
+ index: d.wz,
82
+ title: decodeHTML(d.bt),
83
+ description: decodeHTML(Base64.decode(d.nr ?? '')),
84
+ publisherId: d.fbr,
85
+ publishTime: toRequiredServerTime(d.fbsj),
86
+ startTime: toRequiredServerTime(d.kssj),
87
+ deadline: toRequiredServerTime(d.jzsj),
88
+ lateSubmissionDeadline: toServerTime(d.bjjzsj),
89
+ url: URLS.LEARN_HOMEWORK_DETAIL_TEACHER(courseID, d.zyid),
90
+ completionType: d.zywcfs,
91
+ submissionType: d.zytjfs,
92
+ gradedCount: d.ypys,
93
+ submittedCount: d.yjs,
94
+ unsubmittedCount: d.wjs,
95
+ }) satisfies HomeworkTA,
96
+ );
97
+ }
98
+
99
+ async function getStudentHomeworkList(s: Session, courseID: string, options: HomeworkListOptions): Promise<Homework[]> {
100
+ const [lists, excellentHomeworkListByHomework] = await Promise.all([
101
+ Promise.all(HOMEWORK_LIST_SOURCES.map((source) => getHomeworkListAtUrl(s, courseID, source.url, source.status))),
102
+ // a failed excellent homework list doesn't fail the homework list: its items get `excellentHomeworkList: undefined`
103
+ getExcellentHomeworkListByHomework(s, courseID).catch(() => undefined),
104
+ ]);
105
+ return Promise.all(
106
+ lists.flat().map(async (h) => {
107
+ const homework: Homework = {
108
+ ...h,
109
+ excellentHomeworkList: excellentHomeworkListByHomework && (excellentHomeworkListByHomework[h.baseId] ?? []),
110
+ };
111
+ return isSelected(options.details, homework) ? { ...homework, ...(await getHomeworkDetail(s, h)) } : homework;
112
+ }),
113
+ );
93
114
  }
94
115
 
95
116
  async function getHomeworkListAtUrl(
96
117
  s: Session,
97
118
  courseID: string,
98
119
  url: string,
99
- status: IHomeworkStatus,
100
- ): Promise<IHomework[]> {
120
+ status: HomeworkStatus,
121
+ ): Promise<HomeworkBase[]> {
101
122
  const json = await s.fetchJsonWithToken(url, {
102
123
  method: 'POST',
103
124
  body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
104
125
  });
105
- if (json.result !== 'success') {
106
- return Promise.reject({
107
- reason: FailReason.INVALID_RESPONSE,
108
- extra: json,
109
- });
110
- }
126
+ expectSuccess(json, FailReason.INVALID_RESPONSE);
111
127
 
112
128
  const result = (json.object?.aaData ?? []) as any[];
113
129
 
@@ -119,23 +135,22 @@ async function getHomeworkListAtUrl(
119
135
  baseId: h.zyid,
120
136
  title: decodeHTML(h.bt),
121
137
  url: URLS.LEARN_HOMEWORK_PAGE(h.wlkcid, h.xszyid),
122
- deadline: h.jzsj,
123
- lateSubmissionDeadline: h.bjjzsj ? h.bjjzsj : undefined,
138
+ deadline: toRequiredServerTime(h.jzsj),
139
+ lateSubmissionDeadline: toServerTime(h.bjjzsj),
124
140
  isLateSubmission: h.sfbj === YES,
125
141
  completionType: h.zywcfs,
126
142
  submissionType: h.zytjfs,
127
143
  submitUrl: URLS.LEARN_HOMEWORK_SUBMIT_PAGE(h.wlkcid, h.xszyid),
128
- submitTime: h.scsj === null ? undefined : h.scsj,
144
+ submitTime: toServerTime(h.scsj),
129
145
  grade: h.cj === null ? undefined : h.cj,
130
146
  gradeLevel: GRADE_LEVEL_MAP.get(h.cj),
131
147
  graderName: trimAndDefine(h.jsm),
132
148
  gradeContent: trimAndDefine(h.pynr),
133
- gradeTime: h.pysj === null ? undefined : h.pysj,
149
+ gradeTime: toServerTime(h.pysj),
134
150
  isFavorite: h.sfsc === YES,
135
- favoriteTime: h.scsj === null || h.sfsc !== YES ? undefined : h.scsj,
136
151
  comment: h.bznr ?? undefined,
137
152
  ...status,
138
- }) satisfies IHomework,
153
+ }) satisfies HomeworkBase,
139
154
  );
140
155
  }
141
156
 
@@ -147,12 +162,7 @@ async function getExcellentHomeworkListByHomework(
147
162
  method: 'POST',
148
163
  body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
149
164
  });
150
- if (json.result !== 'success') {
151
- return Promise.reject({
152
- reason: FailReason.INVALID_RESPONSE,
153
- extra: json,
154
- });
155
- }
165
+ expectSuccess(json, FailReason.INVALID_RESPONSE);
156
166
 
157
167
  const result = (json.object?.aaData ?? []) as any[];
158
168
 
@@ -170,7 +180,7 @@ async function getExcellentHomeworkListByHomework(
170
180
  name: h.cy?.split(' ')?.[1],
171
181
  anonymous: h.sfzm === YES,
172
182
  },
173
- }) satisfies IExcellentHomework,
183
+ }) satisfies ExcellentHomeworkBase,
174
184
  )
175
185
  .reduce<{ [id: string]: ExcellentHomework[] }>((acc, cur) => {
176
186
  if (!acc[cur.baseId]) {
@@ -181,7 +191,7 @@ async function getExcellentHomeworkListByHomework(
181
191
  }, {});
182
192
  }
183
193
 
184
- async function parseHomeworkAtUrl(s: Session, url: string): Promise<IHomeworkDetail> {
194
+ async function parseHomeworkAtUrl(s: Session, url: string): Promise<HomeworkDetail> {
185
195
  const result = $(await s.fetchTextWithToken(url));
186
196
 
187
197
  const fileDivs = result('div.list.fujian.clearfix');
@@ -203,43 +213,38 @@ async function parseHomeworkAtUrl(s: Session, url: string): Promise<IHomeworkDet
203
213
  */
204
214
  export async function getHomeworkDetail(
205
215
  s: Session,
206
- homework: Pick<IHomework | IExcellentHomework, 'url' | 'baseId'>,
207
- ): Promise<IHomeworkDetail> {
216
+ homework: Pick<HomeworkBase | ExcellentHomeworkBase, 'url' | 'baseId'>,
217
+ ): Promise<HomeworkDetail> {
208
218
  const [page, description] = await Promise.all([
209
219
  parseHomeworkAtUrl(s, homework.url),
210
220
  getHomeworkDescription(s, homework.baseId),
211
221
  ]);
212
- return { ...page, ...description };
222
+ // the description JSON wins, but an empty one falls back to the page
223
+ return { ...page, description: description.description ?? page.description };
213
224
  }
214
225
 
215
- async function getHomeworkDescription(s: Session, baseId: string): Promise<IHomeworkDetail> {
226
+ async function getHomeworkDescription(s: Session, baseId: string): Promise<HomeworkDetail> {
216
227
  const json = await s.fetchJsonWithToken(URLS.LEARN_HOMEWORK_DETAIL, {
217
228
  method: 'POST',
218
229
  body: URLS.LEARN_HOMEWORK_DETAIL_FORM_DATA(baseId),
219
230
  });
220
- if (json.result !== 'success') {
221
- throw {
222
- reason: FailReason.INVALID_RESPONSE,
223
- extra: json,
224
- } as ApiError;
225
- }
231
+ expectSuccess(json, FailReason.INVALID_RESPONSE);
226
232
  return {
227
233
  description: trimAndDefine(json.msg),
228
234
  };
229
235
  }
230
236
 
231
- function parseHomeworkFile(s: Session, fileDiv: Element): RemoteFile | undefined {
237
+ /** the attachment in one `div.list.fujian` block; `undefined` if the block is missing or has no link */
238
+ function parseHomeworkFile(s: Session, fileDiv: Element | undefined): RemoteFile | undefined {
239
+ if (!fileDiv) return undefined;
232
240
  const fileNode = $(fileDiv)('.ftitle').children('a')[0] ?? $(fileDiv)('.fl').children('a')[0];
233
241
  const href = fileNode?.attribs.href;
234
242
  if (fileNode !== undefined && href) {
235
243
  const size = trimAndDefine($(fileDiv)('.fl > span[class^="color"]').first().text()) ?? '';
236
244
  const params = new URLSearchParams(href.split('?').slice(-1)[0]);
237
245
  const attachmentId = params.get('fileId')!;
238
- // so dirty here...
239
- let downloadUrl = URLS.LEARN_PREFIX + href;
240
- if (params.has('downloadUrl')) {
241
- downloadUrl = URLS.LEARN_PREFIX + params.get('downloadUrl')!;
242
- }
246
+ // a link to the file viewer carries the download path in its `downloadUrl` parameter
247
+ const downloadUrl = URLS.LEARN_PREFIX + (params.get('downloadUrl') ?? href);
243
248
  return {
244
249
  id: attachmentId,
245
250
  name: (fileNode.children[0] as { data?: string } | undefined)?.data ?? '',
@@ -256,19 +261,96 @@ export async function submitHomework(
256
261
  s: Session,
257
262
  id: string,
258
263
  content = '',
259
- attachment?: IHomeworkSubmitAttachment,
264
+ attachment?: HomeworkSubmitAttachment,
260
265
  removeAttachment = false,
261
266
  ): Promise<void> {
262
- const json = await s.fetchJsonWithToken(URLS.LEARN_HOMEWORK_SUBMIT(), {
267
+ const json = await s.fetchJsonWithToken(URLS.LEARN_HOMEWORK_SUBMIT, {
263
268
  method: 'POST',
264
- body: URLS.LEARN_HOMEWORK_SUBMIT_FORM_DATA(id, content, attachment, removeAttachment),
269
+ body: homeworkSubmitForm(id, content, attachment, removeAttachment),
265
270
  // the response only starts after the whole upload, which can take longer than `requestTimeout`
266
271
  timeout: attachment ? 0 : undefined,
267
272
  });
268
- if (json.result !== 'success') {
269
- return Promise.reject({
270
- reason: FailReason.OPERATION_FAILED,
271
- extra: json,
272
- });
273
+ checkHomeworkSubmitResponse(json);
274
+ }
275
+
276
+ /**
277
+ * The request `submitHomework` would send, for sending it with another HTTP client (e.g. one that reports upload
278
+ * progress). Checks the session first (`ensureSession`: one request, and a login only if it has expired), as the
279
+ * request is sent without the helper's re-login and retry. Read the answer with `checkHomeworkSubmitResponse`.
280
+ */
281
+ export async function getHomeworkSubmitRequest(
282
+ s: Session,
283
+ id: string,
284
+ content = '',
285
+ attachment?: HomeworkSubmitAttachment,
286
+ removeAttachment = false,
287
+ ): Promise<HomeworkSubmitRequest> {
288
+ await s.ensureSession(URLS.LEARN_CURRENT_SEMESTER);
289
+ return {
290
+ url: addCSRFTokenToUrl(URLS.LEARN_HOMEWORK_SUBMIT, s.csrfToken),
291
+ body: homeworkSubmitForm(id, content, attachment, removeAttachment),
292
+ };
293
+ }
294
+
295
+ /**
296
+ * Check the server's answer to a homework submission: its parsed JSON, or the body as text. Rejects with
297
+ * `NOT_LOGGED_IN` for the login page of an expired session (which the server sends with status 200),
298
+ * `INVALID_RESPONSE` for any other text that isn't JSON, and `OPERATION_FAILED` (with the answer in `extra`) unless
299
+ * `result` is `'success'`.
300
+ */
301
+ export function checkHomeworkSubmitResponse(body: unknown): void {
302
+ let json = body;
303
+ if (typeof body === 'string') {
304
+ try {
305
+ json = JSON.parse(body);
306
+ } catch {
307
+ throw apiError(
308
+ looksLikeLoginPage(body) ? FailReason.NOT_LOGGED_IN : FailReason.INVALID_RESPONSE,
309
+ body.slice(0, 200),
310
+ );
311
+ }
273
312
  }
313
+ expectSuccess(json, FailReason.OPERATION_FAILED);
314
+ }
315
+
316
+ /**
317
+ * The form the website's submit page sends (`fileUploadProgress` in `/res/js/common/myApp.js`): `fileupload` is the
318
+ * picked file, or the text "undefined" when none is picked (it appends `files[0]` as is); `zynr` is the whole current
319
+ * answer as HTML from its editor, sent every time (also when only the attachment changes); `isDeleted` is "1" when the
320
+ * existing attachment is deleted, and then the server ignores `zynr` (the submitted answer stays).
321
+ */
322
+ export const homeworkSubmitForm = (
323
+ id: string,
324
+ content = '',
325
+ attachment?: HomeworkSubmitAttachment,
326
+ removeAttachment = false,
327
+ ) => {
328
+ const form = new FormData();
329
+ form.append('xszyid', id);
330
+ form.append('zynr', content ?? '');
331
+ if (!attachment) form.append('fileupload', 'undefined');
332
+ else if (attachment.content instanceof Blob) form.append('fileupload', attachment.content, attachment.filename);
333
+ else {
334
+ // React Native's FormData reads a file from an object with its uri, name and type
335
+ const { uri, type } = attachment.content;
336
+ form.append('fileupload', { uri, name: attachment.filename, type: type || 'application/octet-stream' } as any);
337
+ }
338
+ if (removeAttachment) form.append('isDeleted', '1');
339
+ else form.append('isDeleted', '0');
340
+ return form;
341
+ };
342
+
343
+ const HTML_ESCAPES: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
344
+
345
+ /**
346
+ * Plain text as the website's answer editor sends it: one `<p>` per line, with `&nbsp;` for an empty line, and `''`
347
+ * for text that is only whitespace. The server replaces the submitted answer with what is sent, so send its HTML
348
+ * as it is when the answer wasn't edited, to keep its formatting.
349
+ */
350
+ export function homeworkContentHtml(text: string): string {
351
+ if (!text.trim()) return '';
352
+ return text
353
+ .split(/\r?\n/)
354
+ .map((line) => `<p>${line.trim() ? line.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]) : '&nbsp;'}</p>`)
355
+ .join('');
274
356
  }
package/src/index.ts CHANGED
@@ -11,40 +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,
37
39
  QuestionnaireDetail,
38
40
  QuestionnaireListOptions,
41
+ RemoteFile,
39
42
  SemesterInfo,
40
43
  UserInfo,
41
- ContentType,
42
44
  } from './types.ts';
43
- import { CourseType } from './types.ts';
45
+ import { CourseType, FailReason } from './types.ts';
46
+ import { apiError } from './utils.ts';
44
47
  import * as URLS from './urls.ts';
45
48
  import * as user from './user.ts';
46
49
 
47
- 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';
48
53
  export * from './types.ts';
49
54
 
50
55
  /** the main helper class */
@@ -53,7 +58,7 @@ export class Learn2018Helper {
53
58
 
54
59
  /** you can provide a CookieJar and / or CredentialProvider in the configuration */
55
60
  constructor(config?: HelperConfig) {
56
- this.#session = new Session(config, () => auth.login(this.#session));
61
+ this.#session = new Session(config, () => auth.loginWithProvider(this.#session));
57
62
  }
58
63
 
59
64
  /** whether preview URLs open the first page only (`HelperConfig.generatePreviewUrlForFirstPage`) */
@@ -76,35 +81,25 @@ export class Learn2018Helper {
76
81
  }
77
82
 
78
83
  /**
79
- * If using alternative cookie management systems,
80
- * 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.
81
87
  */
82
- public async getRoamingTicket(
83
- username: string,
84
- password: string,
85
- fingerPrint: string,
86
- fingerGenPrint: string = '',
87
- fingerGenPrint3: string = '',
88
- ): Promise<string> {
89
- 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);
90
91
  }
91
92
 
92
93
  /**
93
94
  * Login is necessary if you do not provide a `CredentialProvider`.
94
- * Without credentials it uses the provider, and concurrent calls share one SSO login. Logins never overlap: one with
95
- * credentials waits for a login in progress, and requests that find the session expired meanwhile wait for it.
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.
96
98
  */
97
- public async login(
98
- username?: string,
99
- password?: string,
100
- fingerPrint?: string,
101
- fingerGenPrint?: string,
102
- fingerGenPrint3?: string,
103
- ): Promise<void> {
104
- if (!username || !password || !fingerPrint) return this.#session.relogin();
105
- return this.#session.runLogin(() =>
106
- auth.login(this.#session, username, password, fingerPrint, fingerGenPrint, fingerGenPrint3),
107
- );
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));
108
103
  }
109
104
 
110
105
  /**
@@ -113,7 +108,7 @@ export class Learn2018Helper {
113
108
  * Use it when resuming an app instead of `login()`.
114
109
  */
115
110
  public async ensureSession(): Promise<void> {
116
- return this.#session.ensureSession(URLS.LEARN_CURRENT_SEMESTER());
111
+ return this.#session.ensureSession(URLS.LEARN_CURRENT_SEMESTER);
117
112
  }
118
113
 
119
114
  /** logout (to make everyone happy) */
@@ -128,9 +123,11 @@ export class Learn2018Helper {
128
123
 
129
124
  /**
130
125
  * Get calendar items during the specified period (in yyyymmdd format, both inclusive).
131
- * Ranges longer than 28 days are fetched in windows and merged; windows that fail even after a retry are listed in
132
- * `failedRanges`. Rejects only when every window fails (usually with `FailReason.INVALID_RESPONSE`), or with a
133
- * `RangeError` for a date that is not a valid yyyymmdd.
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.
134
131
  */
135
132
  public async getCalendar(startDate: string, endDate: string, options?: CalendarOptions): Promise<CalendarResult> {
136
133
  return calendar.getCalendar(this.#session, startDate, endDate, options);
@@ -146,7 +143,8 @@ export class Learn2018Helper {
146
143
 
147
144
  /**
148
145
  * Get all courses in the specified semester.
149
- * `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.
150
148
  */
151
149
  public async getCourseList(
152
150
  semesterID: string,
@@ -184,20 +182,20 @@ export class Learn2018Helper {
184
182
  courseID: string,
185
183
  courseType: CourseType = CourseType.STUDENT,
186
184
  options?: NotificationListOptions,
187
- ): Promise<Notification[]> {
185
+ ): Promise<CourseNotification[]> {
188
186
  return notices.getNotificationList(this.#session, courseID, courseType, options);
189
187
  }
190
188
 
191
189
  /** Get the attachment of a notice (one request), `undefined` if it has none. */
192
190
  public async getNotificationAttachment(
193
- notification: Pick<Notification, 'url' | 'attachmentName'>,
191
+ notification: Pick<CourseNotification, 'url' | 'attachmentName'>,
194
192
  courseType: CourseType = CourseType.STUDENT,
195
193
  ): Promise<RemoteFile | undefined> {
196
194
  return notices.getNotificationAttachment(this.#session, notification, courseType);
197
195
  }
198
196
 
199
197
  /** Get all files (课程文件) of the specified course. */
200
- public async getFileList(courseID: string, courseType: CourseType = CourseType.STUDENT): Promise<File[]> {
198
+ public async getFileList(courseID: string, courseType: CourseType = CourseType.STUDENT): Promise<CourseFile[]> {
201
199
  return files.getFileList(this.#session, courseID, courseType);
202
200
  }
203
201
 
@@ -217,7 +215,7 @@ export class Learn2018Helper {
217
215
  courseID: string,
218
216
  categoryId: string,
219
217
  courseType: CourseType = CourseType.STUDENT,
220
- ): Promise<File[]> {
218
+ ): Promise<CourseFile[]> {
221
219
  return files.getFileListByCategory(this.#session, courseID, categoryId, courseType);
222
220
  }
223
221
 
@@ -243,12 +241,12 @@ export class Learn2018Helper {
243
241
  }
244
242
 
245
243
  /** Get the detail (page content, attachments and description) of one homework. */
246
- public async getHomeworkDetail(item: Pick<Homework, 'url' | 'baseId'>): Promise<IHomeworkDetail> {
244
+ public async getHomeworkDetail(item: Pick<Homework, 'url' | 'baseId'>): Promise<HomeworkDetail> {
247
245
  return homework.getHomeworkDetail(this.#session, item);
248
246
  }
249
247
 
250
248
  /** Get the detail (page content, attachments and description) of one excellent homework. */
251
- public async getExcellentHomeworkDetail(item: Pick<ExcellentHomework, 'url' | 'baseId'>): Promise<IHomeworkDetail> {
249
+ public async getExcellentHomeworkDetail(item: Pick<ExcellentHomework, 'url' | 'baseId'>): Promise<HomeworkDetail> {
252
250
  return homework.getHomeworkDetail(this.#session, item);
253
251
  }
254
252
 
@@ -338,15 +336,36 @@ export class Learn2018Helper {
338
336
  return courses.sortCourses(this.#session, courseIDs);
339
337
  }
340
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
+ */
341
346
  public async submitHomework(
342
347
  id: string,
343
348
  content = '',
344
- attachment?: IHomeworkSubmitAttachment,
349
+ attachment?: HomeworkSubmitAttachment,
345
350
  removeAttachment = false,
346
351
  ): Promise<void> {
347
352
  return homework.submitHomework(this.#session, id, content, attachment, removeAttachment);
348
353
  }
349
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
+
350
369
  public async setLanguage(lang: Language): Promise<void> {
351
370
  return user.setLanguage(this.#session, lang);
352
371
  }