@mo7yw4ng/openape 1.0.3 → 1.0.5

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 (87) hide show
  1. package/README.md +30 -5
  2. package/esm/deno.js +1 -1
  3. package/esm/src/commands/announcements.d.ts.map +1 -1
  4. package/esm/src/commands/announcements.js +16 -17
  5. package/esm/src/commands/assignments.d.ts +3 -0
  6. package/esm/src/commands/assignments.d.ts.map +1 -0
  7. package/esm/src/commands/assignments.js +230 -0
  8. package/esm/src/commands/auth.d.ts.map +1 -1
  9. package/esm/src/commands/auth.js +45 -15
  10. package/esm/src/commands/calendar.d.ts.map +1 -1
  11. package/esm/src/commands/calendar.js +20 -21
  12. package/esm/src/commands/courses.js +6 -6
  13. package/esm/src/commands/forums.d.ts.map +1 -1
  14. package/esm/src/commands/forums.js +128 -36
  15. package/esm/src/commands/grades.js +3 -3
  16. package/esm/src/commands/materials.d.ts.map +1 -1
  17. package/esm/src/commands/materials.js +115 -224
  18. package/esm/src/commands/quizzes.d.ts.map +1 -1
  19. package/esm/src/commands/quizzes.js +179 -68
  20. package/esm/src/commands/skills.d.ts.map +1 -1
  21. package/esm/src/commands/skills.js +4 -8
  22. package/esm/src/commands/upload.d.ts +3 -0
  23. package/esm/src/commands/upload.d.ts.map +1 -0
  24. package/esm/src/commands/upload.js +58 -0
  25. package/esm/src/commands/videos.d.ts.map +1 -1
  26. package/esm/src/commands/videos.js +10 -9
  27. package/esm/src/index.d.ts.map +1 -1
  28. package/esm/src/index.js +12 -1
  29. package/esm/src/lib/auth.d.ts +23 -1
  30. package/esm/src/lib/auth.d.ts.map +1 -1
  31. package/esm/src/lib/auth.js +36 -3
  32. package/esm/src/lib/logger.d.ts +1 -1
  33. package/esm/src/lib/logger.d.ts.map +1 -1
  34. package/esm/src/lib/logger.js +7 -4
  35. package/esm/src/lib/moodle.d.ts +183 -1
  36. package/esm/src/lib/moodle.d.ts.map +1 -1
  37. package/esm/src/lib/moodle.js +498 -13
  38. package/esm/src/lib/types.d.ts +81 -164
  39. package/esm/src/lib/types.d.ts.map +1 -1
  40. package/esm/src/lib/types.js +1 -0
  41. package/esm/src/lib/utils.d.ts +20 -0
  42. package/esm/src/lib/utils.d.ts.map +1 -1
  43. package/esm/src/lib/utils.js +48 -1
  44. package/package.json +1 -1
  45. package/script/deno.js +1 -1
  46. package/script/src/commands/announcements.d.ts.map +1 -1
  47. package/script/src/commands/announcements.js +15 -16
  48. package/script/src/commands/assignments.d.ts +3 -0
  49. package/script/src/commands/assignments.d.ts.map +1 -0
  50. package/script/src/commands/assignments.js +269 -0
  51. package/script/src/commands/auth.d.ts.map +1 -1
  52. package/script/src/commands/auth.js +44 -14
  53. package/script/src/commands/calendar.d.ts.map +1 -1
  54. package/script/src/commands/calendar.js +19 -20
  55. package/script/src/commands/courses.js +5 -5
  56. package/script/src/commands/forums.d.ts.map +1 -1
  57. package/script/src/commands/forums.js +128 -36
  58. package/script/src/commands/grades.js +3 -3
  59. package/script/src/commands/materials.d.ts.map +1 -1
  60. package/script/src/commands/materials.js +115 -224
  61. package/script/src/commands/quizzes.d.ts.map +1 -1
  62. package/script/src/commands/quizzes.js +177 -66
  63. package/script/src/commands/skills.d.ts.map +1 -1
  64. package/script/src/commands/skills.js +4 -8
  65. package/script/src/commands/upload.d.ts +3 -0
  66. package/script/src/commands/upload.d.ts.map +1 -0
  67. package/script/src/commands/upload.js +64 -0
  68. package/script/src/commands/videos.d.ts.map +1 -1
  69. package/script/src/commands/videos.js +10 -9
  70. package/script/src/index.d.ts.map +1 -1
  71. package/script/src/index.js +12 -1
  72. package/script/src/lib/auth.d.ts +23 -1
  73. package/script/src/lib/auth.d.ts.map +1 -1
  74. package/script/src/lib/auth.js +70 -3
  75. package/script/src/lib/logger.d.ts +1 -1
  76. package/script/src/lib/logger.d.ts.map +1 -1
  77. package/script/src/lib/logger.js +7 -4
  78. package/script/src/lib/moodle.d.ts +183 -1
  79. package/script/src/lib/moodle.d.ts.map +1 -1
  80. package/script/src/lib/moodle.js +511 -13
  81. package/script/src/lib/types.d.ts +81 -164
  82. package/script/src/lib/types.d.ts.map +1 -1
  83. package/script/src/lib/types.js +1 -0
  84. package/script/src/lib/utils.d.ts +20 -0
  85. package/script/src/lib/utils.d.ts.map +1 -1
  86. package/script/src/lib/utils.js +52 -0
  87. package/skills/openape/SKILL.md +74 -270
