@mo7yw4ng/openape 1.0.5 → 2.0.3

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 (152) hide show
  1. package/bin/openape +29 -0
  2. package/bin/openape.js +29 -0
  3. package/package.json +22 -28
  4. package/LICENSE +0 -21
  5. package/README.md +0 -135
  6. package/esm/_dnt.polyfills.d.ts +0 -101
  7. package/esm/_dnt.polyfills.d.ts.map +0 -1
  8. package/esm/_dnt.polyfills.js +0 -127
  9. package/esm/_dnt.shims.d.ts +0 -6
  10. package/esm/_dnt.shims.d.ts.map +0 -1
  11. package/esm/_dnt.shims.js +0 -61
  12. package/esm/deno.d.ts +0 -25
  13. package/esm/deno.d.ts.map +0 -1
  14. package/esm/deno.js +0 -23
  15. package/esm/package.json +0 -3
  16. package/esm/src/commands/announcements.d.ts +0 -3
  17. package/esm/src/commands/announcements.d.ts.map +0 -1
  18. package/esm/src/commands/announcements.js +0 -134
  19. package/esm/src/commands/assignments.d.ts +0 -3
  20. package/esm/src/commands/assignments.d.ts.map +0 -1
  21. package/esm/src/commands/assignments.js +0 -230
  22. package/esm/src/commands/auth.d.ts +0 -3
  23. package/esm/src/commands/auth.d.ts.map +0 -1
  24. package/esm/src/commands/auth.js +0 -290
  25. package/esm/src/commands/calendar.d.ts +0 -3
  26. package/esm/src/commands/calendar.d.ts.map +0 -1
  27. package/esm/src/commands/calendar.js +0 -179
  28. package/esm/src/commands/courses.d.ts +0 -3
  29. package/esm/src/commands/courses.d.ts.map +0 -1
  30. package/esm/src/commands/courses.js +0 -348
  31. package/esm/src/commands/forums.d.ts +0 -3
  32. package/esm/src/commands/forums.d.ts.map +0 -1
  33. package/esm/src/commands/forums.js +0 -318
  34. package/esm/src/commands/grades.d.ts +0 -3
  35. package/esm/src/commands/grades.d.ts.map +0 -1
  36. package/esm/src/commands/grades.js +0 -121
  37. package/esm/src/commands/materials.d.ts +0 -3
  38. package/esm/src/commands/materials.d.ts.map +0 -1
  39. package/esm/src/commands/materials.js +0 -413
  40. package/esm/src/commands/quizzes.d.ts +0 -3
  41. package/esm/src/commands/quizzes.d.ts.map +0 -1
  42. package/esm/src/commands/quizzes.js +0 -271
  43. package/esm/src/commands/skills.d.ts +0 -3
  44. package/esm/src/commands/skills.d.ts.map +0 -1
  45. package/esm/src/commands/skills.js +0 -106
  46. package/esm/src/commands/upload.d.ts +0 -3
  47. package/esm/src/commands/upload.d.ts.map +0 -1
  48. package/esm/src/commands/upload.js +0 -58
  49. package/esm/src/commands/videos.d.ts +0 -3
  50. package/esm/src/commands/videos.d.ts.map +0 -1
  51. package/esm/src/commands/videos.js +0 -336
  52. package/esm/src/index.d.ts +0 -27
  53. package/esm/src/index.d.ts.map +0 -1
  54. package/esm/src/index.js +0 -160
  55. package/esm/src/lib/auth.d.ts +0 -47
  56. package/esm/src/lib/auth.d.ts.map +0 -1
  57. package/esm/src/lib/auth.js +0 -227
  58. package/esm/src/lib/config.d.ts +0 -6
  59. package/esm/src/lib/config.d.ts.map +0 -1
  60. package/esm/src/lib/config.js +0 -36
  61. package/esm/src/lib/logger.d.ts +0 -3
  62. package/esm/src/lib/logger.d.ts.map +0 -1
  63. package/esm/src/lib/logger.js +0 -27
  64. package/esm/src/lib/moodle.d.ts +0 -433
  65. package/esm/src/lib/moodle.d.ts.map +0 -1
  66. package/esm/src/lib/moodle.js +0 -1318
  67. package/esm/src/lib/session.d.ts +0 -8
  68. package/esm/src/lib/session.d.ts.map +0 -1
  69. package/esm/src/lib/session.js +0 -42
  70. package/esm/src/lib/token.d.ts +0 -38
  71. package/esm/src/lib/token.d.ts.map +0 -1
  72. package/esm/src/lib/token.js +0 -178
  73. package/esm/src/lib/types.d.ts +0 -189
  74. package/esm/src/lib/types.d.ts.map +0 -1
  75. package/esm/src/lib/types.js +0 -2
  76. package/esm/src/lib/utils.d.ts +0 -57
  77. package/esm/src/lib/utils.d.ts.map +0 -1
  78. package/esm/src/lib/utils.js +0 -129
  79. package/script/_dnt.polyfills.d.ts +0 -101
  80. package/script/_dnt.polyfills.d.ts.map +0 -1
  81. package/script/_dnt.polyfills.js +0 -130
  82. package/script/_dnt.shims.d.ts +0 -6
  83. package/script/_dnt.shims.d.ts.map +0 -1
  84. package/script/_dnt.shims.js +0 -65
  85. package/script/deno.d.ts +0 -25
  86. package/script/deno.d.ts.map +0 -1
  87. package/script/deno.js +0 -25
  88. package/script/package.json +0 -3
  89. package/script/src/commands/announcements.d.ts +0 -3
  90. package/script/src/commands/announcements.d.ts.map +0 -1
  91. package/script/src/commands/announcements.js +0 -140
  92. package/script/src/commands/assignments.d.ts +0 -3
  93. package/script/src/commands/assignments.d.ts.map +0 -1
  94. package/script/src/commands/assignments.js +0 -269
  95. package/script/src/commands/auth.d.ts +0 -3
  96. package/script/src/commands/auth.d.ts.map +0 -1
  97. package/script/src/commands/auth.js +0 -296
  98. package/script/src/commands/calendar.d.ts +0 -3
  99. package/script/src/commands/calendar.d.ts.map +0 -1
  100. package/script/src/commands/calendar.js +0 -185
  101. package/script/src/commands/courses.d.ts +0 -3
  102. package/script/src/commands/courses.d.ts.map +0 -1
  103. package/script/src/commands/courses.js +0 -354
  104. package/script/src/commands/forums.d.ts +0 -3
  105. package/script/src/commands/forums.d.ts.map +0 -1
  106. package/script/src/commands/forums.js +0 -324
  107. package/script/src/commands/grades.d.ts +0 -3
  108. package/script/src/commands/grades.d.ts.map +0 -1
  109. package/script/src/commands/grades.js +0 -127
  110. package/script/src/commands/materials.d.ts +0 -3
  111. package/script/src/commands/materials.d.ts.map +0 -1
  112. package/script/src/commands/materials.js +0 -419
  113. package/script/src/commands/quizzes.d.ts +0 -3
  114. package/script/src/commands/quizzes.d.ts.map +0 -1
  115. package/script/src/commands/quizzes.js +0 -277
  116. package/script/src/commands/skills.d.ts +0 -3
  117. package/script/src/commands/skills.d.ts.map +0 -1
  118. package/script/src/commands/skills.js +0 -112
  119. package/script/src/commands/upload.d.ts +0 -3
  120. package/script/src/commands/upload.d.ts.map +0 -1
  121. package/script/src/commands/upload.js +0 -64
  122. package/script/src/commands/videos.d.ts +0 -3
  123. package/script/src/commands/videos.d.ts.map +0 -1
  124. package/script/src/commands/videos.js +0 -342
  125. package/script/src/index.d.ts +0 -27
  126. package/script/src/index.d.ts.map +0 -1
  127. package/script/src/index.js +0 -167
  128. package/script/src/lib/auth.d.ts +0 -47
  129. package/script/src/lib/auth.d.ts.map +0 -1
  130. package/script/src/lib/auth.js +0 -269
  131. package/script/src/lib/config.d.ts +0 -6
  132. package/script/src/lib/config.d.ts.map +0 -1
  133. package/script/src/lib/config.js +0 -42
  134. package/script/src/lib/logger.d.ts +0 -3
  135. package/script/src/lib/logger.d.ts.map +0 -1
  136. package/script/src/lib/logger.js +0 -30
  137. package/script/src/lib/moodle.d.ts +0 -433
  138. package/script/src/lib/moodle.d.ts.map +0 -1
  139. package/script/src/lib/moodle.js +0 -1389
  140. package/script/src/lib/session.d.ts +0 -8
  141. package/script/src/lib/session.d.ts.map +0 -1
  142. package/script/src/lib/session.js +0 -45
  143. package/script/src/lib/token.d.ts +0 -38
  144. package/script/src/lib/token.d.ts.map +0 -1
  145. package/script/src/lib/token.js +0 -189
  146. package/script/src/lib/types.d.ts +0 -189
  147. package/script/src/lib/types.d.ts.map +0 -1
  148. package/script/src/lib/types.js +0 -3
  149. package/script/src/lib/utils.d.ts +0 -57
  150. package/script/src/lib/utils.d.ts.map +0 -1
  151. package/script/src/lib/utils.js +0 -175
  152. package/skills/openape/SKILL.md +0 -115
