@mo7yw4ng/openape 1.0.2 → 1.0.4

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 (81) hide show
  1. package/README.md +37 -9
  2. package/esm/deno.js +1 -1
  3. package/esm/src/commands/announcements.d.ts.map +1 -1
  4. package/esm/src/commands/announcements.js +13 -14
  5. package/esm/src/commands/assignments.d.ts +3 -0
  6. package/esm/src/commands/assignments.d.ts.map +1 -0
  7. package/esm/src/commands/assignments.js +230 -0
  8. package/esm/src/commands/auth.d.ts +1 -1
  9. package/esm/src/commands/auth.d.ts.map +1 -1
  10. package/esm/src/commands/auth.js +24 -8
  11. package/esm/src/commands/calendar.d.ts.map +1 -1
  12. package/esm/src/commands/calendar.js +17 -18
  13. package/esm/src/commands/courses.js +3 -3
  14. package/esm/src/commands/forums.d.ts.map +1 -1
  15. package/esm/src/commands/forums.js +132 -45
  16. package/esm/src/commands/materials.d.ts.map +1 -1
  17. package/esm/src/commands/materials.js +176 -48
  18. package/esm/src/commands/quizzes.d.ts.map +1 -1
  19. package/esm/src/commands/quizzes.js +165 -65
  20. package/esm/src/commands/skills.d.ts.map +1 -1
  21. package/esm/src/commands/skills.js +4 -8
  22. package/esm/src/commands/upload.d.ts +3 -0
  23. package/esm/src/commands/upload.d.ts.map +1 -0
  24. package/esm/src/commands/upload.js +58 -0
  25. package/esm/src/commands/videos.d.ts.map +1 -1
  26. package/esm/src/commands/videos.js +113 -79
  27. package/esm/src/index.d.ts.map +1 -1
  28. package/esm/src/index.js +14 -3
  29. package/esm/src/lib/auth.d.ts +23 -1
  30. package/esm/src/lib/auth.d.ts.map +1 -1
  31. package/esm/src/lib/auth.js +36 -3
  32. package/esm/src/lib/moodle.d.ts +226 -2
  33. package/esm/src/lib/moodle.d.ts.map +1 -1
  34. package/esm/src/lib/moodle.js +648 -37
  35. package/esm/src/lib/types.d.ts +82 -164
  36. package/esm/src/lib/types.d.ts.map +1 -1
  37. package/esm/src/lib/types.js +1 -0
  38. package/esm/src/lib/utils.d.ts +40 -0
  39. package/esm/src/lib/utils.d.ts.map +1 -1
  40. package/esm/src/lib/utils.js +82 -4
  41. package/package.json +1 -2
  42. package/script/deno.js +1 -1
  43. package/script/src/commands/announcements.d.ts.map +1 -1
  44. package/script/src/commands/announcements.js +12 -13
  45. package/script/src/commands/assignments.d.ts +3 -0
  46. package/script/src/commands/assignments.d.ts.map +1 -0
  47. package/script/src/commands/assignments.js +269 -0
  48. package/script/src/commands/auth.d.ts +1 -1
  49. package/script/src/commands/auth.d.ts.map +1 -1
  50. package/script/src/commands/auth.js +24 -8
  51. package/script/src/commands/calendar.d.ts.map +1 -1
  52. package/script/src/commands/calendar.js +16 -17
  53. package/script/src/commands/courses.js +2 -2
  54. package/script/src/commands/forums.d.ts.map +1 -1
  55. package/script/src/commands/forums.js +132 -45
  56. package/script/src/commands/materials.d.ts.map +1 -1
  57. package/script/src/commands/materials.js +177 -49
  58. package/script/src/commands/quizzes.d.ts.map +1 -1
  59. package/script/src/commands/quizzes.js +163 -63
  60. package/script/src/commands/skills.d.ts.map +1 -1
  61. package/script/src/commands/skills.js +4 -8
  62. package/script/src/commands/upload.d.ts +3 -0
  63. package/script/src/commands/upload.d.ts.map +1 -0
  64. package/script/src/commands/upload.js +64 -0
  65. package/script/src/commands/videos.d.ts.map +1 -1
  66. package/script/src/commands/videos.js +114 -80
  67. package/script/src/index.d.ts.map +1 -1
  68. package/script/src/index.js +13 -2
  69. package/script/src/lib/auth.d.ts +23 -1
  70. package/script/src/lib/auth.d.ts.map +1 -1
  71. package/script/src/lib/auth.js +70 -3
  72. package/script/src/lib/moodle.d.ts +226 -2
  73. package/script/src/lib/moodle.d.ts.map +1 -1
  74. package/script/src/lib/moodle.js +663 -37
  75. package/script/src/lib/types.d.ts +82 -164
  76. package/script/src/lib/types.d.ts.map +1 -1
  77. package/script/src/lib/types.js +1 -0
  78. package/script/src/lib/utils.d.ts +40 -0
  79. package/script/src/lib/utils.d.ts.map +1 -1
  80. package/script/src/lib/utils.js +89 -3
  81. package/skills/openape/SKILL.md +73 -291
