@mo7yw4ng/openape 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +8 -5
  2. package/esm/deno.js +1 -1
  3. package/esm/src/commands/auth.d.ts +1 -1
  4. package/esm/src/commands/auth.d.ts.map +1 -1
  5. package/esm/src/commands/auth.js +3 -7
  6. package/esm/src/commands/forums.d.ts.map +1 -1
  7. package/esm/src/commands/forums.js +11 -16
  8. package/esm/src/commands/materials.d.ts.map +1 -1
  9. package/esm/src/commands/materials.js +171 -11
  10. package/esm/src/commands/videos.d.ts.map +1 -1
  11. package/esm/src/commands/videos.js +108 -75
  12. package/esm/src/index.js +2 -2
  13. package/esm/src/lib/moodle.d.ts +47 -1
  14. package/esm/src/lib/moodle.d.ts.map +1 -1
  15. package/esm/src/lib/moodle.js +169 -26
  16. package/esm/src/lib/types.d.ts +1 -0
  17. package/esm/src/lib/types.d.ts.map +1 -1
  18. package/esm/src/lib/utils.d.ts +20 -0
  19. package/esm/src/lib/utils.d.ts.map +1 -1
  20. package/esm/src/lib/utils.js +34 -3
  21. package/package.json +1 -2
  22. package/script/deno.js +1 -1
  23. package/script/src/commands/auth.d.ts +1 -1
  24. package/script/src/commands/auth.d.ts.map +1 -1
  25. package/script/src/commands/auth.js +4 -8
  26. package/script/src/commands/forums.d.ts.map +1 -1
  27. package/script/src/commands/forums.js +11 -16
  28. package/script/src/commands/materials.d.ts.map +1 -1
  29. package/script/src/commands/materials.js +173 -13
  30. package/script/src/commands/videos.d.ts.map +1 -1
  31. package/script/src/commands/videos.js +109 -76
  32. package/script/src/index.js +1 -1
  33. package/script/src/lib/moodle.d.ts +47 -1
  34. package/script/src/lib/moodle.d.ts.map +1 -1
  35. package/script/src/lib/moodle.js +172 -26
  36. package/script/src/lib/types.d.ts +1 -0
  37. package/script/src/lib/types.d.ts.map +1 -1
  38. package/script/src/lib/utils.d.ts +20 -0
  39. package/script/src/lib/utils.d.ts.map +1 -1
  40. package/script/src/lib/utils.js +37 -3
  41. package/skills/openape/SKILL.md +6 -26
@@ -74,6 +74,7 @@ function registerForumsCommand(program) {
74
74
  cmid: wsForum.cmid.toString(),
75
75
  forum_id: wsForum.id,
76
76
  name: wsForum.name,
77
+ timemodified: wsForum.timemodified,
77
78
  // url: `https://ilearning.cycu.edu.tw/mod/forum/view.php?id=${wsForum.cmid}`,
78
79
  });
79
80
  }
@@ -117,7 +118,7 @@ function registerForumsCommand(program) {
117
118
  cmid: wsForum.cmid.toString(),
118
119
  forum_id: wsForum.id,
119
120
  name: wsForum.name,
120
- url: `https://ilearning.cycu.edu.tw/mod/forum/view.php?id=${wsForum.cmid}`,
121
+ timemodified: wsForum.timemodified,
121
122
  });
122
123
  }
123
124
  }
