@mo7yw4ng/openape 1.0.6 → 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 -138
  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 -71
  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 -229
  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 -127
  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 -312
  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 -190
  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 -84
  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 -402
  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 -236
  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 -55
  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 -266
  52. package/esm/src/index.d.ts +0 -28
  53. package/esm/src/index.d.ts.map +0 -1
  54. package/esm/src/index.js +0 -164
  55. package/esm/src/lib/auth.d.ts +0 -66
  56. package/esm/src/lib/auth.d.ts.map +0 -1
  57. package/esm/src/lib/auth.js +0 -286
  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 -26
  64. package/esm/src/lib/moodle.d.ts +0 -447
  65. package/esm/src/lib/moodle.d.ts.map +0 -1
  66. package/esm/src/lib/moodle.js +0 -1353
  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 -52
  77. package/esm/src/lib/utils.d.ts.map +0 -1
  78. package/esm/src/lib/utils.js +0 -122
  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 -74
  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 -268
  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 -133
  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 -315
  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 -193
  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 -87
  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 -408
  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 -239
  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 -61
  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 -272
  125. package/script/src/index.d.ts +0 -28
  126. package/script/src/index.d.ts.map +0 -1
  127. package/script/src/index.js +0 -171
  128. package/script/src/lib/auth.d.ts +0 -66
  129. package/script/src/lib/auth.d.ts.map +0 -1
  130. package/script/src/lib/auth.js +0 -296
  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 -29
  137. package/script/src/lib/moodle.d.ts +0 -447
  138. package/script/src/lib/moodle.d.ts.map +0 -1
  139. package/script/src/lib/moodle.js +0 -1425
  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 -52
  150. package/script/src/lib/utils.d.ts.map +0 -1
  151. package/script/src/lib/utils.js +0 -167
  152. package/skills/openape/SKILL.md +0 -115