@@ -1,5 +1,5 @@
1
- import { getBaseDir, stripHtmlTags } from "../lib/utils.js";
2
- import { getEnrolledCoursesApi, getForumsApi, getForumDiscussionsApi, getDiscussionPostsApi } from "../lib/moodle.js";
1
+ import { getBaseDir, stripHtmlTags, getOutputFormat, formatTimestamp } from "../lib/utils.js";
2
+ import { getEnrolledCoursesApi, getForumsApi, getForumDiscussionsApi, getDiscussionPostsApi, addForumDiscussionApi, addForumPostApi, deleteForumPostApi } from "../lib/moodle.js";
3
3
  import { createLogger } from "../lib/logger.js";
4
4
  import { loadWsToken, loadSesskey } from "../lib/token.js";
5
5
  import path from "node:path";
@@ -7,10 +7,6 @@ import fs from "node:fs";
7
7
  export function registerForumsCommand(program) {
8
8
  const forumsCmd = program.command("forums");
9
9
  forumsCmd.description("Forum operations");
10
- function getOutputFormat(command) {
11
- const opts = command.optsWithGlobals();
12
- return opts.output || "json";
13
- }
14
10
  // Pure API context - no browser required (fast!)
15
11
  async function createApiContext(options, command) {
16
12
  const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
@@ -65,23 +61,24 @@ export function registerForumsCommand(program) {
65
61
  allForums.push({
66
62
  course_id: wsForum.courseid,
67
63
  course_name: course.fullname,
64
+ intro: wsForum.intro,
68
65
  cmid: wsForum.cmid.toString(),
69
66
  forum_id: wsForum.id,
70
67
  name: wsForum.name,
68
+ timemodified: wsForum.timemodified,
71
69
  // url: `https://ilearning.cycu.edu.tw/mod/forum/view.php?id=${wsForum.cmid}`,
72
70
  });
73
71
  }
74
72
  }
75
- const result = {
73
+ console.log(JSON.stringify({
76
74
  status: "success",
77
75
  timestamp: new Date().toISOString(),
78
- forums: allForums,
79
- summary: {
80
- total_courses: courses.length,
81
- total_forums: allForums.length,
82
- },
83
- };
84
- console.log(JSON.stringify(result));
76
+ total_courses: courses.length,
77
+ total_forums: allForums.length,
78
+ }));
79
+ for (const forum of allForums) {
80
+ console.log(JSON.stringify(forum));
81
+ }
85
82
  });
86
83
  forumsCmd
87
84
  .command("list-all")
@@ -108,30 +105,29 @@ export function registerForumsCommand(program) {
108
105
  allForums.push({
109
106
  course_id: wsForum.courseid,
110
107
  course_name: course.fullname,
108
+ intro: wsForum.intro,
111
109
  cmid: wsForum.cmid.toString(),
112
110
  forum_id: wsForum.id,
113
111
  name: wsForum.name,
114
- url: `https://ilearning.cycu.edu.tw/mod/forum/view.php?id=${wsForum.cmid}`,
112
+ timemodified: wsForum.timemodified,
115
113
  });
116
114
  }
