@mo7yw4ng/openape 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -0
  3. package/esm/_dnt.polyfills.d.ts +24 -0
  4. package/esm/_dnt.polyfills.js +1 -0
  5. package/esm/_dnt.shims.d.ts +5 -0
  6. package/esm/_dnt.shims.js +61 -0
  7. package/esm/deno.d.ts +23 -0
  8. package/esm/deno.js +22 -0
  9. package/esm/package.json +3 -0
  10. package/package.json +34 -0
  11. package/script/_dnt.polyfills.d.ts +24 -0
  12. package/script/_dnt.polyfills.js +2 -0
  13. package/script/_dnt.shims.d.ts +5 -0
  14. package/script/_dnt.shims.js +65 -0
  15. package/script/deno.d.ts +23 -0
  16. package/script/deno.js +24 -0
  17. package/script/package.json +3 -0
  18. package/script/src/commands/announcements.d.ts +2 -0
  19. package/script/src/commands/announcements.js +288 -0
  20. package/script/src/commands/auth.d.ts +2 -0
  21. package/script/src/commands/auth.js +238 -0
  22. package/script/src/commands/calendar.d.ts +2 -0
  23. package/script/src/commands/calendar.js +375 -0
  24. package/script/src/commands/courses.d.ts +2 -0
  25. package/script/src/commands/courses.js +484 -0
  26. package/script/src/commands/forums.d.ts +2 -0
  27. package/script/src/commands/forums.js +403 -0
  28. package/script/src/commands/grades.d.ts +2 -0
  29. package/script/src/commands/grades.js +259 -0
  30. package/script/src/commands/materials.d.ts +2 -0
  31. package/script/src/commands/materials.js +423 -0
  32. package/script/src/commands/quizzes.d.ts +2 -0
  33. package/script/src/commands/quizzes.js +228 -0
  34. package/script/src/commands/skills.d.ts +2 -0
  35. package/script/src/commands/skills.js +117 -0
  36. package/script/src/commands/videos.d.ts +2 -0
  37. package/script/src/commands/videos.js +334 -0
  38. package/script/src/index.d.ts +26 -0
  39. package/script/src/index.js +156 -0
  40. package/script/src/lib/auth.d.ts +24 -0
  41. package/script/src/lib/auth.js +203 -0
  42. package/script/src/lib/config.d.ts +5 -0
  43. package/script/src/lib/config.js +43 -0
  44. package/script/src/lib/logger.d.ts +2 -0
  45. package/script/src/lib/logger.js +28 -0
  46. package/script/src/lib/moodle.d.ts +234 -0
  47. package/script/src/lib/moodle.js +966 -0
  48. package/script/src/lib/session.d.ts +7 -0
  49. package/script/src/lib/session.js +71 -0
  50. package/script/src/lib/token.d.ts +27 -0
  51. package/script/src/lib/token.js +154 -0
  52. package/script/src/lib/types.d.ts +261 -0
  53. package/script/src/lib/types.js +2 -0
  54. package/script/src/lib/utils.d.ts +5 -0
  55. package/script/src/lib/utils.js +43 -0
  56. package/skills/openape/SKILL.md +328 -0