@@ -1,5 +1,6 @@
1
1
  import * as dntShim from "../../_dnt.shims.js";
2
2
  import { stripHtmlTags, extractCourseName } from "./utils.js";
3
+ import * as fs from "node:fs";
3
4
  // ── Core Moodle AJAX Wrapper ───────────────────────────────────────────
4
5
  /**
5
6
  * Moodle WS API functions that are known to work via /webservice/rest/server.php
@@ -9,6 +10,12 @@ const WS_API_FUNCTIONS = new Set([
9
10
  "mod_forum_get_forums_by_courses",
10
11
  "mod_forum_get_forum_discussions",
11
12
  "mod_forum_get_forum_discussion_posts",
13
+ "mod_forum_add_discussion",
14
+ "mod_forum_add_discussion_post",
15
+ "mod_forum_delete_post",
16
+ "core_files_upload",
17
+ "core_files_get_files",
18
+ "core_files_get_unused_draft_itemid",
12
19
  "gradereport_user_get_grade_items",
13
20
  "core_calendar_get_calendar_events",
14
21
  "core_course_get_contents",
@@ -19,18 +26,33 @@ const WS_API_FUNCTIONS = new Set([
19
26
  "mod_supervideo_progress_save_mobile",
20
27
  "mod_supervideo_view_supervideo",
21
28
  "mod_quiz_get_quizzes_by_courses",
29
+ "mod_quiz_start_attempt",
30
+ "mod_quiz_get_attempt_data",
22
31
  "mod_resource_get_resources_by_courses",
32
+ "mod_assign_get_assignments",
33
+ "mod_assign_save_submission",
34
+ "mod_assign_get_submission_status",
23
35
  "core_message_get_messages",
24
36
  "core_webservice_get_site_info",
25
37
  ]);
26
38
  /**
27
39
  * Convert args to URLSearchParams, handling arrays properly for Moodle WS API.
28
40
  * Moodle expects array parameters as: courseids[0]=1&courseids[1]=2
41
+ * For options array: options[0][name]=attachmentsid&options[0][value]=123
29
42
  */