117
115
  }
118
- const result = {
116
+ console.log(JSON.stringify({
119
117
  status: "success",
120
118
  timestamp: new Date().toISOString(),
121
- forums: allForums,
122
- summary: {
123
- total_courses: courses.length,
124
- total_forums: allForums.length,
125
- },
126
- };
127
- console.log(JSON.stringify(result));
119
+ total_courses: courses.length,
120
+ total_forums: allForums.length,
121
+ }));
122
+ for (const forum of allForums) {
123
+ console.log(JSON.stringify(forum));
124
+ }
128
125
  });
129
126
  forumsCmd
130
127
  .command("discussions")
131
128
  .description("List discussions in a forum (use forum ID)")
132
129
  .argument("<forum-id>", "Forum ID")
133
130
  .option("--output <format>", "Output format: json|csv|table|silent")
134
- .option("--msg", "Include message content in response")
135
131
  .action(async (forumId, options, command) => {
136
132
  const apiContext = await createApiContext(options, command);
137
133
  if (!apiContext) {
@@ -155,32 +151,29 @@ export function registerForumsCommand(program) {
155
151
  const course = courses.find(c => c.id === targetForum.courseid);
156
152
  // Get discussions via WS API
157
153
  const discussions = await getForumDiscussionsApi(apiContext.session, targetForum.id);
158
- const result = {
154
+ // Output NDJSON: one line per discussion entry for stream-friendly parsing
155
+ const meta = {
159
156
  status: "success",
160
157
  timestamp: new Date().toISOString(),
161
158
  forum_id: targetForum.id,
162
159
  forum_name: targetForum.name,
160
+ forum_intro: targetForum.intro,
163
161
  course_id: course?.id,
164
162
  course_name: course?.fullname,
165
- discussions: discussions.map(d => {
166
- const discussion = {
167
- id: d.id,
168
- name: d.name,
169
- user_id: d.userId,
170
- time_modified: d.timeModified,
171
- post_count: d.postCount,
172
- unread: d.unread,
173
- };
174
- if (options.msg) {
175
- discussion.message = stripHtmlTags(d.message || "");
176
- }
177
- return discussion;
178
- }),
179
- summary: {
180
- total_discussions: discussions.length,
181
- },
163
+ total_discussions: discussions.length,
182
164
  };
183
- console.log(JSON.stringify(result));
165
+ console.log(JSON.stringify(meta));
166
+ for (const d of discussions) {
167
+ console.log(JSON.stringify({
168
+ id: d.id,
169
+ name: d.name,
170
+ user_id: d.userId,
171
+ time_modified: d.timeModified,
172
+ post_count: d.postCount,
173
+ unread: d.unread,
174
+ message: stripHtmlTags(d.message || ""),
175
+ }));
176
+ }
184
177
  });
185
178
  forumsCmd
186
179
  .command("posts")
@@ -205,8 +198,8 @@ export function registerForumsCommand(program) {
205
198
  subject: p.subject,
206
199
  author: p.author,
207
200
  author_id: p.authorId,
208
- created: new Date(p.created * 1000).toISOString(),
209
- modified: new Date(p.modified * 1000).toISOString(),
201
+ created: formatTimestamp(p.created),
202
+ modified: formatTimestamp(p.modified),
210
203
  message: p.message,
211
204
  unread: p.unread,
212
205
  })),
@@ -228,4 +221,98 @@ export function registerForumsCommand(program) {
228
221
  console.table(tablePosts);
229
222
  }
230
223
  });
