@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.
- package/bin/openape +29 -0
- package/bin/openape.js +29 -0
- package/package.json +22 -28
- package/LICENSE +0 -21
- package/README.md +0 -135
- package/esm/_dnt.polyfills.d.ts +0 -101
- package/esm/_dnt.polyfills.d.ts.map +0 -1
- package/esm/_dnt.polyfills.js +0 -127
- package/esm/_dnt.shims.d.ts +0 -6
- package/esm/_dnt.shims.d.ts.map +0 -1
- package/esm/_dnt.shims.js +0 -61
- package/esm/deno.d.ts +0 -25
- package/esm/deno.d.ts.map +0 -1
- package/esm/deno.js +0 -23
- package/esm/package.json +0 -3
- package/esm/src/commands/announcements.d.ts +0 -3
- package/esm/src/commands/announcements.d.ts.map +0 -1
- package/esm/src/commands/announcements.js +0 -134
- package/esm/src/commands/assignments.d.ts +0 -3
- package/esm/src/commands/assignments.d.ts.map +0 -1
- package/esm/src/commands/assignments.js +0 -230
- package/esm/src/commands/auth.d.ts +0 -3
- package/esm/src/commands/auth.d.ts.map +0 -1
- package/esm/src/commands/auth.js +0 -290
- package/esm/src/commands/calendar.d.ts +0 -3
- package/esm/src/commands/calendar.d.ts.map +0 -1
- package/esm/src/commands/calendar.js +0 -179
- package/esm/src/commands/courses.d.ts +0 -3
- package/esm/src/commands/courses.d.ts.map +0 -1
- package/esm/src/commands/courses.js +0 -348
- package/esm/src/commands/forums.d.ts +0 -3
- package/esm/src/commands/forums.d.ts.map +0 -1
- package/esm/src/commands/forums.js +0 -318
- package/esm/src/commands/grades.d.ts +0 -3
- package/esm/src/commands/grades.d.ts.map +0 -1
- package/esm/src/commands/grades.js +0 -121
- package/esm/src/commands/materials.d.ts +0 -3
- package/esm/src/commands/materials.d.ts.map +0 -1
- package/esm/src/commands/materials.js +0 -413
- package/esm/src/commands/quizzes.d.ts +0 -3
- package/esm/src/commands/quizzes.d.ts.map +0 -1
- package/esm/src/commands/quizzes.js +0 -271
- package/esm/src/commands/skills.d.ts +0 -3
- package/esm/src/commands/skills.d.ts.map +0 -1
- package/esm/src/commands/skills.js +0 -106
- package/esm/src/commands/upload.d.ts +0 -3
- package/esm/src/commands/upload.d.ts.map +0 -1
- package/esm/src/commands/upload.js +0 -58
- package/esm/src/commands/videos.d.ts +0 -3
- package/esm/src/commands/videos.d.ts.map +0 -1
- package/esm/src/commands/videos.js +0 -336
- package/esm/src/index.d.ts +0 -27
- package/esm/src/index.d.ts.map +0 -1
- package/esm/src/index.js +0 -160
- package/esm/src/lib/auth.d.ts +0 -47
- package/esm/src/lib/auth.d.ts.map +0 -1
- package/esm/src/lib/auth.js +0 -227
- package/esm/src/lib/config.d.ts +0 -6
- package/esm/src/lib/config.d.ts.map +0 -1
- package/esm/src/lib/config.js +0 -36
- package/esm/src/lib/logger.d.ts +0 -3
- package/esm/src/lib/logger.d.ts.map +0 -1
- package/esm/src/lib/logger.js +0 -27
- package/esm/src/lib/moodle.d.ts +0 -433
- package/esm/src/lib/moodle.d.ts.map +0 -1
- package/esm/src/lib/moodle.js +0 -1318
- package/esm/src/lib/session.d.ts +0 -8
- package/esm/src/lib/session.d.ts.map +0 -1
- package/esm/src/lib/session.js +0 -42
- package/esm/src/lib/token.d.ts +0 -38
- package/esm/src/lib/token.d.ts.map +0 -1
- package/esm/src/lib/token.js +0 -178
- package/esm/src/lib/types.d.ts +0 -189
- package/esm/src/lib/types.d.ts.map +0 -1
- package/esm/src/lib/types.js +0 -2
- package/esm/src/lib/utils.d.ts +0 -57
- package/esm/src/lib/utils.d.ts.map +0 -1
- package/esm/src/lib/utils.js +0 -129
- package/script/_dnt.polyfills.d.ts +0 -101
- package/script/_dnt.polyfills.d.ts.map +0 -1
- package/script/_dnt.polyfills.js +0 -130
- package/script/_dnt.shims.d.ts +0 -6
- package/script/_dnt.shims.d.ts.map +0 -1
- package/script/_dnt.shims.js +0 -65
- package/script/deno.d.ts +0 -25
- package/script/deno.d.ts.map +0 -1
- package/script/deno.js +0 -25
- package/script/package.json +0 -3
- package/script/src/commands/announcements.d.ts +0 -3
- package/script/src/commands/announcements.d.ts.map +0 -1
- package/script/src/commands/announcements.js +0 -140
- package/script/src/commands/assignments.d.ts +0 -3
- package/script/src/commands/assignments.d.ts.map +0 -1
- package/script/src/commands/assignments.js +0 -269
- package/script/src/commands/auth.d.ts +0 -3
- package/script/src/commands/auth.d.ts.map +0 -1
- package/script/src/commands/auth.js +0 -296
- package/script/src/commands/calendar.d.ts +0 -3
- package/script/src/commands/calendar.d.ts.map +0 -1
- package/script/src/commands/calendar.js +0 -185
- package/script/src/commands/courses.d.ts +0 -3
- package/script/src/commands/courses.d.ts.map +0 -1
- package/script/src/commands/courses.js +0 -354
- package/script/src/commands/forums.d.ts +0 -3
- package/script/src/commands/forums.d.ts.map +0 -1
- package/script/src/commands/forums.js +0 -324
- package/script/src/commands/grades.d.ts +0 -3
- package/script/src/commands/grades.d.ts.map +0 -1
- package/script/src/commands/grades.js +0 -127
- package/script/src/commands/materials.d.ts +0 -3
- package/script/src/commands/materials.d.ts.map +0 -1
- package/script/src/commands/materials.js +0 -419
- package/script/src/commands/quizzes.d.ts +0 -3
- package/script/src/commands/quizzes.d.ts.map +0 -1
- package/script/src/commands/quizzes.js +0 -277
- package/script/src/commands/skills.d.ts +0 -3
- package/script/src/commands/skills.d.ts.map +0 -1
- package/script/src/commands/skills.js +0 -112
- package/script/src/commands/upload.d.ts +0 -3
- package/script/src/commands/upload.d.ts.map +0 -1
- package/script/src/commands/upload.js +0 -64
- package/script/src/commands/videos.d.ts +0 -3
- package/script/src/commands/videos.d.ts.map +0 -1
- package/script/src/commands/videos.js +0 -342
- package/script/src/index.d.ts +0 -27
- package/script/src/index.d.ts.map +0 -1
- package/script/src/index.js +0 -167
- package/script/src/lib/auth.d.ts +0 -47
- package/script/src/lib/auth.d.ts.map +0 -1
- package/script/src/lib/auth.js +0 -269
- package/script/src/lib/config.d.ts +0 -6
- package/script/src/lib/config.d.ts.map +0 -1
- package/script/src/lib/config.js +0 -42
- package/script/src/lib/logger.d.ts +0 -3
- package/script/src/lib/logger.d.ts.map +0 -1
- package/script/src/lib/logger.js +0 -30
- package/script/src/lib/moodle.d.ts +0 -433
- package/script/src/lib/moodle.d.ts.map +0 -1
- package/script/src/lib/moodle.js +0 -1389
- package/script/src/lib/session.d.ts +0 -8
- package/script/src/lib/session.d.ts.map +0 -1
- package/script/src/lib/session.js +0 -45
- package/script/src/lib/token.d.ts +0 -38
- package/script/src/lib/token.d.ts.map +0 -1
- package/script/src/lib/token.js +0 -189
- package/script/src/lib/types.d.ts +0 -189
- package/script/src/lib/types.d.ts.map +0 -1
- package/script/src/lib/types.js +0 -3
- package/script/src/lib/utils.d.ts +0 -57
- package/script/src/lib/utils.d.ts.map +0 -1
- package/script/src/lib/utils.js +0 -175
- package/skills/openape/SKILL.md +0 -115
package/esm/src/lib/moodle.js
DELETED
|
@@ -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
|
-
}
|