@mo7yw4ng/openape 1.0.4 → 1.0.6

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 +11 -8
  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 +22 -85
  5. package/esm/src/commands/assignments.d.ts.map +1 -1
  6. package/esm/src/commands/assignments.js +2 -3
  7. package/esm/src/commands/auth.d.ts.map +1 -1
  8. package/esm/src/commands/auth.js +24 -14
  9. package/esm/src/commands/calendar.d.ts.map +1 -1
  10. package/esm/src/commands/calendar.js +32 -84
  11. package/esm/src/commands/courses.d.ts.map +1 -1
  12. package/esm/src/commands/courses.js +2 -38
  13. package/esm/src/commands/forums.d.ts.map +1 -1
  14. package/esm/src/commands/forums.js +47 -175
  15. package/esm/src/commands/grades.d.ts.map +1 -1
  16. package/esm/src/commands/grades.js +10 -47
  17. package/esm/src/commands/materials.d.ts.map +1 -1
  18. package/esm/src/commands/materials.js +135 -223
  19. package/esm/src/commands/quizzes.d.ts.map +1 -1
  20. package/esm/src/commands/quizzes.js +32 -56
  21. package/esm/src/commands/skills.js +3 -3
  22. package/esm/src/commands/upload.d.ts.map +1 -1
  23. package/esm/src/commands/upload.js +2 -5
  24. package/esm/src/commands/videos.d.ts.map +1 -1
  25. package/esm/src/commands/videos.js +6 -76
  26. package/esm/src/index.d.ts +2 -1
  27. package/esm/src/index.d.ts.map +1 -1
  28. package/esm/src/index.js +5 -1
  29. package/esm/src/lib/auth.d.ts +21 -2
  30. package/esm/src/lib/auth.d.ts.map +1 -1
  31. package/esm/src/lib/auth.js +79 -20
  32. package/esm/src/lib/logger.d.ts +2 -2
  33. package/esm/src/lib/logger.d.ts.map +1 -1
  34. package/esm/src/lib/logger.js +6 -4
  35. package/esm/src/lib/moodle.d.ts +18 -0
  36. package/esm/src/lib/moodle.d.ts.map +1 -1
  37. package/esm/src/lib/moodle.js +54 -2
  38. package/esm/src/lib/utils.d.ts +3 -8
  39. package/esm/src/lib/utils.d.ts.map +1 -1
  40. package/esm/src/lib/utils.js +3 -10
  41. package/package.json +1 -1
  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 +23 -89
  45. package/script/src/commands/assignments.d.ts.map +1 -1
  46. package/script/src/commands/assignments.js +2 -3
  47. package/script/src/commands/auth.d.ts.map +1 -1
  48. package/script/src/commands/auth.js +24 -14
  49. package/script/src/commands/calendar.d.ts.map +1 -1
  50. package/script/src/commands/calendar.js +33 -85
  51. package/script/src/commands/courses.d.ts.map +1 -1
  52. package/script/src/commands/courses.js +9 -48
  53. package/script/src/commands/forums.d.ts.map +1 -1
  54. package/script/src/commands/forums.js +50 -181
  55. package/script/src/commands/grades.d.ts.map +1 -1
  56. package/script/src/commands/grades.js +14 -54
  57. package/script/src/commands/materials.d.ts.map +1 -1
  58. package/script/src/commands/materials.js +132 -220
  59. package/script/src/commands/quizzes.d.ts.map +1 -1
  60. package/script/src/commands/quizzes.js +40 -67
  61. package/script/src/commands/skills.js +3 -3
  62. package/script/src/commands/upload.d.ts.map +1 -1
  63. package/script/src/commands/upload.js +2 -5
  64. package/script/src/commands/videos.d.ts.map +1 -1
  65. package/script/src/commands/videos.js +11 -81
  66. package/script/src/index.d.ts +2 -1
  67. package/script/src/index.d.ts.map +1 -1
  68. package/script/src/index.js +5 -1
  69. package/script/src/lib/auth.d.ts +21 -2
  70. package/script/src/lib/auth.d.ts.map +1 -1
  71. package/script/src/lib/auth.js +83 -56
  72. package/script/src/lib/logger.d.ts +2 -2
  73. package/script/src/lib/logger.d.ts.map +1 -1
  74. package/script/src/lib/logger.js +6 -4
  75. package/script/src/lib/moodle.d.ts +18 -0
  76. package/script/src/lib/moodle.d.ts.map +1 -1
  77. package/script/src/lib/moodle.js +56 -2
  78. package/script/src/lib/utils.d.ts +3 -8
  79. package/script/src/lib/utils.d.ts.map +1 -1
  80. package/script/src/lib/utils.js +3 -11
  81. package/skills/openape/SKILL.md +10 -8
