@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
@@ -42,6 +42,9 @@ exports.getSupervideosInCourse = getSupervideosInCourse;
42
42
  exports.getForumsApi = getForumsApi;
43
43
  exports.getForumDiscussionsApi = getForumDiscussionsApi;
44
44
  exports.getDiscussionPostsApi = getDiscussionPostsApi;
45
+ exports.deleteForumPostApi = deleteForumPostApi;
46
+ exports.addForumDiscussionApi = addForumDiscussionApi;
47
+ exports.addForumPostApi = addForumPostApi;
45
48
  exports.getResourcesInCourse = getResourcesInCourse;
46
49
  exports.getCalendarEventsApi = getCalendarEventsApi;
47
50
  exports.getCourseGradesApi = getCourseGradesApi;
@@ -54,10 +57,21 @@ exports.getSiteInfoApi = getSiteInfoApi;
54
57
  exports.getIncompleteVideosApi = getIncompleteVideosApi;
55
58
  exports.getSupervideosInCourseApi = getSupervideosInCourseApi;
56
59
  exports.getQuizzesByCoursesApi = getQuizzesByCoursesApi;
60
+ exports.startQuizAttemptApi = startQuizAttemptApi;
61
+ exports.getAllQuizAttemptDataApi = getAllQuizAttemptDataApi;
62
+ exports.getQuizAttemptDataApi = getQuizAttemptDataApi;
63
+ exports.processQuizAttemptApi = processQuizAttemptApi;
57
64
  exports.getResourcesByCoursesApi = getResourcesByCoursesApi;
65
+ exports.getAssignmentsByCoursesApi = getAssignmentsByCoursesApi;
66
+ exports.getSubmissionStatusApi = getSubmissionStatusApi;
67
+ exports.saveSubmissionApi = saveSubmissionApi;
68
+ exports.generateDraftItemId = generateDraftItemId;
69
+ exports.uploadFileApi = uploadFileApi;
70
+ exports.getDraftFilesApi = getDraftFilesApi;
58
71
  exports.getMessagesApi = getMessagesApi;
59
72
  const dntShim = __importStar(require("../../_dnt.shims.js"));
60
73
  const utils_js_1 = require("./utils.js");
74
+ const fs = __importStar(require("node:fs"));
61
75
  // ── Core Moodle AJAX Wrapper ───────────────────────────────────────────
62
76
  /**
63
77
  * Moodle WS API functions that are known to work via /webservice/rest/server.php
@@ -67,6 +81,12 @@ const WS_API_FUNCTIONS = new Set([
67
81
  "mod_forum_get_forums_by_courses",
68
82
  "mod_forum_get_forum_discussions",
69
83
  "mod_forum_get_forum_discussion_posts",
84
+ "mod_forum_add_discussion",
85
+ "mod_forum_add_discussion_post",
86
+ "mod_forum_delete_post",
87
+ "core_files_upload",
88
+ "core_files_get_files",
89
+ "core_files_get_unused_draft_itemid",
70
90
  "gradereport_user_get_grade_items",
71
91
  "core_calendar_get_calendar_events",
72
92
  "core_course_get_contents",
@@ -77,18 +97,33 @@ const WS_API_FUNCTIONS = new Set([
77
97
  "mod_supervideo_progress_save_mobile",
78
98
  "mod_supervideo_view_supervideo",
79
99
  "mod_quiz_get_quizzes_by_courses",
100
+ "mod_quiz_start_attempt",
101
+ "mod_quiz_get_attempt_data",
80
102
  "mod_resource_get_resources_by_courses",
103
+ "mod_assign_get_assignments",
104
+ "mod_assign_save_submission",
105
+ "mod_assign_get_submission_status",
81
106
  "core_message_get_messages",
82
107
  "core_webservice_get_site_info",
83
108
  ]);
84
109
  /**
85
110
  * Convert args to URLSearchParams, handling arrays properly for Moodle WS API.
86
111
  * Moodle expects array parameters as: courseids[0]=1&courseids[1]=2
112
+ * For options array: options[0][name]=attachmentsid&options[0][value]=123
87
113
  */
