@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,1318 +0,0 @@
1
- import * as dntShim from "../../_dnt.shims.js";
2
- import { stripHtmlTags, extractCourseName } from "./utils.js";
3
- import * as fs from "node:fs";
4
- // ── Core Moodle AJAX Wrapper ───────────────────────────────────────────
5
- /**
6
- * Moodle WS API functions that are known to work via /webservice/rest/server.php
7
- * Other functions should use the sesskey-based AJAX API.
8
- */
9
- const WS_API_FUNCTIONS = new Set([
10
- "mod_forum_get_forums_by_courses",
11
- "mod_forum_get_forum_discussions",
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",
19
- "gradereport_user_get_grade_items",
20
- "core_calendar_get_calendar_events",
21
- "core_course_get_contents",
22
- "core_course_get_course_module",
23
- "core_completion_get_activities_completion_status",
24
- "core_completion_update_activity_completion_status_manually",
25
- "mod_supervideo_progress_save",
26
- "mod_supervideo_progress_save_mobile",
27
- "mod_supervideo_view_supervideo",
28
- "mod_quiz_get_quizzes_by_courses",
29
- "mod_quiz_start_attempt",
30
- "mod_quiz_get_attempt_data",
31
- "mod_resource_get_resources_by_courses",
32
- "mod_assign_get_assignments",
33
- "mod_assign_save_submission",
34
- "mod_assign_get_submission_status",
35
- "core_message_get_messages",
36
- "core_webservice_get_site_info",
37
- ]);
38
- /**
39
- * Convert args to URLSearchParams, handling arrays properly for Moodle WS API.
40
- * Moodle expects array parameters as: courseids[0]=1&courseids[1]=2
41
- * For options array: options[0][name]=attachmentsid&options[0][value]=123
42
- */
43
- function buildWsParams(args) {
44
- const params = new URLSearchParams();
45
- for (const [key, value] of Object.entries(args)) {
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)) {
56
- // Array parameters: courseids[0]=1&courseids[1]=2
57
- value.forEach((v, i) => {
58
- params.append(`${key}[${i}]`, String(v));
59
- });
60
- }
61
- else if (value !== null && value !== undefined) {
62
- params.append(key, String(value));
63
- }
64
- }
65
- return params;
66
- }
67
- /**
68
- * Direct HTTP API call without browser (for WS API only).
69
- * This is much faster than browser-based calls.
70
- */
71
- export async function moodleApiCall(session, methodname, args) {
72
- if (!session.wsToken) {
73
- throw new Error(`WS ${methodname} required for API call: ${methodname}`);
74
- }
75
- const params = buildWsParams(args);
76
- params.set("wstoken", session.wsToken);
77
- params.set("wsfunction", methodname);
78
- params.set("moodlewsrestformat", "json");
79
- const url = `${session.moodleBaseUrl}/webservice/rest/server.php?${params.toString()}`;
80
- const response = await fetch(url, { method: "GET" });
81
- const result = await response.json();
82
- if (result.error) {
83
- throw new Error(`WS ${methodname} failed: ${result.message ?? result.exception?.message ?? "Unknown error"}`);
84
- }
85
- return result;
86
- }
87
- /**
88
- * Send a Moodle AJAX request and return the result.
89
- * Uses Web Service token if available AND the function is in WS_API_FUNCTIONS,
90
- * otherwise falls back to sesskey-based AJAX (via /lib/ajax/service.php).
91
- */
92
- export async function moodleAjax(page, session, methodname, args) {
93
- // Only use WS API for known WS functions
94
- const useWsApi = session.wsToken && WS_API_FUNCTIONS.has(methodname);
95
- if (useWsApi) {
96
- // Use Moodle Web Service API
97
- // Format: /webservice/rest/server.php?wstoken=TOKEN&wsfunction=FUNCTION&moodlewsrestformat=json
98
- const params = buildWsParams(args);
99
- params.set("wstoken", session.wsToken);
100
- params.set("wsfunction", methodname);
101
- params.set("moodlewsrestformat", "json");
102
- const url = `${session.moodleBaseUrl}/webservice/rest/server.php?${params.toString()}`;
103
- const result = await page.evaluate(async ({ url }) => {
104
- const res = await fetch(url, { method: "GET" });
105
- return res.json();
106
- }, { url });
107
- if (result.error) {
108
- throw new Error(`WS ${methodname} failed: ${result.message ?? result.exception?.message ?? "Unknown error"}`);
109
- }
110
- return result;
111
- }
112
- else {
113
- // Legacy sesskey-based AJAX format
114
- const url = `${session.moodleBaseUrl}/lib/ajax/service.php?sesskey=${session.sesskey}&info=${methodname}`;
115
- const payload = [{ index: 0, methodname, args }];
116
- const result = await page.evaluate(async ({ url, payload }) => {
117
- const res = await fetch(url, {
118
- method: "POST",
119
- headers: { "Content-Type": "application/json" },
120
- body: JSON.stringify(payload),
121
- });
122
- return res.json();
123
- }, { url, payload });
124
- if (result?.[0]?.error) {
125
- throw new Error(`AJAX ${methodname} failed: ${result[0].exception?.message ?? "Unknown error"}`);
126
- }
127
- return result[0].data;
128
- }
129
- }
130
- // ── Course Operations ─────────────────────────────────────────────────────
131
- /**
132
- * Fetch enrolled courses via pure API (no browser required).
133
- * Fast and lightweight - uses HTTP fetch directly.
134
- */
135
- export async function getEnrolledCoursesApi(session, options = {}) {
136
- const data = await moodleApiCall(session, "core_course_get_enrolled_courses_by_timeline_classification", {
137
- offset: 0,
138
- limit: options.limit ?? 0,
139
- classification: options.classification ?? "all",
140
- sort: "fullname",
141
- customfieldname: "",
142
- customfieldvalue: "",
143
- requiredfields: [
144
- "id",
145
- "fullname",
146
- "shortname",
147
- "idnumber",
148
- "category",
149
- "progress",
150
- "startdate",
151
- "enddate",
152
- ],
153
- });
154
- return (data?.courses ?? []).map((c) => ({
155
- id: c.id,
156
- fullname: extractCourseName(c.fullname),
157
- shortname: c.shortname,
158
- idnumber: c.idnumber,
159
- category: c.category?.name,
160
- progress: c.progress,
161
- startdate: c.startdate,
162
- enddate: c.enddate,
163
- }));
164
- }
165
- /**
166
- * Fetch all enrolled courses via Moodle AJAX API.
167
- */
168
- export async function getEnrolledCourses(page, session, log, options = {}) {
169
- log.debug("Fetching enrolled courses via AJAX...");
170
- const data = await moodleAjax(page, session, "core_course_get_enrolled_courses_by_timeline_classification", {
171
- offset: 0,
172
- limit: options.limit ?? 0,
173
- classification: options.classification ?? "all",
174
- sort: "fullname",
175
- customfieldname: "",
176
- customfieldvalue: "",
177
- requiredfields: [
178
- "id",
179
- "fullname",
180
- "shortname",
181
- "showcoursecategory",
182
- "showshortname",
183
- "visible",
184
- "enddate",
185
- ],
186
- });
187
- const courses = (data?.courses ?? []).map((c) => ({
188
- id: c.id,
189
- fullname: extractCourseName(c.fullname),
190
- shortname: c.shortname,
191
- idnumber: c.idnumber,
192
- category: c.category?.name,
193
- progress: c.progress,
194
- startdate: c.startdate,
195
- enddate: c.enddate,
196
- }));
197
- log.debug(`Found ${courses.length} course${courses.length === 1 ? "" : "s"}.`);
198
- return courses;
199
- }
200
- /**
201
- * Get course state (modules) via core_courseformat_get_state.
202
- */
203
- export async function getCourseState(page, session, courseId) {
204
- const data = await moodleAjax(page, session, "core_courseformat_get_state", {
205
- courseid: courseId,
206
- });
207
- return typeof data === "string" ? JSON.parse(data) : data;
208
- }
209
- // ── Video Operations ──────────────────────────────────────────────────────
210
- /**
211
- * Get all SuperVideo modules in a course.
212
- */
213
- export async function getSupervideosInCourse(page, session, courseId, log, options = {}) {
214
- const state = await getCourseState(page, session, courseId);
215
- const cms = state?.cm ?? [];
216
- const allSupervideos = cms.filter((cm) => cm.module === "supervideo" || cm.modname === "supervideo");
217
- // Filter: Only include videos with completion tracking enabled (have completionstate field)
218
- // and are not yet completed (completionstate != 1 or isoverallcomplete != true)
219
- const incomplete = allSupervideos.filter((cm) => {
220
- // Has completionstate field = completion tracking is enabled for this video
221
- const hasCompletionTracking = "completionstate" in cm;
222
- // Is not yet completed
223
- const isIncomplete = cm.completionstate !== 1 && cm.isoverallcomplete !== true;
224
- return hasCompletionTracking && isIncomplete;
225
- });
226
- log.debug(` SuperVideo: ${allSupervideos.length} total, ${incomplete.length} incomplete (with completion enabled)`);
227
- // Return only incomplete if requested, otherwise return all
228
- const videos = options.incompleteOnly ? incomplete : allSupervideos;
229
- return videos.map((cm) => ({
230
- cmid: cm.cmid?.toString() ?? cm.id?.toString() ?? "",
231
- name: cm.name,
232
- url: cm.url,
233
- isComplete: !!cm.isoverallcomplete,
234
- }));
235
- }
236
- // ── Forum Operations ──────────────────────────────────────────────────────
237
- /**
238
- * Get all forums via pure WS API (no browser required).
239
- * Fast and lightweight - uses HTTP fetch directly.
240
- */
241
- export async function getForumsApi(session, courseIds) {
242
- const data = await moodleApiCall(session, "mod_forum_get_forums_by_courses", { courseids: courseIds });
243
- return (data ?? []).map((f) => ({
244
- id: f.id,
245
- cmid: f.cmid,
246
- name: f.name,
247
- intro: f.intro,
248
- courseid: f.course, // API returns 'course' not 'courseid'
249
- timemodified: f.timemodified,
250
- }));
251
- }
252
- /**
253
- * Get discussions in a forum via WS API (no browser required).
254
- * Uses mod_forum_get_forum_discussions
255
- */
256
- export async function getForumDiscussionsApi(session, forumId, options) {
257
- const params = { forumid: forumId, sortorder: options?.sortorder ?? 2 };
258
- if (options?.page !== undefined)
259
- params.page = options.page;
260
- if (options?.perpage !== undefined)
261
- params.perpage = options.perpage;
262
- if (options?.groupid !== undefined)
263
- params.groupid = options.groupid;
264
- const data = await moodleApiCall(session, "mod_forum_get_forum_discussions", params);
265
- return (data?.discussions ?? []).map((d) => ({
266
- id: d.discussion,
267
- forumId: d.forum,
268
- name: d.name,
269
- firstPostId: d.firstpost,
270
- userId: d.userid,
271
- userFullName: d.userfullname || "",
272
- groupId: d.groupid,
273
- timedue: d.timedue,
274
- timeModified: d.timemodified,
275
- timeStart: d.timestart,
276
- timeEnd: d.timeend,
277
- userModified: d.usermodified,
278
- userModifiedFullName: d.usermodifiedfullname,
279
- postCount: d.numreplies,
280
- unread: (d.numunread ?? 0) > 0,
281
- subject: stripHtmlTags(d.subject ?? ""),
282
- message: d.message,
283
- pinned: d.pinned,
284
- locked: d.locked,
285
- starred: d.starred,
286
- }));
287
- }
288
- /**
289
- * Get posts in a discussion via WS API (no browser required).
290
- * Uses mod_forum_get_forum_discussion_posts
291
- */
292
- export async function getDiscussionPostsApi(session, discussionId) {
293
- try {
294
- const data = await moodleApiCall(session, "mod_forum_get_discussion_posts", {
295
- discussionid: discussionId,
296
- });
297
- if (!data?.posts || data.posts.length === 0) {
298
- return [];
299
- }
300
- return data.posts.map((p) => ({
301
- id: p.id,
302
- subject: stripHtmlTags(p.subject || ""),
303
- author: p.author?.fullname ?? "Unknown",
304
- authorId: p.author?.id ?? p.userid,
305
- created: p.timecreated,
306
- modified: p.timemodified,
307
- message: stripHtmlTags(p.message || ""),
308
- discussionId: p.discussionid,
309
- unread: p.unread ?? false,
310
- }));
311
- }
312
- catch (error) {
313
- // Return empty array on error instead of throwing
314
- // This allows commands to gracefully handle inaccessible discussions
315
- return [];
316
- }
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
- }
400
- // ── Resource/Material Operations ──────────────────────────────────────────
401
- /**
402
- * Get all resource modules in a course.
403
- */
404
- export async function getResourcesInCourse(page, session, courseId, log) {
405
- const state = await getCourseState(page, session, courseId);
406
- const cms = state?.cm ?? [];
407
- const resources = cms.filter((cm) => ["resource", "url"].includes(cm.module));
408
- log.debug(` Found ${resources.length} resource${resources.length === 1 ? "" : "s"}.`);
409
- return resources.map((cm) => ({
410
- cmid: cm.cmid?.toString() ?? cm.id?.toString() ?? "",
411
- name: cm.name,
412
- url: cm.url,
413
- courseId,
414
- modType: cm.module,
415
- mimetype: undefined,
416
- filesize: undefined,
417
- modified: 0,
418
- }));
419
- }
420
- // ── Calendar Operations ─────────────────────────────────────────────────────
421
- /**
422
- * Get calendar events via pure WS API (no browser required).
423
- * Fast and lightweight - uses HTTP fetch directly.
424
- */
425
- export async function getCalendarEventsApi(session, options = {}) {
426
- const data = await moodleApiCall(session, "core_calendar_get_calendar_events", {
427
- ...options,
428
- });
429
- return (data?.events ?? []).map((e) => ({
430
- id: e.id,
431
- name: e.name,
432
- description: e.description,
433
- format: e.format,
434
- courseid: e.courseid,
435
- categoryid: e.categoryid,
436
- groupid: e.groupid,
437
- userid: e.userid,
438
- moduleid: e.moduleid,
439
- modulename: e.modulename,
440
- instance: e.instance,
441
- eventtype: e.eventtype,
442
- timestart: e.timestart * 1000, // Convert to milliseconds
443
- timeduration: e.timeduration ? e.timeduration * 1000 : undefined,
444
- timedue: e.timedue ? e.timedue * 1000 : undefined,
445
- visible: e.visible,
446
- location: e.location,
447
- }));
448
- }
449
- // ── Grade Operations ──────────────────────────────────────────────────────
450
- /**
451
- * Get course grades for the current user via pure WS API (no browser required).
452
- * Fast and lightweight - uses HTTP fetch directly.
453
- */
454
- export async function getCourseGradesApi(session, courseId) {
455
- const data = await moodleApiCall(session, "gradereport_user_get_grade_items", { courseid: courseId });
456
- // The API returns grade items for the course
457
- const gradeItems = (data?.usergrades ?? []);
458
- // Return a single CourseGrade object with items array
459
- return {
460
- courseId,
461
- courseName: gradeItems[0]?.coursefullname ?? "",
462
- grade: gradeItems[0]?.grade,
463
- gradeFormatted: gradeItems[0]?.gradeformatted,
464
- rank: gradeItems[0]?.rank,
465
- totalUsers: gradeItems[0]?.totalusers,
466
- items: gradeItems.map((g) => ({
467
- id: g.id,
468
- name: g.itemname || g.itemtype,
469
- grade: g.grade,
470
- gradeFormatted: g.gradeformatted,
471
- range: g.grade ? `${g.grademin ?? 0}-${g.grademax ?? 100}` : undefined,
472
- })),
473
- };
474
- }
475
- // ── Video Metadata (from original course.ts) ───────────────────────────────
476
- /**
477
- * Visit a SuperVideo activity page and extract view_id + duration.
478
- */
479
- /**
480
- * Optimized video metadata extraction - minimal page load overhead.
481
- * Blocks images, fonts, stylesheets to speed up viewId extraction.
482
- */
483
- export async function getVideoMetadata(page, activityUrl, log) {
484
- // Block unnecessary resources for faster loading
485
- await page.route("**/*.{png,jpg,jpeg,gif,webp,svg,ico,woff,woff2,ttf,css}", (route) => route.abort());
486
- await page.goto(activityUrl, { waitUntil: "domcontentloaded", timeout: 20000 });
487
- const name = await page.title();
488
- const pageSource = await page.content();
489
- let viewId = null;
490
- const viewIdPatterns = [
491
- /player_create.*?amd\.\w+\((\d+)/,
492
- /view_id['":\s]+(\d+)/,
493
- ];
494
- for (const pattern of viewIdPatterns) {
495
- const match = pageSource.match(pattern);
496
- if (match) {
497
- viewId = parseInt(match[1], 10);
498
- break;
499
- }
500
- }
501
- if (viewId === null) {
502
- throw new Error(`Could not extract view_id from ${activityUrl}`);
503
- }
504
- let duration = null;
505
- const isYoutube = pageSource.includes("youtube.com") || pageSource.includes("youtu.be");
506
- if (!isYoutube) {
507
- try {
508
- await page.waitForSelector("video", { timeout: 10000 });
509
- duration = await page.evaluate(() => {
510
- return new Promise((resolve) => {
511
- const media = document.querySelector("video");
512
- if (!media)
513
- return resolve(null);
514
- if (media.duration && isFinite(media.duration)) {
515
- return resolve(Math.ceil(media.duration));
516
- }
517
- media.addEventListener("loadedmetadata", () => {
518
- resolve(Math.ceil(media.duration));
519
- });
520
- setTimeout(() => resolve(null), 8000);
521
- });
522
- });
523
- }
524
- catch {
525
- // no video element
526
- }
527
- }
528
- if (!duration) {
529
- const durationMatch = pageSource.match(/["']?duration["']?\s*[:=]\s*(\d+)/);
530
- if (durationMatch) {
531
- duration = parseInt(durationMatch[1], 10);
532
- }
533
- }
534
- if (!duration) {
535
- duration = 600;
536
- log.debug(` Duration unknown${isYoutube ? " (YouTube)" : ""}, using ${duration}s`);
537
- }
538
- log.debug(` viewId=${viewId}, duration=${duration}s`);
539
- // Phase 1: Extract video sources
540
- const videoSources = [];
541
- const youtubeIds = [];
542
- // 1. Get src from <video> element
543
- const videoSrc = await page.evaluate(() => {
544
- const video = document.querySelector("video");
545
- return video?.src || null;
546
- });
547
- if (videoSrc)
548
- videoSources.push(videoSrc);
549
- // 2. Get src from <source> elements
550
- const sourceSrcs = await page.evaluate(() => {
551
- const sources = Array.from(document.querySelectorAll("source"));
552
- return sources.map(s => s.src).filter((src) => !!src);
553
- });
554
- videoSources.push(...sourceSrcs);
555
- // 3. Get src from <iframe> elements (YouTube, Vimeo, etc.)
556
- // Wait a bit for iframes to load
557
- await page.waitForTimeout(1000);
558
- const iframeSrcs = await page.evaluate(() => {
559
- const iframes = Array.from(document.querySelectorAll("iframe"));
560
- return iframes.map(f => f.src).filter((src) => !!src && src.length > 0);
561
- });
562
- // Extract YouTube video IDs from iframe URLs
563
- for (const iframeSrc of iframeSrcs) {
564
- videoSources.push(iframeSrc);
565
- // Extract YouTube video ID
566
- const ytMatch = iframeSrc.match(/(?:youtube\.com\/(?:embed\/|v\/|watch\?v=)|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
567
- if (ytMatch) {
568
- youtubeIds.push(ytMatch[1]);
569
- }
570
- }
571
- // 4. Check for blob/data URLs
572
- const hasBlobUrl = await page.evaluate(() => {
573
- const video = document.querySelector("video");
574
- const src = video?.src || "";
575
- return src.startsWith("blob:") || src.startsWith("data:");
576
- });
577
- // Deduplicate sources
578
- const uniqueSources = [...new Set(videoSources)];
579
- log.debug(` Found ${uniqueSources.length} video source(s)`);
580
- if (uniqueSources.length > 0) {
581
- log.debug(` Sources: ${uniqueSources.map(s => s.substring(0, 50) + (s.length > 50 ? "..." : "")).join(", ")}`);
582
- }
583
- if (youtubeIds.length > 0) {
584
- log.debug(` YouTube IDs: ${youtubeIds.join(", ")}`);
585
- }
586
- if (hasBlobUrl) {
587
- log.warn(` Video uses blob URL - cannot download directly`);
588
- }
589
- return {
590
- name,
591
- url: activityUrl,
592
- viewId,
593
- duration,
594
- existingPercent: 0,
595
- videoSources: uniqueSources,
596
- youtubeIds,
597
- };
598
- }
599
- // ── Video Download ─────────────────────────────────────────────────────────────
600
- /**
601
- * Download a video from SuperVideo activity.
602
- * Supports direct video URLs (pluginfile.php) and YouTube videos.
603
- */
604
- export async function downloadVideo(page, metadata, outputPath, log) {
605
- const { name, videoSources, youtubeIds } = metadata;
606
- log.info(`正在下載: ${name}`);
607
- // Priority 1: Direct video URL (pluginfile.php, .mp4, etc.)
608
- const directUrl = videoSources.find(s => s.includes("pluginfile.php") ||
609
- s.endsWith(".mp4") ||
610
- s.endsWith(".webm") ||
611
- s.endsWith(".mov"));
612
- if (directUrl) {
613
- log.debug(` 類型: 直接下載 (${directUrl.substring(0, 60)}...)`);
614
- try {
615
- // Get session cookies from the page for authentication
616
- const cookies = await page.context().cookies();
617
- const cookieHeader = cookies
618
- .map(c => `${c.name}=${c.value}`)
619
- .join("; ");
620
- // Use native fetch with session cookies
621
- const response = await fetch(directUrl, {
622
- headers: {
623
- "Cookie": cookieHeader,
624
- },
625
- });
626
- if (!response.ok) {
627
- throw new Error(`HTTP ${response.status}`);
628
- }
629
- // Get array buffer and convert to Uint8Array
630
- const arrayBuffer = await response.arrayBuffer();
631
- const uint8Array = new Uint8Array(arrayBuffer);
632
- // Write to file
633
- await dntShim.Deno.writeFile(outputPath, uint8Array);
634
- return { success: true, path: outputPath, type: "direct" };
635
- }
636
- catch (e) {
637
- const msg = e instanceof Error ? e.message : String(e);
638
- log.error(` 下載失敗: ${msg}`);
639
- return { success: false, error: msg };
640
- }
641
- }
642
- // Priority 2: YouTube video
643
- if (youtubeIds && youtubeIds.length > 0) {
644
- log.debug(` 類型: YouTube (ID: ${youtubeIds[0]})`);
645
- return {
646
- success: false,
647
- error: `YouTube 影片無法直接下載。請使用 yt-dlp: yt-dlp https://www.youtube.com/watch?v=${youtubeIds[0]}`,
648
- type: "youtube",
649
- };
650
- }
651
- // Priority 3: Other iframe/embedded video
652
- if (videoSources.length > 0) {
653
- log.debug(` 類型: 嵌入影片 (${videoSources[0].substring(0, 60)}...)`);
654
- return {
655
- success: false,
656
- error: "嵌入影片無法直接下載",
657
- type: "embedded",
658
- };
659
- }
660
- return {
661
- success: false,
662
- error: "未找到影片來源",
663
- };
664
- }
665
- // ── Progress Completion (from original progress.ts) ───────────────────────
666
- /**
667
- * Build duration map array for video progress tracking.
668
- * Cached and scaled per duration to avoid repeated allocations.
669
- */
670
- function buildDurationMap(duration) {
671
- // Build the map array (0% to 100% in 1% increments)
672
- const map = Array.from({ length: 100 }, (_, i) => ({
673
- time: Math.round((duration * i) / 100),
674
- percent: i,
675
- }));
676
- return JSON.stringify(map);
677
- }
678
- /**
679
- * Complete a video using WS API (mobile service only).
680
- * Uses mod_supervideo_progress_save_mobile which is accessible via moodle_mobile_app service token.
681
- */
682
- export async function completeVideoApi(session, video) {
683
- const { viewId, duration } = video;
684
- try {
685
- const result = await moodleApiCall(session, "mod_supervideo_progress_save_mobile", // Use mobile service specific function
686
- {
687
- view_id: viewId,
688
- currenttime: duration,
689
- duration: duration,
690
- percent: 100,
691
- mapa: buildDurationMap(duration),
692
- });
693
- // Debug: log the full result
694
- // console.debug(`completeVideoApi result:`, JSON.stringify(result));
695
- const success = result?.[0]?.success === true || result?.success === true;
696
- return { success, error: success ? undefined : `API returned success=false, result=${JSON.stringify(result)}`, result };
697
- }
698
- catch (err) {
699
- const msg = err instanceof Error ? err.message : String(err);
700
- // console.debug(`completeVideoApi error: ${msg}`);
701
- return { success: false, error: msg };
702
- }
703
- }
704
- /**
705
- * Complete a video by forging progress AJAX call (legacy, requires browser).
706
- * Note: This uses sesskey-based AJAX which works for mod_supervideo_progress_save.
707
- */
708
- export async function completeVideo(page, session, video, log) {
709
- const { viewId, duration } = video;
710
- const payload = {
711
- view_id: viewId,
712
- currenttime: duration,
713
- duration: duration,
714
- percent: 100,
715
- mapa: buildDurationMap(duration),
716
- };
717
- const url = `${session.moodleBaseUrl}/lib/ajax/service.php?sesskey=${session.sesskey}&info=mod_supervideo_progress_save`;
718
- const ajaxPayload = [{ index: 0, methodname: "mod_supervideo_progress_save", args: payload }];
719
- try {
720
- const result = await page.evaluate(async ({ url, ajaxPayload }) => {
721
- const res = await fetch(url, {
722
- method: "POST",
723
- headers: { "Content-Type": "application/json" },
724
- body: JSON.stringify(ajaxPayload),
725
- });
726
- return res.json();
727
- }, { url, ajaxPayload });
728
- if (result?.[0]?.error) {
729
- log.debug(` Error: ${result[0].exception?.message ?? "Unknown error"}`);
730
- return false;
731
- }
732
- return true;
733
- }
734
- catch (err) {
735
- log.debug(` Exception: ${err instanceof Error ? err.message : String(err)}`);
736
- return false;
737
- }
738
- }
739
- /**
740
- * Update activity completion status manually via WS API.
741
- * Used for marking resources as complete/incomplete.
742
- */
743
- export async function updateActivityCompletionStatusManually(session, cmid, completed) {
744
- try {
745
- const result = await moodleApiCall(session, "core_completion_update_activity_completion_status_manually", {
746
- cmid: cmid,
747
- completed: completed ? 1 : 0,
748
- });
749
- return result.status === true;
750
- }
751
- catch (e) {
752
- console.debug(`Failed to update completion status for cmid ${cmid}: ${e}`);
753
- return false;
754
- }
755
- }
756
- // ── Site Info (Get User ID) ───────────────────────────────────────────────────
757
- /** Cache for site info to avoid redundant API calls */
758
- let siteInfoCache = null;
759
- let siteInfoCacheTime = 0;
760
- const SITE_INFO_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
761
- /**
762
- * Get site info including current user ID via pure WS API.
763
- * Results are cached for 5 minutes to avoid redundant calls.
764
- */
765
- export async function getSiteInfoApi(session) {
766
- const now = Date.now();
767
- if (siteInfoCache && (now - siteInfoCacheTime) < SITE_INFO_CACHE_TTL) {
768
- return siteInfoCache;
769
- }
770
- const data = await moodleApiCall(session, "core_webservice_get_site_info", {});
771
- siteInfoCache = {
772
- userid: data.userid,
773
- username: data.username,
774
- fullname: data.fullname,
775
- sitename: data.sitename,
776
- };
777
- siteInfoCacheTime = now;
778
- return siteInfoCache;
779
- }
780
- /**
781
- * Get incomplete supervideos with completion tracking enabled via WS API.
782
- * Uses core_completion_get_activities_completion_status to get only videos that:
783
- * 1. Have completion tracking enabled (hascompletion: true)
784
- * 2. Are not yet completed (isoverallcomplete: false or state !== 1)
785
- */
786
- export async function getIncompleteVideosApi(session, courseId) {
787
- // Get user ID
788
- const siteInfo = await getSiteInfoApi(session);
789
- // Get completion status for all activities
790
- const completionData = await moodleApiCall(session, "core_completion_get_activities_completion_status", { courseid: courseId, userid: siteInfo.userid });
791
- if (!completionData?.statuses) {
792
- return [];
793
- }
794
- // Get course contents to get URLs
795
- const contentsData = await moodleApiCall(session, "core_course_get_contents", { courseid: courseId });
796
- // Create a map of cmid to URL
797
- const urlMap = new Map();
798
- for (const section of contentsData || []) {
799
- if (!section.modules)
800
- continue;
801
- for (const module of section.modules) {
802
- if (module.id) {
803
- urlMap.set(module.id, module.url);
804
- }
805
- }
806
- }
807
- // Filter for incomplete supervideos with completion tracking enabled
808
- const incompleteVideos = [];
809
- for (const status of completionData.statuses) {
810
- // Only supervideo modules
811
- if (status.modname !== "supervideo")
812
- continue;
813
- // Must have completion enabled
814
- if (!status.hascompletion)
815
- continue;
816
- // Must be incomplete
817
- if (status.isoverallcomplete === true || status.state === 1)
818
- continue;
819
- const url = urlMap.get(status.cmid) || "";
820
- incompleteVideos.push({
821
- cmid: status.cmid,
822
- name: status.name,
823
- url,
824
- });
825
- }
826
- return incompleteVideos;
827
- }
828
- // ── Videos via WS API ─────────────────────────────────────────────────────────
829
- /**
830
- * Get course contents and filter for SuperVideo modules via pure WS API.
831
- */
832
- export async function getSupervideosInCourseApi(session, courseId) {
833
- const data = await moodleApiCall(session, "core_course_get_contents", { courseid: courseId });
834
- const videos = [];
835
- // data is an array of sections
836
- for (const section of data || []) {
837
- // Each section has modules array
838
- if (!section.modules)
839
- continue;
840
- for (const module of section.modules) {
841
- // Filter for SuperVideo modname
842
- if (module.modname === "supervideo") {
843
- videos.push({
844
- cmid: module.id.toString(),
845
- name: module.name,
846
- url: module.url,
847
- instance: module.instance, // supervideo instance id (not cmid!)
848
- isComplete: false, // Will be updated from completion API
849
- });
850
- }
851
- }
852
- }
853
- // Get completion status using core_completion_get_activities_completion_status
854
- try {
855
- // First get user ID
856
- const siteInfo = await getSiteInfoApi(session);
857
- const completionData = await moodleApiCall(session, "core_completion_get_activities_completion_status", { courseid: courseId, userid: siteInfo.userid });
858
- // Create a map of cmid to completion status
859
- const completionMap = new Map();
860
- if (completionData?.statuses) {
861
- for (const status of completionData.statuses) {
862
- // Only include modules that have completion enabled
863
- if (status.hascompletion) {
864
- completionMap.set(status.cmid, status.isoverallcomplete === true);
865
- }
866
- }
867
- }
868
- // Update isComplete based on completion data
869
- for (const video of videos) {
870
- const cmid = parseInt(video.cmid, 10);
871
- if (completionMap.has(cmid)) {
872
- video.isComplete = completionMap.get(cmid) ?? false;
873
- }
874
- }
875
- }
876
- catch (e) {
877
- // If completion API fails, continue with isComplete=false
878
- console.debug(`Failed to get completion status: ${e}`);
879
- }
880
- return videos;
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
- }
913
- /**
914
- * Get quizzes in courses via pure WS API.
915
- */
916
- export async function getQuizzesByCoursesApi(session, courseIds) {
917
- if (courseIds.length === 0)
918
- return [];
919
- const data = await moodleApiCall(session, "mod_quiz_get_quizzes_by_courses", { courseids: courseIds });
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 };
1084
- }
1085
- // ── Materials via WS API ──────────────────────────────────────────────────────
1086
- /**
1087
- * Get resources in courses via pure WS API.
1088
- */
1089
- export async function getResourcesByCoursesApi(session, courseIds) {
1090
- if (courseIds.length === 0)
1091
- return [];
1092
- const data = await moodleApiCall(session, "mod_resource_get_resources_by_courses", { courseids: courseIds });
1093
- return (data?.resources ?? []).map((r) => {
1094
- // Extract file info from contentfiles array
1095
- const firstFile = r.contentfiles?.[0];
1096
- return {
1097
- cmid: r.coursemodule?.toString() ?? r.id?.toString() ?? "",
1098
- name: r.name,
1099
- url: firstFile?.fileurl ?? "",
1100
- courseId: r.course,
1101
- modType: "resource", // This API only returns resources
1102
- mimetype: firstFile?.mimetype,
1103
- filesize: firstFile?.filesize,
1104
- modified: r.timemodified,
1105
- };
1106
- });
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
- }
1302
- /**
1303
- * Get messages for the current user via pure WS API.
1304
- */
1305
- export async function getMessagesApi(session, userIdTo, options = {}) {
1306
- const data = await moodleApiCall(session, "core_message_get_messages", { useridto: userIdTo, ...options });
1307
- return (data?.messages ?? []).map((m) => ({
1308
- id: m.id,
1309
- useridfrom: m.useridfrom,
1310
- useridto: m.useridto,
1311
- subject: m.subject,
1312
- text: m.smallmessage,
1313
- timecreated: m.timecreated,
1314
- fullmessage: m.fullmessage,
1315
- fullmessageformat: m.fullmessageformat,
1316
- fullmessagehtml: m.fullmessagehtml,
1317
- }));
1318
- }