@@ -1,1389 +0,0 @@
1
- "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
34
- })();
35
- Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.moodleApiCall = moodleApiCall;
37
- exports.moodleAjax = moodleAjax;
38
- exports.getEnrolledCoursesApi = getEnrolledCoursesApi;
39
- exports.getEnrolledCourses = getEnrolledCourses;
40
- exports.getCourseState = getCourseState;
41
- exports.getSupervideosInCourse = getSupervideosInCourse;
42
- exports.getForumsApi = getForumsApi;
43
- exports.getForumDiscussionsApi = getForumDiscussionsApi;
44
- exports.getDiscussionPostsApi = getDiscussionPostsApi;
45
- exports.deleteForumPostApi = deleteForumPostApi;
46
- exports.addForumDiscussionApi = addForumDiscussionApi;
47
- exports.addForumPostApi = addForumPostApi;
48
- exports.getResourcesInCourse = getResourcesInCourse;
49
- exports.getCalendarEventsApi = getCalendarEventsApi;
50
- exports.getCourseGradesApi = getCourseGradesApi;
51
- exports.getVideoMetadata = getVideoMetadata;
52
- exports.downloadVideo = downloadVideo;
53
- exports.completeVideoApi = completeVideoApi;
54
- exports.completeVideo = completeVideo;
55
- exports.updateActivityCompletionStatusManually = updateActivityCompletionStatusManually;
56
- exports.getSiteInfoApi = getSiteInfoApi;
57
- exports.getIncompleteVideosApi = getIncompleteVideosApi;
58
- exports.getSupervideosInCourseApi = getSupervideosInCourseApi;
59
- exports.getQuizzesByCoursesApi = getQuizzesByCoursesApi;
60
- exports.startQuizAttemptApi = startQuizAttemptApi;
61
- exports.getAllQuizAttemptDataApi = getAllQuizAttemptDataApi;
62
- exports.getQuizAttemptDataApi = getQuizAttemptDataApi;
63
- exports.processQuizAttemptApi = processQuizAttemptApi;
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;
71
- exports.getMessagesApi = getMessagesApi;
72
- const dntShim = __importStar(require("../../_dnt.shims.js"));
73
- const utils_js_1 = require("./utils.js");
74
- const fs = __importStar(require("node:fs"));
75
- // ── Core Moodle AJAX Wrapper ───────────────────────────────────────────
76
- /**
77
- * Moodle WS API functions that are known to work via /webservice/rest/server.php
78
- * Other functions should use the sesskey-based AJAX API.
79
- */
80
- const WS_API_FUNCTIONS = new Set([
81
- "mod_forum_get_forums_by_courses",
82
- "mod_forum_get_forum_discussions",
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",
90
- "gradereport_user_get_grade_items",
91
- "core_calendar_get_calendar_events",
92
- "core_course_get_contents",
93
- "core_course_get_course_module",
94
- "core_completion_get_activities_completion_status",
95
- "core_completion_update_activity_completion_status_manually",
96
- "mod_supervideo_progress_save",
97
- "mod_supervideo_progress_save_mobile",
98
- "mod_supervideo_view_supervideo",
99
- "mod_quiz_get_quizzes_by_courses",
100
- "mod_quiz_start_attempt",
101
- "mod_quiz_get_attempt_data",
102
- "mod_resource_get_resources_by_courses",
103
- "mod_assign_get_assignments",
104
- "mod_assign_save_submission",
105
- "mod_assign_get_submission_status",
106
- "core_message_get_messages",
107
- "core_webservice_get_site_info",
108
- ]);
109
- /**
110
- * Convert args to URLSearchParams, handling arrays properly for Moodle WS API.
111
- * Moodle expects array parameters as: courseids[0]=1&courseids[1]=2
112
- * For options array: options[0][name]=attachmentsid&options[0][value]=123
113
- */
114
- function buildWsParams(args) {
115
- const params = new URLSearchParams();
116
- for (const [key, value] of Object.entries(args)) {
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)) {
127
- // Array parameters: courseids[0]=1&courseids[1]=2
128
- value.forEach((v, i) => {
129
- params.append(`${key}[${i}]`, String(v));
130
- });
131
- }
132
- else if (value !== null && value !== undefined) {
133
- params.append(key, String(value));
134
- }
135
- }
136
- return params;
137
- }
138
- /**
139
- * Direct HTTP API call without browser (for WS API only).
140
- * This is much faster than browser-based calls.
141
- */
142
- async function moodleApiCall(session, methodname, args) {
143
- if (!session.wsToken) {
144
- throw new Error(`WS ${methodname} required for API call: ${methodname}`);
145
- }
146
- const params = buildWsParams(args);
147
- params.set("wstoken", session.wsToken);
148
- params.set("wsfunction", methodname);
149
- params.set("moodlewsrestformat", "json");
150
- const url = `${session.moodleBaseUrl}/webservice/rest/server.php?${params.toString()}`;
151
- const response = await fetch(url, { method: "GET" });
152
- const result = await response.json();
153
- if (result.error) {
154
- throw new Error(`WS ${methodname} failed: ${result.message ?? result.exception?.message ?? "Unknown error"}`);
155
- }
156
- return result;
157
- }
158
- /**
159
- * Send a Moodle AJAX request and return the result.
160
- * Uses Web Service token if available AND the function is in WS_API_FUNCTIONS,
161
- * otherwise falls back to sesskey-based AJAX (via /lib/ajax/service.php).
162
- */
163
- async function moodleAjax(page, session, methodname, args) {
164
- // Only use WS API for known WS functions
165
- const useWsApi = session.wsToken && WS_API_FUNCTIONS.has(methodname);
166
- if (useWsApi) {
167
- // Use Moodle Web Service API
168
- // Format: /webservice/rest/server.php?wstoken=TOKEN&wsfunction=FUNCTION&moodlewsrestformat=json
169
- const params = buildWsParams(args);
170
- params.set("wstoken", session.wsToken);
171
- params.set("wsfunction", methodname);
172
- params.set("moodlewsrestformat", "json");
173
- const url = `${session.moodleBaseUrl}/webservice/rest/server.php?${params.toString()}`;
174
- const result = await page.evaluate(async ({ url }) => {
175
- const res = await fetch(url, { method: "GET" });
176
- return res.json();
177
- }, { url });
178
- if (result.error) {
179
- throw new Error(`WS ${methodname} failed: ${result.message ?? result.exception?.message ?? "Unknown error"}`);
180
- }
181
- return result;
182
- }
183
- else {
184
- // Legacy sesskey-based AJAX format
185
- const url = `${session.moodleBaseUrl}/lib/ajax/service.php?sesskey=${session.sesskey}&info=${methodname}`;
186
- const payload = [{ index: 0, methodname, args }];
187
- const result = await page.evaluate(async ({ url, payload }) => {
188
- const res = await fetch(url, {
189
- method: "POST",
190
- headers: { "Content-Type": "application/json" },
191
- body: JSON.stringify(payload),
192
- });
193
- return res.json();
194
- }, { url, payload });
195
- if (result?.[0]?.error) {
196
- throw new Error(`AJAX ${methodname} failed: ${result[0].exception?.message ?? "Unknown error"}`);
197
- }
198
- return result[0].data;
199
- }
200
- }
201
- // ── Course Operations ─────────────────────────────────────────────────────
202
- /**
203
- * Fetch enrolled courses via pure API (no browser required).
204
- * Fast and lightweight - uses HTTP fetch directly.
205
- */
206
- async function getEnrolledCoursesApi(session, options = {}) {
207
- const data = await moodleApiCall(session, "core_course_get_enrolled_courses_by_timeline_classification", {
208
- offset: 0,
209
- limit: options.limit ?? 0,
210
- classification: options.classification ?? "all",
211
- sort: "fullname",
212
- customfieldname: "",
213
- customfieldvalue: "",
214
- requiredfields: [
215
- "id",
216
- "fullname",
217
- "shortname",
218
- "idnumber",
219
- "category",
220
- "progress",
221
- "startdate",
222
- "enddate",
223
- ],
224
- });
225
- return (data?.courses ?? []).map((c) => ({
226
- id: c.id,
227
- fullname: (0, utils_js_1.extractCourseName)(c.fullname),
228
- shortname: c.shortname,
229
- idnumber: c.idnumber,
230
- category: c.category?.name,
231
- progress: c.progress,
232
- startdate: c.startdate,
233
- enddate: c.enddate,
234
- }));
235
- }
236
- /**
237
- * Fetch all enrolled courses via Moodle AJAX API.
238
- */
239
- async function getEnrolledCourses(page, session, log, options = {}) {
240
- log.debug("Fetching enrolled courses via AJAX...");
241
- const data = await moodleAjax(page, session, "core_course_get_enrolled_courses_by_timeline_classification", {
242
- offset: 0,
243
- limit: options.limit ?? 0,
244
- classification: options.classification ?? "all",
245
- sort: "fullname",
246
- customfieldname: "",
247
- customfieldvalue: "",
248
- requiredfields: [
249
- "id",
250
- "fullname",
251
- "shortname",
252
- "showcoursecategory",
253
- "showshortname",
254
- "visible",
255
- "enddate",
256
- ],
257
- });
258
- const courses = (data?.courses ?? []).map((c) => ({
259
- id: c.id,
260
- fullname: (0, utils_js_1.extractCourseName)(c.fullname),
261
- shortname: c.shortname,
262
- idnumber: c.idnumber,
263
- category: c.category?.name,
264
- progress: c.progress,
265
- startdate: c.startdate,
266
- enddate: c.enddate,
267
- }));
268
- log.debug(`Found ${courses.length} course${courses.length === 1 ? "" : "s"}.`);
269
- return courses;
270
- }
271
- /**
272
- * Get course state (modules) via core_courseformat_get_state.
273
- */
274
- async function getCourseState(page, session, courseId) {
275
- const data = await moodleAjax(page, session, "core_courseformat_get_state", {
276
- courseid: courseId,
277
- });
278
- return typeof data === "string" ? JSON.parse(data) : data;
279
- }
280
- // ── Video Operations ──────────────────────────────────────────────────────
281
- /**
282
- * Get all SuperVideo modules in a course.
283
- */
284
- async function getSupervideosInCourse(page, session, courseId, log, options = {}) {
285
- const state = await getCourseState(page, session, courseId);
286
- const cms = state?.cm ?? [];
287
- const allSupervideos = cms.filter((cm) => cm.module === "supervideo" || cm.modname === "supervideo");
288
- // Filter: Only include videos with completion tracking enabled (have completionstate field)
289
- // and are not yet completed (completionstate != 1 or isoverallcomplete != true)
290
- const incomplete = allSupervideos.filter((cm) => {
291
- // Has completionstate field = completion tracking is enabled for this video
292
- const hasCompletionTracking = "completionstate" in cm;
293
- // Is not yet completed
294
- const isIncomplete = cm.completionstate !== 1 && cm.isoverallcomplete !== true;
295
- return hasCompletionTracking && isIncomplete;
296
- });
297
- log.debug(` SuperVideo: ${allSupervideos.length} total, ${incomplete.length} incomplete (with completion enabled)`);
298
- // Return only incomplete if requested, otherwise return all
299
- const videos = options.incompleteOnly ? incomplete : allSupervideos;
300
- return videos.map((cm) => ({
301
- cmid: cm.cmid?.toString() ?? cm.id?.toString() ?? "",
302
- name: cm.name,
303
- url: cm.url,
304
- isComplete: !!cm.isoverallcomplete,
305
- }));
306
- }
307
- // ── Forum Operations ──────────────────────────────────────────────────────
308
- /**
309
- * Get all forums via pure WS API (no browser required).
310
- * Fast and lightweight - uses HTTP fetch directly.
311
- */
312
- async function getForumsApi(session, courseIds) {
313
- const data = await moodleApiCall(session, "mod_forum_get_forums_by_courses", { courseids: courseIds });
314
- return (data ?? []).map((f) => ({
315
- id: f.id,
316
- cmid: f.cmid,
317
- name: f.name,
318
- intro: f.intro,
319
- courseid: f.course, // API returns 'course' not 'courseid'
320
- timemodified: f.timemodified,
321
- }));
322
- }
323
- /**
324
- * Get discussions in a forum via WS API (no browser required).
325
- * Uses mod_forum_get_forum_discussions
326
- */
327
- async function getForumDiscussionsApi(session, forumId, options) {
328
- const params = { forumid: forumId, sortorder: options?.sortorder ?? 2 };
329
- if (options?.page !== undefined)
330
- params.page = options.page;
331
- if (options?.perpage !== undefined)
332
- params.perpage = options.perpage;
333
- if (options?.groupid !== undefined)
334
- params.groupid = options.groupid;
335
- const data = await moodleApiCall(session, "mod_forum_get_forum_discussions", params);
336
- return (data?.discussions ?? []).map((d) => ({
337
- id: d.discussion,
338
- forumId: d.forum,
339
- name: d.name,
340
- firstPostId: d.firstpost,
341
- userId: d.userid,
342
- userFullName: d.userfullname || "",
343
- groupId: d.groupid,
344
- timedue: d.timedue,
345
- timeModified: d.timemodified,
346
- timeStart: d.timestart,
347
- timeEnd: d.timeend,
348
- userModified: d.usermodified,
349
- userModifiedFullName: d.usermodifiedfullname,
350
- postCount: d.numreplies,
351
- unread: (d.numunread ?? 0) > 0,
352
- subject: (0, utils_js_1.stripHtmlTags)(d.subject ?? ""),
353
- message: d.message,
354
- pinned: d.pinned,
355
- locked: d.locked,
356
- starred: d.starred,
357
- }));
358
- }
359
- /**
360
- * Get posts in a discussion via WS API (no browser required).
361
- * Uses mod_forum_get_forum_discussion_posts
362
- */
363
- async function getDiscussionPostsApi(session, discussionId) {
364
- try {
365
- const data = await moodleApiCall(session, "mod_forum_get_discussion_posts", {
366
- discussionid: discussionId,
367
- });
368
- if (!data?.posts || data.posts.length === 0) {
369
- return [];
370
- }
371
- return data.posts.map((p) => ({
372
- id: p.id,
373
- subject: (0, utils_js_1.stripHtmlTags)(p.subject || ""),
374
- author: p.author?.fullname ?? "Unknown",
375
- authorId: p.author?.id ?? p.userid,
376
- created: p.timecreated,
377
- modified: p.timemodified,
378
- message: (0, utils_js_1.stripHtmlTags)(p.message || ""),
379
- discussionId: p.discussionid,
380
- unread: p.unread ?? false,
381
- }));
382
- }
383
- catch (error) {
384
- // Return empty array on error instead of throwing
385
- // This allows commands to gracefully handle inaccessible discussions
386
- return [];
387
- }
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
- }
471
- // ── Resource/Material Operations ──────────────────────────────────────────
472
- /**
473
- * Get all resource modules in a course.
474
- */
475
- async function getResourcesInCourse(page, session, courseId, log) {
476
- const state = await getCourseState(page, session, courseId);
477
- const cms = state?.cm ?? [];
478
- const resources = cms.filter((cm) => ["resource", "url"].includes(cm.module));
479
- log.debug(` Found ${resources.length} resource${resources.length === 1 ? "" : "s"}.`);
480
- return resources.map((cm) => ({
481
- cmid: cm.cmid?.toString() ?? cm.id?.toString() ?? "",
482
- name: cm.name,
483
- url: cm.url,
484
- courseId,
485
- modType: cm.module,
486
- mimetype: undefined,
487
- filesize: undefined,
488
- modified: 0,
489
- }));
490
- }
491
- // ── Calendar Operations ─────────────────────────────────────────────────────
492
- /**
493
- * Get calendar events via pure WS API (no browser required).
494
- * Fast and lightweight - uses HTTP fetch directly.
495
- */
496
- async function getCalendarEventsApi(session, options = {}) {
497
- const data = await moodleApiCall(session, "core_calendar_get_calendar_events", {
498
- ...options,
499
- });
500
- return (data?.events ?? []).map((e) => ({
501
- id: e.id,
502
- name: e.name,
503
- description: e.description,
504
- format: e.format,
505
- courseid: e.courseid,
506
- categoryid: e.categoryid,
507
- groupid: e.groupid,
508
- userid: e.userid,
509
- moduleid: e.moduleid,
510
- modulename: e.modulename,
511
- instance: e.instance,
512
- eventtype: e.eventtype,
513
- timestart: e.timestart * 1000, // Convert to milliseconds
514
- timeduration: e.timeduration ? e.timeduration * 1000 : undefined,
515
- timedue: e.timedue ? e.timedue * 1000 : undefined,
516
- visible: e.visible,
517
- location: e.location,
518
- }));
519
- }
520
- // ── Grade Operations ──────────────────────────────────────────────────────
521
- /**
522
- * Get course grades for the current user via pure WS API (no browser required).
523
- * Fast and lightweight - uses HTTP fetch directly.
524
- */
525
- async function getCourseGradesApi(session, courseId) {
526
- const data = await moodleApiCall(session, "gradereport_user_get_grade_items", { courseid: courseId });
527
- // The API returns grade items for the course
528
- const gradeItems = (data?.usergrades ?? []);
529
- // Return a single CourseGrade object with items array
530
- return {
531
- courseId,
532
- courseName: gradeItems[0]?.coursefullname ?? "",
533
- grade: gradeItems[0]?.grade,
534
- gradeFormatted: gradeItems[0]?.gradeformatted,
535
- rank: gradeItems[0]?.rank,
536
- totalUsers: gradeItems[0]?.totalusers,
537
- items: gradeItems.map((g) => ({
538
- id: g.id,
539
- name: g.itemname || g.itemtype,
540
- grade: g.grade,
541
- gradeFormatted: g.gradeformatted,
542
- range: g.grade ? `${g.grademin ?? 0}-${g.grademax ?? 100}` : undefined,
543
- })),
544
- };
545
- }
546
- // ── Video Metadata (from original course.ts) ───────────────────────────────
547
- /**
548
- * Visit a SuperVideo activity page and extract view_id + duration.
549
- */
550
- /**
551
- * Optimized video metadata extraction - minimal page load overhead.
552
- * Blocks images, fonts, stylesheets to speed up viewId extraction.
553
- */
554
- async function getVideoMetadata(page, activityUrl, log) {
555
- // Block unnecessary resources for faster loading
556
- await page.route("**/*.{png,jpg,jpeg,gif,webp,svg,ico,woff,woff2,ttf,css}", (route) => route.abort());
557
- await page.goto(activityUrl, { waitUntil: "domcontentloaded", timeout: 20000 });
558
- const name = await page.title();
559
- const pageSource = await page.content();
560
- let viewId = null;
561
- const viewIdPatterns = [
562
- /player_create.*?amd\.\w+\((\d+)/,
563
- /view_id['":\s]+(\d+)/,
564
- ];
565
- for (const pattern of viewIdPatterns) {
566
- const match = pageSource.match(pattern);
567
- if (match) {
568
- viewId = parseInt(match[1], 10);
569
- break;
570
- }
571
- }
572
- if (viewId === null) {
573
- throw new Error(`Could not extract view_id from ${activityUrl}`);
574
- }
575
- let duration = null;
576
- const isYoutube = pageSource.includes("youtube.com") || pageSource.includes("youtu.be");
577
- if (!isYoutube) {
578
- try {
579
- await page.waitForSelector("video", { timeout: 10000 });
580
- duration = await page.evaluate(() => {
581
- return new Promise((resolve) => {
582
- const media = document.querySelector("video");
583
- if (!media)
584
- return resolve(null);
585
- if (media.duration && isFinite(media.duration)) {
586
- return resolve(Math.ceil(media.duration));
587
- }
588
- media.addEventListener("loadedmetadata", () => {
589
- resolve(Math.ceil(media.duration));
590
- });
591
- setTimeout(() => resolve(null), 8000);
592
- });
593
- });
594
- }
595
- catch {
596
- // no video element
597
- }
598
- }
599
- if (!duration) {
600
- const durationMatch = pageSource.match(/["']?duration["']?\s*[:=]\s*(\d+)/);
601
- if (durationMatch) {
602
- duration = parseInt(durationMatch[1], 10);
603
- }
604
- }
605
- if (!duration) {
606
- duration = 600;
607
- log.debug(` Duration unknown${isYoutube ? " (YouTube)" : ""}, using ${duration}s`);
608
- }
609
- log.debug(` viewId=${viewId}, duration=${duration}s`);
610
- // Phase 1: Extract video sources
611
- const videoSources = [];
612
- const youtubeIds = [];
613
- // 1. Get src from <video> element
614
- const videoSrc = await page.evaluate(() => {
615
- const video = document.querySelector("video");
616
- return video?.src || null;
617
- });
618
- if (videoSrc)
619
- videoSources.push(videoSrc);
620
- // 2. Get src from <source> elements
621
- const sourceSrcs = await page.evaluate(() => {
622
- const sources = Array.from(document.querySelectorAll("source"));
623
- return sources.map(s => s.src).filter((src) => !!src);
624
- });
625
- videoSources.push(...sourceSrcs);
626
- // 3. Get src from <iframe> elements (YouTube, Vimeo, etc.)
627
- // Wait a bit for iframes to load
628
- await page.waitForTimeout(1000);
629
- const iframeSrcs = await page.evaluate(() => {
630
- const iframes = Array.from(document.querySelectorAll("iframe"));
631
- return iframes.map(f => f.src).filter((src) => !!src && src.length > 0);
632
- });
633
- // Extract YouTube video IDs from iframe URLs
634
- for (const iframeSrc of iframeSrcs) {
635
- videoSources.push(iframeSrc);
636
- // Extract YouTube video ID
637
- const ytMatch = iframeSrc.match(/(?:youtube\.com\/(?:embed\/|v\/|watch\?v=)|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
638
- if (ytMatch) {
639
- youtubeIds.push(ytMatch[1]);
640
- }
641
- }
642
- // 4. Check for blob/data URLs
643
- const hasBlobUrl = await page.evaluate(() => {
644
- const video = document.querySelector("video");
645
- const src = video?.src || "";
646
- return src.startsWith("blob:") || src.startsWith("data:");
647
- });
648
- // Deduplicate sources
649
- const uniqueSources = [...new Set(videoSources)];
650
- log.debug(` Found ${uniqueSources.length} video source(s)`);
651
- if (uniqueSources.length > 0) {
652
- log.debug(` Sources: ${uniqueSources.map(s => s.substring(0, 50) + (s.length > 50 ? "..." : "")).join(", ")}`);
653
- }
654
- if (youtubeIds.length > 0) {
655
- log.debug(` YouTube IDs: ${youtubeIds.join(", ")}`);
656
- }
657
- if (hasBlobUrl) {
658
- log.warn(` Video uses blob URL - cannot download directly`);
659
- }
660
- return {
661
- name,
662
- url: activityUrl,
663
- viewId,
664
- duration,
665
- existingPercent: 0,
666
- videoSources: uniqueSources,
667
- youtubeIds,
668
- };
669
- }
670
- // ── Video Download ─────────────────────────────────────────────────────────────
671
- /**
672
- * Download a video from SuperVideo activity.
673
- * Supports direct video URLs (pluginfile.php) and YouTube videos.
674
- */
675
- async function downloadVideo(page, metadata, outputPath, log) {
676
- const { name, videoSources, youtubeIds } = metadata;
677
- log.info(`正在下載: ${name}`);
678
- // Priority 1: Direct video URL (pluginfile.php, .mp4, etc.)
679
- const directUrl = videoSources.find(s => s.includes("pluginfile.php") ||
680
- s.endsWith(".mp4") ||
681
- s.endsWith(".webm") ||
682
- s.endsWith(".mov"));
683
- if (directUrl) {
684
- log.debug(` 類型: 直接下載 (${directUrl.substring(0, 60)}...)`);
685
- try {
686
- // Get session cookies from the page for authentication
687
- const cookies = await page.context().cookies();
688
- const cookieHeader = cookies
689
- .map(c => `${c.name}=${c.value}`)
690
- .join("; ");
691
- // Use native fetch with session cookies
692
- const response = await fetch(directUrl, {
693
- headers: {
694
- "Cookie": cookieHeader,
695
- },
696
- });
697
- if (!response.ok) {
698
- throw new Error(`HTTP ${response.status}`);
699
- }
700
- // Get array buffer and convert to Uint8Array
701
- const arrayBuffer = await response.arrayBuffer();
702
- const uint8Array = new Uint8Array(arrayBuffer);
703
- // Write to file
704
- await dntShim.Deno.writeFile(outputPath, uint8Array);
705
- return { success: true, path: outputPath, type: "direct" };
706
- }
707
- catch (e) {
708
- const msg = e instanceof Error ? e.message : String(e);
709
- log.error(` 下載失敗: ${msg}`);
710
- return { success: false, error: msg };
711
- }
712
- }
713
- // Priority 2: YouTube video
714
- if (youtubeIds && youtubeIds.length > 0) {
715
- log.debug(` 類型: YouTube (ID: ${youtubeIds[0]})`);
716
- return {
717
- success: false,
718
- error: `YouTube 影片無法直接下載。請使用 yt-dlp: yt-dlp https://www.youtube.com/watch?v=${youtubeIds[0]}`,
719
- type: "youtube",
720
- };
721
- }
722
- // Priority 3: Other iframe/embedded video
723
- if (videoSources.length > 0) {
724
- log.debug(` 類型: 嵌入影片 (${videoSources[0].substring(0, 60)}...)`);
725
- return {
726
- success: false,
727
- error: "嵌入影片無法直接下載",
728
- type: "embedded",
729
- };
730
- }
731
- return {
732
- success: false,
733
- error: "未找到影片來源",
734
- };
735
- }
736
- // ── Progress Completion (from original progress.ts) ───────────────────────
737
- /**
738
- * Build duration map array for video progress tracking.
739
- * Cached and scaled per duration to avoid repeated allocations.
740
- */
741
- function buildDurationMap(duration) {
742
- // Build the map array (0% to 100% in 1% increments)
743
- const map = Array.from({ length: 100 }, (_, i) => ({
744
- time: Math.round((duration * i) / 100),
745
- percent: i,
746
- }));
747
- return JSON.stringify(map);
748
- }
749
- /**
750
- * Complete a video using WS API (mobile service only).
751
- * Uses mod_supervideo_progress_save_mobile which is accessible via moodle_mobile_app service token.
752
- */
753
- async function completeVideoApi(session, video) {
754
- const { viewId, duration } = video;
755
- try {
756
- const result = await moodleApiCall(session, "mod_supervideo_progress_save_mobile", // Use mobile service specific function
757
- {
758
- view_id: viewId,
759
- currenttime: duration,
760
- duration: duration,
761
- percent: 100,
762
- mapa: buildDurationMap(duration),
763
- });
764
- // Debug: log the full result
765
- // console.debug(`completeVideoApi result:`, JSON.stringify(result));
766
- const success = result?.[0]?.success === true || result?.success === true;
767
- return { success, error: success ? undefined : `API returned success=false, result=${JSON.stringify(result)}`, result };
768
- }
769
- catch (err) {
770
- const msg = err instanceof Error ? err.message : String(err);
771
- // console.debug(`completeVideoApi error: ${msg}`);
772
- return { success: false, error: msg };
773
- }
774
- }
775
- /**
776
- * Complete a video by forging progress AJAX call (legacy, requires browser).
777
- * Note: This uses sesskey-based AJAX which works for mod_supervideo_progress_save.
778
- */
779
- async function completeVideo(page, session, video, log) {
780
- const { viewId, duration } = video;
781
- const payload = {
782
- view_id: viewId,
783
- currenttime: duration,
784
- duration: duration,
785
- percent: 100,
786
- mapa: buildDurationMap(duration),
787
- };
788
- const url = `${session.moodleBaseUrl}/lib/ajax/service.php?sesskey=${session.sesskey}&info=mod_supervideo_progress_save`;
789
- const ajaxPayload = [{ index: 0, methodname: "mod_supervideo_progress_save", args: payload }];
790
- try {
791
- const result = await page.evaluate(async ({ url, ajaxPayload }) => {
792
- const res = await fetch(url, {
793
- method: "POST",
794
- headers: { "Content-Type": "application/json" },
795
- body: JSON.stringify(ajaxPayload),
796
- });
797
- return res.json();
798
- }, { url, ajaxPayload });
799
- if (result?.[0]?.error) {
800
- log.debug(` Error: ${result[0].exception?.message ?? "Unknown error"}`);
801
- return false;
802
- }
803
- return true;
804
- }
805
- catch (err) {
806
- log.debug(` Exception: ${err instanceof Error ? err.message : String(err)}`);
807
- return false;
808
- }
809
- }
810
- /**
811
- * Update activity completion status manually via WS API.
812
- * Used for marking resources as complete/incomplete.
813
- */
814
- async function updateActivityCompletionStatusManually(session, cmid, completed) {
815
- try {
816
- const result = await moodleApiCall(session, "core_completion_update_activity_completion_status_manually", {
817
- cmid: cmid,
818
- completed: completed ? 1 : 0,
819
- });
820
- return result.status === true;
821
- }
822
- catch (e) {
823
- console.debug(`Failed to update completion status for cmid ${cmid}: ${e}`);
824
- return false;
825
- }
826
- }
827
- // ── Site Info (Get User ID) ───────────────────────────────────────────────────
828
- /** Cache for site info to avoid redundant API calls */
829
- let siteInfoCache = null;
830
- let siteInfoCacheTime = 0;
831
- const SITE_INFO_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
832
- /**
833
- * Get site info including current user ID via pure WS API.
834
- * Results are cached for 5 minutes to avoid redundant calls.
835
- */
836
- async function getSiteInfoApi(session) {
837
- const now = Date.now();
838
- if (siteInfoCache && (now - siteInfoCacheTime) < SITE_INFO_CACHE_TTL) {
839
- return siteInfoCache;
840
- }
841
- const data = await moodleApiCall(session, "core_webservice_get_site_info", {});
842
- siteInfoCache = {
843
- userid: data.userid,
844
- username: data.username,
845
- fullname: data.fullname,
846
- sitename: data.sitename,
847
- };
848
- siteInfoCacheTime = now;
849
- return siteInfoCache;
850
- }
851
- /**
852
- * Get incomplete supervideos with completion tracking enabled via WS API.
853
- * Uses core_completion_get_activities_completion_status to get only videos that:
854
- * 1. Have completion tracking enabled (hascompletion: true)
855
- * 2. Are not yet completed (isoverallcomplete: false or state !== 1)
856
- */
857
- async function getIncompleteVideosApi(session, courseId) {
858
- // Get user ID
859
- const siteInfo = await getSiteInfoApi(session);
860
- // Get completion status for all activities
861
- const completionData = await moodleApiCall(session, "core_completion_get_activities_completion_status", { courseid: courseId, userid: siteInfo.userid });
862
- if (!completionData?.statuses) {
863
- return [];
864
- }
865
- // Get course contents to get URLs
866
- const contentsData = await moodleApiCall(session, "core_course_get_contents", { courseid: courseId });
867
- // Create a map of cmid to URL
868
- const urlMap = new Map();
869
- for (const section of contentsData || []) {
870
- if (!section.modules)
871
- continue;
872
- for (const module of section.modules) {
873
- if (module.id) {
874
- urlMap.set(module.id, module.url);
875
- }
876
- }
877
- }
878
- // Filter for incomplete supervideos with completion tracking enabled
879
- const incompleteVideos = [];
880
- for (const status of completionData.statuses) {
881
- // Only supervideo modules
882
- if (status.modname !== "supervideo")
883
- continue;
884
- // Must have completion enabled
885
- if (!status.hascompletion)
886
- continue;
887
- // Must be incomplete
888
- if (status.isoverallcomplete === true || status.state === 1)
889
- continue;
890
- const url = urlMap.get(status.cmid) || "";
891
- incompleteVideos.push({
892
- cmid: status.cmid,
893
- name: status.name,
894
- url,
895
- });
896
- }
897
- return incompleteVideos;
898
- }
899
- // ── Videos via WS API ─────────────────────────────────────────────────────────
900
- /**
901
- * Get course contents and filter for SuperVideo modules via pure WS API.
902
- */
903
- async function getSupervideosInCourseApi(session, courseId) {
904
- const data = await moodleApiCall(session, "core_course_get_contents", { courseid: courseId });
905
- const videos = [];
906
- // data is an array of sections
907
- for (const section of data || []) {
908
- // Each section has modules array
909
- if (!section.modules)
910
- continue;
911
- for (const module of section.modules) {
912
- // Filter for SuperVideo modname
913
- if (module.modname === "supervideo") {
914
- videos.push({
915
- cmid: module.id.toString(),
916
- name: module.name,
917
- url: module.url,
918
- instance: module.instance, // supervideo instance id (not cmid!)
919
- isComplete: false, // Will be updated from completion API
920
- });
921
- }
922
- }
923
- }
924
- // Get completion status using core_completion_get_activities_completion_status
925
- try {
926
- // First get user ID
927
- const siteInfo = await getSiteInfoApi(session);
928
- const completionData = await moodleApiCall(session, "core_completion_get_activities_completion_status", { courseid: courseId, userid: siteInfo.userid });
929
- // Create a map of cmid to completion status
930
- const completionMap = new Map();
931
- if (completionData?.statuses) {
932
- for (const status of completionData.statuses) {
933
- // Only include modules that have completion enabled
934
- if (status.hascompletion) {
935
- completionMap.set(status.cmid, status.isoverallcomplete === true);
936
- }
937
- }
938
- }
939
- // Update isComplete based on completion data
940
- for (const video of videos) {
941
- const cmid = parseInt(video.cmid, 10);
942
- if (completionMap.has(cmid)) {
943
- video.isComplete = completionMap.get(cmid) ?? false;
944
- }
945
- }
946
- }
947
- catch (e) {
948
- // If completion API fails, continue with isComplete=false
949
- console.debug(`Failed to get completion status: ${e}`);
950
- }
951
- return videos;
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
- }
984
- /**
985
- * Get quizzes in courses via pure WS API.
986
- */
987
- async function getQuizzesByCoursesApi(session, courseIds) {
988
- if (courseIds.length === 0)
989
- return [];
990
- const data = await moodleApiCall(session, "mod_quiz_get_quizzes_by_courses", { courseids: courseIds });
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 };
1155
- }
1156
- // ── Materials via WS API ──────────────────────────────────────────────────────
1157
- /**
1158
- * Get resources in courses via pure WS API.
1159
- */
1160
- async function getResourcesByCoursesApi(session, courseIds) {
1161
- if (courseIds.length === 0)
1162
- return [];
1163
- const data = await moodleApiCall(session, "mod_resource_get_resources_by_courses", { courseids: courseIds });
1164
- return (data?.resources ?? []).map((r) => {
1165
- // Extract file info from contentfiles array
1166
- const firstFile = r.contentfiles?.[0];
1167
- return {
1168
- cmid: r.coursemodule?.toString() ?? r.id?.toString() ?? "",
1169
- name: r.name,
1170
- url: firstFile?.fileurl ?? "",
1171
- courseId: r.course,
1172
- modType: "resource", // This API only returns resources
1173
- mimetype: firstFile?.mimetype,
1174
- filesize: firstFile?.filesize,
1175
- modified: r.timemodified,
1176
- };
1177
- });
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
- }
1373
- /**
1374
- * Get messages for the current user via pure WS API.
1375
- */
1376
- async function getMessagesApi(session, userIdTo, options = {}) {
1377
- const data = await moodleApiCall(session, "core_message_get_messages", { useridto: userIdTo, ...options });
1378
- return (data?.messages ?? []).map((m) => ({
1379
- id: m.id,
1380
- useridfrom: m.useridfrom,
1381
- useridto: m.useridto,
1382
- subject: m.subject,
1383
- text: m.smallmessage,
1384
- timecreated: m.timecreated,
1385
- fullmessage: m.fullmessage,
1386
- fullmessageformat: m.fullmessageformat,
1387
- fullmessagehtml: m.fullmessagehtml,
1388
- }));
1389
- }