@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/homework.ts CHANGED
@@ -1,37 +1,54 @@
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.
27
46
  * Details (page + description) only for the homework `options.details` selects.
28
47
  */
29
- export function getHomeworkList(s: Session, courseID: string): Promise<Homework[]>;
30
-
31
48
  export function getHomeworkList(
32
49
  s: Session,
33
50
  courseID: string,
34
- courseType: CourseType.STUDENT,
51
+ courseType?: CourseType.STUDENT,
35
52
  options?: HomeworkListOptions,
36
53
  ): Promise<Homework[]>;
37
54
 
@@ -43,77 +60,70 @@ export async function getHomeworkList(
43
60
  courseType: CourseType = CourseType.STUDENT,
44
61
  options: HomeworkListOptions = {},
45
62
  ): Promise<Homework[] | HomeworkTA[]> {
46
- if (courseType === CourseType.TEACHER) {
47
- const json = await (
48
- await s.fetchWithToken(URLS.LEARN_HOMEWORK_LIST_TEACHER, {
49
- method: 'POST',
50
- body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
51
- })
52
- ).json();
53
- if (json.result !== 'success') {
54
- return Promise.reject({
55
- reason: FailReason.INVALID_RESPONSE,
56
- extra: json,
57
- });
58
- }
63
+ return courseType === CourseType.TEACHER
64
+ ? getTeacherHomeworkList(s, courseID)
65
+ : getStudentHomeworkList(s, courseID, options);
66
+ }
59
67
 
60
- 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);
61
74
 
62
- return result.map(
63
- (d) =>
64
- ({
65
- id: d.zyid,
66
- index: d.wz,
67
- title: decodeHTML(d.bt),
68
- description: decodeHTML(Base64.decode(d.nr)),
69
- publisherId: d.fbr,
70
- publishTime: d.fbsj,
71
- startTime: d.kssj,
72
- deadline: d.jzsj,
73
- lateSubmissionDeadline: d.bjjzsj ?? undefined,
74
- url: URLS.LEARN_HOMEWORK_DETAIL_TEACHER(courseID, d.zyid),
75
- completionType: d.zywcfs,
76
- submissionType: d.zytjfs,
77
- gradedCount: d.ypys,
78
- submittedCount: d.yjs,
79
- unsubmittedCount: d.wjs,
80
- }) satisfies HomeworkTA,
81
- );
82
- } else {
83
- const [lists, excellentHomeworkListByHomework] = await Promise.all([
84
- Promise.all(
85
- URLS.LEARN_HOMEWORK_LIST_SOURCE.map((source) => getHomeworkListAtUrl(s, courseID, source.url, source.status)),
86
- ),
87
- // Don't block the whole process if excellent homework list cannot be fetched
88
- getExcellentHomeworkListByHomework(s, courseID).catch((): { [id: string]: ExcellentHomework[] } => ({})),
89
- ]);
90
- return Promise.all(
91
- lists.flat().map(async (h) => {
92
- const homework: Homework = { ...h, excellentHomeworkList: excellentHomeworkListByHomework[h.baseId] };
93
- return isSelected(options.details, homework) ? { ...homework, ...(await getHomeworkDetail(s, h)) } : homework;
94
- }),
95
- );
96
- }
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
+ );
97
114
  }
98
115
 