@@ -1,1353 +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
- * Resolve a forum ID (cmid or instance ID) to a forum instance ID.
254
- * Tries cmid resolution first (via core_course_get_course_module) to get name/course info.
255
- * Falls back to treating the ID as a raw forum instance ID.
256
- */
257
- export async function resolveForumId(session, id) {
258
- const numId = parseInt(id, 10);
259
- // Try cmid resolution first (gets name + course info)
260
- try {
261
- const cm = await moodleApiCall(session, "core_course_get_course_module", { cmid: numId });
262
- if (cm?.cm && cm.cm.modname === "forum") {
263
- return {
264
- forumId: cm.cm.instance,
265
- cmid: numId,
266
- name: cm.cm.name,
267
- courseid: cm.cm.course,
268
- };
269
- }
270
- }
271
- catch {
272
- // Not a valid cmid, try as forum instance ID
273
- }
274
- // Fall back: treat as forum instance ID directly
275
- try {
276
- const data = await moodleApiCall(session, "mod_forum_get_forum_discussions", { forumid: numId, limit: 1 });
277
- // If we get discussions back (even empty), the forum exists
278
- if (data) {
279
- return { forumId: numId };
280
- }
281
- }
282
- catch {
283
- // Invalid forum instance ID
284
- }
285
- return null;
286
- }
287
- /**
288
- * Get discussions in a forum via WS API (no browser required).
289
- * Uses mod_forum_get_forum_discussions
290
- */
291
- export async function getForumDiscussionsApi(session, forumId, options) {
292
- const params = { forumid: forumId, sortorder: options?.sortorder ?? 2 };
293
- if (options?.page !== undefined)
294
- params.page = options.page;
295
- if (options?.perpage !== undefined)
296
- params.perpage = options.perpage;
297
- if (options?.groupid !== undefined)
298
- params.groupid = options.groupid;
299
- const data = await moodleApiCall(session, "mod_forum_get_forum_discussions", params);
300
- return (data?.discussions ?? []).map((d) => ({
301
- id: d.discussion,
302
- forumId: d.forum,
303
- name: d.name,
304
- firstPostId: d.firstpost,
305
- userId: d.userid,
306
- userFullName: d.userfullname || "",
307
- groupId: d.groupid,
308
- timedue: d.timedue,
309
- timeModified: d.timemodified,
310
- timeStart: d.timestart,
311
- timeEnd: d.timeend,
312
- userModified: d.usermodified,
313
- userModifiedFullName: d.usermodifiedfullname,
314
- postCount: d.numreplies,
315
- unread: (d.numunread ?? 0) > 0,
316
- subject: stripHtmlTags(d.subject ?? ""),
317
- message: d.message,
318
- pinned: d.pinned,
319
- locked: d.locked,
320
- starred: d.starred,
321
- }));
322
- }
323
- /**
324
- * Get posts in a discussion via WS API (no browser required).
325
- * Uses mod_forum_get_forum_discussion_posts
326
- */
327
- export async function getDiscussionPostsApi(session, discussionId) {
328
- try {
329
- const data = await moodleApiCall(session, "mod_forum_get_discussion_posts", {
330
- discussionid: discussionId,
331
- });
332
- if (!data?.posts || data.posts.length === 0) {
333
- return [];
334
- }
335
- return data.posts.map((p) => ({
336
- id: p.id,
337
- subject: stripHtmlTags(p.subject || ""),
338
- author: p.author?.fullname ?? "Unknown",
339
- authorId: p.author?.id ?? p.userid,
340
- created: p.timecreated,
341
- modified: p.timemodified,
342
- message: stripHtmlTags(p.message || ""),
343
- discussionId: p.discussionid,
344
- unread: p.unread ?? false,
345
- }));
346
- }
347
- catch (error) {
348
- // Return empty array on error instead of throwing
349
- // This allows commands to gracefully handle inaccessible discussions
350
- return [];
351
- }
352
- }
353
- /**
354
- * Delete a forum post. If the post is a discussion's topic post,
355
- * the entire discussion is deleted.
356
- */
357
- export async function deleteForumPostApi(session, postId) {
358
- try {
359
- const data = await moodleApiCall(session, "mod_forum_delete_post", { postid: postId });
360
- return { success: data?.status === true };
361
- }
362
- catch (error) {
363
- return {
364
- success: false,
365
- error: error instanceof Error ? error.message : String(error),
366
- };
367
- }
368
- }
369
- /**
370
- * Add a new discussion to a forum.
371
- */
372
- export async function addForumDiscussionApi(session, forumId, subject, message) {
373
- try {
374
- const data = await moodleApiCall(session, "mod_forum_add_discussion", {
375
- forumid: forumId,
376
- subject,
377
- message: message.replace(/\n/g, "<br>"),
378
- });
379
- if (data?.discussionid) {
380
- return { success: true, discussionId: data.discussionid };
381
- }
382
- return {
383
- success: false,
384
- error: data?.message ?? data?.error ?? "Failed to add discussion",
385
- };
386
- }
387
- catch (error) {
388
- return {
389
- success: false,
390
- error: error instanceof Error ? error.message : String(error),
391
- };
392
- }
393
- }
394
- /**
395
- * Add a reply post to a discussion.
396
- */
397
- export async function addForumPostApi(session, postId, // Parent post ID to reply to
398
- subject, message, options) {
399
- try {
400
- // Build options array for Moodle WS API
401
- const apiOptions = [];
402
- if (options?.inlineAttachmentId !== undefined) {
403
- apiOptions.push({ name: "inlineattachmentsid", value: options.inlineAttachmentId });
404
- }
405
- if (options?.attachmentId !== undefined) {
406
- apiOptions.push({ name: "attachmentsid", value: options.attachmentId });
407
- }
408
- const params = {
409
- postid: postId,
410
- subject,
411
- message: message.replace(/\n/g, "<br>"),
412
- messageformat: 1, // 1 = HTML, 0 = Moodle, 2 = Plain text, 3 = Markdown
413
- };
414
- // Only add options if not empty
415
- if (apiOptions.length > 0) {
416
- params.options = apiOptions;
417
- }
418
- console.debug(`[DEBUG] add_discussion_post params:`, JSON.stringify(params, null, 2));
419
- const data = await moodleApiCall(session, "mod_forum_add_discussion_post", params);
420
- if (data?.postid) {
421
- return { success: true, postId: data.postid };
422
- }
423
- return {
424
- success: false,
425
- error: data?.message ?? data?.error ?? "Failed to add post",
426
- };
427
- }
428
- catch (error) {
429
- return {
430
- success: false,
431
- error: error instanceof Error ? error.message : String(error),
432
- };
433
- }
434
- }
435
- // ── Resource/Material Operations ──────────────────────────────────────────
436
- /**
437
- * Get all resource modules in a course.
438
- */
439
- export async function getResourcesInCourse(page, session, courseId, log) {
440
- const state = await getCourseState(page, session, courseId);
441
- const cms = state?.cm ?? [];
442
- const resources = cms.filter((cm) => ["resource", "url"].includes(cm.module));
443
- log.debug(` Found ${resources.length} resource${resources.length === 1 ? "" : "s"}.`);
444
- return resources.map((cm) => ({
445
- cmid: cm.cmid?.toString() ?? cm.id?.toString() ?? "",
446
- name: cm.name,
447
- url: cm.url,
448
- courseId,
449
- modType: cm.module,
450
- mimetype: undefined,
451
- filesize: undefined,
452
- modified: 0,
453
- }));
454
- }
455
- // ── Calendar Operations ─────────────────────────────────────────────────────
456
- /**
457
- * Get calendar events via pure WS API (no browser required).
458
- * Fast and lightweight - uses HTTP fetch directly.
459
- */
460
- export async function getCalendarEventsApi(session, options = {}) {
461
- const data = await moodleApiCall(session, "core_calendar_get_calendar_events", {
462
- ...options,
463
- });
464
- return (data?.events ?? []).map((e) => ({
465
- id: e.id,
466
- name: e.name,
467
- description: e.description,
468
- format: e.format,
469
- courseid: e.courseid,
470
- categoryid: e.categoryid,
471
- groupid: e.groupid,
472
- userid: e.userid,
473
- moduleid: e.moduleid,
474
- modulename: e.modulename,
475
- instance: e.instance,
476
- eventtype: e.eventtype,
477
- timestart: e.timestart * 1000, // Convert to milliseconds
478
- timeduration: e.timeduration ? e.timeduration * 1000 : undefined,
479
- timedue: e.timedue ? e.timedue * 1000 : undefined,
480
- visible: e.visible,
481
- location: e.location,
482
- }));
483
- }
484
- // ── Grade Operations ──────────────────────────────────────────────────────
485
- /**
486
- * Get course grades for the current user via pure WS API (no browser required).
487
- * Fast and lightweight - uses HTTP fetch directly.
488
- */
489
- export async function getCourseGradesApi(session, courseId) {
490
- const data = await moodleApiCall(session, "gradereport_user_get_grade_items", { courseid: courseId });
491
- // The API returns grade items for the course
492
- const gradeItems = (data?.usergrades ?? []);
493
- // Return a single CourseGrade object with items array
494
- return {
495
- courseId,
496
- courseName: gradeItems[0]?.coursefullname ?? "",
497
- grade: gradeItems[0]?.grade,
498
- gradeFormatted: gradeItems[0]?.gradeformatted,
499
- rank: gradeItems[0]?.rank,
500
- totalUsers: gradeItems[0]?.totalusers,
501
- items: gradeItems.map((g) => ({
502
- id: g.id,
503
- name: g.itemname || g.itemtype,
504
- grade: g.grade,
505
- gradeFormatted: g.gradeformatted,
506
- range: g.grade ? `${g.grademin ?? 0}-${g.grademax ?? 100}` : undefined,
507
- })),
508
- };
509
- }
510
- // ── Video Metadata (from original course.ts) ───────────────────────────────
511
- /**
512
- * Visit a SuperVideo activity page and extract view_id + duration.
513
- */
514
- /**
515
- * Optimized video metadata extraction - minimal page load overhead.
516
- * Blocks images, fonts, stylesheets to speed up viewId extraction.
517
- */
518
- export async function getVideoMetadata(page, activityUrl, log) {
519
- // Block unnecessary resources for faster loading
520
- await page.route("**/*.{png,jpg,jpeg,gif,webp,svg,ico,woff,woff2,ttf,css}", (route) => route.abort());
521
- await page.goto(activityUrl, { waitUntil: "domcontentloaded", timeout: 20000 });
522
- const name = await page.title();
523
- const pageSource = await page.content();
524
- let viewId = null;
525
- const viewIdPatterns = [
526
- /player_create.*?amd\.\w+\((\d+)/,
527
- /view_id['":\s]+(\d+)/,
528
- ];
529
- for (const pattern of viewIdPatterns) {
530
- const match = pageSource.match(pattern);
531
- if (match) {
532
- viewId = parseInt(match[1], 10);
533
- break;
534
- }
535
- }
536
- if (viewId === null) {
537
- throw new Error(`Could not extract view_id from ${activityUrl}`);
538
- }
539
- let duration = null;
540
- const isYoutube = pageSource.includes("youtube.com") || pageSource.includes("youtu.be");
541
- if (!isYoutube) {
542
- try {
543
- await page.waitForSelector("video", { timeout: 10000 });
544
- duration = await page.evaluate(() => {
545
- return new Promise((resolve) => {
546
- const media = document.querySelector("video");
547
- if (!media)
548
- return resolve(null);
549
- if (media.duration && isFinite(media.duration)) {
550
- return resolve(Math.ceil(media.duration));
551
- }
552
- media.addEventListener("loadedmetadata", () => {
553
- resolve(Math.ceil(media.duration));
554
- });
555
- setTimeout(() => resolve(null), 8000);
556
- });
557
- });
558
- }
559
- catch {
560
- // no video element
561
- }
562
- }
563
- if (!duration) {
564
- const durationMatch = pageSource.match(/["']?duration["']?\s*[:=]\s*(\d+)/);
565
- if (durationMatch) {
566
- duration = parseInt(durationMatch[1], 10);
567
- }
568
- }
569
- if (!duration) {
570
- duration = 600;
571
- log.debug(` Duration unknown${isYoutube ? " (YouTube)" : ""}, using ${duration}s`);
572
- }
573
- log.debug(` viewId=${viewId}, duration=${duration}s`);
574
- // Phase 1: Extract video sources
575
- const videoSources = [];
576
- const youtubeIds = [];
577
- // 1. Get src from <video> element
578
- const videoSrc = await page.evaluate(() => {
579
- const video = document.querySelector("video");
580
- return video?.src || null;
581
- });
582
- if (videoSrc)
583
- videoSources.push(videoSrc);
584
- // 2. Get src from <source> elements
585
- const sourceSrcs = await page.evaluate(() => {
586
- const sources = Array.from(document.querySelectorAll("source"));
587
- return sources.map(s => s.src).filter((src) => !!src);
588
- });
589
- videoSources.push(...sourceSrcs);
590
- // 3. Get src from <iframe> elements (YouTube, Vimeo, etc.)
591
- // Wait a bit for iframes to load
592
- await page.waitForTimeout(1000);
593
- const iframeSrcs = await page.evaluate(() => {
594
- const iframes = Array.from(document.querySelectorAll("iframe"));
595
- return iframes.map(f => f.src).filter((src) => !!src && src.length > 0);
596
- });
597
- // Extract YouTube video IDs from iframe URLs
598
- for (const iframeSrc of iframeSrcs) {
599
- videoSources.push(iframeSrc);
600
- // Extract YouTube video ID
601
- const ytMatch = iframeSrc.match(/(?:youtube\.com\/(?:embed\/|v\/|watch\?v=)|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
602
- if (ytMatch) {
603
- youtubeIds.push(ytMatch[1]);
604
- }
605
- }
606
- // 4. Check for blob/data URLs
607
- const hasBlobUrl = await page.evaluate(() => {
608
- const video = document.querySelector("video");
609
- const src = video?.src || "";
610
- return src.startsWith("blob:") || src.startsWith("data:");
611
- });
612
- // Deduplicate sources
613
- const uniqueSources = [...new Set(videoSources)];
614
- log.debug(` Found ${uniqueSources.length} video source(s)`);
615
- if (uniqueSources.length > 0) {
616
- log.debug(` Sources: ${uniqueSources.map(s => s.substring(0, 50) + (s.length > 50 ? "..." : "")).join(", ")}`);
617
- }
618
- if (youtubeIds.length > 0) {
619
- log.debug(` YouTube IDs: ${youtubeIds.join(", ")}`);
620
- }
621
- if (hasBlobUrl) {
622
- log.warn(` Video uses blob URL - cannot download directly`);
623
- }
624
- return {
625
- name,
626
- url: activityUrl,
627
- viewId,
628
- duration,
629
- existingPercent: 0,
630
- videoSources: uniqueSources,
631
- youtubeIds,
632
- };
633
- }
634
- // ── Video Download ─────────────────────────────────────────────────────────────
635
- /**
636
- * Download a video from SuperVideo activity.
637
- * Supports direct video URLs (pluginfile.php) and YouTube videos.
638
- */
639
- export async function downloadVideo(page, metadata, outputPath, log) {
640
- const { name, videoSources, youtubeIds } = metadata;
641
- log.info(`正在下載: ${name}`);
642
- // Priority 1: Direct video URL (pluginfile.php, .mp4, etc.)
643
- const directUrl = videoSources.find(s => s.includes("pluginfile.php") ||
644
- s.endsWith(".mp4") ||
645
- s.endsWith(".webm") ||
646
- s.endsWith(".mov"));
647
- if (directUrl) {
648
- log.debug(` 類型: 直接下載 (${directUrl.substring(0, 60)}...)`);
649
- try {
650
- // Get session cookies from the page for authentication
651
- const cookies = await page.context().cookies();
652
- const cookieHeader = cookies
653
- .map(c => `${c.name}=${c.value}`)
654
- .join("; ");
655
- // Use native fetch with session cookies
656
- const response = await fetch(directUrl, {
657
- headers: {
658
- "Cookie": cookieHeader,
659
- },
660
- });
661
- if (!response.ok) {
662
- throw new Error(`HTTP ${response.status}`);
663
- }
664
- // Get array buffer and convert to Uint8Array
665
- const arrayBuffer = await response.arrayBuffer();
666
- const uint8Array = new Uint8Array(arrayBuffer);
667
- // Write to file
668
- await dntShim.Deno.writeFile(outputPath, uint8Array);
669
- return { success: true, path: outputPath, type: "direct" };
670
- }
671
- catch (e) {
672
- const msg = e instanceof Error ? e.message : String(e);
673
- log.error(` 下載失敗: ${msg}`);
674
- return { success: false, error: msg };
675
- }
676
- }
677
- // Priority 2: YouTube video
678
- if (youtubeIds && youtubeIds.length > 0) {
679
- log.debug(` 類型: YouTube (ID: ${youtubeIds[0]})`);
680
- return {
681
- success: false,
682
- error: `YouTube 影片無法直接下載。請使用 yt-dlp: yt-dlp https://www.youtube.com/watch?v=${youtubeIds[0]}`,
683
- type: "youtube",
684
- };
685
- }
686
- // Priority 3: Other iframe/embedded video
687
- if (videoSources.length > 0) {
688
- log.debug(` 類型: 嵌入影片 (${videoSources[0].substring(0, 60)}...)`);
689
- return {
690
- success: false,
691
- error: "嵌入影片無法直接下載",
692
- type: "embedded",
693
- };
694
- }
695
- return {
696
- success: false,
697
- error: "未找到影片來源",
698
- };
699
- }
700
- // ── Progress Completion (from original progress.ts) ───────────────────────
701
- /**
702
- * Build duration map array for video progress tracking.
703
- * Cached and scaled per duration to avoid repeated allocations.
704
- */
705
- function buildDurationMap(duration) {
706
- // Build the map array (0% to 100% in 1% increments)
707
- const map = Array.from({ length: 100 }, (_, i) => ({
708
- time: Math.round((duration * i) / 100),
709
- percent: i,
710
- }));
711
- return JSON.stringify(map);
712
- }
713
- /**
714
- * Complete a video using WS API (mobile service only).
715
- * Uses mod_supervideo_progress_save_mobile which is accessible via moodle_mobile_app service token.
716
- */
717
- export async function completeVideoApi(session, video) {
718
- const { viewId, duration } = video;
719
- try {
720
- const result = await moodleApiCall(session, "mod_supervideo_progress_save_mobile", // Use mobile service specific function
721
- {
722
- view_id: viewId,
723
- currenttime: duration,
724
- duration: duration,
725
- percent: 100,
726
- mapa: buildDurationMap(duration),
727
- });
728
- // Debug: log the full result
729
- // console.debug(`completeVideoApi result:`, JSON.stringify(result));
730
- const success = result?.[0]?.success === true || result?.success === true;
731
- return { success, error: success ? undefined : `API returned success=false, result=${JSON.stringify(result)}`, result };
732
- }
733
- catch (err) {
734
- const msg = err instanceof Error ? err.message : String(err);
735
- // console.debug(`completeVideoApi error: ${msg}`);
736
- return { success: false, error: msg };
737
- }
738
- }
739
- /**
740
- * Complete a video by forging progress AJAX call (legacy, requires browser).
741
- * Note: This uses sesskey-based AJAX which works for mod_supervideo_progress_save.
742
- */
743
- export async function completeVideo(page, session, video, log) {
744
- const { viewId, duration } = video;
745
- const payload = {
746
- view_id: viewId,
747
- currenttime: duration,
748
- duration: duration,
749
- percent: 100,
750
- mapa: buildDurationMap(duration),
751
- };
752
- const url = `${session.moodleBaseUrl}/lib/ajax/service.php?sesskey=${session.sesskey}&info=mod_supervideo_progress_save`;
753
- const ajaxPayload = [{ index: 0, methodname: "mod_supervideo_progress_save", args: payload }];
754
- try {
755
- const result = await page.evaluate(async ({ url, ajaxPayload }) => {
756
- const res = await fetch(url, {
757
- method: "POST",
758
- headers: { "Content-Type": "application/json" },
759
- body: JSON.stringify(ajaxPayload),
760
- });
761
- return res.json();
762
- }, { url, ajaxPayload });
763
- if (result?.[0]?.error) {
764
- log.debug(` Error: ${result[0].exception?.message ?? "Unknown error"}`);
765
- return false;
766
- }
767
- return true;
768
- }
769
- catch (err) {
770
- log.debug(` Exception: ${err instanceof Error ? err.message : String(err)}`);
771
- return false;
772
- }
773
- }
774
- /**
775
- * Update activity completion status manually via WS API.
776
- * Used for marking resources as complete/incomplete.
777
- */
778
- export async function updateActivityCompletionStatusManually(session, cmid, completed) {
779
- try {
780
- const result = await moodleApiCall(session, "core_completion_update_activity_completion_status_manually", {
781
- cmid: cmid,
782
- completed: completed ? 1 : 0,
783
- });
784
- return result.status === true;
785
- }
786
- catch (e) {
787
- console.debug(`Failed to update completion status for cmid ${cmid}: ${e}`);
788
- return false;
789
- }
790
- }
791
- // ── Site Info (Get User ID) ───────────────────────────────────────────────────
792
- /** Cache for site info to avoid redundant API calls */
793
- let siteInfoCache = null;
794
- let siteInfoCacheTime = 0;
795
- const SITE_INFO_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
796
- /**
797
- * Get site info including current user ID via pure WS API.
798
- * Results are cached for 5 minutes to avoid redundant calls.
799
- */
800
- export async function getSiteInfoApi(session) {
801
- const now = Date.now();
802
- if (siteInfoCache && (now - siteInfoCacheTime) < SITE_INFO_CACHE_TTL) {
803
- return siteInfoCache;
804
- }
805
- const data = await moodleApiCall(session, "core_webservice_get_site_info", {});
806
- siteInfoCache = {
807
- userid: data.userid,
808
- username: data.username,
809
- fullname: data.fullname,
810
- sitename: data.sitename,
811
- };
812
- siteInfoCacheTime = now;
813
- return siteInfoCache;
814
- }
815
- /**
816
- * Get incomplete supervideos with completion tracking enabled via WS API.
817
- * Uses core_completion_get_activities_completion_status to get only videos that:
818
- * 1. Have completion tracking enabled (hascompletion: true)
819
- * 2. Are not yet completed (isoverallcomplete: false or state !== 1)
820
- */
821
- export async function getIncompleteVideosApi(session, courseId) {
822
- // Get user ID
823
- const siteInfo = await getSiteInfoApi(session);
824
- // Get completion status for all activities
825
- const completionData = await moodleApiCall(session, "core_completion_get_activities_completion_status", { courseid: courseId, userid: siteInfo.userid });
826
- if (!completionData?.statuses) {
827
- return [];
828
- }
829
- // Get course contents to get URLs
830
- const contentsData = await moodleApiCall(session, "core_course_get_contents", { courseid: courseId });
831
- // Create a map of cmid to URL
832
- const urlMap = new Map();
833
- for (const section of contentsData || []) {
834
- if (!section.modules)
835
- continue;
836
- for (const module of section.modules) {
837
- if (module.id) {
838
- urlMap.set(module.id, module.url);
839
- }
840
- }
841
- }
842
- // Filter for incomplete supervideos with completion tracking enabled
843
- const incompleteVideos = [];
844
- for (const status of completionData.statuses) {
845
- // Only supervideo modules
846
- if (status.modname !== "supervideo")
847
- continue;
848
- // Must have completion enabled
849
- if (!status.hascompletion)
850
- continue;
851
- // Must be incomplete
852
- if (status.isoverallcomplete === true || status.state === 1)
853
- continue;
854
- const url = urlMap.get(status.cmid) || "";
855
- incompleteVideos.push({
856
- cmid: status.cmid,
857
- name: status.name,
858
- url,
859
- });
860
- }
861
- return incompleteVideos;
862
- }
863
- // ── Videos via WS API ─────────────────────────────────────────────────────────
864
- /**
865
- * Get course contents and filter for SuperVideo modules via pure WS API.
866
- */
867
- export async function getSupervideosInCourseApi(session, courseId) {
868
- const data = await moodleApiCall(session, "core_course_get_contents", { courseid: courseId });
869
- const videos = [];
870
- // data is an array of sections
871
- for (const section of data || []) {
872
- // Each section has modules array
873
- if (!section.modules)
874
- continue;
875
- for (const module of section.modules) {
876
- // Filter for SuperVideo modname
877
- if (module.modname === "supervideo") {
878
- videos.push({
879
- cmid: module.id.toString(),
880
- name: module.name,
881
- url: module.url,
882
- instance: module.instance, // supervideo instance id (not cmid!)
883
- isComplete: false, // Will be updated from completion API
884
- });
885
- }
886
- }
887
- }
888
- // Get completion status using core_completion_get_activities_completion_status
889
- try {
890
- // First get user ID
891
- const siteInfo = await getSiteInfoApi(session);
892
- const completionData = await moodleApiCall(session, "core_completion_get_activities_completion_status", { courseid: courseId, userid: siteInfo.userid });
893
- // Create a map of cmid to completion status
894
- const completionMap = new Map();
895
- if (completionData?.statuses) {
896
- for (const status of completionData.statuses) {
897
- // Only include modules that have completion enabled
898
- if (status.hascompletion) {
899
- completionMap.set(status.cmid, status.isoverallcomplete === true);
900
- }
901
- }
902
- }
903
- // Update isComplete based on completion data
904
- for (const video of videos) {
905
- const cmid = parseInt(video.cmid, 10);
906
- if (completionMap.has(cmid)) {
907
- video.isComplete = completionMap.get(cmid) ?? false;
908
- }
909
- }
910
- }
911
- catch (e) {
912
- // If completion API fails, continue with isComplete=false
913
- console.debug(`Failed to get completion status: ${e}`);
914
- }
915
- return videos;
916
- }
917
- // ── Quizzes via WS API ────────────────────────────────────────────────────────
918
- /**
919
- * Get user attempts for given quiz IDs via WS API.
920
- * Returns a map of quiz ID -> { finished: boolean, attemptsUsed: number }.
921
- * Note: mod_quiz_get_user_attempts only accepts a single quizid, so we query in parallel.
922
- */
923
- async function getUserQuizAttemptInfo(session, quizIds) {
924
- if (quizIds.length === 0)
925
- return new Map();
926
- const info = new Map();
927
- // Query each quiz in parallel (API only accepts single quizid)
928
- const results = await Promise.allSettled(quizIds.map(quizId => moodleApiCall(session, "mod_quiz_get_user_attempts", { quizid: quizId })));
929
- for (let i = 0; i < quizIds.length; i++) {
930
- const quizId = quizIds[i];
931
- const result = results[i];
932
- if (result.status === "fulfilled" && result.value?.attempts) {
933
- let used = 0;
934
- let hasFinished = false;
935
- for (const a of result.value.attempts) {
936
- used++;
937
- if (a.state === "finished")
938
- hasFinished = true;
939
- }
940
- info.set(quizId, { finished: hasFinished, attemptsUsed: used });
941
- }
942
- else {
943
- info.set(quizId, { finished: false, attemptsUsed: 0 });
944
- }
945
- }
946
- return info;
947
- }
948
- /**
949
- * Get quizzes in courses via pure WS API.
950
- */
951
- export async function getQuizzesByCoursesApi(session, courseIds) {
952
- if (courseIds.length === 0)
953
- return [];
954
- const data = await moodleApiCall(session, "mod_quiz_get_quizzes_by_courses", { courseids: courseIds });
955
- const quizzes = (data?.quizzes ?? []);
956
- // Fetch user attempts to determine completion status and left attempts
957
- const quizIds = quizzes.map(q => q.id);
958
- const attemptInfo = await getUserQuizAttemptInfo(session, quizIds);
959
- return quizzes.map(q => {
960
- const info = attemptInfo.get(q.id);
961
- return {
962
- quizid: q.id.toString(),
963
- name: q.name,
964
- url: q.viewurl,
965
- intro: q.intro,
966
- isComplete: info?.finished ?? false,
967
- attemptsUsed: info?.attemptsUsed ?? 0,
968
- timeClose: q.timeclose,
969
- maxAttempts: q.attempts,
970
- courseId: q.course,
971
- };
972
- });
973
- }
974
- /**
975
- * Start a new quiz attempt via pure WS API.
976
- */
977
- export async function startQuizAttemptApi(session, quizId, options = {}) {
978
- const params = {
979
- quizid: parseInt(quizId, 10),
980
- forcenew: options.forcenew ? 1 : 0,
981
- };
982
- if (options.precheck) {
983
- params.precheck = 1;
984
- }
985
- const data = await moodleApiCall(session, "mod_quiz_start_attempt", params);
986
- if (!data?.attempt) {
987
- throw new Error("No attempt data returned");
988
- }
989
- const attempt = data.attempt;
990
- const attemptId = attempt.id || attempt.attempt;
991
- return {
992
- attempt: {
993
- attempt: attemptId,
994
- attemptid: attemptId,
995
- quizid: attempt.quizid,
996
- userid: attempt.userid,
997
- attemptnumber: attempt.attemptnumber,
998
- state: attempt.state,
999
- timestart: attempt.timestart,
1000
- timefinish: attempt.timefinish,
1001
- preview: attempt.preview === 1,
1002
- },
1003
- page: data.page,
1004
- messages: data.messages,
1005
- };
1006
- }
1007
- /**
1008
- * Get quiz attempt data including questions via pure WS API.
1009
- */
1010
- export async function getAllQuizAttemptDataApi(session, attemptId) {
1011
- const firstPage = await getQuizAttemptDataApi(session, attemptId, 0);
1012
- // Moodle re-indexes question keys per page (always starts at 0),
1013
- // so we must re-key by actual slot number to avoid overwrites.
1014
- const allQuestions = {};
1015
- for (const q of Object.values(firstPage.questions)) {
1016
- allQuestions[q.slot] = q;
1017
- }
1018
- let nextPage = firstPage.nextpage;
1019
- while (nextPage !== undefined && nextPage !== null && nextPage !== -1) {
1020
- const pageData = await getQuizAttemptDataApi(session, attemptId, nextPage);
1021
- for (const q of Object.values(pageData.questions)) {
1022
- allQuestions[q.slot] = q;
1023
- }
1024
- nextPage = pageData.nextpage;
1025
- }
1026
- return { ...firstPage, questions: allQuestions, nextpage: undefined };
1027
- }
1028
- export async function getQuizAttemptDataApi(session, attemptId, page = 0) {
1029
- const data = await moodleApiCall(session, "mod_quiz_get_attempt_data", { attemptid: attemptId, page });
1030
- if (!data?.attempt || !data?.questions) {
1031
- throw new Error("Invalid attempt data response");
1032
- }
1033
- const attempt = data.attempt;
1034
- const attemptIdValue = attempt.id || attempt.attempt;
1035
- const questions = {};
1036
- for (const [slot, question] of Object.entries(data.questions)) {
1037
- questions[parseInt(slot, 10)] = {
1038
- slot: question.slot,
1039
- type: question.type,
1040
- id: question.id,
1041
- maxmark: question.maxmark,
1042
- page: question.page,
1043
- quizid: question.quizid,
1044
- html: question.html,
1045
- status: question.status,
1046
- stateclass: question.stateclass,
1047
- sequencecheck: question.sequencecheck,
1048
- questionnumber: question.questionnumber,
1049
- };
1050
- }
1051
- return {
1052
- attempt: {
1053
- attempt: attemptIdValue,
1054
- attemptid: attemptIdValue,
1055
- uniqueid: attempt.uniqueid,
1056
- quizid: attempt.quizid,
1057
- userid: attempt.userid,
1058
- attemptnumber: attempt.attemptnumber,
1059
- state: attempt.state,
1060
- timestart: attempt.timestart,
1061
- timefinish: attempt.timefinish,
1062
- },
1063
- questions,
1064
- nextpage: data.nextpage,
1065
- prevpage: data.prevpage,
1066
- };
1067
- }
1068
- /**
1069
- * Process (save answers / finish) a quiz attempt via WS API.
1070
- *
1071
- * Answer formats:
1072
- * - Single choice (multichoice): { slot, answer: "0" }
1073
- * - Multiple choice (multichoices): { slot, answer: "0,2,3" } (comma-separated choice indices)
1074
- * - Short answer (shortanswer): { slot, answer: "text answer" }
1075
- *
1076
- * @param session - WS session
1077
- * @param attemptId - The attempt ID
1078
- * @param uniqueId - The usage attempt uniqueid (from attempt data)
1079
- * @param answers - Array of { slot, answer } pairs
1080
- * @param sequenceChecks - Map of slot -> sequencecheck value (required for deferredfeedback)
1081
- * @param finish - Whether to finish the attempt after saving
1082
- */
1083
- export async function processQuizAttemptApi(session, attemptId, uniqueId, answers, sequenceChecks, finish = true) {
1084
- const params = {
1085
- attemptid: attemptId,
1086
- finishattempt: finish ? 1 : 0,
1087
- };
1088
- let i = 0;
1089
- for (const a of answers) {
1090
- // Include sequencecheck first (required for deferredfeedback quizzes)
1091
- const seq = sequenceChecks.get(a.slot);
1092
- if (seq !== undefined) {
1093
- params[`data[${i}][name]`] = `q${uniqueId}:${a.slot}_:sequencecheck`;
1094
- params[`data[${i}][value]`] = seq.toString();
1095
- i++;
1096
- }
1097
- // Detect answer format:
1098
- // Comma-separated numeric values = multichoices (checkboxes)
1099
- // Single numeric value = multichoice (radio)
1100
- // Non-numeric text = shortanswer
1101
- if (/^\d+(,\d+)*$/.test(a.answer) && a.answer.includes(",")) {
1102
- // Multichoices: send each choice as qXXX:Y_choiceN with value 1
1103
- const choices = a.answer.split(",");
1104
- for (const choice of choices) {
1105
- params[`data[${i}][name]`] = `q${uniqueId}:${a.slot}_choice${choice}`;
1106
- params[`data[${i}][value]`] = "1";
1107
- i++;
1108
- }
1109
- }
1110
- else {
1111
- // Single choice or shortanswer: send as qXXX:Y_answer
1112
- params[`data[${i}][name]`] = `q${uniqueId}:${a.slot}_answer`;
1113
- params[`data[${i}][value]`] = a.answer;
1114
- i++;
1115
- }
1116
- }
1117
- const data = await moodleApiCall(session, "mod_quiz_process_attempt", params);
1118
- return { state: data?.state ?? "unknown", warnings: data?.warnings };
1119
- }
1120
- // ── Materials via WS API ──────────────────────────────────────────────────────
1121
- /**
1122
- * Get resources in courses via pure WS API.
1123
- */
1124
- export async function getResourcesByCoursesApi(session, courseIds) {
1125
- if (courseIds.length === 0)
1126
- return [];
1127
- const data = await moodleApiCall(session, "mod_resource_get_resources_by_courses", { courseids: courseIds });
1128
- return (data?.resources ?? []).map((r) => {
1129
- // Extract file info from contentfiles array
1130
- const firstFile = r.contentfiles?.[0];
1131
- return {
1132
- cmid: r.coursemodule?.toString() ?? r.id?.toString() ?? "",
1133
- name: r.name,
1134
- url: firstFile?.fileurl ?? "",
1135
- courseId: r.course,
1136
- modType: "resource", // This API only returns resources
1137
- mimetype: firstFile?.mimetype,
1138
- filesize: firstFile?.filesize,
1139
- modified: r.timemodified,
1140
- };
1141
- });
1142
- }
1143
- /**
1144
- * Get assignments in courses via pure WS API.
1145
- */
1146
- export async function getAssignmentsByCoursesApi(session, courseIds) {
1147
- if (courseIds.length === 0)
1148
- return [];
1149
- const data = await moodleApiCall(session, "mod_assign_get_assignments", { courseids: courseIds });
1150
- const assignments = [];
1151
- // The API returns an array of courses, each containing assignments
1152
- for (const course of (data?.courses ?? [])) {
1153
- if (!course.assignments)
1154
- continue;
1155
- for (const a of course.assignments) {
1156
- assignments.push({
1157
- id: a.id,
1158
- cmid: a.cmid?.toString() ?? "",
1159
- name: a.name,
1160
- url: a.viewurl ?? "",
1161
- courseId: course.id,
1162
- duedate: a.duedate,
1163
- cutoffdate: a.cutoffdate,
1164
- allowSubmissionsFromDate: a.allowsubmissionsfromdate,
1165
- gradingduedate: a.gradingduedate,
1166
- lateSubmission: a.latesubmissions ? true : false,
1167
- extensionduedate: a.extensionduedate,
1168
- });
1169
- }
1170
- }
1171
- return assignments;
1172
- }
1173
- /**
1174
- * Get assignment submission status via pure WS API.
1175
- */
1176
- export async function getSubmissionStatusApi(session, assignmentId) {
1177
- const siteInfo = await getSiteInfoApi(session);
1178
- const data = await moodleApiCall(session, "mod_assign_get_submission_status", {
1179
- assignid: assignmentId,
1180
- userid: siteInfo.userid,
1181
- });
1182
- const lastAttempt = data?.lastattempt;
1183
- // Note: API returns "submission" (singular), not "submissions" (plural)
1184
- const submission = lastAttempt?.submission;
1185
- // Find file submissions from submission plugins
1186
- const plugins = submission?.plugins || [];
1187
- const filePlugin = plugins.find((p) => p.type === "file");
1188
- const extensions = (filePlugin?.fileareas || [])
1189
- .flatMap((fa) => fa?.files || [])
1190
- .map((f) => ({
1191
- id: f.id,
1192
- filename: f.filename,
1193
- filesize: f.filesize,
1194
- }));
1195
- // Get feedback from the separate feedback object
1196
- const feedback = data?.feedback;
1197
- const commentsPlugin = feedback?.plugins?.find((p) => p.type === "comments");
1198
- const commentText = commentsPlugin?.editorfields?.find((e) => e.name === "comments")?.text || null;
1199
- return {
1200
- submitted: submission?.status === "submitted",
1201
- graded: lastAttempt?.gradingstatus === "graded",
1202
- grader: feedback?.gradername || null,
1203
- grade: feedback?.gradefordisplay || null,
1204
- feedback: commentText,
1205
- lastModified: submission?.timemodified || null,
1206
- extensions,
1207
- };
1208
- }
1209
- /**
1210
- * Save/submit an assignment via pure WS API.
1211
- * Supports online text submissions and file submissions (by draft ID).
1212
- */
1213
- export async function saveSubmissionApi(session, assignmentId, options) {
1214
- try {
1215
- const siteInfo = await getSiteInfoApi(session);
1216
- // Build plugins array based on submission type
1217
- const plugins = [];
1218
- if (options.onlineText) {
1219
- plugins.push({
1220
- type: "onlinetext",
1221
- online_text: {
1222
- text: options.onlineText.text,
1223
- format: options.onlineText.format ?? 1,
1224
- itemid: 0,
1225
- },
1226
- });
1227
- }
1228
- if (options.fileId !== undefined) {
1229
- plugins.push({
1230
- type: "file",
1231
- files_filemanager: options.fileId,
1232
- });
1233
- }
1234
- await moodleApiCall(session, "mod_assign_save_submission", {
1235
- assignmentid: assignmentId,
1236
- userid: siteInfo.userid,
1237
- plugins,
1238
- });
1239
- return { success: true };
1240
- }
1241
- catch (e) {
1242
- return { success: false, error: e instanceof Error ? e.message : String(e) };
1243
- }
1244
- }
1245
- // ── File Upload via WS API ──────────────────────────────────────────────────────
1246
- /**
1247
- * Generate a unique draft item ID.
1248
- * Uses timestamp (last 8 digits) to ensure uniqueness.
1249
- */
1250
- export function generateDraftItemId() {
1251
- // Use current timestamp in seconds, take last 8 digits
1252
- return Math.floor(Date.now() / 1000) % 100000000;
1253
- }
1254
- /**
1255
- * Upload a file to Moodle draft area via pure WS API.
1256
- * This is the first step before submitting files to assignments, forums, etc.
1257
- *
1258
- * Note: We generate our own draft item ID instead of asking Moodle for one.
1259
- */
1260
- export async function uploadFileApi(session, filePath, options) {
1261
- try {
1262
- // Generate or use provided draft ID
1263
- const draftItemId = options?.draftId ?? generateDraftItemId();
1264
- // Read file content using fs.promises
1265
- const fileContent = await fs.promises.readFile(filePath);
1266
- const fileName = options?.filename || filePath.split(/[/\\]/).pop() || "unknown";
1267
- // Get site info for user context
1268
- const siteInfo = await getSiteInfoApi(session);
1269
- const userContextId = getUserContextId(siteInfo.userid);
1270
- // Prepare multipart form data
1271
- const formData = new FormData();
1272
- formData.append("token", session.wsToken);
1273
- formData.append("file", new Blob([new Uint8Array(fileContent)]), fileName);
1274
- formData.append("filepath", options?.filepath || "/");
1275
- formData.append("itemid", String(draftItemId)); // Use our generated draft ID
1276
- formData.append("contextid", String(userContextId)); // Use calculated user context
1277
- formData.append("component", "user");
1278
- formData.append("filearea", "draft");
1279
- formData.append("qformat", ""); // Not used
1280
- // Upload via upload.php (uses multipart/form-data)
1281
- const url = `${session.moodleBaseUrl}/webservice/upload.php`;
1282
- const response = await fetch(url, {
1283
- method: "POST",
1284
- body: formData,
1285
- });
1286
- if (!response.ok) {
1287
- return { success: false, error: `HTTP ${response.status}` };
1288
- }
1289
- const result = await response.json();
1290
- console.debug(`[DEBUG] upload.php response:`, JSON.stringify(result, null, 2));
1291
- // Check for errors in response
1292
- if (result?.error) {
1293
- return { success: false, error: result.message ?? result.error ?? "Upload failed" };
1294
- }
1295
- // Success - return the draft ID we used
1296
- return { success: true, draftId: draftItemId };
1297
- }
1298
- catch (e) {
1299
- return { success: false, error: e instanceof Error ? e.message : String(e) };
1300
- }
1301
- }
1302
- /**
1303
- * Calculate user context ID in Moodle.
1304
- * User context ID = (userid * CONTEXT_DEPTH) + CONTEXT_USER
1305
- * Where CONTEXT_DEPTH = 10 and CONTEXT_USER = 30
1306
- */
1307
- function getUserContextId(userId) {
1308
- return userId * 10 + 30;
1309
- }
1310
- /**
1311
- * Get user's private files (not draft) via pure WS API.
1312
- * Draft files are temporary and cannot be listed, but private files can.
1313
- */
1314
- export async function getDraftFilesApi(session) {
1315
- const siteInfo = await getSiteInfoApi(session);
1316
- const userContextId = getUserContextId(siteInfo.userid);
1317
- const data = await moodleApiCall(session, "core_files_get_files", {
1318
- contextid: userContextId,
1319
- component: "user",
1320
- filearea: "private",
1321
- itemid: 0,
1322
- filepath: "/",
1323
- modified: null,
1324
- });
1325
- console.debug(`[DEBUG] core_files_get_files response:`, JSON.stringify(data, null, 2));
1326
- // The API returns a parents array with files inside
1327
- const files = data?.parents?.[0]?.files || data?.files || [];
1328
- return files.map((f) => ({
1329
- itemId: f.itemid || 0,
1330
- filename: f.filename || "",
1331
- filesize: f.filesize || 0,
1332
- filepath: f.filepath || "/",
1333
- timeModified: f.timemodified || 0,
1334
- url: f.url || "",
1335
- }));
1336
- }
1337
- /**
1338
- * Get messages for the current user via pure WS API.
1339
- */
1340
- export async function getMessagesApi(session, userIdTo, options = {}) {
1341
- const data = await moodleApiCall(session, "core_message_get_messages", { useridto: userIdTo, ...options });
1342
- return (data?.messages ?? []).map((m) => ({
1343
- id: m.id,
1344
- useridfrom: m.useridfrom,
1345
- useridto: m.useridto,
1346
- subject: m.subject,
1347
- text: m.smallmessage,
1348
- timecreated: m.timecreated,
1349
- fullmessage: m.fullmessage,
1350
- fullmessageformat: m.fullmessageformat,
1351
- fullmessagehtml: m.fullmessagehtml,
1352
- }));
1353
- }