224
+ forumsCmd
225
+ .command("post")
226
+ .description("Post a new discussion to a forum")
227
+ .argument("<forum-id>", "Forum ID")
228
+ .argument("<subject>", "Discussion subject")
229
+ .argument("<message>", "Discussion message")
230
+ .option("--subscribe", "Subscribe to the discussion", false)
231
+ .option("--pin", "Pin the discussion", false)
232
+ .action(async (forumId, subject, message, options, command) => {
233
+ const apiContext = await createApiContext(options, command);
234
+ if (!apiContext) {
235
+ process.exitCode = 1;
236
+ return;
237
+ }
238
+ const { log, session } = apiContext;
239
+ // Get courses to find the forum
240
+ const courses = await getEnrolledCoursesApi(session, {
241
+ classification: "inprogress",
242
+ });
243
+ const courseIds = courses.map(c => c.id);
244
+ const wsForums = await getForumsApi(session, courseIds);
245
+ // Find forum by cmid or instance ID
246
+ const targetForum = wsForums.find(f => f.cmid.toString() === forumId || f.id === parseInt(forumId, 10));
247
+ if (!targetForum) {
248
+ log.error(`Forum not found: ${forumId}`);
249
+ process.exitCode = 1;
250
+ return;
251
+ }
252
+ const course = courses.find(c => c.id === targetForum.courseid);
253
+ log.info(`Posting to forum: ${targetForum.name} (${course?.fullname})`);
254
+ const result = await addForumDiscussionApi(session, targetForum.id, subject, message);
255
+ if (result.success) {
256
+ log.success(`✓ Discussion posted successfully!`);
257
+ log.info(` Discussion ID: ${result.discussionId}`);
258
+ }
259
+ else {
260
+ log.error(`✗ Failed to post discussion: ${result.error}`);
261
+ process.exitCode = 1;
262
+ }
263
+ });
264
+ forumsCmd
265
+ .command("reply")
266
+ .description("Reply to a discussion post")
267
+ .argument("<post-id>", "Parent post ID to reply to")
268
+ .argument("<subject>", "Reply subject")
269
+ .argument("<message>", "Reply message")
270
+ .option("--attachment-id <id>", "Draft file ID for attachment")
271
+ .option("--inline-attachment-id <id>", "Draft file ID for inline attachment")
272
+ .action(async (postId, subject, message, options, command) => {
273
+ const apiContext = await createApiContext(options, command);
274
+ if (!apiContext) {
275
+ process.exitCode = 1;
276
+ return;
277
+ }
278
+ const { log, session } = apiContext;
279
+ log.info(`Replying to post: ${postId}`);
280
+ log.info(` Subject: ${subject}`);
281
+ log.info(` Message: ${message}`);
282
+ if (options.attachmentId) {
283
+ log.info(` Attachment ID: ${options.attachmentId}`);
284
+ }
285
+ const result = await addForumPostApi(session, parseInt(postId, 10), subject, message, {
286
+ attachmentId: options.attachmentId ? parseInt(options.attachmentId, 10) : undefined,
287
+ inlineAttachmentId: options.inlineAttachmentId ? parseInt(options.inlineAttachmentId, 10) : undefined,
288
+ });
289
+ if (result.success) {
290
+ log.success(`✓ Reply posted successfully!`);
291
+ log.info(` Post ID: ${result.postId}`);
292
+ }
293
+ else {
294
+ log.error(`✗ Failed to post reply: ${result.error}`);
295
+ process.exitCode = 1;
296
+ }
297
+ });
298
+ forumsCmd
299
+ .command("delete")
300
+ .description("Delete a forum post or discussion (by post ID)")
301
+ .argument("<post-id>", "Post ID to delete (deletes entire discussion if it's the first post)")
302
+ .action(async (postId, options, command) => {
303
+ const apiContext = await createApiContext(options, command);
304
+ if (!apiContext) {
305
+ process.exitCode = 1;
306
+ return;
307
+ }
308
+ const { log, session } = apiContext;
309
+ const result = await deleteForumPostApi(session, parseInt(postId, 10));
310
+ if (result.success) {
311
+ log.success(`✓ Post ${postId} deleted successfully!`);
312
+ }
313
+ else {
314
+ log.error(`✗ Failed to delete post: ${result.error}`);
315
+ process.exitCode = 1;
316
+ }
317
+ });
231
318
  }