99
116
  async function getHomeworkListAtUrl(
100
117
  s: Session,
101
118
  courseID: string,
102
119
  url: string,
103
- status: IHomeworkStatus,
104
- ): Promise<IHomework[]> {
105
- const json = await (
106
- await s.fetchWithToken(url, {
107
- method: 'POST',
108
- body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
109
- })
110
- ).json();
111
- if (json.result !== 'success') {
112
- return Promise.reject({
113
- reason: FailReason.INVALID_RESPONSE,
114
- extra: json,
115
- });
116
- }
120
+ status: HomeworkStatus,
121
+ ): Promise<HomeworkBase[]> {
122
+ const json = await s.fetchJsonWithToken(url, {
123
+ method: 'POST',
124
+ body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
125
+ });
126
+ expectSuccess(json, FailReason.INVALID_RESPONSE);
117
127
 
118
128
  const result = (json.object?.aaData ?? []) as any[];
119
129
 
@@ -125,23 +135,22 @@ async function getHomeworkListAtUrl(
125
135
  baseId: h.zyid,
126
136
  title: decodeHTML(h.bt),
127
137
  url: URLS.LEARN_HOMEWORK_PAGE(h.wlkcid, h.xszyid),
128
- deadline: h.jzsj,
129
- lateSubmissionDeadline: h.bjjzsj ? h.bjjzsj : undefined,
138
+ deadline: toRequiredServerTime(h.jzsj),
139
+ lateSubmissionDeadline: toServerTime(h.bjjzsj),
130
140
  isLateSubmission: h.sfbj === YES,
131
141
  completionType: h.zywcfs,
132
142
  submissionType: h.zytjfs,
133
143
  submitUrl: URLS.LEARN_HOMEWORK_SUBMIT_PAGE(h.wlkcid, h.xszyid),
134
- submitTime: h.scsj === null ? undefined : h.scsj,
144
+ submitTime: toServerTime(h.scsj),
135
145
  grade: h.cj === null ? undefined : h.cj,
136
146
  gradeLevel: GRADE_LEVEL_MAP.get(h.cj),
137
147
  graderName: trimAndDefine(h.jsm),
138
148
  gradeContent: trimAndDefine(h.pynr),
139
- gradeTime: h.pysj === null ? undefined : h.pysj,
149
+ gradeTime: toServerTime(h.pysj),
140
150
  isFavorite: h.sfsc === YES,
141
- favoriteTime: h.scsj === null || h.sfsc !== YES ? undefined : h.scsj,
142
151
  comment: h.bznr ?? undefined,
143
152
  ...status,
144
- }) satisfies IHomework,
153
+ }) satisfies HomeworkBase,
145
154
  );
146
155
  }
147
156
 
@@ -149,18 +158,11 @@ async function getExcellentHomeworkListByHomework(
149
158
  s: Session,
150
159
  courseID: string,
151
160
  ): Promise<{ [id: string]: ExcellentHomework[] }> {
152
- const json = await (
153
- await s.fetchWithToken(URLS.LEARN_HOMEWORK_LIST_EXCELLENT, {
154
- method: 'POST',
155
- body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
156
- })
157
- ).json();
158
- if (json.result !== 'success') {
159
- return Promise.reject({
160
- reason: FailReason.INVALID_RESPONSE,
161
- extra: json,
162
- });
163
- }
161
+ const json = await s.fetchJsonWithToken(URLS.LEARN_HOMEWORK_LIST_EXCELLENT, {
162
+ method: 'POST',
163
+ body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
164
+ });
165
+ expectSuccess(json, FailReason.INVALID_RESPONSE);
164
166
 
165
167
  const result = (json.object?.aaData ?? []) as any[];
166
168
 
@@ -178,7 +180,7 @@ async function getExcellentHomeworkListByHomework(
178
180
  name: h.cy?.split(' ')?.[1],
179
181
  anonymous: h.sfzm === YES,
180
182
  },
181
- }) satisfies IExcellentHomework,
183
+ }) satisfies ExcellentHomeworkBase,
182
184
  )