@@ -137,7 +138,6 @@ function registerForumsCommand(program) {
137
138
  .description("List discussions in a forum (use forum ID)")
138
139
  .argument("<forum-id>", "Forum ID")
139
140
  .option("--output <format>", "Output format: json|csv|table|silent")
140
- .option("--msg", "Include message content in response")
141
141
  .action(async (forumId, options, command) => {
142
142
  const apiContext = await createApiContext(options, command);
143
143
  if (!apiContext) {
@@ -168,20 +168,15 @@ function registerForumsCommand(program) {
168
168
  forum_name: targetForum.name,
169
169
  course_id: course?.id,
170
170
  course_name: course?.fullname,
171
- discussions: discussions.map(d => {
172
- const discussion = {
173
- id: d.id,
174
- name: d.name,
175
- user_id: d.userId,
176
- time_modified: d.timeModified,
177
- post_count: d.postCount,
178
- unread: d.unread,
179
- };
180
- if (options.msg) {
181
- discussion.message = (0, utils_js_1.stripHtmlTags)(d.message || "");
182
- }
183
- return discussion;
184
- }),
171
+ discussions: discussions.map(d => ({
172
+ id: d.id,
173
+ name: d.name,
174
+ user_id: d.userId,
175
+ time_modified: d.timeModified,
176
+ post_count: d.postCount,
177
+ unread: d.unread,
178
+ message: ((0, utils_js_1.stripHtmlTags)(d.message || "")).substring(0, 250) + "...",
179
+ })),
185
180
  summary: {
186
181
  total_discussions: discussions.length,
187
182
  },
@@ -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,CAqlB/D"}
@@ -10,21 +10,17 @@ const logger_js_1 = require("../lib/logger.js");
10
10
  const auth_js_1 = require("../lib/auth.js");
11
11
  const session_js_1 = require("../lib/session.js");
12
12
  const auth_js_2 = require("../lib/auth.js");
13
+ const index_js_1 = require("../index.js");
13
14
  const token_js_1 = require("../lib/token.js");
14
15
  const node_path_1 = __importDefault(require("node:path"));
15
16
  const node_fs_1 = __importDefault(require("node:fs"));
16
17
  function registerMaterialsCommand(program) {
17
18
  const materialsCmd = program.command("materials");
18
19
  materialsCmd.description("Material/resource operations");
19
- // Helper to get output format from global or local options
20
- function getOutputFormat(command) {
21
- const opts = command.optsWithGlobals();
22
- return opts.output || "json";
23
- }
24
20
  // Pure API context - no browser required (fast!)
25
21
  async function createApiContext(options, command) {
26
22
  const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
27
- const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
23
+ const outputFormat = (0, utils_js_1.getOutputFormat)(command || { optsWithGlobals: () => ({ output: "json" }) });
28
24
  const silent = outputFormat === "json" && !opts.verbose;
29
25
  const log = (0, logger_js_1.createLogger)(opts.verbose, silent);
30
26
  const baseDir = (0, utils_js_1.getBaseDir)();
@@ -52,7 +48,7 @@ function registerMaterialsCommand(program) {
52
48
  // Helper function to create session context (for download commands)
53
49
  async function createSessionContext(options, command) {
54
50
  const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
55
- const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
51
+ const outputFormat = (0, utils_js_1.getOutputFormat)(command || { optsWithGlobals: () => ({ output: "json" }) });
56
52
  const silent = outputFormat === "json" && !opts.verbose;
57
53
  const log = (0, logger_js_1.createLogger)(opts.verbose, silent);
58
54
  const baseDir = (0, utils_js_1.getBaseDir)();
@@ -83,10 +79,6 @@ function registerMaterialsCommand(program) {
83
79
  throw err;
84
80
  }
85
81
  }
86
- // Helper to sanitize filenames
87
- function sanitizeFilename(name) {
88
- return name.replace(/[<>:"/\\|?*]/g, "_").replace(/\s+/g, "_");
89
- }
90
82
  // Helper to download a single resource
91
83
  async function downloadResource(page, resource, outputDir, log) {
92
84
  try {
@@ -96,7 +88,7 @@ function registerMaterialsCommand(program) {
96
88
  return null;
97
89
  }
98
90
  // Create course directory
99
- const courseDir = node_path_1.default.join(outputDir, sanitizeFilename(resource.course_name));
91
+ const courseDir = node_path_1.default.join(outputDir, (0, utils_js_1.sanitizeFilename)(resource.course_name));
100
92
  if (!node_fs_1.default.existsSync(courseDir)) {
101
93
  node_fs_1.default.mkdirSync(courseDir, { recursive: true });
102
94
  }
@@ -114,7 +106,7 @@ function registerMaterialsCommand(program) {
114
106
  // Extract filename from URL or use resource name
115
107
  const urlObj = new URL(downloadUrl);
116
108
  const filenameParam = urlObj.searchParams.get("filename");
117
- let filename = filenameParam || sanitizeFilename(resource.name);
109
+ let filename = filenameParam || (0, utils_js_1.sanitizeFilename)(resource.name);
118
110
  // Add extension if missing
119
111
  if (resource.mimetype && !node_path_1.default.extname(filename)) {
120
112
  const extMap = {
@@ -365,4 +357,172 @@ function registerMaterialsCommand(program) {
365
357
  await (0, auth_js_2.closeBrowserSafely)(browser, browserContext);
366
358
  }
367
359
  });
360
+ materialsCmd
361
+ .command("complete")
362
+ .description("Mark all incomplete resources (non-video) as complete in a course")
363
+ .argument("<course-id>", "Course ID")
364
+ .option("--dry-run", "Show what would be marked complete without doing it")
365
+ .option("--output <format>", "Output format: json|csv|table|silent")
366
+ .action(async (courseId, options, command) => {
367
+ const output = (0, utils_js_1.getOutputFormat)(command);
368
+ const apiContext = await createApiContext(options, command);
369
+ if (!apiContext) {
370
+ process.exitCode = 1;
371
+ return;
372
+ }
373
+ try {
374
+ const { log, session } = apiContext;
375
+ // Get user ID
376
+ const siteInfo = await (0, moodle_js_1.getSiteInfoApi)(session);
377
+ // Get completion status for all activities in the course
378
+ const completionData = await (0, moodle_js_1.moodleApiCall)(session, "core_completion_get_activities_completion_status", { courseid: parseInt(courseId, 10), userid: siteInfo.userid });
379
+ if (!completionData?.statuses) {
380
+ log.info("No activities found in this course.");
381
+ return;
382
+ }
383
+ // Filter for resources (non-video) that have completion enabled but are not complete
384
+ const incompleteResources = completionData.statuses.filter((status) => {
385
+ // Only resources, not supervideo
386
+ if (status.modname === "supervideo")
387
+ return false;
388
+ // Must have completion enabled
389
+ if (!status.hascompletion)
390
+ return false;
391
+ // Must be incomplete
392
+ if (status.isoverallcomplete)
393
+ return false;
394
+ return true;
395
+ });
396
+ if (incompleteResources.length === 0) {
397
+ log.info("All resources are already complete (or no resources with completion tracking).");
398
+ return;
399
+ }
400
+ log.info(`Found ${incompleteResources.length} incomplete resources to complete:`);
401
+ for (const resource of incompleteResources) {
402
+ log.info(` - ${resource.name} (cmid: ${resource.cmid})`);
403
+ }
404
+ if (options.dryRun) {
405
+ log.info("\n[Dry run] No changes made.");
406
+ return;
407
+ }
408
+ // Mark each resource as complete
409
+ const results = [];
410
+ for (const resource of incompleteResources) {
411
+ const success = await (0, moodle_js_1.updateActivityCompletionStatusManually)(session, resource.cmid, true);
412
+ results.push({
413
+ cmid: resource.cmid,
414
+ name: resource.name,
415
+ success,
416
+ });
417
+ if (success) {
418
+ log.success(` ✓ Completed: ${resource.name}`);
419
+ }
420
+ else {
421
+ log.error(` ✗ Failed: ${resource.name}`);
422
+ }
423
+ }
424
+ const completed = results.filter(r => r.success).length;
425
+ const failed = results.filter(r => !r.success).length;
426
+ log.info(`\n執行結果: ${completed} 成功, ${failed} 失敗`);
427
+ if (output !== "silent") {
428
+ (0, index_js_1.formatAndOutput)(results, output, log);
429
+ }
430
+ }
431
+ catch (e) {
432
+ apiContext.log.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
433
+ process.exitCode = 1;
434
+ }
435
+ });
436
+ materialsCmd
437
+ .command("complete-all")
438
+ .description("Mark all incomplete resources (non-video) as complete across all in-progress courses")
439
+ .option("--dry-run", "Show what would be marked complete without doing it")
440
+ .option("--level <type>", "Course level: in_progress (default) | all", "in_progress")
441
+ .option("--output <format>", "Output format: json|csv|table|silent")
442
+ .action(async (options, command) => {
443
+ const output = (0, utils_js_1.getOutputFormat)(command);
444
+ const apiContext = await createApiContext(options, command);
445
+ if (!apiContext) {
446
+ process.exitCode = 1;
447
+ return;
448
+ }
449
+ try {
450
+ const { log, session } = apiContext;
451
+ // Get user ID
452
+ const siteInfo = await (0, moodle_js_1.getSiteInfoApi)(session);
453
+ // Get all courses
454
+ const classification = options.level === "all" ? undefined : "inprogress";
455
+ const courses = await (0, moodle_js_1.getEnrolledCoursesApi)(session, { classification });
456
+ log.info(`Scanning ${courses.length} courses for incomplete resources...`);
457
+ const allResults = [];
458
+ let totalIncomplete = 0;
459
+ for (const course of courses) {
460
+ try {
461
+ // Get completion status for all activities in the course
462
+ const completionData = await (0, moodle_js_1.moodleApiCall)(session, "core_completion_get_activities_completion_status", { courseid: course.id, userid: siteInfo.userid });
463
+ if (!completionData?.statuses)
464
+ continue;
465
+ // Filter for resources (non-video) that have completion enabled but are not complete
466
+ const incompleteResources = completionData.statuses.filter((status) => {
467
+ if (status.modname === "supervideo")
468
+ return false;
469
+ if (!status.hascompletion)
470
+ return false;
471
+ if (status.isoverallcomplete)
472
+ return false;
473
+ return true;
474
+ });
475
+ if (incompleteResources.length > 0) {
476
+ log.info(`\n${course.fullname}: ${incompleteResources.length} incomplete resources`);
477
+ totalIncomplete += incompleteResources.length;
478
+ if (options.dryRun) {
479
+ for (const resource of incompleteResources) {
480
+ log.info(` - ${resource.name} (cmid: ${resource.cmid})`);
481
+ }
482
+ }
483
+ else {
484
+ for (const resource of incompleteResources) {
485
+ const success = await (0, moodle_js_1.updateActivityCompletionStatusManually)(session, resource.cmid, true);
486
+ allResults.push({
487
+ courseId: course.id,
488
+ courseName: course.fullname,
489
+ cmid: resource.cmid,
490
+ name: resource.name,
491
+ success,
492
+ });
493
+ if (success) {
494
+ log.success(` ✓ Completed: ${resource.name}`);
495
+ }
496
+ else {
497
+ log.error(` ✗ Failed: ${resource.name}`);
498
+ }
499
+ }
500
+ }
501
+ }
502
+ }
503
+ catch (e) {
504
+ log.warn(`Failed to process course ${course.fullname}: ${e}`);
505
+ }
506
+ }
507
+ if (totalIncomplete === 0) {
508
+ log.info("\nAll resources are already complete (or no resources with completion tracking).");
509
+ return;
510
+ }
511
+ if (options.dryRun) {
512
+ log.info(`\n[Dry run] Found ${totalIncomplete} incomplete resources across ${courses.length} courses.`);
513
+ log.info("Run without --dry-run to mark them as complete.");
514
+ return;
515
+ }
516
+ const completed = allResults.filter(r => r.success).length;
517
+ const failed = allResults.filter(r => !r.success).length;
518
+ log.info(`\n執行結果: ${completed} 成功, ${failed} 失敗`);
519
+ if (output !== "silent") {
520
+ (0, index_js_1.formatAndOutput)(allResults, output, log);
521
+ }
522
+ }
523
+ catch (e) {
524
+ apiContext.log.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
525
+ process.exitCode = 1;
526
+ }
527
+ });
368
528
  }
@@ -1 +1 @@
1
- {"version":3,"file":"videos.d.ts","sourceRoot":"","sources":["../../../src/src/commands/videos.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAYpC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAuV5D"}
1
+ {"version":3,"file":"videos.d.ts","sourceRoot":"","sources":["../../../src/src/commands/videos.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAYpC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CA+X5D"}
@@ -17,15 +17,11 @@ const node_fs_1 = __importDefault(require("node:fs"));
17
17
  function registerVideosCommand(program) {
18
18
  const videosCmd = program.command("videos");
19
19
  videosCmd.description("Video progress operations");
20
- // Helper to get output format from global or local options
21
- function getOutputFormat(command) {
22
- const opts = command.optsWithGlobals();
23
- return opts.output || "json";
24
- }
25
20
  // Pure API context - no browser required (fast!)
26
21
  async function createApiContext(options, command) {
27
22
  const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
28
- const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
23
+ // Don't silence logs for commands that don't have explicit output format control
24
+ const outputFormat = command && command.optsWithGlobals ? (0, utils_js_1.getOutputFormat)(command) : "table";
29
25
  const silent = outputFormat === "json" && !opts.verbose;
30
26
  const log = (0, logger_js_1.createLogger)(opts.verbose, silent);
31
27
  const baseDir = (0, utils_js_1.getBaseDir)();
@@ -53,7 +49,7 @@ function registerVideosCommand(program) {
53
49
  // Helper function to create session context (for browser-only commands)
54
50
  async function createSessionContext(options, command) {
55
51
  const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
56
- const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
52
+ const outputFormat = (0, utils_js_1.getOutputFormat)(command || { optsWithGlobals: () => ({ output: "json" }) });
57
53
  const silent = outputFormat === "json" && !opts.verbose;
58
54
  const log = (0, logger_js_1.createLogger)(opts.verbose, silent);
59
55
  const baseDir = (0, utils_js_1.getBaseDir)();
@@ -91,52 +87,67 @@ function registerVideosCommand(program) {
91
87
  .option("--incomplete-only", "Show only incomplete videos")
92
88
  .option("--output <format>", "Output format: json|csv|table|silent")
93
89
  .action(async (courseId, options, command) => {
94
- const output = getOutputFormat(command);
90
+ const output = (0, utils_js_1.getOutputFormat)(command);
95
91
  const apiContext = await createApiContext(options, command);
96
92
  if (!apiContext) {
97
93
  process.exitCode = 1;
98
94
  return;
99
95
  }
100
- const videos = await (0, moodle_js_1.getSupervideosInCourseApi)(apiContext.session, parseInt(courseId, 10));
101
- // Note: API doesn't provide completion status, so --incomplete-only shows all
96
+ let videos = await (0, moodle_js_1.getSupervideosInCourseApi)(apiContext.session, parseInt(courseId, 10));
97
+ // Filter for incomplete videos if requested
102
98
  if (options.incompleteOnly) {
103
- apiContext.log.warn("--incomplete-only is not supported in API mode, showing all videos");
99
+ videos = videos.filter(v => !v.isComplete);
104
100
  }
105
101
  (0, index_js_1.formatAndOutput)(videos, output, apiContext.log);
106
102
  });
107
103
  videosCmd
108
104
  .command("complete")
109
- .description("Complete videos in a course (requires browser)")
105
+ .description("Complete videos in a course (uses API for list & completion, browser for metadata)")
110
106
  .argument("<course-id>", "Course ID")
111
107
  .option("--dry-run", "Discover videos but don't complete them")
112
108
  .option("--output <format>", "Output format: json|csv|table|silent")
113
109
  .action(async (courseId, options, command) => {
110
+ const output = (0, utils_js_1.getOutputFormat)(command);
111
+ // Get API context for getting incomplete videos and completion
112
+ const apiContext = await createApiContext(options, command);
113
+ if (!apiContext) {
114
+ process.exitCode = 1;
115
+ return;
116
+ }
117
+ // Get incomplete videos via API (fast, no browser needed)
118
+ const incompleteVideos = await (0, moodle_js_1.getIncompleteVideosApi)(apiContext.session, parseInt(courseId, 10));
119
+ if (incompleteVideos.length === 0) {
120
+ apiContext.log.info("所有影片已完成(或無影片)。");
121
+ return;
122
+ }
123
+ apiContext.log.info(`找到 ${incompleteVideos.length} 部未完成影片`);
124
+ // Dry-run: show videos without needing browser
125
+ if (options.dryRun) {
126
+ const results = incompleteVideos.map(v => ({ name: v.name, success: true }));
127
+ for (const video of incompleteVideos) {
128
+ apiContext.log.info(` [試執行] ${video.name}`);
129
+ }
130
+ apiContext.log.info(`\n執行結果: ${results.length} 影片將被完成`);
131
+ if (output !== "silent") {
132
+ (0, index_js_1.formatAndOutput)(results, output, apiContext.log);
133
+ }
134
+ return;
135
+ }
136
+ // Need browser only for getting viewId and duration (not needed for dry-run)
114
137
  const context = await createSessionContext(options, command);
115
138
  if (!context) {
116
139
  process.exitCode = 1;
117
140
  return;
118
141
  }
119
- const { log, page, session, browser, context: browserContext } = context;
120
- const output = getOutputFormat(command);
142
+ const { log, page, browser, context: browserContext } = context;
121
143
  try {
122
- const videos = await (0, moodle_js_1.getSupervideosInCourse)(page, session, parseInt(courseId, 10), log, {
123
- incompleteOnly: true, // Only operate on incomplete videos
124
- });
125
- if (videos.length === 0) {
126
- log.info("所有影片已完成(或無影片)。");
127
- return;
128
- }
129
144
  const results = [];
130
- for (const sv of videos) {
145
+ for (const sv of incompleteVideos) {
131
146
  log.info(`處理中: ${sv.name}`);
132
147
  try {
133
148
  const video = await (0, moodle_js_1.getVideoMetadata)(page, sv.url, log);
134
- if (options.dryRun) {
135
- log.info(` [試執行] viewId=${video.viewId}, duration=${video.duration}s`);
136
- results.push({ name: sv.name, success: true });
137
- continue;
138
- }
139
- const success = await (0, moodle_js_1.completeVideo)(page, session, { ...video, cmid: sv.cmid }, log);
149
+ // Use WS API for completion
150
+ const success = await (0, moodle_js_1.completeVideoApi)(apiContext.session, { ...video, cmid: sv.cmid.toString() });
140
151
  if (success) {
141
152
  log.success(` 已完成!`);
142
153
  results.push({ name: sv.name, success: true });
@@ -165,65 +176,94 @@ function registerVideosCommand(program) {
165
176
  });
166
177
  videosCmd
167
178
  .command("complete-all")
168
- .description("Complete all incomplete videos across all courses (requires browser)")
179
+ .description("Complete all incomplete videos across all courses (uses API for list & completion, browser for metadata)")
169
180
  .option("--dry-run", "Discover videos but don't complete them")
170
181
  .option("--output <format>", "Output format: json|csv|table|silent")
171
182
  .action(async (options, command) => {
183
+ const output = (0, utils_js_1.getOutputFormat)(command);
184
+ // Get API context for getting incomplete videos and completion
185
+ const apiContext = await createApiContext(options, command);
186
+ if (!apiContext) {
187
+ process.exitCode = 1;
188
+ return;
189
+ }
190
+ // Get all courses via API
191
+ const classification = undefined; // all courses
192
+ const courses = await (0, moodle_js_1.getEnrolledCoursesApi)(apiContext.session, { classification });
193
+ apiContext.log.info(`掃描 ${courses.length} 個課程...`);
194
+ // Collect all incomplete videos across all courses using flatMap for cleaner code
195
+ const allIncompleteVideos = (await Promise.allSettled(courses.map(async (course) => {
196
+ try {
197
+ const videos = await (0, moodle_js_1.getIncompleteVideosApi)(apiContext.session, course.id);
198
+ return videos.map((video) => ({
199
+ courseId: course.id,
200
+ courseName: course.fullname,
201
+ cmid: video.cmid,
202
+ name: video.name,
203
+ url: video.url,
204
+ }));
205
+ }
206
+ catch (e) {
207
+ apiContext.log.warn(`無法取得課程 ${course.fullname} 的影片: ${e}`);
208
+ return [];
209
+ }
210
+ })))
211
+ .filter((result) => result.status === "fulfilled")
212
+ .flatMap((result) => result.status === "fulfilled" ? result.value : []);
213
+ if (allIncompleteVideos.length === 0) {
214
+ apiContext.log.info("所有影片已完成(或無影片)。");
215
+ return;
216
+ }
217
+ apiContext.log.info(`找到 ${allIncompleteVideos.length} 部未完成影片`);
218
+ // Dry-run: show videos without needing browser
219
+ if (options.dryRun) {
220
+ for (const video of allIncompleteVideos) {
221
+ apiContext.log.info(` [試執行] [${video.courseName}] ${video.name}`);
222
+ }
223
+ apiContext.log.info("\n===== 執行結果 =====");
224
+ apiContext.log.info(`掃描課程數: ${courses.length}`);
225
+ apiContext.log.info(`找到未完成影片: ${allIncompleteVideos.length}`);
226
+ apiContext.log.info(`執行影片數: ${allIncompleteVideos.length} (試執行)`);
227
+ return;
228
+ }
229
+ // Need browser only for getting viewId and duration (not needed for dry-run)
172
230
  const context = await createSessionContext(options, command);
173
231
  if (!context) {
174
232
  process.exitCode = 1;
175
233
  return;
176
234
  }
177
- const { log, page, session, browser, context: browserContext } = context;
178
- const output = getOutputFormat(command);
235
+ const { log, page, browser, context: browserContext } = context;
179
236
  try {
180
- const courses = await (0, moodle_js_1.getEnrolledCourses)(page, session, log);
181
237
  const allResults = [];
182
- let totalVideos = 0;
183
238
  let totalCompleted = 0;
184
239
  let totalFailed = 0;
185
- for (const course of courses) {
186
- log.info(`\n======================================`);
187
- log.info(`課程: ${course.fullname}`);
188
- log.info(`======================================`);
189
- const videos = await (0, moodle_js_1.getSupervideosInCourse)(page, session, course.id, log);
190
- if (videos.length === 0) {
191
- log.info(" 所有影片已完成(或無影片)。");
192
- continue;
193
- }
194
- totalVideos += videos.length;
195
- for (const sv of videos) {
196
- log.info(` 處理中: ${sv.name}`);
197
- try {
198
- const video = await (0, moodle_js_1.getVideoMetadata)(page, sv.url, log);
199
- if (options.dryRun) {
200
- log.info(` [試執行] viewId=${video.viewId}, duration=${video.duration}s`);
201
- allResults.push({ courseName: course.fullname, name: sv.name, success: true });
202
- continue;
203
- }
204
- const success = await (0, moodle_js_1.completeVideo)(page, session, { ...video, cmid: sv.cmid }, log);
205
- if (success) {
206
- log.success(` 已完成!`);
207
- allResults.push({ courseName: course.fullname, name: sv.name, success: true });
208
- totalCompleted++;
209
- }
210
- else {
211
- log.error(` 失敗。`);
212
- allResults.push({ courseName: course.fullname, name: sv.name, success: false, error: "Failed to complete" });
213
- totalFailed++;
214
- }
240
+ for (const video of allIncompleteVideos) {
241
+ log.info(`處理中: [${video.courseName}] ${video.name}`);
242
+ try {
243
+ const metadata = await (0, moodle_js_1.getVideoMetadata)(page, video.url, log);
244
+ // Use WS API for completion
245
+ const success = await (0, moodle_js_1.completeVideoApi)(apiContext.session, { ...metadata, cmid: video.cmid.toString() });
246
+ if (success) {
247
+ log.success(` 已完成!`);
248
+ allResults.push({ courseName: video.courseName, name: video.name, success: true });
249
+ totalCompleted++;
215
250
  }
216
- catch (err) {
217
- const msg = err instanceof Error ? err.message : String(err);
218
- log.error(` 錯誤: ${msg}`);
219
- allResults.push({ courseName: course.fullname, name: sv.name, success: false, error: msg });
251
+ else {
252
+ log.error(` 失敗。`);
253
+ allResults.push({ courseName: video.courseName, name: video.name, success: false, error: "Failed to complete" });
220
254
  totalFailed++;
221
255
  }
222
256
  }
257
+ catch (err) {
258
+ const msg = err instanceof Error ? err.message : String(err);
259
+ log.error(` 錯誤: ${msg}`);
260
+ allResults.push({ courseName: video.courseName, name: video.name, success: false, error: msg });
261
+ totalFailed++;
262
+ }
223
263
  }
224
264
  log.info("\n===== 執行結果 =====");
225
265
  log.info(`掃描課程數: ${courses.length}`);
226
- log.info(`掃描影片數: ${totalVideos}`);
266
+ log.info(`找到未完成影片: ${allIncompleteVideos.length}`);
227
267
  log.info(`執行影片數: ${totalCompleted}`);
228
268
  if (totalFailed > 0)
229
269
  log.warn(`失敗影片數: ${totalFailed}`);
@@ -235,13 +275,6 @@ function registerVideosCommand(program) {
235
275
  await (0, auth_js_2.closeBrowserSafely)(browser, browserContext);
236
276
  }
237
277
  });
238
- // Helper function to sanitize filename
239
- function sanitizeFilename(name) {
240
- return name
241
- .replace(/[<>:"/\\|?*]/g, "_")
242
- .replace(/\s+/g, "_")
243
- .substring(0, 200);
244
- }
245
278
  videosCmd
246
279
  .command("download")
247
280
  .description("Download videos from a course (requires browser)")
@@ -265,7 +298,7 @@ function registerVideosCommand(program) {
265
298
  node_fs_1.default.mkdirSync(outputDir, { recursive: true });
266
299
  const downloaded = [];
267
300
  for (const video of videos) {
268
- const filename = sanitizeFilename(video.name) + ".mp4";
301
+ const filename = (0, utils_js_1.sanitizeFilename)(video.name) + ".mp4";
269
302
  const outputPath = node_path_1.default.join(outputDir, filename);
270
303
  log.info(`處理中: ${video.name}`);
271
304
  try {
@@ -41,7 +41,7 @@ program
41
41
  .option("--silent", "Suppress all log output (JSON only)")
42
42
  .option("--headed", "Run browser in visible mode");
43
43
  // Register subcommands
44
- (0, auth_js_2.registerAuthCommand)(program);
44
+ (0, auth_js_2.registerCommand)(program);
45
45
  (0, courses_js_1.registerCoursesCommand)(program);
46
46
  (0, videos_js_1.registerVideosCommand)(program);
47
47
  (0, quizzes_js_1.registerQuizzesCommand)(program);