@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.
- package/README.md +8 -5
- package/esm/deno.js +1 -1
- package/esm/src/commands/auth.d.ts +1 -1
- package/esm/src/commands/auth.d.ts.map +1 -1
- package/esm/src/commands/auth.js +3 -7
- package/esm/src/commands/forums.d.ts.map +1 -1
- package/esm/src/commands/forums.js +11 -16
- package/esm/src/commands/materials.d.ts.map +1 -1
- package/esm/src/commands/materials.js +171 -11
- package/esm/src/commands/videos.d.ts.map +1 -1
- package/esm/src/commands/videos.js +108 -75
- package/esm/src/index.js +2 -2
- package/esm/src/lib/moodle.d.ts +47 -1
- package/esm/src/lib/moodle.d.ts.map +1 -1
- package/esm/src/lib/moodle.js +169 -26
- package/esm/src/lib/types.d.ts +1 -0
- package/esm/src/lib/types.d.ts.map +1 -1
- package/esm/src/lib/utils.d.ts +20 -0
- package/esm/src/lib/utils.d.ts.map +1 -1
- package/esm/src/lib/utils.js +34 -3
- package/package.json +1 -2
- package/script/deno.js +1 -1
- package/script/src/commands/auth.d.ts +1 -1
- package/script/src/commands/auth.d.ts.map +1 -1
- package/script/src/commands/auth.js +4 -8
- package/script/src/commands/forums.d.ts.map +1 -1
- package/script/src/commands/forums.js +11 -16
- package/script/src/commands/materials.d.ts.map +1 -1
- package/script/src/commands/materials.js +173 -13
- package/script/src/commands/videos.d.ts.map +1 -1
- package/script/src/commands/videos.js +109 -76
- package/script/src/index.js +1 -1
- package/script/src/lib/moodle.d.ts +47 -1
- package/script/src/lib/moodle.d.ts.map +1 -1
- package/script/src/lib/moodle.js +172 -26
- package/script/src/lib/types.d.ts +1 -0
- package/script/src/lib/types.d.ts.map +1 -1
- package/script/src/lib/utils.d.ts +20 -0
- package/script/src/lib/utils.d.ts.map +1 -1
- package/script/src/lib/utils.js +37 -3
- 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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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;
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
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
|
|
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
|
-
|
|
135
|
-
|
|
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 (
|
|
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,
|
|
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
|
|
186
|
-
log.info(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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(
|
|
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 {
|
package/script/src/index.js
CHANGED
|
@@ -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.
|
|
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);
|