183
185
  .reduce<{ [id: string]: ExcellentHomework[] }>((acc, cur) => {
184
186
  if (!acc[cur.baseId]) {
@@ -189,9 +191,8 @@ async function getExcellentHomeworkListByHomework(
189
191
  }, {});
190
192
  }
191
193
 
192
- async function parseHomeworkAtUrl(s: Session, url: string): Promise<IHomeworkDetail> {
193
- const response = await s.fetchWithToken(url);
194
- const result = $(await response.text());
194
+ async function parseHomeworkAtUrl(s: Session, url: string): Promise<HomeworkDetail> {
195
+ const result = $(await s.fetchTextWithToken(url));
195
196
 
196
197
  const fileDivs = result('div.list.fujian.clearfix');
197
198
 
@@ -212,47 +213,41 @@ async function parseHomeworkAtUrl(s: Session, url: string): Promise<IHomeworkDet
212
213
  */
213
214
  export async function getHomeworkDetail(
214
215
  s: Session,
215
- homework: Pick<IHomework | IExcellentHomework, 'url' | 'baseId'>,
216
- ): Promise<IHomeworkDetail> {
216
+ homework: Pick<HomeworkBase | ExcellentHomeworkBase, 'url' | 'baseId'>,
217
+ ): Promise<HomeworkDetail> {
217
218
  const [page, description] = await Promise.all([
218
219
  parseHomeworkAtUrl(s, homework.url),
219
220
  getHomeworkDescription(s, homework.baseId),
220
221
  ]);
221
- 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 };
222
224
  }
223
225
 
224
- async function getHomeworkDescription(s: Session, baseId: string): Promise<IHomeworkDetail> {
225
- const json = await (
226
- await s.fetchWithToken(URLS.LEARN_HOMEWORK_DETAIL, {
227
- method: 'POST',
228
- body: URLS.LEARN_HOMEWORK_DETAIL_FORM_DATA(baseId),
229
- })
230
- ).json();
231
- if (json.result !== 'success') {
232
- throw {
233
- reason: FailReason.INVALID_RESPONSE,
234
- extra: json,
235
- } as ApiError;
236
- }
226
+ async function getHomeworkDescription(s: Session, baseId: string): Promise<HomeworkDetail> {
227
+ const json = await s.fetchJsonWithToken(URLS.LEARN_HOMEWORK_DETAIL, {
228
+ method: 'POST',
229
+ body: URLS.LEARN_HOMEWORK_DETAIL_FORM_DATA(baseId),
230
+ });
231
+ expectSuccess(json, FailReason.INVALID_RESPONSE);
237
232
  return {
238
233
  description: trimAndDefine(json.msg),
239
234
  };
240
235
  }
241
236
 
242
- 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;
243
240
  const fileNode = $(fileDiv)('.ftitle').children('a')[0] ?? $(fileDiv)('.fl').children('a')[0];
244
- if (fileNode !== undefined) {
245
- const size = trimAndDefine($(fileDiv)('.fl > span[class^="color"]').first().text())!;
246
- const params = new URLSearchParams(fileNode.attribs.href.split('?').slice(-1)[0]);
241
+ const href = fileNode?.attribs.href;
242
+ if (fileNode !== undefined && href) {
243
+ const size = trimAndDefine($(fileDiv)('.fl > span[class^="color"]').first().text()) ?? '';
244
+ const params = new URLSearchParams(href.split('?').slice(-1)[0]);
247
245
  const attachmentId = params.get('fileId')!;
248
- // so dirty here...
249
- let downloadUrl = URLS.LEARN_PREFIX + fileNode.attribs.href;
250
- if (params.has('downloadUrl')) {
251
- downloadUrl = URLS.LEARN_PREFIX + params.get('downloadUrl')!;
252
- }
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);
253
248
  return {
254
249
  id: attachmentId,
255
- name: (fileNode.children[0] as { data: string }).data,
250
+ name: (fileNode.children[0] as { data?: string } | undefined)?.data ?? '',
256
251
  downloadUrl,
257
252
  previewUrl: URLS.LEARN_FILE_PREVIEW(ContentType.HOMEWORK, attachmentId, CourseType.STUDENT, s.previewFirstPage),
258
253
  size,
@@ -266,19 +261,96 @@ export async function submitHomework(
266
261
  s: Session,
267
262
  id: string,
268
263
  content = '',
269
- attachment?: IHomeworkSubmitAttachment,
264
+ attachment?: HomeworkSubmitAttachment,
270
265
  removeAttachment = false,
271
266
  ): Promise<void> {
272
- const json = await (
273
- await s.fetchWithToken(URLS.LEARN_HOMEWORK_SUBMIT(), {
274
- method: 'POST',
275
- body: URLS.LEARN_HOMEWORK_SUBMIT_FORM_DATA(id, content, attachment, removeAttachment),
276
- })
277
- ).json();
278
- if (json.result !== 'success') {
279
- return Promise.reject({
280
- reason: FailReason.OPERATION_FAILED,
281
- extra: json,
282
- });
267
+ const json = await s.fetchJsonWithToken(URLS.LEARN_HOMEWORK_SUBMIT, {
268
+ method: 'POST',
269
+ body: homeworkSubmitForm(id, content, attachment, removeAttachment),
270
+ // the response only starts after the whole upload, which can take longer than `requestTimeout`
271
+ timeout: attachment ? 0 : undefined,
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
+ }
283
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('');
284
356
  }