@@ -1,75 +1,24 @@
1
- import { getOutputFormat, sanitizeFilename, getSessionPath, formatFileSize } from "../lib/utils.js";
2
- import { getEnrolledCourses, getEnrolledCoursesApi, getResourcesByCoursesApi, updateActivityCompletionStatusManually, getSiteInfoApi, moodleApiCall } from "../lib/moodle.js";
3
- import { createLogger } from "../lib/logger.js";
4
- import { launchAuthenticated, createApiContext } from "../lib/auth.js";
5
- import { extractSessionInfo } from "../lib/session.js";
6
- import { closeBrowserSafely } from "../lib/auth.js";
1
+ import { getOutputFormat, sanitizeFilename, formatFileSize } from "../lib/utils.js";
2
+ import { getEnrolledCoursesApi, getResourcesByCoursesApi, updateActivityCompletionStatusManually, getSiteInfoApi, moodleApiCall } from "../lib/moodle.js";
3
+ import { createApiContext } from "../lib/auth.js";
7
4
  import { formatAndOutput } from "../index.js";
8
5
  import path from "node:path";
9
6
  import fs from "node:fs";
10
7
  export function registerMaterialsCommand(program) {
11
8
  const materialsCmd = program.command("materials");
12
9
  materialsCmd.description("Material/resource operations");
13
- // Helper function to create session context (for download commands)
14
- async function createSessionContext(options, command) {
15
- const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
16
- const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
17
- const silent = outputFormat === "json" && !opts.verbose;
18
- const log = createLogger(opts.verbose, silent);
19
- const sessionPath = getSessionPath();
20
- if (!fs.existsSync(sessionPath)) {
21
- log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
22
- return null;
23
- }
24
- const config = {
25
- username: "",
26
- password: "",
27
- courseUrl: "",
28
- moodleBaseUrl: "https://ilearning.cycu.edu.tw",
29
- headless: !options.headed,
30
- slowMo: 0,
31
- authStatePath: sessionPath,
32
- ollamaBaseUrl: "",
33
- };
34
- log.info("啟動瀏覽器...");
35
- const { browser, context, page } = await launchAuthenticated(config, log);
36
- try {
37
- const session = await extractSessionInfo(page, config, log);
38
- return { log, page, session, browser, context };
39
- }
40
- catch (err) {
41
- await context.close();
42
- await browser.close();
43
- throw err;
44
- }
45
- }
46
- // Helper to download a single resource
47
- async function downloadResource(page, resource, outputDir, log) {
10
+ // Helper to download a single resource via HTTP (no browser needed)
11
+ async function downloadResourceHttp(resource, outputDir, log, token) {
48
12
  try {
49
13
  // Only download resource type (skip url)
50
14
  if (resource.modType !== "resource") {
51
15
  log.debug(` Skipping ${resource.modType}: ${resource.name}`);
52
16
  return null;
53
17
  }
54
- // Create course directory
55
18
  const courseDir = path.join(outputDir, sanitizeFilename(resource.course_name));
56
19
  await fs.promises.mkdir(courseDir, { recursive: true });
57
- // Navigate to resource page
58
- log.debug(` Downloading: ${resource.name}`);
59
- await page.goto(resource.url, { waitUntil: "domcontentloaded", timeout: 30000 });
60
- // Try to find download link on the page
61
- const downloadLinks = await page.$$eval('a[href*="forcedownload=1"]', (links) => links.map((a) => a.href));
62
- if (downloadLinks.length === 0) {
63
- log.warn(` No download link found for: ${resource.name}`);
64
- return null;
65
- }
66
- // Download the first available file
67
- const downloadUrl = downloadLinks[0];
68
- // Extract filename from URL or use resource name
69
- const urlObj = new URL(downloadUrl);
70
- const filenameParam = urlObj.searchParams.get("filename");
71
- let filename = filenameParam || sanitizeFilename(resource.name);
72
- // Add extension if missing
20
+ // Build filename
21
+ let filename = sanitizeFilename(resource.name);
73
22
  if (resource.mimetype && !path.extname(filename)) {
74
23
  const extMap = {
75
24
  "application/pdf": ".pdf",
@@ -88,33 +37,61 @@ export function registerMaterialsCommand(program) {
88
37
  }
89
38
  }
90
39
  const outputPath = path.join(courseDir, filename);
91
- // Trigger download
92
- const downloadPromise = page.waitForEvent("download", { timeout: 30000 });
93
- await page.goto(downloadUrl, { waitUntil: "domcontentloaded" });
94
- const download = await downloadPromise;
95
- // Save file
96
- await download.saveAs(outputPath);
97
- const stats = await fs.promises.stat(outputPath);
98
- log.success(` Downloaded: ${filename} (${formatFileSize(stats.size, 1)} KB)`);
99
- return {
100
- filename,
101
- path: outputPath,
102
- size: stats.size,
103
- course_id: resource.course_id,
104
- course_name: resource.course_name,
105
- };
40
+ // Skip if already exists
41
+ if (fs.existsSync(outputPath)) {
42
+ log.debug(` Skipping (exists): ${filename}`);
43
+ const stats = await fs.promises.stat(outputPath);
44
+ return { filename, path: outputPath, size: stats.size, course_id: resource.course_id, course_name: resource.course_name };
45
+ }
46
+ // Download via HTTP with WS token
47
+ const separator = resource.url.includes("?") ? "&" : "?";
48
+ const downloadUrl = `${resource.url}${separator}token=${token}`;
49
+ log.debug(` Downloading: ${resource.name}`);
50
+ const response = await fetch(downloadUrl);
51
+ if (!response.ok) {
52
+ log.warn(` Failed to download ${resource.name}: HTTP ${response.status}`);
53
+ return null;
54
+ }
55
+ const arrayBuffer = await response.arrayBuffer();
56
+ const data = new Uint8Array(arrayBuffer);
57
+ await fs.promises.writeFile(outputPath, data);
58
+ log.success(` Downloaded: ${filename} (${formatFileSize(data.byteLength, 1)} KB)`);
59
+ return { filename, path: outputPath, size: data.byteLength, course_id: resource.course_id, course_name: resource.course_name };
106
60
  }
107
61
  catch (err) {
108
62
  log.warn(` Failed to download ${resource.name}: ${err instanceof Error ? err.message : String(err)}`);
109
63
  return null;
110
64
  }
111
65
  }
66
+ // Helper to build material list from API resources
67
+ function buildMaterialsList(courses, apiResources) {
68
+ const courseMap = new Map(courses.map(c => [c.id, c]));
69
+ const materials = [];
70
+ for (const resource of apiResources) {
71
+ const course = courseMap.get(resource.courseId);
72
+ if (course) {
73
+ materials.push({
74
+ course_id: resource.courseId,
75
+ course_name: course.fullname,
76
+ cmid: resource.cmid,
77
+ name: resource.name,
78
+ url: resource.url,
79
+ modType: resource.modType,
80
+ mimetype: resource.mimetype,
81
+ filesize: resource.filesize,
82
+ modified: resource.modified,
83
+ });
84
+ }
85
+ }
86
+ return materials;
87
+ }
112
88
  materialsCmd
113
89
  .command("list-all")
114
90
  .description("List all materials/resources across all courses")
115
91
  .option("--level <type>", "Course level: in_progress (default) | all", "in_progress")
116
92
  .option("--output <format>", "Output format: json|csv|table|silent")
117
93
  .action(async (options, command) => {
94
+ const output = getOutputFormat(command);
118
95
  const apiContext = await createApiContext(options, command);
119
96
  if (!apiContext) {
120
97
  process.exitCode = 1;
@@ -124,10 +101,8 @@ export function registerMaterialsCommand(program) {
124
101
  const courses = await getEnrolledCoursesApi(apiContext.session, {
125
102
  classification,
126
103
  });
127
- // Get materials via WS API (no browser needed!)
128
104
  const courseIds = courses.map(c => c.id);
129
105
  const apiResources = await getResourcesByCoursesApi(apiContext.session, courseIds);
130
- // Build a map of courseId -> course for quick lookup
131
106
  const courseMap = new Map(courses.map(c => [c.id, c]));
132
107
  const allMaterials = [];
133
108
  for (const resource of apiResources) {
@@ -146,178 +121,115 @@ export function registerMaterialsCommand(program) {
146
121
  });
147
122
  }
148
123
  }
149
- const output = {
124
+ const items = allMaterials.map(m => ({
125
+ course_id: m.course_id,
126
+ course_name: m.course_name,
127
+ id: m.cmid,
128
+ name: m.name,
129
+ type: m.modType,
130
+ mimetype: m.mimetype,
131
+ filesize: m.filesize,
132
+ modified: m.modified ? new Date(m.modified * 1000).toISOString() : null,
133
+ url: m.url,
134
+ }));
135
+ formatAndOutput(items, output, apiContext.log, {
150
136
  status: "success",
151
137
  timestamp: new Date().toISOString(),
152
- level: options.level,
153
- materials: allMaterials.map(m => ({
154
- course_id: m.course_id,
155
- course_name: m.course_name,
156
- id: m.cmid,
157
- name: m.name,
158
- type: m.modType,
159
- mimetype: m.mimetype,
160
- filesize: m.filesize,
161
- modified: m.modified ? new Date(m.modified * 1000).toISOString() : null,
162
- url: m.url,
163
- })),
164
- summary: {
165
- total_courses: courses.length,
166
- total_materials: allMaterials.length,
167
- by_type: allMaterials.reduce((acc, m) => {
168
- acc[m.modType] = (acc[m.modType] || 0) + 1;
169
- return acc;
170
- }, {}),
171
- },
172
- };
173
- console.log(JSON.stringify(output));
138
+ total_courses: courses.length,
139
+ total_materials: allMaterials.length,
140
+ by_type: allMaterials.reduce((acc, m) => {
141
+ acc[m.modType] = (acc[m.modType] || 0) + 1;
142
+ return acc;
143
+ }, {}),
144
+ });
174
145
  });
175
146
  materialsCmd
176
147
  .command("download")
177
- .description("Download all materials from a specific course (requires browser)")
148
+ .description("Download all materials from a specific course")
178
149
  .argument("<course-id>", "Course ID")
179
150
  .option("--output-dir <path>", "Output directory", "./downloads")
180
151
  .action(async (courseId, options, command) => {
181
- const context = await createSessionContext(options, command);
182
- if (!context) {
152
+ const apiContext = await createApiContext(options, command);
153
+ if (!apiContext) {
183
154
  process.exitCode = 1;
184
155
  return;
185
156
  }
186
- const { log, page, session, browser, context: browserContext } = context;
187
- try {
188
- const courses = await getEnrolledCourses(page, session, log);
189
- const course = courses.find(c => c.id === parseInt(courseId, 10));
190
- if (!course) {
191
- log.error(`Course not found: ${courseId}`);
192
- process.exitCode = 1;
193
- return;
194
- }
195
- // Navigate to course page to find materials
196
- await page.goto(`https://ilearning.cycu.edu.tw/course/view.php?id=${course.id}`, { waitUntil: "domcontentloaded" });
197
- // Find all resource links
198
- const materials = [];
199
- const resourceLinks = await page.$$eval('a[href*="/mod/resource/view.php"]', (links) => {
200
- return links.map((a) => ({
201
- url: a.href,
202
- name: a.textContent?.trim() || "",
203
- }));
204
- });
205
- for (const link of resourceLinks) {
206
- const cmidMatch = link.url.match(/id=(\d+)/);
207
- if (cmidMatch) {
208
- materials.push({
209
- course_id: course.id,
210
- course_name: course.fullname,
211
- cmid: cmidMatch[1],
212
- name: link.name,
213
- url: link.url,
214
- modType: "resource",
215
- });
216
- }
217
- }
218
- log.info(`Found ${materials.length} materials in course: ${course.fullname}`);
219
- const downloadedFiles = [];
220
- for (const material of materials) {
221
- const result = await downloadResource(page, material, options.outputDir, log);
222
- if (result) {
223
- downloadedFiles.push(result);
224
- }
225
- }
226
- const summary = {
227
- total_materials: materials.length,
228
- downloaded: downloadedFiles.length,
229
- skipped: materials.length - downloadedFiles.length,
230
- total_size: downloadedFiles.reduce((sum, f) => sum + f.size, 0),
231
- };
232
- const output = {
233
- status: "success",
234
- timestamp: new Date().toISOString(),
235
- downloaded_files: downloadedFiles.map(f => ({
236
- filename: f.filename,
237
- path: f.path,
238
- size: f.size,
239
- course_id: f.course_id,
240
- course_name: f.course_name,
241
- })),
242
- summary,
243
- };
244
- console.log(JSON.stringify(output));
157
+ const { log, session } = apiContext;
158
+ const courses = await getEnrolledCoursesApi(session);
159
+ const course = courses.find((c) => c.id === parseInt(courseId, 10));
160
+ if (!course) {
161
+ log.error(`Course not found: ${courseId}`);
162
+ process.exitCode = 1;
163
+ return;
245
164
  }
246
- finally {
247
- await closeBrowserSafely(browser, browserContext);
165
+ const apiResources = await getResourcesByCoursesApi(session, [course.id]);
166
+ const materials = buildMaterialsList(courses, apiResources);
167
+ log.info(`Found ${materials.length} materials in course: ${course.fullname}`);
168
+ const downloadedFiles = [];
169
+ for (const material of materials) {
170
+ const result = await downloadResourceHttp(material, options.outputDir, log, session.wsToken);
171
+ if (result) {
172
+ downloadedFiles.push(result);
173
+ }
248
174
  }
175
+ const items = downloadedFiles.map(f => ({
176
+ filename: f.filename,
177
+ path: f.path,
178
+ size: f.size,
179
+ course_id: f.course_id,
180
+ course_name: f.course_name,
181
+ }));
182
+ formatAndOutput(items, "json", log, {
183
+ status: "success",
184
+ timestamp: new Date().toISOString(),
185
+ total_materials: materials.length,
186
+ downloaded: downloadedFiles.length,
187
+ skipped: materials.length - downloadedFiles.length,
188
+ total_size: downloadedFiles.reduce((sum, f) => sum + f.size, 0),
189
+ });
249
190
  });
250
191
  materialsCmd
251
192
  .command("download-all")
252
- .description("Download all materials from all courses (requires browser)")
193
+ .description("Download all materials from all courses")
253
194
  .option("--output-dir <path>", "Output directory", "./downloads")
254
195
  .option("--level <type>", "Course level: in_progress (default) | all", "in_progress")
255
196
  .action(async (options, command) => {
256
- const context = await createSessionContext(options, command);
257
- if (!context) {
197
+ const apiContext = await createApiContext(options, command);
198
+ if (!apiContext) {
258
199
  process.exitCode = 1;
259
200
  return;
260
201
  }
261
- const { log, page, session, browser, context: browserContext } = context;
262
- try {
263
- const classification = options.level === "all" ? undefined : "inprogress";
264
- const courses = await getEnrolledCourses(page, session, log, { classification });
265
- log.info(`Scanning ${courses.length} courses for materials...`);
266
- const allMaterials = [];
267
- for (const course of courses) {
268
- await page.goto(`https://ilearning.cycu.edu.tw/course/view.php?id=${course.id}`, { waitUntil: "domcontentloaded" });
269
- const resourceLinks = await page.$$eval('a[href*="/mod/resource/view.php"]', (links) => {
270
- return links.map((a) => ({
271
- url: a.href,
272
- name: a.textContent?.trim() || "",
273
- }));
274
- });
275
- for (const link of resourceLinks) {
276
- const cmidMatch = link.url.match(/id=(\d+)/);
277
- if (cmidMatch) {
278
- allMaterials.push({
279
- course_id: course.id,
280
- course_name: course.fullname,
281
- cmid: cmidMatch[1],
282
- name: link.name,
283
- url: link.url,
284
- modType: "resource",
285
- });
286
- }
287
- }
288
- }
289
- log.info(`Found ${allMaterials.length} materials across ${courses.length} courses`);
290
- const downloadedFiles = [];
291
- for (const material of allMaterials) {
292
- const result = await downloadResource(page, material, options.outputDir, log);
293
- if (result) {
294
- downloadedFiles.push(result);
295
- }
202
+ const { log, session } = apiContext;
203
+ const classification = options.level === "all" ? undefined : "inprogress";
204
+ const courses = await getEnrolledCoursesApi(session, { classification });
205
+ log.info(`Scanning ${courses.length} courses for materials...`);
206
+ const courseIds = courses.map((c) => c.id);
207
+ const apiResources = await getResourcesByCoursesApi(session, courseIds);
208
+ const allMaterials = buildMaterialsList(courses, apiResources);
209
+ log.info(`Found ${allMaterials.length} materials across ${courses.length} courses`);
210
+ const downloadedFiles = [];
211
+ for (const material of allMaterials) {
212
+ const result = await downloadResourceHttp(material, options.outputDir, log, session.wsToken);
213
+ if (result) {
214
+ downloadedFiles.push(result);
296
215
  }
297
- const summary = {
298
- total_courses: courses.length,
299
- total_materials: allMaterials.length,
300
- downloaded: downloadedFiles.length,
301
- skipped: allMaterials.length - downloadedFiles.length,
302
- total_size: downloadedFiles.reduce((sum, f) => sum + f.size, 0),
303
- };
304
- const output = {
305
- status: "success",
306
- timestamp: new Date().toISOString(),
307
- downloaded_files: downloadedFiles.map(f => ({
308
- filename: f.filename,
309
- path: f.path,
310
- size: f.size,
311
- course_id: f.course_id,
312
- course_name: f.course_name,
313
- })),
314
- summary,
315
- };
316
- console.log(JSON.stringify(output));
317
- }
318
- finally {
319
- await closeBrowserSafely(browser, browserContext);
320
216
  }
217
+ const items = downloadedFiles.map(f => ({
218
+ filename: f.filename,
219
+ path: f.path,
220
+ size: f.size,
221
+ course_id: f.course_id,
222
+ course_name: f.course_name,
223
+ }));
224
+ formatAndOutput(items, "json", log, {
225
+ status: "success",
226
+ timestamp: new Date().toISOString(),
227
+ total_courses: courses.length,
228
+ total_materials: allMaterials.length,
229
+ downloaded: downloadedFiles.length,
230
+ skipped: allMaterials.length - downloadedFiles.length,
231
+ total_size: downloadedFiles.reduce((sum, f) => sum + f.size, 0),
232
+ });
321
233
  });
322
234
  materialsCmd
323
235
  .command("complete")
@@ -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;AAwDpC,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CA0Q7D"}
1
+ {"version":3,"file":"quizzes.d.ts","sourceRoot":"","sources":["../../../src/src/commands/quizzes.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAsEpC,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CA2N7D"}
@@ -1,10 +1,7 @@
1
- import { getBaseDir, formatTimestamp } from "../lib/utils.js";
2
- import { getEnrolledCoursesApi, getQuizzesByCoursesApi, startQuizAttemptApi, getQuizAttemptDataApi, processQuizAttemptApi } from "../lib/moodle.js";
3
- import { createLogger } from "../lib/logger.js";
1
+ import { formatTimestamp, getOutputFormat } from "../lib/utils.js";
2
+ import { getEnrolledCoursesApi, getQuizzesByCoursesApi, startQuizAttemptApi, getQuizAttemptDataApi, getAllQuizAttemptDataApi, processQuizAttemptApi } from "../lib/moodle.js";
3
+ import { createApiContext } from "../lib/auth.js";
4
4
  import { formatAndOutput } from "../index.js";
5
- import { loadWsToken } from "../lib/token.js";
6
- import path from "node:path";
7
- import fs from "node:fs";
8
5
  function stripHtmlKeepLines(html) {
9
6
  return html
10
7
  .replace(/<br\s*\/?>/gi, "\n")
@@ -41,41 +38,24 @@ function parseSavedAnswer(html) {
41
38
  return textMatch[1];
42
39
  return null;
43
40
  }
41
+ function parseQuizQuestions(questions) {
42
+ return Object.values(questions).map((q) => {
43
+ const parsed = parseQuestionHtml(q.html ?? "");
44
+ const savedAnswer = parseSavedAnswer(q.html ?? "");
45
+ return {
46
+ slot: q.slot,
47
+ type: q.type,
48
+ status: q.status,
49
+ stateclass: q.stateclass,
50
+ savedAnswer,
51
+ question: parsed.text,
52
+ options: parsed.options,
53
+ };
54
+ });
55
+ }
44
56
  export function registerQuizzesCommand(program) {
45
57
  const quizzesCmd = program.command("quizzes");
46
58
  quizzesCmd.description("Quiz operations");
47
- function getOutputFormat(command) {
48
- const opts = command.optsWithGlobals();
49
- return opts.output || "json";
50
- }
51
- // Pure API context - no browser required (fast!)
52
- async function createApiContext(options, command) {
53
- const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
54
- const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
55
- const silent = outputFormat === "json" && !opts.verbose;
56
- const log = createLogger(opts.verbose, silent);
57
- const baseDir = getBaseDir();
58
- const sessionPath = path.resolve(baseDir, ".auth", "storage-state.json");
59
- // Check if session exists
60
- if (!fs.existsSync(sessionPath)) {
61
- log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
62
- log.info(`Session 預期位置: ${sessionPath}`);
63
- return null;
64
- }
65
- // Try to load WS token
66
- const wsToken = loadWsToken(sessionPath);
67
- if (!wsToken) {
68
- log.error("未找到 WS token。請先執行 'openape auth login' 進行登入。");
69
- return null;
70
- }
71
- return {
72
- log,
73
- session: {
74
- wsToken,
75
- moodleBaseUrl: "https://ilearning.cycu.edu.tw",
76
- },
77
- };
78
- }
79
59
  quizzesCmd
80
60
  .command("list")
81
61
  .description("List incomplete quizzes in a course")
@@ -154,8 +134,12 @@ export function registerQuizzesCommand(program) {
154
134
  }
155
135
  try {
156
136
  const result = await startQuizAttemptApi(apiContext.session, quizCmid);
137
+ apiContext.log.success(`Quiz attempt ${result.attempt.attemptid} started.`);
138
+ const attemptId = result.attempt.attemptid;
139
+ const data = await getAllQuizAttemptDataApi(apiContext.session, attemptId);
140
+ const questions = parseQuizQuestions(data.questions);
157
141
  const outputData = [{
158
- attemptId: result.attempt.attemptid,
142
+ attemptId,
159
143
  quizId: result.attempt.quizid,
160
144
  state: result.attempt.state,
161
145
  timeStart: formatTimestamp(result.attempt.timestart),
@@ -163,8 +147,9 @@ export function registerQuizzesCommand(program) {
163
147
  ? formatTimestamp(result.attempt.timefinish)
164
148
  : null,
165
149
  isPreview: result.attempt.preview,
150
+ totalQuestions: questions.length,
151
+ questions,
166
152
  }];
167
- apiContext.log.success(`Quiz attempt ${result.attempt.attemptid} started.`);
168
153
  formatAndOutput(outputData, output, apiContext.log);
169
154
  }
170
155
  catch (error) {
@@ -176,7 +161,7 @@ export function registerQuizzesCommand(program) {
176
161
  .command("info")
177
162
  .description("Get quiz attempt data and questions")
178
163
  .argument("<attempt-id>", "Quiz attempt ID")
179
- .option("--page <number>", "Page number", "0")
164
+ .option("--page <number>", "Page number (-1 for all pages)", "-1")
180
165
  .option("--output <format>", "Output format: json|csv|table|silent")
181
166
  .action(async (attemptId, options, command) => {
182
167
  const output = getOutputFormat(command);
@@ -186,20 +171,11 @@ export function registerQuizzesCommand(program) {
186
171
  return;
187
172
  }
188
173
  try {
189
- const data = await getQuizAttemptDataApi(apiContext.session, parseInt(attemptId), parseInt(options.page));
190
- const questions = Object.values(data.questions).map((q) => {
191
- const parsed = parseQuestionHtml(q.html ?? "");
192
- const savedAnswer = parseSavedAnswer(q.html ?? "");
193
- return {
194
- number: q.questionnumber ?? q.slot,
195
- type: q.type,
196
- status: q.status,
197
- stateclass: q.stateclass,
198
- savedAnswer,
199
- question: parsed.text,
200
- options: parsed.options,
201
- };
202
- });
174
+ const pageNumber = parseInt(options.page);
175
+ const data = pageNumber === -1
176
+ ? await getAllQuizAttemptDataApi(apiContext.session, parseInt(attemptId))
177
+ : await getQuizAttemptDataApi(apiContext.session, parseInt(attemptId), pageNumber);
178
+ const questions = parseQuizQuestions(data.questions);
203
179
  const outputData = [{
204
180
  attemptId: data.attempt.attemptid,
205
181
  quizId: data.attempt.quizid,
@@ -240,7 +216,7 @@ export function registerQuizzesCommand(program) {
240
216
  }
241
217
  try {
242
218
  // Get attempt data to find uniqueid and sequencecheck values
243
- const attemptData = await getQuizAttemptDataApi(apiContext.session, parseInt(attemptId), 0);
219
+ const attemptData = await getAllQuizAttemptDataApi(apiContext.session, parseInt(attemptId));
244
220
  const uniqueId = attemptData.attempt.uniqueid ?? attemptData.attempt.attemptid;
245
221
  const sequenceChecks = new Map();
246
222
  for (const q of Object.values(attemptData.questions)) {
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
+ import { fileURLToPath } from "node:url";
4
5
  const SKILL_NAME = "openape";
5
6
  const GITHUB_RAW_URL = `https://raw.githubusercontent.com/mo7yw4ng/openape/refs/heads/main/skills/${SKILL_NAME}/SKILL.md`;
6
7
  /**
@@ -18,11 +19,10 @@ const PLATFORMS = {
18
19
  async function readSkillContent() {
19
20
  // Try local path first (relative to this file's location)
20
21
  try {
21
- const base = path.dirname(new URL(globalThis[Symbol.for("import-meta-ponyfill-esmodule")](import.meta).url).pathname);
22
- const normalized = process.platform === "win32" ? base.replace(/^\//, "") : base;
22
+ const base = path.dirname(fileURLToPath(globalThis[Symbol.for("import-meta-ponyfill-esmodule")](import.meta).url));
23
23
  // When running from source: src/commands/ → ../../skills/openape/SKILL.md
24
24
  // When bundled by dnt into build/: esm/commands/ or script/ → ../../skills/openape/SKILL.md
25
- const localPath = path.resolve(normalized, "..", "..", "skills", SKILL_NAME, "SKILL.md");
25
+ const localPath = path.resolve(base, "..", "..", "skills", SKILL_NAME, "SKILL.md");
26
26
  return await fs.promises.readFile(localPath, "utf-8");
27
27
  }
28
28
  catch {
@@ -1 +1 @@
1
- {"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../../../src/src/commands/upload.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAQpC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CA2D5D"}
1
+ {"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../../../src/src/commands/upload.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAQpC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAwD5D"}
@@ -20,19 +20,16 @@ export function registerUploadCommand(program) {
20
20
  process.exitCode = 1;
21
21
  return;
22
22
  }
23
- // Resolve file path
24
23
  const resolvedPath = path.resolve(filePath);
25
- // Check if file exists
24
+ let stats;
26
25
  try {
27
- await fs.access(resolvedPath);
26
+ stats = await fs.stat(resolvedPath);
28
27
  }
29
28
  catch {
30
29
  apiContext.log.error(`檔案不存在: ${filePath}`);
31
30
  process.exitCode = 1;
32
31
  return;
33
32
  }
34
- // Get file size
35
- const stats = await fs.stat(resolvedPath);
36
33
  const fileSizeKB = formatFileSize(stats.size);
37
34
  apiContext.log.info(`上傳檔案: ${path.basename(resolvedPath)} (${fileSizeKB} KB)`);
38
35
  // Upload file
@@ -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,CAgY5D"}
1
+ {"version":3,"file":"videos.d.ts","sourceRoot":"","sources":["../../../src/src/commands/videos.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAQpC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAgT5D"}