@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,423 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.registerMaterialsCommand = void 0;
7
+ const utils_js_1 = require("../lib/utils.js");
8
+ const moodle_js_1 = require("../lib/moodle.js");
9
+ const logger_js_1 = require("../lib/logger.js");
10
+ const auth_js_1 = require("../lib/auth.js");
11
+ const session_js_1 = require("../lib/session.js");
12
+ const auth_js_2 = require("../lib/auth.js");
13
+ const token_js_1 = require("../lib/token.js");
14
+ const path_1 = __importDefault(require("path"));
15
+ const fs_1 = __importDefault(require("fs"));
16
+ function registerMaterialsCommand(program) {
17
+ const materialsCmd = program.command("materials");
18
+ 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
+ // Pure API context - no browser required (fast!)
25
+ async function createApiContext(options, command) {
26
+ const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
27
+ const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
28
+ const silent = outputFormat === "json" && !opts.verbose;
29
+ const log = (0, logger_js_1.createLogger)(opts.verbose, silent);
30
+ const baseDir = (0, utils_js_1.getBaseDir)();
31
+ const sessionPath = path_1.default.resolve(baseDir, ".auth", "storage-state.json");
32
+ // Check if session exists
33
+ if (!fs_1.default.existsSync(sessionPath)) {
34
+ return null;
35
+ }
36
+ // Try to load WS token
37
+ const wsToken = (0, token_js_1.loadWsToken)(sessionPath);
38
+ if (!wsToken) {
39
+ return null;
40
+ }
41
+ return {
42
+ log,
43
+ session: {
44
+ wsToken,
45
+ moodleBaseUrl: "https://ilearning.cycu.edu.tw",
46
+ },
47
+ };
48
+ }
49
+ // Helper function to create session context
50
+ async function createSessionContext(options, command) {
51
+ // Get global options if command is provided (for --verbose, --silent flags)
52
+ const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
53
+ // Auto-enable silent mode for JSON output (unless --verbose is also set)
54
+ const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
55
+ const silent = outputFormat === "json" && !opts.verbose;
56
+ const log = (0, logger_js_1.createLogger)(opts.verbose, silent);
57
+ // Determine session path
58
+ const baseDir = (0, utils_js_1.getBaseDir)();
59
+ const sessionPath = path_1.default.resolve(baseDir, ".auth", "storage-state.json");
60
+ // Check if session exists
61
+ if (!fs_1.default.existsSync(sessionPath)) {
62
+ log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
63
+ log.info(`Session 預期位置: ${sessionPath}`);
64
+ return null;
65
+ }
66
+ // Create minimal config
67
+ const config = {
68
+ username: "",
69
+ password: "",
70
+ courseUrl: "",
71
+ moodleBaseUrl: "https://ilearning.cycu.edu.tw",
72
+ headless: !options.headed,
73
+ slowMo: 0,
74
+ authStatePath: sessionPath,
75
+ ollamaBaseUrl: "",
76
+ };
77
+ log.info("啟動瀏覽器...");
78
+ const { browser, context, page } = await (0, auth_js_1.launchAuthenticated)(config, log);
79
+ try {
80
+ const session = await (0, session_js_1.extractSessionInfo)(page, config, log);
81
+ return { log, page, session, browser, context };
82
+ }
83
+ catch (err) {
84
+ await context.close();
85
+ await browser.close();
86
+ throw err;
87
+ }
88
+ }
89
+ // Helper to sanitize filenames
90
+ function sanitizeFilename(name) {
91
+ // Remove or replace invalid characters
92
+ return name.replace(/[<>:"/\\|?*]/g, "_").replace(/\s+/g, "_");
93
+ }
94
+ // Helper to download a single resource
95
+ async function downloadResource(page, resource, outputDir, log) {
96
+ try {
97
+ // Only download resource type (skip url)
98
+ if (resource.modType !== "resource") {
99
+ log.debug(` Skipping ${resource.modType}: ${resource.name}`);
100
+ return null;
101
+ }
102
+ // Create course directory
103
+ const courseDir = path_1.default.join(outputDir, sanitizeFilename(resource.course_name));
104
+ if (!fs_1.default.existsSync(courseDir)) {
105
+ fs_1.default.mkdirSync(courseDir, { recursive: true });
106
+ }
107
+ // Navigate to resource page
108
+ log.debug(` Downloading: ${resource.name}`);
109
+ await page.goto(resource.url, { waitUntil: "domcontentloaded", timeout: 30000 });
110
+ // Try to find download link on the page
111
+ const downloadLinks = await page.$$eval('a[href*="forcedownload=1"]', (links) => links.map((a) => a.href));
112
+ if (downloadLinks.length === 0) {
113
+ log.warn(` No download link found for: ${resource.name}`);
114
+ return null;
115
+ }
116
+ // Download the first available file
117
+ const downloadUrl = downloadLinks[0];
118
+ // Extract filename from URL or use resource name
119
+ const urlObj = new URL(downloadUrl);
120
+ const filenameParam = urlObj.searchParams.get("filename");
121
+ let filename = filenameParam || sanitizeFilename(resource.name);
122
+ // Add extension if missing
123
+ if (resource.mimetype && !path_1.default.extname(filename)) {
124
+ const extMap = {
125
+ "application/pdf": ".pdf",
126
+ "application/vnd.ms-powerpoint": ".ppt",
127
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
128
+ "application/msword": ".doc",
129
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
130
+ "application/vnd.ms-excel": ".xls",
131
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
132
+ "application/zip": ".zip",
133
+ "image/jpeg": ".jpg",
134
+ "image/png": ".png",
135
+ };
136
+ if (extMap[resource.mimetype]) {
137
+ filename += extMap[resource.mimetype];
138
+ }
139
+ }
140
+ const outputPath = path_1.default.join(courseDir, filename);
141
+ // Trigger download
142
+ const downloadPromise = page.waitForEvent("download", { timeout: 30000 });
143
+ await page.goto(downloadUrl, { waitUntil: "domcontentloaded" });
144
+ const download = await downloadPromise;
145
+ // Save file
146
+ await download.saveAs(outputPath);
147
+ const stats = fs_1.default.statSync(outputPath);
148
+ log.success(` Downloaded: ${filename} (${(stats.size / 1024).toFixed(1)} KB)`);
149
+ return {
150
+ filename,
151
+ path: outputPath,
152
+ size: stats.size,
153
+ course_id: resource.course_id,
154
+ course_name: resource.course_name,
155
+ };
156
+ }
157
+ catch (err) {
158
+ log.warn(` Failed to download ${resource.name}: ${err instanceof Error ? err.message : String(err)}`);
159
+ return null;
160
+ }
161
+ }
162
+ materialsCmd
163
+ .command("list-all")
164
+ .description("List all materials/resources across all courses")
165
+ .option("--level <type>", "Course level: in_progress (default) | all", "in_progress")
166
+ .option("--output <format>", "Output format: json|csv|table|silent")
167
+ .action(async (options, command) => {
168
+ // Try pure API mode (no browser, fast!)
169
+ const apiContext = await createApiContext(options, command);
170
+ if (apiContext) {
171
+ try {
172
+ const classification = options.level === "all" ? undefined : "inprogress";
173
+ const courses = await (0, moodle_js_1.getEnrolledCoursesApi)(apiContext.session, {
174
+ classification,
175
+ });
176
+ // Get materials via WS API (no browser needed!)
177
+ const courseIds = courses.map(c => c.id);
178
+ const apiResources = await (0, moodle_js_1.getResourcesByCoursesApi)(apiContext.session, courseIds);
179
+ // Build a map of courseId -> course for quick lookup
180
+ const courseMap = new Map(courses.map(c => [c.id, c]));
181
+ const allMaterials = [];
182
+ for (const resource of apiResources) {
183
+ const course = courseMap.get(resource.courseId);
184
+ if (course) {
185
+ allMaterials.push({
186
+ course_id: resource.courseId,
187
+ course_name: course.fullname,
188
+ cmid: resource.cmid,
189
+ name: resource.name,
190
+ url: resource.url,
191
+ modType: resource.modType,
192
+ mimetype: resource.mimetype,
193
+ filesize: resource.filesize,
194
+ modified: resource.modified,
195
+ });
196
+ }
197
+ }
198
+ const output = {
199
+ status: "success",
200
+ timestamp: new Date().toISOString(),
201
+ level: options.level,
202
+ materials: allMaterials.map(m => ({
203
+ course_id: m.course_id,
204
+ course_name: m.course_name,
205
+ id: m.cmid,
206
+ name: m.name,
207
+ type: m.modType,
208
+ mimetype: m.mimetype,
209
+ filesize: m.filesize,
210
+ modified: m.modified ? new Date(m.modified * 1000).toISOString() : null,
211
+ url: m.url,
212
+ })),
213
+ summary: {
214
+ total_courses: courses.length,
215
+ total_materials: allMaterials.length,
216
+ by_type: allMaterials.reduce((acc, m) => {
217
+ acc[m.modType] = (acc[m.modType] || 0) + 1;
218
+ return acc;
219
+ }, {}),
220
+ },
221
+ };
222
+ console.log(JSON.stringify(output));
223
+ return;
224
+ }
225
+ catch (e) {
226
+ // API failed, fall through to browser mode
227
+ const msg = e instanceof Error ? e.message : String(e);
228
+ console.error(`// API mode failed: ${msg}, trying browser mode...`);
229
+ }
230
+ }
231
+ // Fallback to browser mode
232
+ const context = await createSessionContext(options, command);
233
+ if (!context) {
234
+ process.exitCode = 1;
235
+ return;
236
+ }
237
+ const { log, page, session, browser, context: browserContext } = context;
238
+ try {
239
+ // Map level to classification
240
+ const classification = options.level === "all" ? undefined : "inprogress";
241
+ const courses = await (0, moodle_js_1.getEnrolledCourses)(page, session, log, { classification });
242
+ const allMaterials = [];
243
+ for (const course of courses) {
244
+ const resources = await (0, moodle_js_1.getResourcesInCourse)(page, session, course.id, log);
245
+ for (const resource of resources) {
246
+ allMaterials.push({
247
+ course_id: course.id,
248
+ course_name: course.fullname,
249
+ cmid: resource.cmid,
250
+ name: resource.name,
251
+ url: resource.url,
252
+ modType: resource.modType,
253
+ mimetype: resource.mimetype,
254
+ filesize: resource.filesize,
255
+ modified: resource.modified,
256
+ });
257
+ }
258
+ }
259
+ const output = {
260
+ status: "success",
261
+ timestamp: new Date().toISOString(),
262
+ level: options.level,
263
+ materials: allMaterials.map(m => ({
264
+ course_id: m.course_id,
265
+ course_name: m.course_name,
266
+ id: m.cmid,
267
+ name: m.name,
268
+ type: m.modType,
269
+ mimetype: m.mimetype,
270
+ filesize: m.filesize,
271
+ modified: m.modified ? new Date(m.modified * 1000).toISOString() : null,
272
+ url: m.url,
273
+ })),
274
+ summary: {
275
+ total_courses: courses.length,
276
+ total_materials: allMaterials.length,
277
+ by_type: allMaterials.reduce((acc, m) => {
278
+ acc[m.modType] = (acc[m.modType] || 0) + 1;
279
+ return acc;
280
+ }, {}),
281
+ },
282
+ };
283
+ console.log(JSON.stringify(output));
284
+ }
285
+ finally {
286
+ await (0, auth_js_2.closeBrowserSafely)(browser, browserContext);
287
+ }
288
+ });
289
+ materialsCmd
290
+ .command("download")
291
+ .description("Download all materials from a specific course")
292
+ .argument("<course-id>", "Course ID")
293
+ .option("--output-dir <path>", "Output directory", "./downloads")
294
+ .option("--output <format>", "Output format: json|csv|table|silent")
295
+ .action(async (courseId, options, command) => {
296
+ const context = await createSessionContext(options, command);
297
+ if (!context) {
298
+ process.exitCode = 1;
299
+ return;
300
+ }
301
+ const { log, page, session, browser, context: browserContext } = context;
302
+ try {
303
+ const courses = await (0, moodle_js_1.getEnrolledCourses)(page, session, log);
304
+ const course = courses.find(c => c.id === parseInt(courseId, 10));
305
+ if (!course) {
306
+ log.error(`Course not found: ${courseId}`);
307
+ process.exitCode = 1;
308
+ return;
309
+ }
310
+ const resources = await (0, moodle_js_1.getResourcesInCourse)(page, session, course.id, log);
311
+ const materials = resources.map(r => ({
312
+ course_id: course.id,
313
+ course_name: course.fullname,
314
+ cmid: r.cmid,
315
+ name: r.name,
316
+ url: r.url,
317
+ modType: r.modType,
318
+ mimetype: r.mimetype,
319
+ filesize: r.filesize,
320
+ modified: r.modified,
321
+ }));
322
+ log.info(`Found ${materials.length} materials in course: ${course.fullname}`);
323
+ const downloadedFiles = [];
324
+ for (const material of materials) {
325
+ const result = await downloadResource(page, material, options.outputDir, log);
326
+ if (result) {
327
+ downloadedFiles.push(result);
328
+ }
329
+ }
330
+ const summary = {
331
+ total_materials: materials.length,
332
+ downloaded: downloadedFiles.length,
333
+ skipped: materials.length - downloadedFiles.length,
334
+ total_size: downloadedFiles.reduce((sum, f) => sum + f.size, 0),
335
+ };
336
+ const output = {
337
+ status: "success",
338
+ timestamp: new Date().toISOString(),
339
+ downloaded_files: downloadedFiles.map(f => ({
340
+ filename: f.filename,
341
+ path: f.path,
342
+ size: f.size,
343
+ course_id: f.course_id,
344
+ course_name: f.course_name,
345
+ })),
346
+ summary,
347
+ };
348
+ console.log(JSON.stringify(output));
349
+ }
350
+ finally {
351
+ await (0, auth_js_2.closeBrowserSafely)(browser, browserContext);
352
+ }
353
+ });
354
+ materialsCmd
355
+ .command("download-all")
356
+ .description("Download all materials from all courses")
357
+ .option("--output-dir <path>", "Output directory", "./downloads")
358
+ .option("--level <type>", "Course level: in_progress (default) | all", "in_progress")
359
+ .option("--output <format>", "Output format: json|csv|table|silent")
360
+ .action(async (options, command) => {
361
+ const context = await createSessionContext(options, command);
362
+ if (!context) {
363
+ process.exitCode = 1;
364
+ return;
365
+ }
366
+ const { log, page, session, browser, context: browserContext } = context;
367
+ try {
368
+ // Map level to classification
369
+ const classification = options.level === "all" ? undefined : "inprogress";
370
+ const courses = await (0, moodle_js_1.getEnrolledCourses)(page, session, log, { classification });
371
+ log.info(`Scanning ${courses.length} courses for materials...`);
372
+ const allMaterials = [];
373
+ for (const course of courses) {
374
+ const resources = await (0, moodle_js_1.getResourcesInCourse)(page, session, course.id, log);
375
+ for (const resource of resources) {
376
+ allMaterials.push({
377
+ course_id: course.id,
378
+ course_name: course.fullname,
379
+ cmid: resource.cmid,
380
+ name: resource.name,
381
+ url: resource.url,
382
+ modType: resource.modType,
383
+ mimetype: resource.mimetype,
384
+ filesize: resource.filesize,
385
+ modified: resource.modified,
386
+ });
387
+ }
388
+ }
389
+ log.info(`Found ${allMaterials.length} materials across ${courses.length} courses`);
390
+ const downloadedFiles = [];
391
+ for (const material of allMaterials) {
392
+ const result = await downloadResource(page, material, options.outputDir, log);
393
+ if (result) {
394
+ downloadedFiles.push(result);
395
+ }
396
+ }
397
+ const summary = {
398
+ total_courses: courses.length,
399
+ total_materials: allMaterials.length,
400
+ downloaded: downloadedFiles.length,
401
+ skipped: allMaterials.length - downloadedFiles.length,
402
+ total_size: downloadedFiles.reduce((sum, f) => sum + f.size, 0),
403
+ };
404
+ const output = {
405
+ status: "success",
406
+ timestamp: new Date().toISOString(),
407
+ downloaded_files: downloadedFiles.map(f => ({
408
+ filename: f.filename,
409
+ path: f.path,
410
+ size: f.size,
411
+ course_id: f.course_id,
412
+ course_name: f.course_name,
413
+ })),
414
+ summary,
415
+ };
416
+ console.log(JSON.stringify(output));
417
+ }
418
+ finally {
419
+ await (0, auth_js_2.closeBrowserSafely)(browser, browserContext);
420
+ }
421
+ });
422
+ }
423
+ exports.registerMaterialsCommand = registerMaterialsCommand;
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function registerQuizzesCommand(program: Command): void;
@@ -0,0 +1,228 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.registerQuizzesCommand = void 0;
7
+ const utils_js_1 = require("../lib/utils.js");
8
+ const moodle_js_1 = require("../lib/moodle.js");
9
+ const logger_js_1 = require("../lib/logger.js");
10
+ const auth_js_1 = require("../lib/auth.js");
11
+ const session_js_1 = require("../lib/session.js");
12
+ const auth_js_2 = require("../lib/auth.js");
13
+ const index_js_1 = require("../index.js");
14
+ const token_js_1 = require("../lib/token.js");
15
+ const path_1 = __importDefault(require("path"));
16
+ const fs_1 = __importDefault(require("fs"));
17
+ function registerQuizzesCommand(program) {
18
+ const quizzesCmd = program.command("quizzes");
19
+ quizzesCmd.description("Quiz operations");
20
+ function getOutputFormat(command) {
21
+ const opts = command.optsWithGlobals();
22
+ return opts.output || "json";
23
+ }
24
+ // Pure API context - no browser required (fast!)
25
+ async function createApiContext(options, command) {
26
+ const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
27
+ const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
28
+ const silent = outputFormat === "json" && !opts.verbose;
29
+ const log = (0, logger_js_1.createLogger)(opts.verbose, silent);
30
+ const baseDir = (0, utils_js_1.getBaseDir)();
31
+ const sessionPath = path_1.default.resolve(baseDir, ".auth", "storage-state.json");
32
+ // Check if session exists
33
+ if (!fs_1.default.existsSync(sessionPath)) {
34
+ return null;
35
+ }
36
+ // Try to load WS token
37
+ const wsToken = (0, token_js_1.loadWsToken)(sessionPath);
38
+ if (!wsToken) {
39
+ return null;
40
+ }
41
+ return {
42
+ log,
43
+ session: {
44
+ wsToken,
45
+ moodleBaseUrl: "https://ilearning.cycu.edu.tw",
46
+ },
47
+ };
48
+ }
49
+ // Helper function to create session context
50
+ async function createSessionContext(options, command) {
51
+ // Get global options if command is provided (for --verbose, --silent flags)
52
+ const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
53
+ // Auto-enable silent mode for JSON output (unless --verbose is also set)
54
+ const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
55
+ const silent = outputFormat === "json" && !opts.verbose;
56
+ const log = (0, logger_js_1.createLogger)(opts.verbose, silent);
57
+ const baseDir = (0, utils_js_1.getBaseDir)();
58
+ const sessionPath = path_1.default.resolve(baseDir, ".auth", "storage-state.json");
59
+ if (!fs_1.default.existsSync(sessionPath)) {
60
+ log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
61
+ return null;
62
+ }
63
+ const config = {
64
+ username: "",
65
+ password: "",
66
+ courseUrl: "",
67
+ moodleBaseUrl: "https://ilearning.cycu.edu.tw",
68
+ headless: !options.headed,
69
+ slowMo: 0,
70
+ authStatePath: sessionPath,
71
+ ollamaBaseUrl: "",
72
+ };
73
+ log.info("啟動瀏覽器...");
74
+ const { browser, context, page } = await (0, auth_js_1.launchAuthenticated)(config, log);
75
+ try {
76
+ const session = await (0, session_js_1.extractSessionInfo)(page, config, log);
77
+ return { log, page, session, browser, context };
78
+ }
79
+ catch (err) {
80
+ await context.close();
81
+ await browser.close();
82
+ throw err;
83
+ }
84
+ }
85
+ quizzesCmd
86
+ .command("list")
87
+ .description("List quizzes in a course")
88
+ .argument("<course-id>", "Course ID")
89
+ .option("--available-only", "Show only available quizzes")
90
+ .option("--output <format>", "Output format: json|csv|table|silent")
91
+ .action(async (courseId, options, command) => {
92
+ const output = getOutputFormat(command);
93
+ // Try pure API mode (no browser, fast!)
94
+ const apiContext = await createApiContext(options, command);
95
+ if (apiContext) {
96
+ try {
97
+ const quizzes = await (0, moodle_js_1.getQuizzesByCoursesApi)(apiContext.session, [parseInt(courseId, 10)]);
98
+ // Filter by available only if requested (API returns all, no completion status)
99
+ // Note: API doesn't provide completion status, so --available-only won't work in API mode
100
+ if (options.availableOnly) {
101
+ apiContext.log.warn("--available-only is not supported in API mode, showing all quizzes");
102
+ }
103
+ (0, index_js_1.formatAndOutput)(quizzes, output, apiContext.log);
104
+ return;
105
+ }
106
+ catch (e) {
107
+ // API failed, fall through to browser mode
108
+ const msg = e instanceof Error ? e.message : String(e);
109
+ console.error(`// API mode failed: ${msg}, trying browser mode...`);
110
+ }
111
+ }
112
+ // Fallback to browser mode
113
+ const context = await createSessionContext(options, command);
114
+ if (!context) {
115
+ process.exitCode = 1;
116
+ return;
117
+ }
118
+ const { log, page, session, browser, context: browserContext } = context;
119
+ try {
120
+ const quizzes = await (0, moodle_js_1.getQuizzesInCourse)(page, session, parseInt(courseId, 10), log);
121
+ let filteredQuizzes = quizzes;
122
+ if (options.availableOnly) {
123
+ filteredQuizzes = quizzes.filter(q => !q.isComplete);
124
+ }
125
+ (0, index_js_1.formatAndOutput)(filteredQuizzes, output, log);
126
+ }
127
+ finally {
128
+ await (0, auth_js_2.closeBrowserSafely)(browser, browserContext);
129
+ }
130
+ });
131
+ quizzesCmd
132
+ .command("list-all")
133
+ .description("List all available quizzes across all courses")
134
+ .option("--level <type>", "Course level: in_progress (default) | all", "in_progress")
135
+ .option("--output <format>", "Output format: json|csv|table|silent")
136
+ .action(async (options, command) => {
137
+ const output = getOutputFormat(command);
138
+ // Try pure API mode (no browser, fast!)
139
+ const apiContext = await createApiContext(options, command);
140
+ if (apiContext) {
141
+ try {
142
+ const classification = options.level === "all" ? undefined : "inprogress";
143
+ const courses = await (0, moodle_js_1.getEnrolledCoursesApi)(apiContext.session, {
144
+ classification,
145
+ });
146
+ // Get quizzes via WS API (no browser needed!)
147
+ const courseIds = courses.map(c => c.id);
148
+ const apiQuizzes = await (0, moodle_js_1.getQuizzesByCoursesApi)(apiContext.session, courseIds);
149
+ // Build a map of courseId -> course for quick lookup
150
+ const courseMap = new Map(courses.map(c => [c.id, c]));
151
+ const allQuizzes = [];
152
+ for (const q of apiQuizzes) {
153
+ const course = courseMap.get(q.courseId);
154
+ if (course) {
155
+ allQuizzes.push({
156
+ courseName: course.fullname,
157
+ name: q.name,
158
+ url: q.url,
159
+ cmid: q.cmid,
160
+ isComplete: q.isComplete,
161
+ });
162
+ }
163
+ }
164
+ apiContext.log.info(`\n總計發現 ${allQuizzes.length} 個測驗。`);
165
+ (0, index_js_1.formatAndOutput)(allQuizzes, output, apiContext.log);
166
+ return;
167
+ }
168
+ catch (e) {
169
+ // API failed, fall through to browser mode
170
+ const msg = e instanceof Error ? e.message : String(e);
171
+ console.error(`// API mode failed: ${msg}, trying browser mode...`);
172
+ }
173
+ }
174
+ // Fallback to browser mode
175
+ const context = await createSessionContext(options, command);
176
+ if (!context) {
177
+ process.exitCode = 1;
178
+ return;
179
+ }
180
+ const { log, page, session, browser, context: browserContext } = context;
181
+ try {
182
+ const classification = options.level === "all" ? undefined : "inprogress";
183
+ const courses = await (0, moodle_js_1.getEnrolledCourses)(page, session, log, { classification });
184
+ const allQuizzes = [];
185
+ for (const course of courses) {
186
+ const quizzes = await (0, moodle_js_1.getQuizzesInCourse)(page, session, course.id, log);
187
+ for (const q of quizzes) {
188
+ allQuizzes.push({
189
+ courseName: course.fullname,
190
+ name: q.name,
191
+ url: q.url,
192
+ cmid: q.cmid,
193
+ isComplete: q.isComplete,
194
+ });
195
+ }
196
+ }
197
+ log.info(`\n總計發現 ${allQuizzes.length} 個測驗。`);
198
+ (0, index_js_1.formatAndOutput)(allQuizzes, output, log);
199
+ }
200
+ finally {
201
+ await (0, auth_js_2.closeBrowserSafely)(browser, browserContext);
202
+ }
203
+ });
204
+ quizzesCmd
205
+ .command("open")
206
+ .description("Open a quiz URL in browser (manual mode)")
207
+ .argument("<quiz-url>", "Quiz URL")
208
+ .option("--headed", "Run browser in visible mode (default: true)")
209
+ .action(async (quizUrl, options, command) => {
210
+ const context = await createSessionContext({ ...options, headed: true }, command);
211
+ if (!context) {
212
+ process.exitCode = 1;
213
+ return;
214
+ }
215
+ const { log, page, browser, context: browserContext } = context;
216
+ try {
217
+ log.info(`導航至測驗頁面: ${quizUrl}`);
218
+ await page.goto(quizUrl, { waitUntil: "domcontentloaded" });
219
+ log.info("瀏覽器已開啟,請手動完成測驗。");
220
+ log.info("按 Ctrl+C 關閉瀏覽器。");
221
+ await new Promise(() => { });
222
+ }
223
+ finally {
224
+ await (0, auth_js_2.closeBrowserSafely)(browser, browserContext);
225
+ }
226
+ });
227
+ }
228
+ exports.registerQuizzesCommand = registerQuizzesCommand;
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function registerSkillsCommand(program: Command): void;