@mo7yw4ng/openape 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -5
- package/esm/deno.js +1 -1
- package/esm/src/commands/announcements.d.ts.map +1 -1
- package/esm/src/commands/announcements.js +16 -17
- package/esm/src/commands/assignments.d.ts +3 -0
- package/esm/src/commands/assignments.d.ts.map +1 -0
- package/esm/src/commands/assignments.js +230 -0
- package/esm/src/commands/auth.d.ts.map +1 -1
- package/esm/src/commands/auth.js +45 -15
- package/esm/src/commands/calendar.d.ts.map +1 -1
- package/esm/src/commands/calendar.js +20 -21
- package/esm/src/commands/courses.js +6 -6
- package/esm/src/commands/forums.d.ts.map +1 -1
- package/esm/src/commands/forums.js +128 -36
- package/esm/src/commands/grades.js +3 -3
- package/esm/src/commands/materials.d.ts.map +1 -1
- package/esm/src/commands/materials.js +115 -224
- package/esm/src/commands/quizzes.d.ts.map +1 -1
- package/esm/src/commands/quizzes.js +179 -68
- package/esm/src/commands/skills.d.ts.map +1 -1
- package/esm/src/commands/skills.js +4 -8
- package/esm/src/commands/upload.d.ts +3 -0
- package/esm/src/commands/upload.d.ts.map +1 -0
- package/esm/src/commands/upload.js +58 -0
- package/esm/src/commands/videos.d.ts.map +1 -1
- package/esm/src/commands/videos.js +10 -9
- package/esm/src/index.d.ts.map +1 -1
- package/esm/src/index.js +12 -1
- package/esm/src/lib/auth.d.ts +23 -1
- package/esm/src/lib/auth.d.ts.map +1 -1
- package/esm/src/lib/auth.js +36 -3
- package/esm/src/lib/logger.d.ts +1 -1
- package/esm/src/lib/logger.d.ts.map +1 -1
- package/esm/src/lib/logger.js +7 -4
- package/esm/src/lib/moodle.d.ts +183 -1
- package/esm/src/lib/moodle.d.ts.map +1 -1
- package/esm/src/lib/moodle.js +498 -13
- package/esm/src/lib/types.d.ts +81 -164
- package/esm/src/lib/types.d.ts.map +1 -1
- package/esm/src/lib/types.js +1 -0
- package/esm/src/lib/utils.d.ts +20 -0
- package/esm/src/lib/utils.d.ts.map +1 -1
- package/esm/src/lib/utils.js +48 -1
- package/package.json +1 -1
- package/script/deno.js +1 -1
- package/script/src/commands/announcements.d.ts.map +1 -1
- package/script/src/commands/announcements.js +15 -16
- package/script/src/commands/assignments.d.ts +3 -0
- package/script/src/commands/assignments.d.ts.map +1 -0
- package/script/src/commands/assignments.js +269 -0
- package/script/src/commands/auth.d.ts.map +1 -1
- package/script/src/commands/auth.js +44 -14
- package/script/src/commands/calendar.d.ts.map +1 -1
- package/script/src/commands/calendar.js +19 -20
- package/script/src/commands/courses.js +5 -5
- package/script/src/commands/forums.d.ts.map +1 -1
- package/script/src/commands/forums.js +128 -36
- package/script/src/commands/grades.js +3 -3
- package/script/src/commands/materials.d.ts.map +1 -1
- package/script/src/commands/materials.js +115 -224
- package/script/src/commands/quizzes.d.ts.map +1 -1
- package/script/src/commands/quizzes.js +177 -66
- package/script/src/commands/skills.d.ts.map +1 -1
- package/script/src/commands/skills.js +4 -8
- package/script/src/commands/upload.d.ts +3 -0
- package/script/src/commands/upload.d.ts.map +1 -0
- package/script/src/commands/upload.js +64 -0
- package/script/src/commands/videos.d.ts.map +1 -1
- package/script/src/commands/videos.js +10 -9
- package/script/src/index.d.ts.map +1 -1
- package/script/src/index.js +12 -1
- package/script/src/lib/auth.d.ts +23 -1
- package/script/src/lib/auth.d.ts.map +1 -1
- package/script/src/lib/auth.js +70 -3
- package/script/src/lib/logger.d.ts +1 -1
- package/script/src/lib/logger.d.ts.map +1 -1
- package/script/src/lib/logger.js +7 -4
- package/script/src/lib/moodle.d.ts +183 -1
- package/script/src/lib/moodle.d.ts.map +1 -1
- package/script/src/lib/moodle.js +511 -13
- package/script/src/lib/types.d.ts +81 -164
- package/script/src/lib/types.d.ts.map +1 -1
- package/script/src/lib/types.js +1 -0
- package/script/src/lib/utils.d.ts +20 -0
- package/script/src/lib/utils.d.ts.map +1 -1
- package/script/src/lib/utils.js +52 -0
- package/skills/openape/SKILL.md +74 -270
package/esm/src/lib/moodle.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as dntShim from "../../_dnt.shims.js";
|
|
2
2
|
import { stripHtmlTags, extractCourseName } from "./utils.js";
|
|
3
|
+
import * as fs from "node:fs";
|
|
3
4
|
// ── Core Moodle AJAX Wrapper ───────────────────────────────────────────
|
|
4
5
|
/**
|
|
5
6
|
* Moodle WS API functions that are known to work via /webservice/rest/server.php
|
|
@@ -9,6 +10,12 @@ const WS_API_FUNCTIONS = new Set([
|
|
|
9
10
|
"mod_forum_get_forums_by_courses",
|
|
10
11
|
"mod_forum_get_forum_discussions",
|
|
11
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",
|
|
12
19
|
"gradereport_user_get_grade_items",
|
|
13
20
|
"core_calendar_get_calendar_events",
|
|
14
21
|
"core_course_get_contents",
|
|
@@ -19,18 +26,33 @@ const WS_API_FUNCTIONS = new Set([
|
|
|
19
26
|
"mod_supervideo_progress_save_mobile",
|
|
20
27
|
"mod_supervideo_view_supervideo",
|
|
21
28
|
"mod_quiz_get_quizzes_by_courses",
|
|
29
|
+
"mod_quiz_start_attempt",
|
|
30
|
+
"mod_quiz_get_attempt_data",
|
|
22
31
|
"mod_resource_get_resources_by_courses",
|
|
32
|
+
"mod_assign_get_assignments",
|
|
33
|
+
"mod_assign_save_submission",
|
|
34
|
+
"mod_assign_get_submission_status",
|
|
23
35
|
"core_message_get_messages",
|
|
24
36
|
"core_webservice_get_site_info",
|
|
25
37
|
]);
|
|
26
38
|
/**
|
|
27
39
|
* Convert args to URLSearchParams, handling arrays properly for Moodle WS API.
|
|
28
40
|
* Moodle expects array parameters as: courseids[0]=1&courseids[1]=2
|
|
41
|
+
* For options array: options[0][name]=attachmentsid&options[0][value]=123
|
|
29
42
|
*/
|
|
30
43
|
function buildWsParams(args) {
|
|
31
44
|
const params = new URLSearchParams();
|
|
32
45
|
for (const [key, value] of Object.entries(args)) {
|
|
33
|
-
if (Array.isArray(value)) {
|
|
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)) {
|
|
34
56
|
// Array parameters: courseids[0]=1&courseids[1]=2
|
|
35
57
|
value.forEach((v, i) => {
|
|
36
58
|
params.append(`${key}[${i}]`, String(v));
|
|
@@ -48,7 +70,7 @@ function buildWsParams(args) {
|
|
|
48
70
|
*/
|
|
49
71
|
export async function moodleApiCall(session, methodname, args) {
|
|
50
72
|
if (!session.wsToken) {
|
|
51
|
-
throw new Error(`WS
|
|
73
|
+
throw new Error(`WS ${methodname} required for API call: ${methodname}`);
|
|
52
74
|
}
|
|
53
75
|
const params = buildWsParams(args);
|
|
54
76
|
params.set("wstoken", session.wsToken);
|
|
@@ -222,6 +244,7 @@ export async function getForumsApi(session, courseIds) {
|
|
|
222
244
|
id: f.id,
|
|
223
245
|
cmid: f.cmid,
|
|
224
246
|
name: f.name,
|
|
247
|
+
intro: f.intro,
|
|
225
248
|
courseid: f.course, // API returns 'course' not 'courseid'
|
|
226
249
|
timemodified: f.timemodified,
|
|
227
250
|
}));
|
|
@@ -292,6 +315,88 @@ export async function getDiscussionPostsApi(session, discussionId) {
|
|
|
292
315
|
return [];
|
|
293
316
|
}
|
|
294
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
|
+
}
|
|
295
400
|
// ── Resource/Material Operations ──────────────────────────────────────────
|
|
296
401
|
/**
|
|
297
402
|
* Get all resource modules in a course.
|
|
@@ -586,13 +691,13 @@ export async function completeVideoApi(session, video) {
|
|
|
586
691
|
mapa: buildDurationMap(duration),
|
|
587
692
|
});
|
|
588
693
|
// Debug: log the full result
|
|
589
|
-
console.debug(`completeVideoApi result:`, JSON.stringify(result));
|
|
694
|
+
// console.debug(`completeVideoApi result:`, JSON.stringify(result));
|
|
590
695
|
const success = result?.[0]?.success === true || result?.success === true;
|
|
591
696
|
return { success, error: success ? undefined : `API returned success=false, result=${JSON.stringify(result)}`, result };
|
|
592
697
|
}
|
|
593
698
|
catch (err) {
|
|
594
699
|
const msg = err instanceof Error ? err.message : String(err);
|
|
595
|
-
console.debug(`completeVideoApi error: ${msg}`);
|
|
700
|
+
// console.debug(`completeVideoApi error: ${msg}`);
|
|
596
701
|
return { success: false, error: msg };
|
|
597
702
|
}
|
|
598
703
|
}
|
|
@@ -774,6 +879,37 @@ export async function getSupervideosInCourseApi(session, courseId) {
|
|
|
774
879
|
}
|
|
775
880
|
return videos;
|
|
776
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
|
+
}
|
|
777
913
|
/**
|
|
778
914
|
* Get quizzes in courses via pure WS API.
|
|
779
915
|
*/
|
|
@@ -781,15 +917,170 @@ export async function getQuizzesByCoursesApi(session, courseIds) {
|
|
|
781
917
|
if (courseIds.length === 0)
|
|
782
918
|
return [];
|
|
783
919
|
const data = await moodleApiCall(session, "mod_quiz_get_quizzes_by_courses", { courseids: courseIds });
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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 };
|
|
793
1084
|
}
|
|
794
1085
|
// ── Materials via WS API ──────────────────────────────────────────────────────
|
|
795
1086
|
/**
|
|
@@ -814,6 +1105,200 @@ export async function getResourcesByCoursesApi(session, courseIds) {
|
|
|
814
1105
|
};
|
|
815
1106
|
});
|
|
816
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
|
+
}
|
|
817
1302
|
/**
|
|
818
1303
|
* Get messages for the current user via pure WS API.
|
|
819
1304
|
*/
|