88
114
  function buildWsParams(args) {
89
115
  const params = new URLSearchParams();
90
116
  for (const [key, value] of Object.entries(args)) {
91
- if (Array.isArray(value)) {
117
+ if (key === "options" && Array.isArray(value)) {
118
+ // Special handling for options array: options[0][name]=xxx&options[0][value]=yyy
119
+ value.forEach((opt, i) => {
120
+ if (opt && typeof opt === "object" && "name" in opt && "value" in opt) {
121
+ params.append(`${key}[${i}][name]`, String(opt.name));
122
+ params.append(`${key}[${i}][value]`, String(opt.value));
123
+ }
124
+ });
125
+ }
126
+ else if (Array.isArray(value)) {
92
127
  // Array parameters: courseids[0]=1&courseids[1]=2
93
128
  value.forEach((v, i) => {
94
129
  params.append(`${key}[${i}]`, String(v));
@@ -106,7 +141,7 @@ function buildWsParams(args) {
106
141
  */
107
142
  async function moodleApiCall(session, methodname, args) {
108
143
  if (!session.wsToken) {
109
- throw new Error(`WS Token required for API call: ${methodname}`);
144
+ throw new Error(`WS ${methodname} required for API call: ${methodname}`);
110
145
  }
111
146
  const params = buildWsParams(args);
112
147
  params.set("wstoken", session.wsToken);
@@ -280,6 +315,7 @@ async function getForumsApi(session, courseIds) {
280
315
  id: f.id,
281
316
  cmid: f.cmid,
282
317
  name: f.name,
318
+ intro: f.intro,
283
319
  courseid: f.course, // API returns 'course' not 'courseid'
284
320
  timemodified: f.timemodified,
285
321
  }));
@@ -350,6 +386,88 @@ async function getDiscussionPostsApi(session, discussionId) {
350
386
  return [];
351
387
  }
352
388
  }
389
+ /**
390
+ * Delete a forum post. If the post is a discussion's topic post,
391
+ * the entire discussion is deleted.
392
+ */
393
+ async function deleteForumPostApi(session, postId) {
394
+ try {
395
+ const data = await moodleApiCall(session, "mod_forum_delete_post", { postid: postId });
396
+ return { success: data?.status === true };
397
+ }
398
+ catch (error) {
399
+ return {
400
+ success: false,
401
+ error: error instanceof Error ? error.message : String(error),
402
+ };
403
+ }
404
+ }
405
+ /**
406
+ * Add a new discussion to a forum.
407
+ */
408
+ async function addForumDiscussionApi(session, forumId, subject, message) {
409
+ try {
410
+ const data = await moodleApiCall(session, "mod_forum_add_discussion", {
411
+ forumid: forumId,
412
+ subject,
413
+ message: message.replace(/\n/g, "<br>"),
414
+ });
415
+ if (data?.discussionid) {
416
+ return { success: true, discussionId: data.discussionid };
417
+ }
418
+ return {
419
+ success: false,
420
+ error: data?.message ?? data?.error ?? "Failed to add discussion",
421
+ };
422
+ }
423
+ catch (error) {
424
+ return {
425
+ success: false,
426
+ error: error instanceof Error ? error.message : String(error),
427
+ };
428
+ }
429
+ }
430
+ /**
431
+ * Add a reply post to a discussion.
432
+ */
433
+ async function addForumPostApi(session, postId, // Parent post ID to reply to
434
+ subject, message, options) {
435
+ try {
436
+ // Build options array for Moodle WS API
437
+ const apiOptions = [];
438
+ if (options?.inlineAttachmentId !== undefined) {
439
+ apiOptions.push({ name: "inlineattachmentsid", value: options.inlineAttachmentId });
440
+ }
441
+ if (options?.attachmentId !== undefined) {
442
+ apiOptions.push({ name: "attachmentsid", value: options.attachmentId });
443
+ }
444
+ const params = {
445
+ postid: postId,
446
+ subject,
447
+ message: message.replace(/\n/g, "<br>"),
448
+ messageformat: 1, // 1 = HTML, 0 = Moodle, 2 = Plain text, 3 = Markdown
449
+ };
450
+ // Only add options if not empty
451
+ if (apiOptions.length > 0) {
452
+ params.options = apiOptions;
453
+ }
454
+ console.debug(`[DEBUG] add_discussion_post params:`, JSON.stringify(params, null, 2));
455
+ const data = await moodleApiCall(session, "mod_forum_add_discussion_post", params);
456
+ if (data?.postid) {
457
+ return { success: true, postId: data.postid };
458
+ }
459
+ return {
460
+ success: false,
461
+ error: data?.message ?? data?.error ?? "Failed to add post",
462
+ };
463
+ }
464
+ catch (error) {
465
+ return {
466
+ success: false,
467
+ error: error instanceof Error ? error.message : String(error),
468
+ };
469
+ }
470
+ }
353
471
  // ── Resource/Material Operations ──────────────────────────────────────────
354
472
  /**
355
473
  * Get all resource modules in a course.
@@ -644,13 +762,13 @@ async function completeVideoApi(session, video) {
644
762
  mapa: buildDurationMap(duration),
645
763
  });
646
764
  // Debug: log the full result
647
- console.debug(`completeVideoApi result:`, JSON.stringify(result));
765
+ // console.debug(`completeVideoApi result:`, JSON.stringify(result));
648
766
  const success = result?.[0]?.success === true || result?.success === true;
649
767
  return { success, error: success ? undefined : `API returned success=false, result=${JSON.stringify(result)}`, result };
650
768
  }
651
769
  catch (err) {
652
770
  const msg = err instanceof Error ? err.message : String(err);
653
- console.debug(`completeVideoApi error: ${msg}`);
771
+ // console.debug(`completeVideoApi error: ${msg}`);
654
772
  return { success: false, error: msg };
655
773
  }
656
774
  }
@@ -832,6 +950,37 @@ async function getSupervideosInCourseApi(session, courseId) {
832
950
  }
833
951
  return videos;
834
952
  }
953
+ // ── Quizzes via WS API ────────────────────────────────────────────────────────
954
+ /**
955
+ * Get user attempts for given quiz IDs via WS API.
956
+ * Returns a map of quiz ID -> { finished: boolean, attemptsUsed: number }.
957
+ * Note: mod_quiz_get_user_attempts only accepts a single quizid, so we query in parallel.
958
+ */
959
+ async function getUserQuizAttemptInfo(session, quizIds) {
960
+ if (quizIds.length === 0)
961
+ return new Map();
962
+ const info = new Map();
963
+ // Query each quiz in parallel (API only accepts single quizid)
964
+ const results = await Promise.allSettled(quizIds.map(quizId => moodleApiCall(session, "mod_quiz_get_user_attempts", { quizid: quizId })));
965
+ for (let i = 0; i < quizIds.length; i++) {
966
+ const quizId = quizIds[i];
967
+ const result = results[i];
968
+ if (result.status === "fulfilled" && result.value?.attempts) {
969
+ let used = 0;
970
+ let hasFinished = false;
971
+ for (const a of result.value.attempts) {
972
+ used++;
973
+ if (a.state === "finished")
974
+ hasFinished = true;
975
+ }
976
+ info.set(quizId, { finished: hasFinished, attemptsUsed: used });
977
+ }
978
+ else {
979
+ info.set(quizId, { finished: false, attemptsUsed: 0 });
980
+ }
981
+ }
982
+ return info;
983
+ }
835
984
  /**
836
985
  * Get quizzes in courses via pure WS API.
837
986
  */
@@ -839,15 +988,170 @@ async function getQuizzesByCoursesApi(session, courseIds) {
839
988
  if (courseIds.length === 0)
840
989
  return [];
841
990
  const data = await moodleApiCall(session, "mod_quiz_get_quizzes_by_courses", { courseids: courseIds });
842
- return (data?.quizzes ?? []).map((q) => ({
843
- cmid: q.coursemodule.toString(),
844
- name: q.name,
845
- url: q.viewurl,
846
- isComplete: false, // API doesn't provide completion status
847
- timeOpen: q.timeopen,
848
- timeClose: q.timeclose,
849
- courseId: q.course,
850
- }));
991
+ const quizzes = (data?.quizzes ?? []);
992
+ // Fetch user attempts to determine completion status and left attempts
993
+ const quizIds = quizzes.map(q => q.id);
994
+ const attemptInfo = await getUserQuizAttemptInfo(session, quizIds);
995
+ return quizzes.map(q => {
996
+ const info = attemptInfo.get(q.id);
997
+ return {
998
+ quizid: q.id.toString(),
999
+ name: q.name,
1000
+ url: q.viewurl,
1001
+ intro: q.intro,
1002
+ isComplete: info?.finished ?? false,
1003
+ attemptsUsed: info?.attemptsUsed ?? 0,
1004
+ timeClose: q.timeclose,
1005
+ maxAttempts: q.attempts,
1006
+ courseId: q.course,
1007
+ };
1008
+ });
1009
+ }
1010
+ /**
1011
+ * Start a new quiz attempt via pure WS API.
1012
+ */
1013
+ async function startQuizAttemptApi(session, quizId, options = {}) {
1014
+ const params = {
1015
+ quizid: parseInt(quizId, 10),
1016
+ forcenew: options.forcenew ? 1 : 0,
1017
+ };
1018
+ if (options.precheck) {
1019
+ params.precheck = 1;
1020
+ }
1021
+ const data = await moodleApiCall(session, "mod_quiz_start_attempt", params);
1022
+ if (!data?.attempt) {
1023
+ throw new Error("No attempt data returned");
1024
+ }
1025
+ const attempt = data.attempt;
1026
+ const attemptId = attempt.id || attempt.attempt;
1027
+ return {
1028
+ attempt: {
1029
+ attempt: attemptId,
1030
+ attemptid: attemptId,
1031
+ quizid: attempt.quizid,
1032
+ userid: attempt.userid,
1033
+ attemptnumber: attempt.attemptnumber,
1034
+ state: attempt.state,
1035
+ timestart: attempt.timestart,
1036
+ timefinish: attempt.timefinish,
1037
+ preview: attempt.preview === 1,
1038
+ },
1039
+ page: data.page,
1040
+ messages: data.messages,
1041
+ };
1042
+ }
1043
+ /**
1044
+ * Get quiz attempt data including questions via pure WS API.
1045
+ */
1046
+ async function getAllQuizAttemptDataApi(session, attemptId) {
1047
+ const firstPage = await getQuizAttemptDataApi(session, attemptId, 0);
1048
+ // Moodle re-indexes question keys per page (always starts at 0),
1049
+ // so we must re-key by actual slot number to avoid overwrites.
1050
+ const allQuestions = {};
1051
+ for (const q of Object.values(firstPage.questions)) {
1052
+ allQuestions[q.slot] = q;
1053
+ }
1054
+ let nextPage = firstPage.nextpage;
1055
+ while (nextPage !== undefined && nextPage !== null && nextPage !== -1) {
1056
+ const pageData = await getQuizAttemptDataApi(session, attemptId, nextPage);
1057
+ for (const q of Object.values(pageData.questions)) {
1058
+ allQuestions[q.slot] = q;
1059
+ }
1060
+ nextPage = pageData.nextpage;
1061
+ }
1062
+ return { ...firstPage, questions: allQuestions, nextpage: undefined };
1063
+ }
1064
+ async function getQuizAttemptDataApi(session, attemptId, page = 0) {
1065
+ const data = await moodleApiCall(session, "mod_quiz_get_attempt_data", { attemptid: attemptId, page });
1066
+ if (!data?.attempt || !data?.questions) {
1067
+ throw new Error("Invalid attempt data response");
1068
+ }
1069
+ const attempt = data.attempt;
1070
+ const attemptIdValue = attempt.id || attempt.attempt;
1071
+ const questions = {};
1072
+ for (const [slot, question] of Object.entries(data.questions)) {
1073
+ questions[parseInt(slot, 10)] = {
1074
+ slot: question.slot,
1075
+ type: question.type,
1076
+ id: question.id,
1077
+ maxmark: question.maxmark,
1078
+ page: question.page,
1079
+ quizid: question.quizid,
1080
+ html: question.html,
1081
+ status: question.status,
1082
+ stateclass: question.stateclass,
1083
+ sequencecheck: question.sequencecheck,
1084
+ questionnumber: question.questionnumber,
1085
+ };
1086
+ }
1087
+ return {
1088
+ attempt: {
1089
+ attempt: attemptIdValue,
1090
+ attemptid: attemptIdValue,
1091
+ uniqueid: attempt.uniqueid,
1092
+ quizid: attempt.quizid,
1093
+ userid: attempt.userid,
1094
+ attemptnumber: attempt.attemptnumber,
1095
+ state: attempt.state,
1096
+ timestart: attempt.timestart,
1097
+ timefinish: attempt.timefinish,
1098
+ },
1099
+ questions,
1100
+ nextpage: data.nextpage,
1101
+ prevpage: data.prevpage,
1102
+ };
1103
+ }
1104
+ /**
1105
+ * Process (save answers / finish) a quiz attempt via WS API.
1106
+ *
1107
+ * Answer formats:
1108
+ * - Single choice (multichoice): { slot, answer: "0" }
1109
+ * - Multiple choice (multichoices): { slot, answer: "0,2,3" } (comma-separated choice indices)
1110
+ * - Short answer (shortanswer): { slot, answer: "text answer" }
1111
+ *
1112
+ * @param session - WS session
1113
+ * @param attemptId - The attempt ID
1114
+ * @param uniqueId - The usage attempt uniqueid (from attempt data)
1115
+ * @param answers - Array of { slot, answer } pairs
1116
+ * @param sequenceChecks - Map of slot -> sequencecheck value (required for deferredfeedback)
1117
+ * @param finish - Whether to finish the attempt after saving
1118
+ */
1119
+ async function processQuizAttemptApi(session, attemptId, uniqueId, answers, sequenceChecks, finish = true) {
1120
+ const params = {
1121
+ attemptid: attemptId,
1122
+ finishattempt: finish ? 1 : 0,
1123
+ };
1124
+ let i = 0;
1125
+ for (const a of answers) {
1126
+ // Include sequencecheck first (required for deferredfeedback quizzes)
1127
+ const seq = sequenceChecks.get(a.slot);
1128
+ if (seq !== undefined) {
1129
+ params[`data[${i}][name]`] = `q${uniqueId}:${a.slot}_:sequencecheck`;
1130
+ params[`data[${i}][value]`] = seq.toString();
1131
+ i++;
1132
+ }
1133
+ // Detect answer format:
1134
+ // Comma-separated numeric values = multichoices (checkboxes)
1135
+ // Single numeric value = multichoice (radio)
1136
+ // Non-numeric text = shortanswer
1137
+ if (/^\d+(,\d+)*$/.test(a.answer) && a.answer.includes(",")) {
1138
+ // Multichoices: send each choice as qXXX:Y_choiceN with value 1
1139
+ const choices = a.answer.split(",");
1140
+ for (const choice of choices) {
1141
+ params[`data[${i}][name]`] = `q${uniqueId}:${a.slot}_choice${choice}`;
1142
+ params[`data[${i}][value]`] = "1";
1143
+ i++;
1144
+ }
1145
+ }
1146
+ else {
1147
+ // Single choice or shortanswer: send as qXXX:Y_answer
1148
+ params[`data[${i}][name]`] = `q${uniqueId}:${a.slot}_answer`;
1149
+ params[`data[${i}][value]`] = a.answer;
1150
+ i++;
1151
+ }
1152
+ }
1153
+ const data = await moodleApiCall(session, "mod_quiz_process_attempt", params);
1154
+ return { state: data?.state ?? "unknown", warnings: data?.warnings };
851
1155
  }
852
1156
  // ── Materials via WS API ──────────────────────────────────────────────────────
853
1157
  /**
@@ -872,6 +1176,200 @@ async function getResourcesByCoursesApi(session, courseIds) {
872
1176
  };
873
1177
  });
874
1178
  }
1179
+ /**
1180
+ * Get assignments in courses via pure WS API.
1181
+ */
1182
+ async function getAssignmentsByCoursesApi(session, courseIds) {
1183
+ if (courseIds.length === 0)
1184
+ return [];
1185
+ const data = await moodleApiCall(session, "mod_assign_get_assignments", { courseids: courseIds });
1186
+ const assignments = [];
1187
+ // The API returns an array of courses, each containing assignments
1188
+ for (const course of (data?.courses ?? [])) {
1189
+ if (!course.assignments)
1190
+ continue;
1191
+ for (const a of course.assignments) {
1192
+ assignments.push({
1193
+ id: a.id,
1194
+ cmid: a.cmid?.toString() ?? "",
1195
+ name: a.name,
1196
+ url: a.viewurl ?? "",
1197
+ courseId: course.id,
1198
+ duedate: a.duedate,
1199
+ cutoffdate: a.cutoffdate,
1200
+ allowSubmissionsFromDate: a.allowsubmissionsfromdate,
1201
+ gradingduedate: a.gradingduedate,
1202
+ lateSubmission: a.latesubmissions ? true : false,
1203
+ extensionduedate: a.extensionduedate,
1204
+ });
1205
+ }
1206
+ }
1207
+ return assignments;
1208
+ }
1209
+ /**
1210
+ * Get assignment submission status via pure WS API.
1211
+ */
1212
+ async function getSubmissionStatusApi(session, assignmentId) {
1213
+ const siteInfo = await getSiteInfoApi(session);
1214
+ const data = await moodleApiCall(session, "mod_assign_get_submission_status", {
1215
+ assignid: assignmentId,
1216
+ userid: siteInfo.userid,
1217
+ });
1218
+ const lastAttempt = data?.lastattempt;
1219
+ // Note: API returns "submission" (singular), not "submissions" (plural)
1220
+ const submission = lastAttempt?.submission;
1221
+ // Find file submissions from submission plugins
1222
+ const plugins = submission?.plugins || [];
1223
+ const filePlugin = plugins.find((p) => p.type === "file");
1224
+ const extensions = (filePlugin?.fileareas || [])
1225
+ .flatMap((fa) => fa?.files || [])
1226
+ .map((f) => ({
1227
+ id: f.id,
1228
+ filename: f.filename,
1229
+ filesize: f.filesize,
1230
+ }));
1231
+ // Get feedback from the separate feedback object
1232
+ const feedback = data?.feedback;
1233
+ const commentsPlugin = feedback?.plugins?.find((p) => p.type === "comments");
1234
+ const commentText = commentsPlugin?.editorfields?.find((e) => e.name === "comments")?.text || null;
1235
+ return {
1236
+ submitted: submission?.status === "submitted",
1237
+ graded: lastAttempt?.gradingstatus === "graded",
1238
+ grader: feedback?.gradername || null,
1239
+ grade: feedback?.gradefordisplay || null,
1240
+ feedback: commentText,
1241
+ lastModified: submission?.timemodified || null,
1242
+ extensions,
1243
+ };
1244
+ }
1245
+ /**
1246
+ * Save/submit an assignment via pure WS API.
1247
+ * Supports online text submissions and file submissions (by draft ID).
1248
+ */
1249
+ async function saveSubmissionApi(session, assignmentId, options) {
1250
+ try {
1251
+ const siteInfo = await getSiteInfoApi(session);
1252
+ // Build plugins array based on submission type
1253
+ const plugins = [];
1254
+ if (options.onlineText) {
1255
+ plugins.push({
1256
+ type: "onlinetext",
1257
+ online_text: {
1258
+ text: options.onlineText.text,
1259
+ format: options.onlineText.format ?? 1,
1260
+ itemid: 0,
1261
+ },
1262
+ });
1263
+ }
1264
+ if (options.fileId !== undefined) {
1265
+ plugins.push({
1266
+ type: "file",
1267
+ files_filemanager: options.fileId,
1268
+ });
1269
+ }
1270
+ await moodleApiCall(session, "mod_assign_save_submission", {
1271
+ assignmentid: assignmentId,
1272
+ userid: siteInfo.userid,
1273
+ plugins,
1274
+ });
1275
+ return { success: true };
1276
+ }
1277
+ catch (e) {
1278
+ return { success: false, error: e instanceof Error ? e.message : String(e) };
1279
+ }
1280
+ }
1281
+ // ── File Upload via WS API ──────────────────────────────────────────────────────
1282
+ /**
1283
+ * Generate a unique draft item ID.
1284
+ * Uses timestamp (last 8 digits) to ensure uniqueness.
1285
+ */
1286
+ function generateDraftItemId() {
1287
+ // Use current timestamp in seconds, take last 8 digits
1288
+ return Math.floor(Date.now() / 1000) % 100000000;
1289
+ }
1290
+ /**
1291
+ * Upload a file to Moodle draft area via pure WS API.
1292
+ * This is the first step before submitting files to assignments, forums, etc.
1293
+ *
1294
+ * Note: We generate our own draft item ID instead of asking Moodle for one.
1295
+ */
1296
+ async function uploadFileApi(session, filePath, options) {
1297
+ try {
1298
+ // Generate or use provided draft ID
1299
+ const draftItemId = options?.draftId ?? generateDraftItemId();
1300
+ // Read file content using fs.promises
1301
+ const fileContent = await fs.promises.readFile(filePath);
1302
+ const fileName = options?.filename || filePath.split(/[/\\]/).pop() || "unknown";
1303
+ // Get site info for user context
1304
+ const siteInfo = await getSiteInfoApi(session);
1305
+ const userContextId = getUserContextId(siteInfo.userid);
1306
+ // Prepare multipart form data
1307
+ const formData = new FormData();
1308
+ formData.append("token", session.wsToken);
1309
+ formData.append("file", new Blob([new Uint8Array(fileContent)]), fileName);
1310
+ formData.append("filepath", options?.filepath || "/");
1311
+ formData.append("itemid", String(draftItemId)); // Use our generated draft ID
1312
+ formData.append("contextid", String(userContextId)); // Use calculated user context
1313
+ formData.append("component", "user");
1314
+ formData.append("filearea", "draft");
1315
+ formData.append("qformat", ""); // Not used
1316
+ // Upload via upload.php (uses multipart/form-data)
1317
+ const url = `${session.moodleBaseUrl}/webservice/upload.php`;
1318
+ const response = await fetch(url, {
1319
+ method: "POST",
1320
+ body: formData,
1321
+ });
1322
+ if (!response.ok) {
1323
+ return { success: false, error: `HTTP ${response.status}` };
1324
+ }
1325
+ const result = await response.json();
1326
+ console.debug(`[DEBUG] upload.php response:`, JSON.stringify(result, null, 2));
1327
+ // Check for errors in response
1328
+ if (result?.error) {
1329
+ return { success: false, error: result.message ?? result.error ?? "Upload failed" };
1330
+ }
1331
+ // Success - return the draft ID we used
1332
+ return { success: true, draftId: draftItemId };
1333
+ }
1334
+ catch (e) {
1335
+ return { success: false, error: e instanceof Error ? e.message : String(e) };
1336
+ }
1337
+ }
1338
+ /**
1339
+ * Calculate user context ID in Moodle.
1340
+ * User context ID = (userid * CONTEXT_DEPTH) + CONTEXT_USER
1341
+ * Where CONTEXT_DEPTH = 10 and CONTEXT_USER = 30
1342
+ */
1343
+ function getUserContextId(userId) {
1344
+ return userId * 10 + 30;
1345
+ }
1346
+ /**
1347
+ * Get user's private files (not draft) via pure WS API.
1348
+ * Draft files are temporary and cannot be listed, but private files can.
1349
+ */
1350
+ async function getDraftFilesApi(session) {
1351
+ const siteInfo = await getSiteInfoApi(session);
1352
+ const userContextId = getUserContextId(siteInfo.userid);
1353
+ const data = await moodleApiCall(session, "core_files_get_files", {
1354
+ contextid: userContextId,
1355
+ component: "user",
1356
+ filearea: "private",
1357
+ itemid: 0,
1358
+ filepath: "/",
1359
+ modified: null,
1360
+ });
1361
+ console.debug(`[DEBUG] core_files_get_files response:`, JSON.stringify(data, null, 2));
1362
+ // The API returns a parents array with files inside
1363
+ const files = data?.parents?.[0]?.files || data?.files || [];
1364
+ return files.map((f) => ({
1365
+ itemId: f.itemid || 0,
1366
+ filename: f.filename || "",
1367
+ filesize: f.filesize || 0,
1368
+ filepath: f.filepath || "/",
1369
+ timeModified: f.timemodified || 0,
1370
+ url: f.url || "",
1371
+ }));
1372
+ }
875
1373
  /**
876
1374
  * Get messages for the current user via pure WS API.
877
1375
  */