30
43
  function buildWsParams(args) {
31
44
  const params = new URLSearchParams();
32
45
  for (const [key, value] of Object.entries(args)) {
33
- if (Array.isArray(value)) {
46
+ if (key === "options" && Array.isArray(value)) {
47
+ // Special handling for options array: options[0][name]=xxx&options[0][value]=yyy
48
+ value.forEach((opt, i) => {
49
+ if (opt && typeof opt === "object" && "name" in opt && "value" in opt) {
50
+ params.append(`${key}[${i}][name]`, String(opt.name));
51
+ params.append(`${key}[${i}][value]`, String(opt.value));
52
+ }
53
+ });
54
+ }
55
+ else if (Array.isArray(value)) {
34
56
  // Array parameters: courseids[0]=1&courseids[1]=2
35
57
  value.forEach((v, i) => {
36
58
  params.append(`${key}[${i}]`, String(v));
@@ -48,7 +70,7 @@ function buildWsParams(args) {
48
70
  */
49
71
  export async function moodleApiCall(session, methodname, args) {
50
72
  if (!session.wsToken) {
51
- throw new Error(`WS Token required for API call: ${methodname}`);
73
+ throw new Error(`WS ${methodname} required for API call: ${methodname}`);
52
74
  }
53
75
  const params = buildWsParams(args);
54
76
  params.set("wstoken", session.wsToken);
@@ -222,6 +244,7 @@ export async function getForumsApi(session, courseIds) {
222
244
  id: f.id,
223
245
  cmid: f.cmid,
224
246
  name: f.name,
247
+ intro: f.intro,
225
248
  courseid: f.course, // API returns 'course' not 'courseid'
226
249
  timemodified: f.timemodified,
227
250
  }));
@@ -292,6 +315,88 @@ export async function getDiscussionPostsApi(session, discussionId) {
292
315
  return [];
293
316
  }
294
317
  }
318
+ /**
319
+ * Delete a forum post. If the post is a discussion's topic post,
320
+ * the entire discussion is deleted.
321
+ */
322
+ export async function deleteForumPostApi(session, postId) {
323
+ try {
324
+ const data = await moodleApiCall(session, "mod_forum_delete_post", { postid: postId });
325
+ return { success: data?.status === true };
326
+ }
327
+ catch (error) {
328
+ return {
329
+ success: false,
330
+ error: error instanceof Error ? error.message : String(error),
331
+ };
332
+ }
333
+ }
334
+ /**
335
+ * Add a new discussion to a forum.
336
+ */
337
+ export async function addForumDiscussionApi(session, forumId, subject, message) {
338
+ try {
339
+ const data = await moodleApiCall(session, "mod_forum_add_discussion", {
340
+ forumid: forumId,
341
+ subject,
342
+ message: message.replace(/\n/g, "<br>"),
343
+ });
344
+ if (data?.discussionid) {
345
+ return { success: true, discussionId: data.discussionid };
346
+ }
347
+ return {
348
+ success: false,
349
+ error: data?.message ?? data?.error ?? "Failed to add discussion",
350
+ };
351
+ }
352
+ catch (error) {
353
+ return {
354
+ success: false,
355
+ error: error instanceof Error ? error.message : String(error),
356
+ };
357
+ }
358
+ }
359
+ /**
360
+ * Add a reply post to a discussion.
361
+ */
362
+ export async function addForumPostApi(session, postId, // Parent post ID to reply to
363
+ subject, message, options) {
364
+ try {
365
+ // Build options array for Moodle WS API
366
+ const apiOptions = [];
367
+ if (options?.inlineAttachmentId !== undefined) {
368
+ apiOptions.push({ name: "inlineattachmentsid", value: options.inlineAttachmentId });
369
+ }
370
+ if (options?.attachmentId !== undefined) {
371
+ apiOptions.push({ name: "attachmentsid", value: options.attachmentId });
372
+ }
373
+ const params = {
374
+ postid: postId,
375
+ subject,
376
+ message: message.replace(/\n/g, "<br>"),
377
+ messageformat: 1, // 1 = HTML, 0 = Moodle, 2 = Plain text, 3 = Markdown
378
+ };
379
+ // Only add options if not empty
380
+ if (apiOptions.length > 0) {
381
+ params.options = apiOptions;
382
+ }
383
+ console.debug(`[DEBUG] add_discussion_post params:`, JSON.stringify(params, null, 2));
384
+ const data = await moodleApiCall(session, "mod_forum_add_discussion_post", params);
385
+ if (data?.postid) {
386
+ return { success: true, postId: data.postid };
387
+ }
388
+ return {
389
+ success: false,
390
+ error: data?.message ?? data?.error ?? "Failed to add post",
391
+ };
392
+ }
393
+ catch (error) {
394
+ return {
395
+ success: false,
396
+ error: error instanceof Error ? error.message : String(error),
397
+ };
398
+ }
399
+ }
295
400
  // ── Resource/Material Operations ──────────────────────────────────────────
296
401
  /**
297
402
  * Get all resource modules in a course.
@@ -586,13 +691,13 @@ export async function completeVideoApi(session, video) {
586
691
  mapa: buildDurationMap(duration),
587
692
  });
588
693
  // Debug: log the full result
589
- console.debug(`completeVideoApi result:`, JSON.stringify(result));
694
+ // console.debug(`completeVideoApi result:`, JSON.stringify(result));
590
695
  const success = result?.[0]?.success === true || result?.success === true;
591
696
  return { success, error: success ? undefined : `API returned success=false, result=${JSON.stringify(result)}`, result };
592
697
  }
593
698
  catch (err) {
594
699
  const msg = err instanceof Error ? err.message : String(err);
595
- console.debug(`completeVideoApi error: ${msg}`);
700
+ // console.debug(`completeVideoApi error: ${msg}`);
596
701
  return { success: false, error: msg };
597
702
  }
598
703
  }
@@ -774,6 +879,37 @@ export async function getSupervideosInCourseApi(session, courseId) {
774
879
  }
775
880
  return videos;
776
881
  }
882
+ // ── Quizzes via WS API ────────────────────────────────────────────────────────
883
+ /**
884
+ * Get user attempts for given quiz IDs via WS API.
885
+ * Returns a map of quiz ID -> { finished: boolean, attemptsUsed: number }.
886
+ * Note: mod_quiz_get_user_attempts only accepts a single quizid, so we query in parallel.
887
+ */
888
+ async function getUserQuizAttemptInfo(session, quizIds) {
889
+ if (quizIds.length === 0)
890
+ return new Map();
891
+ const info = new Map();
892
+ // Query each quiz in parallel (API only accepts single quizid)
893
+ const results = await Promise.allSettled(quizIds.map(quizId => moodleApiCall(session, "mod_quiz_get_user_attempts", { quizid: quizId })));
894
+ for (let i = 0; i < quizIds.length; i++) {
895
+ const quizId = quizIds[i];
896
+ const result = results[i];
897
+ if (result.status === "fulfilled" && result.value?.attempts) {
898
+ let used = 0;
899
+ let hasFinished = false;
900
+ for (const a of result.value.attempts) {
901
+ used++;
902
+ if (a.state === "finished")
903
+ hasFinished = true;
904
+ }
905
+ info.set(quizId, { finished: hasFinished, attemptsUsed: used });
906
+ }
907
+ else {
908
+ info.set(quizId, { finished: false, attemptsUsed: 0 });
909
+ }
910
+ }
911
+ return info;
912
+ }
777
913
  /**
778
914
  * Get quizzes in courses via pure WS API.
779
915
  */
@@ -781,15 +917,170 @@ export async function getQuizzesByCoursesApi(session, courseIds) {
781
917
  if (courseIds.length === 0)
782
918
  return [];
783
919
  const data = await moodleApiCall(session, "mod_quiz_get_quizzes_by_courses", { courseids: courseIds });
784
- return (data?.quizzes ?? []).map((q) => ({
785
- cmid: q.coursemodule.toString(),
786
- name: q.name,
787
- url: q.viewurl,
788
- isComplete: false, // API doesn't provide completion status
789
- timeOpen: q.timeopen,
790
- timeClose: q.timeclose,
791
- courseId: q.course,
792
- }));
920
+ const quizzes = (data?.quizzes ?? []);
921
+ // Fetch user attempts to determine completion status and left attempts
922
+ const quizIds = quizzes.map(q => q.id);
923
+ const attemptInfo = await getUserQuizAttemptInfo(session, quizIds);
924
+ return quizzes.map(q => {
925
+ const info = attemptInfo.get(q.id);
926
+ return {
927
+ quizid: q.id.toString(),
928
+ name: q.name,
929
+ url: q.viewurl,
930
+ intro: q.intro,
931
+ isComplete: info?.finished ?? false,
932
+ attemptsUsed: info?.attemptsUsed ?? 0,
933
+ timeClose: q.timeclose,
934
+ maxAttempts: q.attempts,
935
+ courseId: q.course,
936
+ };
937
+ });
938
+ }
939
+ /**
940
+ * Start a new quiz attempt via pure WS API.
941
+ */
942
+ export async function startQuizAttemptApi(session, quizId, options = {}) {
943
+ const params = {
944
+ quizid: parseInt(quizId, 10),
945
+ forcenew: options.forcenew ? 1 : 0,
946
+ };
947
+ if (options.precheck) {
948
+ params.precheck = 1;
949
+ }
950
+ const data = await moodleApiCall(session, "mod_quiz_start_attempt", params);
951
+ if (!data?.attempt) {
952
+ throw new Error("No attempt data returned");
953
+ }
954
+ const attempt = data.attempt;
955
+ const attemptId = attempt.id || attempt.attempt;
956
+ return {
957
+ attempt: {
958
+ attempt: attemptId,
959
+ attemptid: attemptId,
960
+ quizid: attempt.quizid,
961
+ userid: attempt.userid,
962
+ attemptnumber: attempt.attemptnumber,
963
+ state: attempt.state,
964
+ timestart: attempt.timestart,
965
+ timefinish: attempt.timefinish,
966
+ preview: attempt.preview === 1,
967
+ },
968
+ page: data.page,
969
+ messages: data.messages,
970
+ };
971
+ }
972
+ /**
973
+ * Get quiz attempt data including questions via pure WS API.
974
+ */
975
+ export async function getAllQuizAttemptDataApi(session, attemptId) {
976
+ const firstPage = await getQuizAttemptDataApi(session, attemptId, 0);
977
+ // Moodle re-indexes question keys per page (always starts at 0),
978
+ // so we must re-key by actual slot number to avoid overwrites.
979
+ const allQuestions = {};
980
+ for (const q of Object.values(firstPage.questions)) {
981
+ allQuestions[q.slot] = q;
982
+ }
983
+ let nextPage = firstPage.nextpage;
984
+ while (nextPage !== undefined && nextPage !== null && nextPage !== -1) {
985
+ const pageData = await getQuizAttemptDataApi(session, attemptId, nextPage);
986
+ for (const q of Object.values(pageData.questions)) {
987
+ allQuestions[q.slot] = q;
988
+ }
989
+ nextPage = pageData.nextpage;
990
+ }
991
+ return { ...firstPage, questions: allQuestions, nextpage: undefined };
992
+ }
993
+ export async function getQuizAttemptDataApi(session, attemptId, page = 0) {
994
+ const data = await moodleApiCall(session, "mod_quiz_get_attempt_data", { attemptid: attemptId, page });
995
+ if (!data?.attempt || !data?.questions) {
996
+ throw new Error("Invalid attempt data response");
997
+ }
998
+ const attempt = data.attempt;
999
+ const attemptIdValue = attempt.id || attempt.attempt;
1000
+ const questions = {};
1001
+ for (const [slot, question] of Object.entries(data.questions)) {
1002
+ questions[parseInt(slot, 10)] = {
1003
+ slot: question.slot,
1004
+ type: question.type,
1005
+ id: question.id,
1006
+ maxmark: question.maxmark,
1007
+ page: question.page,
1008
+ quizid: question.quizid,
1009
+ html: question.html,
1010
+ status: question.status,
1011
+ stateclass: question.stateclass,
1012
+ sequencecheck: question.sequencecheck,
1013
+ questionnumber: question.questionnumber,
1014
+ };
1015
+ }
1016
+ return {
1017
+ attempt: {
1018
+ attempt: attemptIdValue,
1019
+ attemptid: attemptIdValue,
1020
+ uniqueid: attempt.uniqueid,
1021
+ quizid: attempt.quizid,
1022
+ userid: attempt.userid,
1023
+ attemptnumber: attempt.attemptnumber,
1024
+ state: attempt.state,
1025
+ timestart: attempt.timestart,
1026
+ timefinish: attempt.timefinish,
1027
+ },
1028
+ questions,
1029
+ nextpage: data.nextpage,
1030
+ prevpage: data.prevpage,
1031
+ };
1032
+ }
1033
+ /**
1034
+ * Process (save answers / finish) a quiz attempt via WS API.
1035
+ *
1036
+ * Answer formats:
1037
+ * - Single choice (multichoice): { slot, answer: "0" }
1038
+ * - Multiple choice (multichoices): { slot, answer: "0,2,3" } (comma-separated choice indices)
1039
+ * - Short answer (shortanswer): { slot, answer: "text answer" }
1040
+ *
1041
+ * @param session - WS session
1042
+ * @param attemptId - The attempt ID
1043
+ * @param uniqueId - The usage attempt uniqueid (from attempt data)
1044
+ * @param answers - Array of { slot, answer } pairs
1045
+ * @param sequenceChecks - Map of slot -> sequencecheck value (required for deferredfeedback)
1046
+ * @param finish - Whether to finish the attempt after saving
1047
+ */
1048
+ export async function processQuizAttemptApi(session, attemptId, uniqueId, answers, sequenceChecks, finish = true) {
1049
+ const params = {
1050
+ attemptid: attemptId,
1051
+ finishattempt: finish ? 1 : 0,
1052
+ };
1053
+ let i = 0;
1054
+ for (const a of answers) {
1055
+ // Include sequencecheck first (required for deferredfeedback quizzes)
1056
+ const seq = sequenceChecks.get(a.slot);
1057
+ if (seq !== undefined) {
1058
+ params[`data[${i}][name]`] = `q${uniqueId}:${a.slot}_:sequencecheck`;
1059
+ params[`data[${i}][value]`] = seq.toString();
1060
+ i++;
1061
+ }
1062
+ // Detect answer format:
1063
+ // Comma-separated numeric values = multichoices (checkboxes)
1064
+ // Single numeric value = multichoice (radio)
1065
+ // Non-numeric text = shortanswer
1066
+ if (/^\d+(,\d+)*$/.test(a.answer) && a.answer.includes(",")) {
1067
+ // Multichoices: send each choice as qXXX:Y_choiceN with value 1
1068
+ const choices = a.answer.split(",");
1069
+ for (const choice of choices) {
1070
+ params[`data[${i}][name]`] = `q${uniqueId}:${a.slot}_choice${choice}`;
1071
+ params[`data[${i}][value]`] = "1";
1072
+ i++;
1073
+ }
1074
+ }
1075
+ else {
1076
+ // Single choice or shortanswer: send as qXXX:Y_answer
1077
+ params[`data[${i}][name]`] = `q${uniqueId}:${a.slot}_answer`;
1078
+ params[`data[${i}][value]`] = a.answer;
1079
+ i++;
1080
+ }
1081
+ }
1082
+ const data = await moodleApiCall(session, "mod_quiz_process_attempt", params);
1083
+ return { state: data?.state ?? "unknown", warnings: data?.warnings };
793
1084
  }
794
1085
  // ── Materials via WS API ──────────────────────────────────────────────────────
795
1086
  /**
@@ -814,6 +1105,200 @@ export async function getResourcesByCoursesApi(session, courseIds) {
814
1105
  };
815
1106
  });
816
1107
  }
1108
+ /**
1109
+ * Get assignments in courses via pure WS API.
1110
+ */
1111
+ export async function getAssignmentsByCoursesApi(session, courseIds) {
1112
+ if (courseIds.length === 0)
1113
+ return [];
1114
+ const data = await moodleApiCall(session, "mod_assign_get_assignments", { courseids: courseIds });
1115
+ const assignments = [];
1116
+ // The API returns an array of courses, each containing assignments
1117
+ for (const course of (data?.courses ?? [])) {
1118
+ if (!course.assignments)
1119
+ continue;
1120
+ for (const a of course.assignments) {
1121
+ assignments.push({
1122
+ id: a.id,
1123
+ cmid: a.cmid?.toString() ?? "",
1124
+ name: a.name,
1125
+ url: a.viewurl ?? "",
1126
+ courseId: course.id,
1127
+ duedate: a.duedate,
1128
+ cutoffdate: a.cutoffdate,
1129
+ allowSubmissionsFromDate: a.allowsubmissionsfromdate,
1130
+ gradingduedate: a.gradingduedate,
1131
+ lateSubmission: a.latesubmissions ? true : false,
1132
+ extensionduedate: a.extensionduedate,
1133
+ });
1134
+ }
1135
+ }
1136
+ return assignments;
1137
+ }
1138
+ /**
1139
+ * Get assignment submission status via pure WS API.
1140
+ */
1141
+ export async function getSubmissionStatusApi(session, assignmentId) {
1142
+ const siteInfo = await getSiteInfoApi(session);
1143
+ const data = await moodleApiCall(session, "mod_assign_get_submission_status", {
1144
+ assignid: assignmentId,
1145
+ userid: siteInfo.userid,
1146
+ });
1147
+ const lastAttempt = data?.lastattempt;
1148
+ // Note: API returns "submission" (singular), not "submissions" (plural)
1149
+ const submission = lastAttempt?.submission;
1150
+ // Find file submissions from submission plugins
1151
+ const plugins = submission?.plugins || [];
1152
+ const filePlugin = plugins.find((p) => p.type === "file");
1153
+ const extensions = (filePlugin?.fileareas || [])
1154
+ .flatMap((fa) => fa?.files || [])
1155
+ .map((f) => ({
1156
+ id: f.id,
1157
+ filename: f.filename,
1158
+ filesize: f.filesize,
1159
+ }));
1160
+ // Get feedback from the separate feedback object
1161
+ const feedback = data?.feedback;
1162
+ const commentsPlugin = feedback?.plugins?.find((p) => p.type === "comments");
1163
+ const commentText = commentsPlugin?.editorfields?.find((e) => e.name === "comments")?.text || null;
1164
+ return {
1165
+ submitted: submission?.status === "submitted",
1166
+ graded: lastAttempt?.gradingstatus === "graded",
1167
+ grader: feedback?.gradername || null,
1168
+ grade: feedback?.gradefordisplay || null,
1169
+ feedback: commentText,
1170
+ lastModified: submission?.timemodified || null,
1171
+ extensions,
1172
+ };
1173
+ }
1174
+ /**
1175
+ * Save/submit an assignment via pure WS API.
1176
+ * Supports online text submissions and file submissions (by draft ID).
1177
+ */
1178
+ export async function saveSubmissionApi(session, assignmentId, options) {
1179
+ try {
1180
+ const siteInfo = await getSiteInfoApi(session);
1181
+ // Build plugins array based on submission type
1182
+ const plugins = [];
1183
+ if (options.onlineText) {
1184
+ plugins.push({
1185
+ type: "onlinetext",
1186
+ online_text: {
1187
+ text: options.onlineText.text,
1188
+ format: options.onlineText.format ?? 1,
1189
+ itemid: 0,
1190
+ },
1191
+ });
1192
+ }
1193
+ if (options.fileId !== undefined) {
1194
+ plugins.push({
1195
+ type: "file",
1196
+ files_filemanager: options.fileId,
1197
+ });
1198
+ }
1199
+ await moodleApiCall(session, "mod_assign_save_submission", {
1200
+ assignmentid: assignmentId,
1201
+ userid: siteInfo.userid,
1202
+ plugins,
1203
+ });
1204
+ return { success: true };
1205
+ }
1206
+ catch (e) {
1207
+ return { success: false, error: e instanceof Error ? e.message : String(e) };
1208
+ }
1209
+ }
1210
+ // ── File Upload via WS API ──────────────────────────────────────────────────────
1211
+ /**
1212
+ * Generate a unique draft item ID.
1213
+ * Uses timestamp (last 8 digits) to ensure uniqueness.
1214
+ */
1215
+ export function generateDraftItemId() {
1216
+ // Use current timestamp in seconds, take last 8 digits
1217
+ return Math.floor(Date.now() / 1000) % 100000000;
1218
+ }
1219
+ /**
1220
+ * Upload a file to Moodle draft area via pure WS API.
1221
+ * This is the first step before submitting files to assignments, forums, etc.
1222
+ *
1223
+ * Note: We generate our own draft item ID instead of asking Moodle for one.
1224
+ */
1225
+ export async function uploadFileApi(session, filePath, options) {
1226
+ try {
1227
+ // Generate or use provided draft ID
1228
+ const draftItemId = options?.draftId ?? generateDraftItemId();
1229
+ // Read file content using fs.promises
1230
+ const fileContent = await fs.promises.readFile(filePath);
1231
+ const fileName = options?.filename || filePath.split(/[/\\]/).pop() || "unknown";
1232
+ // Get site info for user context
1233
+ const siteInfo = await getSiteInfoApi(session);
1234
+ const userContextId = getUserContextId(siteInfo.userid);
1235
+ // Prepare multipart form data
1236
+ const formData = new FormData();
1237
+ formData.append("token", session.wsToken);
1238
+ formData.append("file", new Blob([new Uint8Array(fileContent)]), fileName);
1239
+ formData.append("filepath", options?.filepath || "/");
1240
+ formData.append("itemid", String(draftItemId)); // Use our generated draft ID
1241
+ formData.append("contextid", String(userContextId)); // Use calculated user context
1242
+ formData.append("component", "user");
1243
+ formData.append("filearea", "draft");
1244
+ formData.append("qformat", ""); // Not used
1245
+ // Upload via upload.php (uses multipart/form-data)
1246
+ const url = `${session.moodleBaseUrl}/webservice/upload.php`;
1247
+ const response = await fetch(url, {
1248
+ method: "POST",
1249
+ body: formData,
1250
+ });
1251
+ if (!response.ok) {
1252
+ return { success: false, error: `HTTP ${response.status}` };
1253
+ }
1254
+ const result = await response.json();
1255
+ console.debug(`[DEBUG] upload.php response:`, JSON.stringify(result, null, 2));
1256
+ // Check for errors in response
1257
+ if (result?.error) {
1258
+ return { success: false, error: result.message ?? result.error ?? "Upload failed" };
1259
+ }
1260
+ // Success - return the draft ID we used
1261
+ return { success: true, draftId: draftItemId };
1262
+ }
1263
+ catch (e) {
1264
+ return { success: false, error: e instanceof Error ? e.message : String(e) };
1265
+ }
1266
+ }
1267
+ /**
1268
+ * Calculate user context ID in Moodle.
1269
+ * User context ID = (userid * CONTEXT_DEPTH) + CONTEXT_USER
1270
+ * Where CONTEXT_DEPTH = 10 and CONTEXT_USER = 30
1271
+ */
1272
+ function getUserContextId(userId) {
1273
+ return userId * 10 + 30;
1274
+ }
1275
+ /**
1276
+ * Get user's private files (not draft) via pure WS API.
1277
+ * Draft files are temporary and cannot be listed, but private files can.
1278
+ */
1279
+ export async function getDraftFilesApi(session) {
1280
+ const siteInfo = await getSiteInfoApi(session);
1281
+ const userContextId = getUserContextId(siteInfo.userid);
1282
+ const data = await moodleApiCall(session, "core_files_get_files", {
1283
+ contextid: userContextId,
1284
+ component: "user",
1285
+ filearea: "private",
1286
+ itemid: 0,
1287
+ filepath: "/",
1288
+ modified: null,
1289
+ });
1290
+ console.debug(`[DEBUG] core_files_get_files response:`, JSON.stringify(data, null, 2));
1291
+ // The API returns a parents array with files inside
1292
+ const files = data?.parents?.[0]?.files || data?.files || [];
1293
+ return files.map((f) => ({
1294
+ itemId: f.itemid || 0,
1295
+ filename: f.filename || "",
1296
+ filesize: f.filesize || 0,
1297
+ filepath: f.filepath || "/",
1298
+ timeModified: f.timemodified || 0,
1299
+ url: f.url || "",
1300
+ }));
1301
+ }
817
1302
  /**
818
1303
  * Get messages for the current user via pure WS API.
819
1304
  */