@@ -0,0 +1,966 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.getMessagesApi = exports.getResourcesByCoursesApi = exports.getQuizzesByCoursesApi = exports.getSupervideosInCourseApi = exports.getSiteInfoApi = exports.completeVideo = exports.downloadVideo = exports.getVideoMetadata = exports.getCalendarEventsApi = exports.getCalendarEvents = exports.getCourseGradesApi = exports.getCourseGrades = exports.getResourcesInCourse = exports.getDiscussionPosts = exports.getForumDiscussions = exports.getForumsByCourseIds = exports.getForumIdFromPage = exports.getForumsApi = exports.getForumsInCourse = exports.getQuizzesInCourse = exports.getSupervideosInCourse = exports.getCourseState = exports.getEnrolledCourses = exports.getEnrolledCoursesApi = exports.moodleAjax = exports.moodleApiCall = void 0;
27
+ const dntShim = __importStar(require("../../_dnt.shims.js"));
28
+ const node_html_parser_1 = require("node-html-parser");
29
+ // ── HTML Parsing Helpers ──────────────────────────────────────────────────
30
+ /**
31
+ * Get the HTML content of a page and parse it.
32
+ */
33
+ async function fetchAndParse(page, url) {
34
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
35
+ const content = await page.content();
36
+ return (0, node_html_parser_1.parse)(content);
37
+ }
38
+ // ── Core Moodle AJAX Wrapper ───────────────────────────────────────────
39
+ /**
40
+ * Moodle WS API functions that are known to work via /webservice/rest/server.php
41
+ * Other functions should use the sesskey-based AJAX API.
42
+ */
43
+ const WS_API_FUNCTIONS = new Set([
44
+ "mod_forum_get_forums_by_courses",
45
+ "mod_forum_get_forum_discussions",
46
+ "mod_forum_get_forum_discussion_posts",
47
+ "gradereport_user_get_grade_items",
48
+ "core_calendar_get_calendar_events",
49
+ "core_course_get_contents",
50
+ "mod_quiz_get_quizzes_by_courses",
51
+ "mod_resource_get_resources_by_courses",
52
+ "core_message_get_messages",
53
+ "core_webservice_get_site_info",
54
+ ]);
55
+ /**
56
+ * Convert args to URLSearchParams, handling arrays properly for Moodle WS API.
57
+ * Moodle expects array parameters as: courseids[0]=1&courseids[1]=2
58
+ */
59
+ function buildWsParams(args) {
60
+ const params = new URLSearchParams();
61
+ for (const [key, value] of Object.entries(args)) {
62
+ if (Array.isArray(value)) {
63
+ // Array parameters: courseids[0]=1&courseids[1]=2
64
+ value.forEach((v, i) => {
65
+ params.append(`${key}[${i}]`, String(v));
66
+ });
67
+ }
68
+ else if (value !== null && value !== undefined) {
69
+ params.append(key, String(value));
70
+ }
71
+ }
72
+ return params;
73
+ }
74
+ /**
75
+ * Direct HTTP API call without browser (for WS API only).
76
+ * This is much faster than browser-based calls.
77
+ */
78
+ async function moodleApiCall(session, methodname, args) {
79
+ if (!session.wsToken) {
80
+ throw new Error(`WS Token required for API call: ${methodname}`);
81
+ }
82
+ const params = buildWsParams(args);
83
+ params.set("wstoken", session.wsToken);
84
+ params.set("wsfunction", methodname);
85
+ params.set("moodlewsrestformat", "json");
86
+ const url = `${session.moodleBaseUrl}/webservice/rest/server.php?${params.toString()}`;
87
+ const response = await fetch(url, { method: "GET" });
88
+ const result = await response.json();
89
+ if (result.error) {
90
+ throw new Error(`WS ${methodname} failed: ${result.message ?? result.exception?.message ?? "Unknown error"}`);
91
+ }
92
+ return result;
93
+ }
94
+ exports.moodleApiCall = moodleApiCall;
95
+ /**
96
+ * Send a Moodle AJAX request and return the result.
97
+ * Uses Web Service token if available AND the function is in WS_API_FUNCTIONS,
98
+ * otherwise falls back to sesskey-based AJAX (via /lib/ajax/service.php).
99
+ */
100
+ async function moodleAjax(page, session, methodname, args) {
101
+ // Only use WS API for known WS functions
102
+ const useWsApi = session.wsToken && WS_API_FUNCTIONS.has(methodname);
103
+ if (useWsApi) {
104
+ // Use Moodle Web Service API
105
+ // Format: /webservice/rest/server.php?wstoken=TOKEN&wsfunction=FUNCTION&moodlewsrestformat=json
106
+ const params = buildWsParams(args);
107
+ params.set("wstoken", session.wsToken);
108
+ params.set("wsfunction", methodname);
109
+ params.set("moodlewsrestformat", "json");
110
+ const url = `${session.moodleBaseUrl}/webservice/rest/server.php?${params.toString()}`;
111
+ const result = await page.evaluate(async ({ url }) => {
112
+ const res = await fetch(url, { method: "GET" });
113
+ return res.json();
114
+ }, { url });
115
+ if (result.error) {
116
+ throw new Error(`WS ${methodname} failed: ${result.message ?? result.exception?.message ?? "Unknown error"}`);
117
+ }
118
+ return result;
119
+ }
120
+ else {
121
+ // Legacy sesskey-based AJAX format
122
+ const url = `${session.moodleBaseUrl}/lib/ajax/service.php?sesskey=${session.sesskey}&info=${methodname}`;
123
+ const payload = [{ index: 0, methodname, args }];
124
+ const result = await page.evaluate(async ({ url, payload }) => {
125
+ const res = await fetch(url, {
126
+ method: "POST",
127
+ headers: { "Content-Type": "application/json" },
128
+ body: JSON.stringify(payload),
129
+ });
130
+ return res.json();
131
+ }, { url, payload });
132
+ if (result?.[0]?.error) {
133
+ throw new Error(`AJAX ${methodname} failed: ${result[0].exception?.message ?? "Unknown error"}`);
134
+ }
135
+ return result[0].data;
136
+ }
137
+ }
138
+ exports.moodleAjax = moodleAjax;
139
+ // ── Course Operations ─────────────────────────────────────────────────────
140
+ /**
141
+ * Fetch enrolled courses via pure API (no browser required).
142
+ * Fast and lightweight - uses HTTP fetch directly.
143
+ */
144
+ async function getEnrolledCoursesApi(session, options = {}) {
145
+ const data = await moodleApiCall(session, "core_course_get_enrolled_courses_by_timeline_classification", {
146
+ offset: 0,
147
+ limit: options.limit ?? 0,
148
+ classification: options.classification ?? "all",
149
+ sort: "fullname",
150
+ customfieldname: "",
151
+ customfieldvalue: "",
152
+ requiredfields: [
153
+ "id",
154
+ "fullname",
155
+ "shortname",
156
+ "idnumber",
157
+ "category",
158
+ "progress",
159
+ "startdate",
160
+ "enddate",
161
+ ],
162
+ });
163
+ return (data?.courses ?? []).map((c) => ({
164
+ id: c.id,
165
+ fullname: c.fullname,
166
+ shortname: c.shortname,
167
+ idnumber: c.idnumber,
168
+ category: c.category?.name,
169
+ progress: c.progress,
170
+ startdate: c.startdate,
171
+ enddate: c.enddate,
172
+ }));
173
+ }
174
+ exports.getEnrolledCoursesApi = getEnrolledCoursesApi;
175
+ /**
176
+ * Fetch all enrolled courses via Moodle AJAX API.
177
+ */
178
+ async function getEnrolledCourses(page, session, log, options = {}) {
179
+ log.debug("Fetching enrolled courses via AJAX...");
180
+ const data = await moodleAjax(page, session, "core_course_get_enrolled_courses_by_timeline_classification", {
181
+ offset: 0,
182
+ limit: options.limit ?? 0,
183
+ classification: options.classification ?? "all",
184
+ sort: "fullname",
185
+ customfieldname: "",
186
+ customfieldvalue: "",
187
+ requiredfields: [
188
+ "id",
189
+ "fullname",
190
+ "shortname",
191
+ "showcoursecategory",
192
+ "showshortname",
193
+ "visible",
194
+ "enddate",
195
+ ],
196
+ });
197
+ const courses = (data?.courses ?? []).map((c) => ({
198
+ id: c.id,
199
+ fullname: c.fullname,
200
+ shortname: c.shortname,
201
+ idnumber: c.idnumber,
202
+ category: c.category?.name,
203
+ progress: c.progress,
204
+ startdate: c.startdate,
205
+ enddate: c.enddate,
206
+ }));
207
+ log.debug(`Found ${courses.length} course${courses.length === 1 ? "" : "s"}.`);
208
+ return courses;
209
+ }
210
+ exports.getEnrolledCourses = getEnrolledCourses;
211
+ /**
212
+ * Get course state (modules) via core_courseformat_get_state.
213
+ */
214
+ async function getCourseState(page, session, courseId) {
215
+ const data = await moodleAjax(page, session, "core_courseformat_get_state", {
216
+ courseid: courseId,
217
+ });
218
+ return typeof data === "string" ? JSON.parse(data) : data;
219
+ }
220
+ exports.getCourseState = getCourseState;
221
+ // ── Video Operations ──────────────────────────────────────────────────────
222
+ /**
223
+ * Get all SuperVideo modules in a course.
224
+ */
225
+ async function getSupervideosInCourse(page, session, courseId, log, options = {}) {
226
+ const state = await getCourseState(page, session, courseId);
227
+ const cms = state?.cm ?? [];
228
+ log.debug(` Course state returned ${cms.length} modules`);
229
+ // Debug: log first few modules
230
+ for (let i = 0; i < Math.min(3, cms.length); i++) {
231
+ log.debug(` Module ${i}: ${JSON.stringify(cms[i])}`);
232
+ }
233
+ const allSupervideos = cms.filter((cm) => cm.module === "supervideo" || cm.modname === "supervideo");
234
+ log.debug(` Found ${allSupervideos.length} supervideo modules`);
235
+ const incomplete = allSupervideos.filter((cm) => !("isoverallcomplete" in cm && cm.isoverallcomplete));
236
+ log.debug(` SuperVideo: ${allSupervideos.length} total, ${incomplete.length} incomplete`);
237
+ // Return only incomplete if requested, otherwise return all
238
+ const videos = options.incompleteOnly ? incomplete : allSupervideos;
239
+ return videos.map((cm) => ({
240
+ cmid: cm.cmid?.toString() ?? cm.id?.toString() ?? "",
241
+ name: cm.name,
242
+ url: cm.url,
243
+ isComplete: !!cm.isoverallcomplete,
244
+ }));
245
+ }
246
+ exports.getSupervideosInCourse = getSupervideosInCourse;
247
+ // ── Quiz Operations ───────────────────────────────────────────────────────
248
+ /**
249
+ * Get all Quiz modules in a course.
250
+ */
251
+ async function getQuizzesInCourse(page, session, courseId, log) {
252
+ const state = await getCourseState(page, session, courseId);
253
+ const cms = state?.cm ?? [];
254
+ const allQuizzes = cms.filter((cm) => cm.module === "quiz");
255
+ const available = allQuizzes.filter((cm) => !("isoverallcomplete" in cm) || !cm.isoverallcomplete);
256
+ log.debug(` Quiz: ${allQuizzes.length} total, ${available.length} available`);
257
+ return available.map((cm) => ({
258
+ cmid: cm.cmid?.toString() ?? cm.id?.toString() ?? "",
259
+ name: cm.name,
260
+ url: cm.url,
261
+ isComplete: !!cm.isoverallcomplete,
262
+ timeOpen: cm.timeopen,
263
+ timeClose: cm.timeclose,
264
+ }));
265
+ }
266
+ exports.getQuizzesInCourse = getQuizzesInCourse;
267
+ // ── Forum Operations ──────────────────────────────────────────────────────
268
+ /**
269
+ * Get all forum modules in a course.
270
+ * If WS token is available, fetches forum IDs directly via WS API.
271
+ */
272
+ async function getForumsInCourse(page, session, courseId, log) {
273
+ // First get basic forum info from course state
274
+ const state = await getCourseState(page, session, courseId);
275
+ const cms = state?.cm ?? [];
276
+ const forums = cms.filter((cm) => cm.module === "forum");
277
+ log.debug(` Found ${forums.length} forum${forums.length === 1 ? "" : "s"}.`);
278
+ const result = forums.map((cm) => ({
279
+ cmid: cm.cmid?.toString() ?? cm.id?.toString() ?? "",
280
+ forumId: 0,
281
+ name: cm.name,
282
+ url: cm.url,
283
+ courseId,
284
+ forumType: cm.modname,
285
+ }));
286
+ // If WS token is available, fetch forum IDs directly
287
+ if (session.wsToken && forums.length > 0) {
288
+ try {
289
+ const wsForums = await moodleAjax(page, session, "mod_forum_get_forums_by_courses", { courseids: [courseId] });
290
+ // Create maps for lookup by different fields
291
+ const byId = new Map(); // cmid -> forum id
292
+ const byName = new Map(); // name -> forum id
293
+ for (const wsForum of wsForums || []) {
294
+ if (wsForum.cmid) {
295
+ byId.set(wsForum.cmid, wsForum.id);
296
+ }
297
+ if (wsForum.name) {
298
+ byName.set(wsForum.name, wsForum.id);
299
+ }
300
+ }
301
+ // Merge forum IDs into result
302
+ for (const forum of result) {
303
+ const cmid = parseInt(forum.cmid, 10);
304
+ if (byId.has(cmid)) {
305
+ forum.forumId = byId.get(cmid);
306
+ }
307
+ else if (byName.has(forum.name)) {
308
+ forum.forumId = byName.get(forum.name);
309
+ }
310
+ }
311
+ const matchedCount = result.filter(f => f.forumId > 0).length;
312
+ log.debug(` WS API provided forum IDs for ${matchedCount}/${result.length} forums.`);
313
+ }
314
+ catch (e) {
315
+ log.debug(` WS API forum lookup failed: ${e instanceof Error ? e.message : String(e)}`);
316
+ }
317
+ }
318
+ return result;
319
+ }
320
+ exports.getForumsInCourse = getForumsInCourse;
321
+ /**
322
+ * Get all forums via pure WS API (no browser required).
323
+ * Fast and lightweight - uses HTTP fetch directly.
324
+ */
325
+ async function getForumsApi(session, courseIds) {
326
+ const data = await moodleApiCall(session, "mod_forum_get_forums_by_courses", { courseids: courseIds });
327
+ return (data ?? []).map((f) => ({
328
+ id: f.id,
329
+ cmid: f.cmid,
330
+ name: f.name,
331
+ courseid: f.course, // API returns 'course' not 'courseid'
332
+ }));
333
+ }
334
+ exports.getForumsApi = getForumsApi;
335
+ /**
336
+ * Extract forum ID from forum page.
337
+ * First tries to find it in embedded page data, then falls back to
338
+ * extracting it from discussion posts API.
339
+ */
340
+ async function getForumIdFromPage(page, cmid, session) {
341
+ try {
342
+ await page.goto(`https://ilearning.cycu.edu.tw/mod/forum/view.php?id=${cmid}`, { waitUntil: "domcontentloaded", timeout: 30000 });
343
+ // First try: extract from page HTML
344
+ const forumId = await page.evaluate(() => {
345
+ // Try multiple patterns to find the forum ID
346
+ const patterns = [
347
+ /"forumid":(\d+)/,
348
+ /"forumId":(\d+)/,
349
+ /forumid=(\d+)/,
350
+ /data-forum-id="(\d+)"/,
351
+ ];
352
+ const html = document.body.innerHTML;
353
+ for (const pattern of patterns) {
354
+ const match = html.match(pattern);
355
+ if (match)
356
+ return parseInt(match[1], 10);
357
+ }
358
+ // Try to find it in a script tag with forum configuration
359
+ const scripts = Array.from(document.querySelectorAll('script'));
360
+ for (const script of scripts) {
361
+ const text = script.textContent || '';
362
+ const match = text.match(/"forumid":(\d+)/);
363
+ if (match)
364
+ return parseInt(match[1], 10);
365
+ }
366
+ // Try to find from discussion links - extract from API data embedded in page
367
+ const discussLinks = Array.from(document.querySelectorAll('a[href*="discuss.php"]'));
368
+ for (const link of discussLinks) {
369
+ const href = link.href;
370
+ // The discussion page might have forum info
371
+ const dMatch = href.match(/d=(\d+)/);
372
+ if (dMatch) {
373
+ // Try to find parent element with forum data
374
+ let parent = link.parentElement;
375
+ let depth = 0;
376
+ while (parent && depth < 10) {
377
+ const parentHtml = parent.innerHTML;
378
+ const fMatch = parentHtml.match(/"forum":(\d+)/);
379
+ if (fMatch)
380
+ return parseInt(fMatch[1], 10);
381
+ parent = parent.parentElement;
382
+ depth++;
383
+ }
384
+ }
385
+ }
386
+ return null;
387
+ });
388
+ if (forumId)
389
+ return forumId;
390
+ // Fallback: if session is provided, try to get instance ID from discussion posts
391
+ if (session) {
392
+ // Get first discussion ID from page
393
+ const firstDiscussionId = await page.evaluate(() => {
394
+ const link = document.querySelector('a[href*="discuss.php"]');
395
+ if (!link)
396
+ return null;
397
+ const href = link.href;
398
+ const match = href.match(/d=(\d+)/);
399
+ return match ? parseInt(match[1], 10) : null;
400
+ });
401
+ if (firstDiscussionId) {
402
+ // Try to get posts and extract forum ID from response
403
+ const data = await moodleAjax(page, session, "mod_forum_get_forum_discussion_posts", {
404
+ discussionid: firstDiscussionId,
405
+ });
406
+ if (data?.posts && data.posts.length > 0) {
407
+ const firstPost = data.posts[0];
408
+ if (firstPost.forum) {
409
+ return firstPost.forum;
410
+ }
411
+ }
412
+ }
413
+ }
414
+ return null;
415
+ }
416
+ catch {
417
+ return null;
418
+ }
419
+ }
420
+ exports.getForumIdFromPage = getForumIdFromPage;
421
+ /**
422
+ * Get forums by course IDs via AJAX.
423
+ * Returns forum instance IDs directly from Moodle API.
424
+ * This is the cleanest way to get forum instance IDs.
425
+ */
426
+ async function getForumsByCourseIds(page, session, courseIds) {
427
+ if (courseIds.length === 0)
428
+ return [];
429
+ try {
430
+ const data = await moodleAjax(page, session, "mod_forum_get_forums_by_courses", {
431
+ courseids: courseIds,
432
+ });
433
+ return data ?? [];
434
+ }
435
+ catch (e) {
436
+ // Re-throw with more context
437
+ throw new Error(`mod_forum_get_forums_by_courses failed: ${e?.message || e}`);
438
+ }
439
+ }
440
+ exports.getForumsByCourseIds = getForumsByCourseIds;
441
+ /**
442
+ * Get discussions in a forum via AJAX.
443
+ * Note: Requires forum instance ID, not cmid. Use getForumsByCourseIds() first.
444
+ */
445
+ async function getForumDiscussions(page, session, forumId) {
446
+ const data = await moodleAjax(page, session, "mod_forum_get_forum_discussions", {
447
+ forumid: forumId,
448
+ });
449
+ return (data?.discussions ?? []).map((d) => ({
450
+ id: d.id,
451
+ forumId: d.forum,
452
+ name: d.name,
453
+ firstPostId: d.firstpost,
454
+ userId: d.userid,
455
+ groupId: d.groupid,
456
+ timedue: d.timedue,
457
+ timeModified: d.timemodified,
458
+ userModified: d.usermodified,
459
+ postCount: d.numdiscussion,
460
+ unread: d.unread,
461
+ }));
462
+ }
463
+ exports.getForumDiscussions = getForumDiscussions;
464
+ /**
465
+ * Get posts in a discussion via AJAX.
466
+ */
467
+ async function getDiscussionPosts(page, session, discussionId) {
468
+ try {
469
+ const data = await moodleAjax(page, session, "mod_forum_get_forum_discussion_posts", {
470
+ discussionid: discussionId,
471
+ });
472
+ if (!data?.posts || data.posts.length === 0) {
473
+ return [];
474
+ }
475
+ return data.posts.map((p) => ({
476
+ id: p.id,
477
+ subject: p.subject || "",
478
+ author: p.author?.fullname ?? p.username ?? "Unknown",
479
+ authorId: p.userid,
480
+ created: p.created,
481
+ modified: p.modified,
482
+ message: p.message || "",
483
+ discussionId: p.discussion,
484
+ unread: p.unread ?? false,
485
+ }));
486
+ }
487
+ catch (error) {
488
+ // Return empty array on error instead of throwing
489
+ // This allows commands to gracefully handle inaccessible discussions
490
+ return [];
491
+ }
492
+ }
493
+ exports.getDiscussionPosts = getDiscussionPosts;
494
+ // ── Resource/Material Operations ──────────────────────────────────────────
495
+ /**
496
+ * Get all resource modules in a course.
497
+ */
498
+ async function getResourcesInCourse(page, session, courseId, log) {
499
+ const state = await getCourseState(page, session, courseId);
500
+ const cms = state?.cm ?? [];
501
+ const resources = cms.filter((cm) => ["resource", "url"].includes(cm.module));
502
+ log.debug(` Found ${resources.length} resource${resources.length === 1 ? "" : "s"}.`);
503
+ return resources.map((cm) => ({
504
+ cmid: cm.cmid?.toString() ?? cm.id?.toString() ?? "",
505
+ name: cm.name,
506
+ url: cm.url,
507
+ courseId,
508
+ modType: cm.module,
509
+ mimetype: undefined,
510
+ filesize: undefined,
511
+ modified: 0,
512
+ }));
513
+ }
514
+ exports.getResourcesInCourse = getResourcesInCourse;
515
+ // ── Grade Operations ──────────────────────────────────────────────────────
516
+ /**
517
+ * Get course grades for the current user via AJAX.
518
+ */
519
+ async function getCourseGrades(page, session, courseId) {
520
+ const data = await moodleAjax(page, session, "gradereport_user_get_grade_items", {
521
+ courseid: courseId,
522
+ });
523
+ const userGrades = data?.usergrades?.[0];
524
+ if (!userGrades) {
525
+ return { courseId, courseName: "", items: [] };
526
+ }
527
+ return {
528
+ courseId,
529
+ courseName: userGrades.coursefullname ?? "",
530
+ grade: userGrades.grade,
531
+ gradeFormatted: userGrades.gradeformatted,
532
+ rank: userGrades.rank,
533
+ totalUsers: userGrades.totalusers,
534
+ items: (userGrades.gradeitems ?? []).map((item) => ({
535
+ id: item.id,
536
+ name: item.itemname || item.itemmodule,
537
+ grade: item.grade,
538
+ gradeFormatted: item.gradeformatted,
539
+ range: item.graderangeformatted,
540
+ percentage: item.percentage,
541
+ weight: item.weight,
542
+ feedback: item.feedback,
543
+ graded: !!item.graded,
544
+ })),
545
+ };
546
+ }
547
+ exports.getCourseGrades = getCourseGrades;
548
+ /**
549
+ * Get course grades for the current user via pure WS API (no browser required).
550
+ * Fast and lightweight - uses HTTP fetch directly.
551
+ */
552
+ async function getCourseGradesApi(session, courseId) {
553
+ const data = await moodleApiCall(session, "gradereport_user_get_grade_items", {
554
+ courseid: courseId,
555
+ });
556
+ const userGrades = data?.usergrades?.[0];
557
+ if (!userGrades) {
558
+ return { courseId, courseName: "", items: [] };
559
+ }
560
+ return {
561
+ courseId,
562
+ courseName: userGrades.coursefullname ?? "",
563
+ grade: userGrades.grade,
564
+ gradeFormatted: userGrades.gradeformatted,
565
+ rank: userGrades.rank,
566
+ totalUsers: userGrades.totalusers,
567
+ items: (userGrades.gradeitems ?? []).map((item) => ({
568
+ id: item.id,
569
+ name: item.itemname || item.itemmodule,
570
+ grade: item.grade,
571
+ gradeFormatted: item.gradeformatted,
572
+ range: item.graderangeformatted,
573
+ percentage: item.percentage,
574
+ weight: item.weight,
575
+ feedback: item.feedback,
576
+ graded: !!item.graded,
577
+ })),
578
+ };
579
+ }
580
+ exports.getCourseGradesApi = getCourseGradesApi;
581
+ // ── Calendar Operations ───────────────────────────────────────────────────
582
+ /**
583
+ * Get calendar events via AJAX.
584
+ */
585
+ async function getCalendarEvents(page, session, options = {}) {
586
+ const data = await moodleAjax(page, session, "core_calendar_get_calendar_events", {
587
+ ...options,
588
+ });
589
+ return (data?.events ?? []).map((e) => ({
590
+ id: e.id,
591
+ name: e.name,
592
+ description: e.description,
593
+ format: e.format,
594
+ courseid: e.courseid,
595
+ categoryid: e.categoryid,
596
+ groupid: e.groupid,
597
+ userid: e.userid,
598
+ moduleid: e.moduleid,
599
+ modulename: e.modulename,
600
+ instance: e.instance,
601
+ eventtype: e.eventtype,
602
+ timestart: e.timestart * 1000,
603
+ timeduration: e.timeduration ? e.timeduration * 1000 : undefined,
604
+ timedue: e.timedue ? e.timedue * 1000 : undefined,
605
+ visible: e.visible,
606
+ location: e.location,
607
+ }));
608
+ }
609
+ exports.getCalendarEvents = getCalendarEvents;
610
+ /**
611
+ * Get calendar events via pure WS API (no browser required).
612
+ * Fast and lightweight - uses HTTP fetch directly.
613
+ */
614
+ async function getCalendarEventsApi(session, options = {}) {
615
+ const data = await moodleApiCall(session, "core_calendar_get_calendar_events", {
616
+ ...options,
617
+ });
618
+ return (data?.events ?? []).map((e) => ({
619
+ id: e.id,
620
+ name: e.name,
621
+ description: e.description,
622
+ format: e.format,
623
+ courseid: e.courseid,
624
+ categoryid: e.categoryid,
625
+ groupid: e.groupid,
626
+ userid: e.userid,
627
+ moduleid: e.moduleid,
628
+ modulename: e.modulename,
629
+ instance: e.instance,
630
+ eventtype: e.eventtype,
631
+ timestart: e.timestart * 1000,
632
+ timeduration: e.timeduration ? e.timeduration * 1000 : undefined,
633
+ timedue: e.timedue ? e.timedue * 1000 : undefined,
634
+ visible: e.visible,
635
+ location: e.location,
636
+ }));
637
+ }
638
+ exports.getCalendarEventsApi = getCalendarEventsApi;
639
+ // ── Video Metadata (from original course.ts) ───────────────────────────────
640
+ /**
641
+ * Visit a SuperVideo activity page and extract view_id + duration.
642
+ */
643
+ async function getVideoMetadata(page, activityUrl, log) {
644
+ await page.goto(activityUrl, { waitUntil: "domcontentloaded", timeout: 20000 });
645
+ const name = await page.title();
646
+ const pageSource = await page.content();
647
+ let viewId = null;
648
+ const viewIdPatterns = [
649
+ /player_create.*?amd\.\w+\((\d+)/,
650
+ /view_id['":\s]+(\d+)/,
651
+ ];
652
+ for (const pattern of viewIdPatterns) {
653
+ const match = pageSource.match(pattern);
654
+ if (match) {
655
+ viewId = parseInt(match[1], 10);
656
+ break;
657
+ }
658
+ }
659
+ if (viewId === null) {
660
+ throw new Error(`Could not extract view_id from ${activityUrl}`);
661
+ }
662
+ let duration = null;
663
+ const isYoutube = pageSource.includes("youtube.com") || pageSource.includes("youtu.be");
664
+ if (!isYoutube) {
665
+ try {
666
+ await page.waitForSelector("video", { timeout: 10000 });
667
+ duration = await page.evaluate(() => {
668
+ return new Promise((resolve) => {
669
+ const media = document.querySelector("video");
670
+ if (!media)
671
+ return resolve(null);
672
+ if (media.duration && isFinite(media.duration)) {
673
+ return resolve(Math.ceil(media.duration));
674
+ }
675
+ media.addEventListener("loadedmetadata", () => {
676
+ resolve(Math.ceil(media.duration));
677
+ });
678
+ setTimeout(() => resolve(null), 8000);
679
+ });
680
+ });
681
+ }
682
+ catch {
683
+ // no video element
684
+ }
685
+ }
686
+ if (!duration) {
687
+ const durationMatch = pageSource.match(/["']?duration["']?\s*[:=]\s*(\d+)/);
688
+ if (durationMatch) {
689
+ duration = parseInt(durationMatch[1], 10);
690
+ }
691
+ }
692
+ if (!duration) {
693
+ duration = 600;
694
+ log.debug(` Duration unknown${isYoutube ? " (YouTube)" : ""}, using ${duration}s`);
695
+ }
696
+ log.debug(` viewId=${viewId}, duration=${duration}s`);
697
+ // Phase 1: Extract video sources
698
+ const videoSources = [];
699
+ const youtubeIds = [];
700
+ // 1. Get src from <video> element
701
+ const videoSrc = await page.evaluate(() => {
702
+ const video = document.querySelector("video");
703
+ return video?.src || null;
704
+ });
705
+ if (videoSrc)
706
+ videoSources.push(videoSrc);
707
+ // 2. Get src from <source> elements
708
+ const sourceSrcs = await page.evaluate(() => {
709
+ const sources = Array.from(document.querySelectorAll("source"));
710
+ return sources.map(s => s.src).filter((src) => !!src);
711
+ });
712
+ videoSources.push(...sourceSrcs);
713
+ // 3. Get src from <iframe> elements (YouTube, Vimeo, etc.)
714
+ // Wait a bit for iframes to load
715
+ await page.waitForTimeout(1000);
716
+ const iframeSrcs = await page.evaluate(() => {
717
+ const iframes = Array.from(document.querySelectorAll("iframe"));
718
+ return iframes.map(f => f.src).filter((src) => !!src && src.length > 0);
719
+ });
720
+ // Extract YouTube video IDs from iframe URLs
721
+ for (const iframeSrc of iframeSrcs) {
722
+ videoSources.push(iframeSrc);
723
+ // Extract YouTube video ID
724
+ const ytMatch = iframeSrc.match(/(?:youtube\.com\/(?:embed\/|v\/|watch\?v=)|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
725
+ if (ytMatch) {
726
+ youtubeIds.push(ytMatch[1]);
727
+ }
728
+ }
729
+ // 4. Check for blob/data URLs
730
+ const hasBlobUrl = await page.evaluate(() => {
731
+ const video = document.querySelector("video");
732
+ const src = video?.src || "";
733
+ return src.startsWith("blob:") || src.startsWith("data:");
734
+ });
735
+ // Deduplicate sources
736
+ const uniqueSources = [...new Set(videoSources)];
737
+ log.debug(` Found ${uniqueSources.length} video source(s)`);
738
+ if (uniqueSources.length > 0) {
739
+ log.debug(` Sources: ${uniqueSources.map(s => s.substring(0, 50) + (s.length > 50 ? "..." : "")).join(", ")}`);
740
+ }
741
+ if (youtubeIds.length > 0) {
742
+ log.debug(` YouTube IDs: ${youtubeIds.join(", ")}`);
743
+ }
744
+ if (hasBlobUrl) {
745
+ log.warn(` Video uses blob URL - cannot download directly`);
746
+ }
747
+ return {
748
+ name,
749
+ url: activityUrl,
750
+ viewId,
751
+ duration,
752
+ existingPercent: 0,
753
+ videoSources: uniqueSources,
754
+ youtubeIds,
755
+ };
756
+ }
757
+ exports.getVideoMetadata = getVideoMetadata;
758
+ // ── Video Download ─────────────────────────────────────────────────────────────
759
+ /**
760
+ * Download a video from SuperVideo activity.
761
+ * Supports direct video URLs (pluginfile.php) and YouTube videos.
762
+ */
763
+ async function downloadVideo(page, metadata, outputPath, log) {
764
+ const { name, videoSources, youtubeIds } = metadata;
765
+ log.info(`正在下載: ${name}`);
766
+ // Priority 1: Direct video URL (pluginfile.php, .mp4, etc.)
767
+ const directUrl = videoSources.find(s => s.includes("pluginfile.php") ||
768
+ s.endsWith(".mp4") ||
769
+ s.endsWith(".webm") ||
770
+ s.endsWith(".mov"));
771
+ if (directUrl) {
772
+ log.debug(` 類型: 直接下載 (${directUrl.substring(0, 60)}...)`);
773
+ try {
774
+ // Get session cookies from the page for authentication
775
+ const cookies = await page.context().cookies();
776
+ const cookieHeader = cookies
777
+ .map(c => `${c.name}=${c.value}`)
778
+ .join("; ");
779
+ // Use native fetch with session cookies
780
+ const response = await fetch(directUrl, {
781
+ headers: {
782
+ "Cookie": cookieHeader,
783
+ },
784
+ });
785
+ if (!response.ok) {
786
+ throw new Error(`HTTP ${response.status}`);
787
+ }
788
+ // Get array buffer and convert to Uint8Array
789
+ const arrayBuffer = await response.arrayBuffer();
790
+ const uint8Array = new Uint8Array(arrayBuffer);
791
+ // Write to file using Deno
792
+ await dntShim.Deno.writeFile(outputPath, uint8Array);
793
+ return { success: true, path: outputPath, type: "direct" };
794
+ }
795
+ catch (e) {
796
+ const msg = e instanceof Error ? e.message : String(e);
797
+ log.error(` 下載失敗: ${msg}`);
798
+ return { success: false, error: msg };
799
+ }
800
+ }
801
+ // Priority 2: YouTube video
802
+ if (youtubeIds && youtubeIds.length > 0) {
803
+ log.debug(` 類型: YouTube (ID: ${youtubeIds[0]})`);
804
+ return {
805
+ success: false,
806
+ error: `YouTube 影片無法直接下載。請使用 yt-dlp: yt-dlp https://www.youtube.com/watch?v=${youtubeIds[0]}`,
807
+ type: "youtube",
808
+ };
809
+ }
810
+ // Priority 3: Other iframe/embedded video
811
+ if (videoSources.length > 0) {
812
+ log.debug(` 類型: 嵌入影片 (${videoSources[0].substring(0, 60)}...)`);
813
+ return {
814
+ success: false,
815
+ error: "嵌入影片無法直接下載",
816
+ type: "embedded",
817
+ };
818
+ }
819
+ return {
820
+ success: false,
821
+ error: "未找到影片來源",
822
+ };
823
+ }
824
+ exports.downloadVideo = downloadVideo;
825
+ // ── Progress Completion (from original progress.ts) ───────────────────────
826
+ /**
827
+ * Complete a video by forging progress AJAX call.
828
+ */
829
+ async function completeVideo(page, session, video, log) {
830
+ const { viewId, duration } = video;
831
+ // Build duration map array (required by Moodle)
832
+ const map = Array.from({ length: 100 }, (_, i) => ({
833
+ time: Math.round((duration * i) / 100),
834
+ percent: i,
835
+ }));
836
+ const payload = {
837
+ view_id: viewId,
838
+ currenttime: duration,
839
+ duration: duration,
840
+ percent: 100,
841
+ mapa: JSON.stringify(map),
842
+ };
843
+ const url = `${session.moodleBaseUrl}/lib/ajax/service.php?sesskey=${session.sesskey}&info=mod_supervideo_progress_save`;
844
+ const ajaxPayload = [{ index: 0, methodname: "mod_supervideo_progress_save", args: payload }];
845
+ try {
846
+ const result = await page.evaluate(async ({ url, ajaxPayload }) => {
847
+ const res = await fetch(url, {
848
+ method: "POST",
849
+ headers: { "Content-Type": "application/json" },
850
+ body: JSON.stringify(ajaxPayload),
851
+ });
852
+ return res.json();
853
+ }, { url, ajaxPayload });
854
+ if (result?.[0]?.error) {
855
+ log.debug(` Error: ${result[0].exception?.message ?? "Unknown error"}`);
856
+ return false;
857
+ }
858
+ return true;
859
+ }
860
+ catch (err) {
861
+ log.debug(` Exception: ${err instanceof Error ? err.message : String(err)}`);
862
+ return false;
863
+ }
864
+ }
865
+ exports.completeVideo = completeVideo;
866
+ // ── Site Info (Get User ID) ───────────────────────────────────────────────────
867
+ /**
868
+ * Get site info including current user ID via pure WS API.
869
+ */
870
+ async function getSiteInfoApi(session) {
871
+ const data = await moodleApiCall(session, "core_webservice_get_site_info", {});
872
+ return {
873
+ userid: data.userid,
874
+ username: data.username,
875
+ fullname: data.fullname,
876
+ sitename: data.sitename,
877
+ };
878
+ }
879
+ exports.getSiteInfoApi = getSiteInfoApi;
880
+ // ── Videos via WS API ─────────────────────────────────────────────────────────
881
+ /**
882
+ * Get course contents and filter for SuperVideo modules via pure WS API.
883
+ */
884
+ async function getSupervideosInCourseApi(session, courseId) {
885
+ const data = await moodleApiCall(session, "core_course_get_contents", { courseid: courseId });
886
+ const videos = [];
887
+ // data is an array of sections
888
+ for (const section of data || []) {
889
+ // Each section has modules array
890
+ if (!section.modules)
891
+ continue;
892
+ for (const module of section.modules) {
893
+ // Filter for SuperVideo modname
894
+ if (module.modname === "supervideo") {
895
+ videos.push({
896
+ cmid: module.id.toString(),
897
+ name: module.name,
898
+ url: module.url,
899
+ isComplete: false, // API doesn't provide completion status
900
+ });
901
+ }
902
+ }
903
+ }
904
+ return videos;
905
+ }
906
+ exports.getSupervideosInCourseApi = getSupervideosInCourseApi;
907
+ /**
908
+ * Get quizzes in courses via pure WS API.
909
+ */
910
+ async function getQuizzesByCoursesApi(session, courseIds) {
911
+ if (courseIds.length === 0)
912
+ return [];
913
+ const data = await moodleApiCall(session, "mod_quiz_get_quizzes_by_courses", { courseids: courseIds });
914
+ return (data?.quizzes ?? []).map((q) => ({
915
+ cmid: q.coursemodule.toString(),
916
+ name: q.name,
917
+ url: q.viewurl,
918
+ isComplete: false,
919
+ timeOpen: q.timeopen,
920
+ timeClose: q.timeclose,
921
+ courseId: q.course,
922
+ }));
923
+ }
924
+ exports.getQuizzesByCoursesApi = getQuizzesByCoursesApi;
925
+ // ── Materials via WS API ──────────────────────────────────────────────────────
926
+ /**
927
+ * Get resources in courses via pure WS API.
928
+ */
929
+ async function getResourcesByCoursesApi(session, courseIds) {
930
+ if (courseIds.length === 0)
931
+ return [];
932
+ const data = await moodleApiCall(session, "mod_resource_get_resources_by_courses", { courseids: courseIds });
933
+ return (data?.resources ?? []).map((r) => {
934
+ // Extract file info from contentfiles array
935
+ const firstFile = r.contentfiles?.[0];
936
+ return {
937
+ cmid: r.coursemodule?.toString() ?? r.id?.toString() ?? "",
938
+ name: r.name,
939
+ url: firstFile?.fileurl ?? "",
940
+ courseId: r.course,
941
+ modType: "resource",
942
+ mimetype: firstFile?.mimetype,
943
+ filesize: firstFile?.filesize,
944
+ modified: r.timemodified,
945
+ };
946
+ });
947
+ }
948
+ exports.getResourcesByCoursesApi = getResourcesByCoursesApi;
949
+ /**
950
+ * Get messages for the current user via pure WS API.
951
+ */
952
+ async function getMessagesApi(session, userIdTo, options = {}) {
953
+ const data = await moodleApiCall(session, "core_message_get_messages", { useridto: userIdTo, ...options });
954
+ return (data?.messages ?? []).map((m) => ({
955
+ id: m.id,
956
+ useridfrom: m.useridfrom,
957
+ useridto: m.useridto,
958
+ subject: m.subject,
959
+ text: m.smallmessage,
960
+ timecreated: m.timecreated,
961
+ fullmessage: m.fullmessage,
962
+ fullmessageformat: m.fullmessageformat,
963
+ fullmessagehtml: m.fullmessagehtml,
964
+ }));
965
+ }
966
+ exports.getMessagesApi = getMessagesApi;