@@ -1 +1 @@
1
- {"version":3,"file":"materials.d.ts","sourceRoot":"","sources":["../../../src/src/commands/materials.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AA+BpC,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAka/D"}
1
+ {"version":3,"file":"materials.d.ts","sourceRoot":"","sources":["../../../src/src/commands/materials.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAgCpC,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CA8iB/D"}
@@ -1,56 +1,22 @@
1
- import { getBaseDir } from "../lib/utils.js";
2
- import { getEnrolledCourses, getEnrolledCoursesApi, getResourcesByCoursesApi } from "../lib/moodle.js";
1
+ import { getOutputFormat, sanitizeFilename, getSessionPath, formatFileSize } from "../lib/utils.js";
2
+ import { getEnrolledCourses, getEnrolledCoursesApi, getResourcesByCoursesApi, updateActivityCompletionStatusManually, getSiteInfoApi, moodleApiCall } from "../lib/moodle.js";
3
3
  import { createLogger } from "../lib/logger.js";
4
- import { launchAuthenticated } from "../lib/auth.js";
4
+ import { launchAuthenticated, createApiContext } from "../lib/auth.js";
5
5
  import { extractSessionInfo } from "../lib/session.js";
6
6
  import { closeBrowserSafely } from "../lib/auth.js";
7
- import { loadWsToken } from "../lib/token.js";
7
+ import { formatAndOutput } from "../index.js";
8
8
  import path from "node:path";
9
9
  import fs from "node:fs";
10
10
  export function registerMaterialsCommand(program) {
11
11
  const materialsCmd = program.command("materials");
12
12
  materialsCmd.description("Material/resource operations");
13
- // Helper to get output format from global or local options
14
- function getOutputFormat(command) {
15
- const opts = command.optsWithGlobals();
16
- return opts.output || "json";
17
- }
18
- // Pure API context - no browser required (fast!)
19
- async function createApiContext(options, command) {
20
- const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
21
- const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
22
- const silent = outputFormat === "json" && !opts.verbose;
23
- const log = createLogger(opts.verbose, silent);
24
- const baseDir = getBaseDir();
25
- const sessionPath = path.resolve(baseDir, ".auth", "storage-state.json");
26
- // Check if session exists
27
- if (!fs.existsSync(sessionPath)) {
28
- log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
29
- log.info(`Session 預期位置: ${sessionPath}`);
30
- return null;
31
- }
32
- // Try to load WS token
33
- const wsToken = loadWsToken(sessionPath);
34
- if (!wsToken) {
35
- log.error("未找到 WS token。請先執行 'openape auth login' 進行登入。");
36
- return null;
37
- }
38
- return {
39
- log,
40
- session: {
41
- wsToken,
42
- moodleBaseUrl: "https://ilearning.cycu.edu.tw",
43
- },
44
- };
45
- }
46
13
  // Helper function to create session context (for download commands)
47
14
  async function createSessionContext(options, command) {
48
15
  const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
49
16
  const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
50
17
  const silent = outputFormat === "json" && !opts.verbose;
51
18
  const log = createLogger(opts.verbose, silent);
52
- const baseDir = getBaseDir();
53
- const sessionPath = path.resolve(baseDir, ".auth", "storage-state.json");
19
+ const sessionPath = getSessionPath();
54
20
  if (!fs.existsSync(sessionPath)) {
55
21
  log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
56
22
  return null;
@@ -77,10 +43,6 @@ export function registerMaterialsCommand(program) {
77
43
  throw err;
78
44
  }
79
45
  }
80
- // Helper to sanitize filenames
81
- function sanitizeFilename(name) {
82
- return name.replace(/[<>:"/\\|?*]/g, "_").replace(/\s+/g, "_");
83
- }
84
46
  // Helper to download a single resource
85
47
  async function downloadResource(page, resource, outputDir, log) {
86
48
  try {
@@ -91,9 +53,7 @@ export function registerMaterialsCommand(program) {
91
53
  }
92
54
  // Create course directory
93
55
  const courseDir = path.join(outputDir, sanitizeFilename(resource.course_name));
94
- if (!fs.existsSync(courseDir)) {
95
- fs.mkdirSync(courseDir, { recursive: true });
96
- }
56
+ await fs.promises.mkdir(courseDir, { recursive: true });
97
57
  // Navigate to resource page
98
58
  log.debug(` Downloading: ${resource.name}`);
99
59
  await page.goto(resource.url, { waitUntil: "domcontentloaded", timeout: 30000 });
@@ -134,8 +94,8 @@ export function registerMaterialsCommand(program) {
134
94
  const download = await downloadPromise;
135
95
  // Save file
136
96
  await download.saveAs(outputPath);
137
- const stats = fs.statSync(outputPath);
138
- log.success(` Downloaded: ${filename} (${(stats.size / 1024).toFixed(1)} KB)`);
97
+ const stats = await fs.promises.stat(outputPath);
98
+ log.success(` Downloaded: ${filename} (${formatFileSize(stats.size, 1)} KB)`);
139
99
  return {
140
100
  filename,
141
101
  path: outputPath,
@@ -359,4 +319,172 @@ export function registerMaterialsCommand(program) {
359
319
  await closeBrowserSafely(browser, browserContext);
360
320
  }
361
321
  });
322
+ materialsCmd
323
+ .command("complete")
324
+ .description("Mark all incomplete resources (non-video) as complete in a course")
325
+ .argument("<course-id>", "Course ID")
326
+ .option("--dry-run", "Show what would be marked complete without doing it")
327
+ .option("--output <format>", "Output format: json|csv|table|silent")
328
+ .action(async (courseId, options, command) => {
329
+ const output = getOutputFormat(command);
330
+ const apiContext = await createApiContext(options, command);
331
+ if (!apiContext) {
332
+ process.exitCode = 1;
333
+ return;
334
+ }
335
+ try {
336
+ const { log, session } = apiContext;
337
+ // Get user ID
338
+ const siteInfo = await getSiteInfoApi(session);
339
+ // Get completion status for all activities in the course
340
+ const completionData = await moodleApiCall(session, "core_completion_get_activities_completion_status", { courseid: parseInt(courseId, 10), userid: siteInfo.userid });
341
+ if (!completionData?.statuses) {
342
+ log.info("No activities found in this course.");
343
+ return;
344
+ }
345
+ // Filter for resources (non-video) that have completion enabled but are not complete
346
+ const incompleteResources = completionData.statuses.filter((status) => {
347
+ // Only resources, not supervideo
348
+ if (status.modname === "supervideo")
349
+ return false;
350
+ // Must have completion enabled
351
+ if (!status.hascompletion)
352
+ return false;
353
+ // Must be incomplete
354
+ if (status.isoverallcomplete)
355
+ return false;
356
+ return true;
357
+ });
358
+ if (incompleteResources.length === 0) {
359
+ log.info("All resources are already complete (or no resources with completion tracking).");
360
+ return;
361
+ }
362
+ log.info(`Found ${incompleteResources.length} incomplete resources to complete:`);
363
+ for (const resource of incompleteResources) {
364
+ log.info(` - ${resource.name} (cmid: ${resource.cmid})`);
365
+ }
366
+ if (options.dryRun) {
367
+ log.info("\n[Dry run] No changes made.");
368
+ return;
369
+ }
370
+ // Mark each resource as complete
371
+ const results = [];
372
+ for (const resource of incompleteResources) {
373
+ const success = await updateActivityCompletionStatusManually(session, resource.cmid, true);
374
+ results.push({
375
+ cmid: resource.cmid,
376
+ name: resource.name,
377
+ success,
378
+ });
379
+ if (success) {
380
+ log.success(` ✓ Completed: ${resource.name}`);
381
+ }
382
+ else {
383
+ log.error(` ✗ Failed: ${resource.name}`);
384
+ }
385
+ }
386
+ const completed = results.filter(r => r.success).length;
387
+ const failed = results.filter(r => !r.success).length;
388
+ log.info(`\n執行結果: ${completed} 成功, ${failed} 失敗`);
389
+ if (output !== "silent") {
390
+ formatAndOutput(results, output, log);
391
+ }
392
+ }
393
+ catch (e) {
394
+ apiContext.log.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
395
+ process.exitCode = 1;
396
+ }
397
+ });
398
+ materialsCmd
399
+ .command("complete-all")
400
+ .description("Mark all incomplete resources (non-video) as complete across all in-progress courses")
401
+ .option("--dry-run", "Show what would be marked complete without doing it")
402
+ .option("--level <type>", "Course level: in_progress (default) | all", "in_progress")
403
+ .option("--output <format>", "Output format: json|csv|table|silent")
404
+ .action(async (options, command) => {
405
+ const output = getOutputFormat(command);
406
+ const apiContext = await createApiContext(options, command);
407
+ if (!apiContext) {
408
+ process.exitCode = 1;
409
+ return;
410
+ }
411
+ try {
412
+ const { log, session } = apiContext;
413
+ // Get user ID
414
+ const siteInfo = await getSiteInfoApi(session);
415
+ // Get all courses
416
+ const classification = options.level === "all" ? undefined : "inprogress";
417
+ const courses = await getEnrolledCoursesApi(session, { classification });
418
+ log.info(`Scanning ${courses.length} courses for incomplete resources...`);
419
+ const allResults = [];
420
+ let totalIncomplete = 0;
421
+ for (const course of courses) {
422
+ try {
423
+ // Get completion status for all activities in the course
424
+ const completionData = await moodleApiCall(session, "core_completion_get_activities_completion_status", { courseid: course.id, userid: siteInfo.userid });
425
+ if (!completionData?.statuses)
426
+ continue;
427
+ // Filter for resources (non-video) that have completion enabled but are not complete
428
+ const incompleteResources = completionData.statuses.filter((status) => {
429
+ if (status.modname === "supervideo")
430
+ return false;
431
+ if (!status.hascompletion)
432
+ return false;
433
+ if (status.isoverallcomplete)
434
+ return false;
435
+ return true;
436
+ });
437
+ if (incompleteResources.length > 0) {
438
+ log.info(`\n${course.fullname}: ${incompleteResources.length} incomplete resources`);
439
+ totalIncomplete += incompleteResources.length;
440
+ if (options.dryRun) {
441
+ for (const resource of incompleteResources) {
442
+ log.info(` - ${resource.name} (cmid: ${resource.cmid})`);
443
+ }
444
+ }
445
+ else {
446
+ for (const resource of incompleteResources) {
447
+ const success = await updateActivityCompletionStatusManually(session, resource.cmid, true);
448
+ allResults.push({
449
+ courseId: course.id,
450
+ courseName: course.fullname,
451
+ cmid: resource.cmid,
452
+ name: resource.name,
453
+ success,
454
+ });
455
+ if (success) {
456
+ log.success(` ✓ Completed: ${resource.name}`);
457
+ }
458
+ else {
459
+ log.error(` ✗ Failed: ${resource.name}`);
460
+ }
461
+ }
462
+ }
463
+ }
464
+ }
465
+ catch (e) {
466
+ log.warn(`Failed to process course ${course.fullname}: ${e}`);
467
+ }
468
+ }
469
+ if (totalIncomplete === 0) {
470
+ log.info("\nAll resources are already complete (or no resources with completion tracking).");
471
+ return;
472
+ }
473
+ if (options.dryRun) {
474
+ log.info(`\n[Dry run] Found ${totalIncomplete} incomplete resources across ${courses.length} courses.`);
475
+ log.info("Run without --dry-run to mark them as complete.");
476
+ return;
477
+ }
478
+ const completed = allResults.filter(r => r.success).length;
479
+ const failed = allResults.filter(r => !r.success).length;
480
+ log.info(`\n執行結果: ${completed} 成功, ${failed} 失敗`);
481
+ if (output !== "silent") {
482
+ formatAndOutput(allResults, output, log);
483
+ }
484
+ }
485
+ catch (e) {
486
+ apiContext.log.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
487
+ process.exitCode = 1;
488
+ }
489
+ });
362
490
  }
@@ -1 +1 @@
1
- {"version":3,"file":"quizzes.d.ts","sourceRoot":"","sources":["../../../src/src/commands/quizzes.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAYpC,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAuL7D"}
1
+ {"version":3,"file":"quizzes.d.ts","sourceRoot":"","sources":["../../../src/src/commands/quizzes.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAwDpC,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CA0Q7D"}