@mo7yw4ng/openape 1.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +30 -5
  2. package/esm/deno.js +1 -1
  3. package/esm/src/commands/announcements.d.ts.map +1 -1
  4. package/esm/src/commands/announcements.js +13 -14
  5. package/esm/src/commands/assignments.d.ts +3 -0
  6. package/esm/src/commands/assignments.d.ts.map +1 -0
  7. package/esm/src/commands/assignments.js +230 -0
  8. package/esm/src/commands/auth.d.ts.map +1 -1
  9. package/esm/src/commands/auth.js +21 -1
  10. package/esm/src/commands/calendar.d.ts.map +1 -1
  11. package/esm/src/commands/calendar.js +17 -18
  12. package/esm/src/commands/courses.js +3 -3
  13. package/esm/src/commands/forums.d.ts.map +1 -1
  14. package/esm/src/commands/forums.js +125 -33
  15. package/esm/src/commands/materials.d.ts.map +1 -1
  16. package/esm/src/commands/materials.js +6 -38
  17. package/esm/src/commands/quizzes.d.ts.map +1 -1
  18. package/esm/src/commands/quizzes.js +165 -65
  19. package/esm/src/commands/skills.d.ts.map +1 -1
  20. package/esm/src/commands/skills.js +4 -8
  21. package/esm/src/commands/upload.d.ts +3 -0
  22. package/esm/src/commands/upload.d.ts.map +1 -0
  23. package/esm/src/commands/upload.js +58 -0
  24. package/esm/src/commands/videos.d.ts.map +1 -1
  25. package/esm/src/commands/videos.js +5 -4
  26. package/esm/src/index.d.ts.map +1 -1
  27. package/esm/src/index.js +12 -1
  28. package/esm/src/lib/auth.d.ts +23 -1
  29. package/esm/src/lib/auth.d.ts.map +1 -1
  30. package/esm/src/lib/auth.js +36 -3
  31. package/esm/src/lib/moodle.d.ts +179 -1
  32. package/esm/src/lib/moodle.d.ts.map +1 -1
  33. package/esm/src/lib/moodle.js +481 -13
  34. package/esm/src/lib/types.d.ts +81 -164
  35. package/esm/src/lib/types.d.ts.map +1 -1
  36. package/esm/src/lib/types.js +1 -0
  37. package/esm/src/lib/utils.d.ts +20 -0
  38. package/esm/src/lib/utils.d.ts.map +1 -1
  39. package/esm/src/lib/utils.js +48 -1
  40. package/package.json +1 -1
  41. package/script/deno.js +1 -1
  42. package/script/src/commands/announcements.d.ts.map +1 -1
  43. package/script/src/commands/announcements.js +12 -13
  44. package/script/src/commands/assignments.d.ts +3 -0
  45. package/script/src/commands/assignments.d.ts.map +1 -0
  46. package/script/src/commands/assignments.js +269 -0
  47. package/script/src/commands/auth.d.ts.map +1 -1
  48. package/script/src/commands/auth.js +20 -0
  49. package/script/src/commands/calendar.d.ts.map +1 -1
  50. package/script/src/commands/calendar.js +16 -17
  51. package/script/src/commands/courses.js +2 -2
  52. package/script/src/commands/forums.d.ts.map +1 -1
  53. package/script/src/commands/forums.js +125 -33
  54. package/script/src/commands/materials.d.ts.map +1 -1
  55. package/script/src/commands/materials.js +7 -39
  56. package/script/src/commands/quizzes.d.ts.map +1 -1
  57. package/script/src/commands/quizzes.js +163 -63
  58. package/script/src/commands/skills.d.ts.map +1 -1
  59. package/script/src/commands/skills.js +4 -8
  60. package/script/src/commands/upload.d.ts +3 -0
  61. package/script/src/commands/upload.d.ts.map +1 -0
  62. package/script/src/commands/upload.js +64 -0
  63. package/script/src/commands/videos.d.ts.map +1 -1
  64. package/script/src/commands/videos.js +5 -4
  65. package/script/src/index.d.ts.map +1 -1
  66. package/script/src/index.js +12 -1
  67. package/script/src/lib/auth.d.ts +23 -1
  68. package/script/src/lib/auth.d.ts.map +1 -1
  69. package/script/src/lib/auth.js +70 -3
  70. package/script/src/lib/moodle.d.ts +179 -1
  71. package/script/src/lib/moodle.d.ts.map +1 -1
  72. package/script/src/lib/moodle.js +493 -13
  73. package/script/src/lib/types.d.ts +81 -164
  74. package/script/src/lib/types.d.ts.map +1 -1
  75. package/script/src/lib/types.js +1 -0
  76. package/script/src/lib/utils.d.ts +20 -0
  77. package/script/src/lib/utils.d.ts.map +1 -1
  78. package/script/src/lib/utils.js +52 -0
  79. package/skills/openape/SKILL.md +73 -271
@@ -1,5 +1,5 @@
1
- import { getBaseDir, stripHtmlTags } from "../lib/utils.js";
2
- import { getEnrolledCoursesApi, getForumsApi, getForumDiscussionsApi, getDiscussionPostsApi } from "../lib/moodle.js";
1
+ import { getBaseDir, stripHtmlTags, getOutputFormat, formatTimestamp } from "../lib/utils.js";
2
+ import { getEnrolledCoursesApi, getForumsApi, getForumDiscussionsApi, getDiscussionPostsApi, addForumDiscussionApi, addForumPostApi, deleteForumPostApi } from "../lib/moodle.js";
3
3
  import { createLogger } from "../lib/logger.js";
4
4
  import { loadWsToken, loadSesskey } from "../lib/token.js";
5
5
  import path from "node:path";
@@ -7,10 +7,6 @@ import fs from "node:fs";
7
7
  export function registerForumsCommand(program) {
8
8
  const forumsCmd = program.command("forums");
9
9
  forumsCmd.description("Forum operations");
10
- function getOutputFormat(command) {
11
- const opts = command.optsWithGlobals();
12
- return opts.output || "json";
13
- }
14
10
  // Pure API context - no browser required (fast!)
15
11
  async function createApiContext(options, command) {
16
12
  const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
@@ -65,6 +61,7 @@ export function registerForumsCommand(program) {
65
61
  allForums.push({
66
62
  course_id: wsForum.courseid,
67
63
  course_name: course.fullname,
64
+ intro: wsForum.intro,
68
65
  cmid: wsForum.cmid.toString(),
69
66
  forum_id: wsForum.id,
70
67
  name: wsForum.name,
@@ -73,16 +70,15 @@ export function registerForumsCommand(program) {
73
70
  });
74
71
  }
75
72
  }
76
- const result = {
73
+ console.log(JSON.stringify({
77
74
  status: "success",
78
75
  timestamp: new Date().toISOString(),
79
- forums: allForums,
80
- summary: {
81
- total_courses: courses.length,
82
- total_forums: allForums.length,
83
- },
84
- };
85
- console.log(JSON.stringify(result));
76
+ total_courses: courses.length,
77
+ total_forums: allForums.length,
78
+ }));
79
+ for (const forum of allForums) {
80
+ console.log(JSON.stringify(forum));
81
+ }
86
82
  });
87
83
  forumsCmd
88
84
  .command("list-all")
@@ -109,6 +105,7 @@ export function registerForumsCommand(program) {
109
105
  allForums.push({
110
106
  course_id: wsForum.courseid,
111
107
  course_name: course.fullname,
108
+ intro: wsForum.intro,
112
109
  cmid: wsForum.cmid.toString(),
113
110
  forum_id: wsForum.id,
114
111
  name: wsForum.name,
@@ -116,16 +113,15 @@ export function registerForumsCommand(program) {
116
113
  });
117
114
  }
118
115
  }
119
- const result = {
116
+ console.log(JSON.stringify({
120
117
  status: "success",
121
118
  timestamp: new Date().toISOString(),
122
- forums: allForums,
123
- summary: {
124
- total_courses: courses.length,
125
- total_forums: allForums.length,
126
- },
127
- };
128
- console.log(JSON.stringify(result));
119
+ total_courses: courses.length,
120
+ total_forums: allForums.length,
121
+ }));
122
+ for (const forum of allForums) {
123
+ console.log(JSON.stringify(forum));
124
+ }
129
125
  });
130
126
  forumsCmd
131
127
  .command("discussions")
@@ -155,27 +151,29 @@ export function registerForumsCommand(program) {
155
151
  const course = courses.find(c => c.id === targetForum.courseid);
156
152
  // Get discussions via WS API
157
153
  const discussions = await getForumDiscussionsApi(apiContext.session, targetForum.id);
158
- const result = {
154
+ // Output NDJSON: one line per discussion entry for stream-friendly parsing
155
+ const meta = {
159
156
  status: "success",
160
157
  timestamp: new Date().toISOString(),
161
158
  forum_id: targetForum.id,
162
159
  forum_name: targetForum.name,
160
+ forum_intro: targetForum.intro,
163
161
  course_id: course?.id,
164
162
  course_name: course?.fullname,
165
- discussions: discussions.map(d => ({
163
+ total_discussions: discussions.length,
164
+ };
165
+ console.log(JSON.stringify(meta));
166
+ for (const d of discussions) {
167
+ console.log(JSON.stringify({
166
168
  id: d.id,
167
169
  name: d.name,
168
170
  user_id: d.userId,
169
171
  time_modified: d.timeModified,
170
172
  post_count: d.postCount,
171
173
  unread: d.unread,
172
- message: (stripHtmlTags(d.message || "")).substring(0, 250) + "...",
173
- })),
174
- summary: {
175
- total_discussions: discussions.length,
176
- },
177
- };
178
- console.log(JSON.stringify(result));
174
+ message: stripHtmlTags(d.message || ""),
175
+ }));
176
+ }
179
177
  });
180
178
  forumsCmd
181
179
  .command("posts")
@@ -200,8 +198,8 @@ export function registerForumsCommand(program) {
200
198
  subject: p.subject,
201
199
  author: p.author,
202
200
  author_id: p.authorId,
203
- created: new Date(p.created * 1000).toISOString(),
204
- modified: new Date(p.modified * 1000).toISOString(),
201
+ created: formatTimestamp(p.created),
202
+ modified: formatTimestamp(p.modified),
205
203
  message: p.message,
206
204
  unread: p.unread,
207
205
  })),
@@ -223,4 +221,98 @@ export function registerForumsCommand(program) {
223
221
  console.table(tablePosts);
224
222
  }
225
223
  });
224
+ forumsCmd
225
+ .command("post")
226
+ .description("Post a new discussion to a forum")
227
+ .argument("<forum-id>", "Forum ID")
228
+ .argument("<subject>", "Discussion subject")
229
+ .argument("<message>", "Discussion message")
230
+ .option("--subscribe", "Subscribe to the discussion", false)
231
+ .option("--pin", "Pin the discussion", false)
232
+ .action(async (forumId, subject, message, options, command) => {
233
+ const apiContext = await createApiContext(options, command);
234
+ if (!apiContext) {
235
+ process.exitCode = 1;
236
+ return;
237
+ }
238
+ const { log, session } = apiContext;
239
+ // Get courses to find the forum
240
+ const courses = await getEnrolledCoursesApi(session, {
241
+ classification: "inprogress",
242
+ });
243
+ const courseIds = courses.map(c => c.id);
244
+ const wsForums = await getForumsApi(session, courseIds);
245
+ // Find forum by cmid or instance ID
246
+ const targetForum = wsForums.find(f => f.cmid.toString() === forumId || f.id === parseInt(forumId, 10));
247
+ if (!targetForum) {
248
+ log.error(`Forum not found: ${forumId}`);
249
+ process.exitCode = 1;
250
+ return;
251
+ }
252
+ const course = courses.find(c => c.id === targetForum.courseid);
253
+ log.info(`Posting to forum: ${targetForum.name} (${course?.fullname})`);
254
+ const result = await addForumDiscussionApi(session, targetForum.id, subject, message);
255
+ if (result.success) {
256
+ log.success(`✓ Discussion posted successfully!`);
257
+ log.info(` Discussion ID: ${result.discussionId}`);
258
+ }
259
+ else {
260
+ log.error(`✗ Failed to post discussion: ${result.error}`);
261
+ process.exitCode = 1;
262
+ }
263
+ });
264
+ forumsCmd
265
+ .command("reply")
266
+ .description("Reply to a discussion post")
267
+ .argument("<post-id>", "Parent post ID to reply to")
268
+ .argument("<subject>", "Reply subject")
269
+ .argument("<message>", "Reply message")
270
+ .option("--attachment-id <id>", "Draft file ID for attachment")
271
+ .option("--inline-attachment-id <id>", "Draft file ID for inline attachment")
272
+ .action(async (postId, subject, message, options, command) => {
273
+ const apiContext = await createApiContext(options, command);
274
+ if (!apiContext) {
275
+ process.exitCode = 1;
276
+ return;
277
+ }
278
+ const { log, session } = apiContext;
279
+ log.info(`Replying to post: ${postId}`);
280
+ log.info(` Subject: ${subject}`);
281
+ log.info(` Message: ${message}`);
282
+ if (options.attachmentId) {
283
+ log.info(` Attachment ID: ${options.attachmentId}`);
284
+ }
285
+ const result = await addForumPostApi(session, parseInt(postId, 10), subject, message, {
286
+ attachmentId: options.attachmentId ? parseInt(options.attachmentId, 10) : undefined,
287
+ inlineAttachmentId: options.inlineAttachmentId ? parseInt(options.inlineAttachmentId, 10) : undefined,
288
+ });
289
+ if (result.success) {
290
+ log.success(`✓ Reply posted successfully!`);
291
+ log.info(` Post ID: ${result.postId}`);
292
+ }
293
+ else {
294
+ log.error(`✗ Failed to post reply: ${result.error}`);
295
+ process.exitCode = 1;
296
+ }
297
+ });
298
+ forumsCmd
299
+ .command("delete")
300
+ .description("Delete a forum post or discussion (by post ID)")
301
+ .argument("<post-id>", "Post ID to delete (deletes entire discussion if it's the first post)")
302
+ .action(async (postId, options, command) => {
303
+ const apiContext = await createApiContext(options, command);
304
+ if (!apiContext) {
305
+ process.exitCode = 1;
306
+ return;
307
+ }
308
+ const { log, session } = apiContext;
309
+ const result = await deleteForumPostApi(session, parseInt(postId, 10));
310
+ if (result.success) {
311
+ log.success(`✓ Post ${postId} deleted successfully!`);
312
+ }
313
+ else {
314
+ log.error(`✗ Failed to delete post: ${result.error}`);
315
+ process.exitCode = 1;
316
+ }
317
+ });
226
318
  }
@@ -1 +1 @@
1
- {"version":3,"file":"materials.d.ts","sourceRoot":"","sources":["../../../src/src/commands/materials.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAgCpC,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAqlB/D"}
1
+ {"version":3,"file":"materials.d.ts","sourceRoot":"","sources":["../../../src/src/commands/materials.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAgCpC,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CA8iB/D"}
@@ -1,52 +1,22 @@
1
- import { getBaseDir, getOutputFormat, sanitizeFilename } from "../lib/utils.js";
1
+ import { getOutputFormat, sanitizeFilename, getSessionPath, formatFileSize } from "../lib/utils.js";
2
2
  import { getEnrolledCourses, getEnrolledCoursesApi, getResourcesByCoursesApi, updateActivityCompletionStatusManually, getSiteInfoApi, moodleApiCall } from "../lib/moodle.js";
3
3
  import { createLogger } from "../lib/logger.js";
4
- import { launchAuthenticated } from "../lib/auth.js";
4
+ import { launchAuthenticated, createApiContext } from "../lib/auth.js";
5
5
  import { extractSessionInfo } from "../lib/session.js";
6
6
  import { closeBrowserSafely } from "../lib/auth.js";
7
7
  import { formatAndOutput } from "../index.js";
8
- import { loadWsToken } from "../lib/token.js";
9
8
  import path from "node:path";
10
9
  import fs from "node:fs";
11
10
  export function registerMaterialsCommand(program) {
12
11
  const materialsCmd = program.command("materials");
13
12
  materialsCmd.description("Material/resource operations");
14
- // Pure API context - no browser required (fast!)
15
- async function createApiContext(options, command) {
16
- const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
17
- const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
18
- const silent = outputFormat === "json" && !opts.verbose;
19
- const log = createLogger(opts.verbose, silent);
20
- const baseDir = getBaseDir();
21
- const sessionPath = path.resolve(baseDir, ".auth", "storage-state.json");
22
- // Check if session exists
23
- if (!fs.existsSync(sessionPath)) {
24
- log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
25
- log.info(`Session 預期位置: ${sessionPath}`);
26
- return null;
27
- }
28
- // Try to load WS token
29
- const wsToken = loadWsToken(sessionPath);
30
- if (!wsToken) {
31
- log.error("未找到 WS token。請先執行 'openape auth login' 進行登入。");
32
- return null;
33
- }
34
- return {
35
- log,
36
- session: {
37
- wsToken,
38
- moodleBaseUrl: "https://ilearning.cycu.edu.tw",
39
- },
40
- };
41
- }
42
13
  // Helper function to create session context (for download commands)
43
14
  async function createSessionContext(options, command) {
44
15
  const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
45
16
  const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
46
17
  const silent = outputFormat === "json" && !opts.verbose;
47
18
  const log = createLogger(opts.verbose, silent);
48
- const baseDir = getBaseDir();
49
- const sessionPath = path.resolve(baseDir, ".auth", "storage-state.json");
19
+ const sessionPath = getSessionPath();
50
20
  if (!fs.existsSync(sessionPath)) {
51
21
  log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
52
22
  return null;
@@ -83,9 +53,7 @@ export function registerMaterialsCommand(program) {
83
53
  }
84
54
  // Create course directory
85
55
  const courseDir = path.join(outputDir, sanitizeFilename(resource.course_name));
86
- if (!fs.existsSync(courseDir)) {
87
- fs.mkdirSync(courseDir, { recursive: true });
88
- }
56
+ await fs.promises.mkdir(courseDir, { recursive: true });
89
57
  // Navigate to resource page
90
58
  log.debug(` Downloading: ${resource.name}`);
91
59
  await page.goto(resource.url, { waitUntil: "domcontentloaded", timeout: 30000 });
@@ -126,8 +94,8 @@ export function registerMaterialsCommand(program) {
126
94
  const download = await downloadPromise;
127
95
  // Save file
128
96
  await download.saveAs(outputPath);
129
- const stats = fs.statSync(outputPath);
130
- log.success(` Downloaded: ${filename} (${(stats.size / 1024).toFixed(1)} KB)`);
97
+ const stats = await fs.promises.stat(outputPath);
98
+ log.success(` Downloaded: ${filename} (${formatFileSize(stats.size, 1)} KB)`);
131
99
  return {
132
100
  filename,
133
101
  path: outputPath,
@@ -1 +1 @@
1
- {"version":3,"file":"quizzes.d.ts","sourceRoot":"","sources":["../../../src/src/commands/quizzes.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAYpC,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAuL7D"}
1
+ {"version":3,"file":"quizzes.d.ts","sourceRoot":"","sources":["../../../src/src/commands/quizzes.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAwDpC,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CA0Q7D"}
@@ -1,13 +1,46 @@
1
- import { getBaseDir } from "../lib/utils.js";
2
- import { getEnrolledCoursesApi, getQuizzesByCoursesApi } from "../lib/moodle.js";
1
+ import { getBaseDir, formatTimestamp } from "../lib/utils.js";
2
+ import { getEnrolledCoursesApi, getQuizzesByCoursesApi, startQuizAttemptApi, getQuizAttemptDataApi, processQuizAttemptApi } from "../lib/moodle.js";
3
3
  import { createLogger } from "../lib/logger.js";
4
- import { launchAuthenticated } from "../lib/auth.js";
5
- import { extractSessionInfo } from "../lib/session.js";
6
- import { closeBrowserSafely } from "../lib/auth.js";
7
4
  import { formatAndOutput } from "../index.js";
8
5
  import { loadWsToken } from "../lib/token.js";
9
6
  import path from "node:path";
10
7
  import fs from "node:fs";
8
+ function stripHtmlKeepLines(html) {
9
+ return html
10
+ .replace(/<br\s*\/?>/gi, "\n")
11
+ .replace(/<\/p>/gi, "\n")
12
+ .replace(/<[^>]+>/g, "")
13
+ .replace(/&nbsp;/g, " ")
14
+ .replace(/&amp;/g, "&")
15
+ .replace(/&lt;/g, "<")
16
+ .replace(/&gt;/g, ">")
17
+ .replace(/\n{3,}/g, "\n\n")
18
+ .trim();
19
+ }
20
+ function parseQuestionHtml(html) {
21
+ const qtextMatch = html.match(/<div class="qtext">([\s\S]*?)<\/div>\s*<\/div>/);
22
+ const text = stripHtmlKeepLines(qtextMatch?.[1] ?? "");
23
+ const options = [];
24
+ const optionRegex = /data-region="answer-label">([\s\S]*?)<\/div>\s*<\/div>/g;
25
+ let match;
26
+ while ((match = optionRegex.exec(html)) !== null) {
27
+ options.push(stripHtmlKeepLines(match[1]));
28
+ }
29
+ return { text, options };
30
+ }
31
+ function parseSavedAnswer(html) {
32
+ const radioChecked = html.match(/<input type="radio"[^>]*value="(\d+)"[^>]*checked="checked"/);
33
+ if (radioChecked && radioChecked[1] !== "-1")
34
+ return radioChecked[1];
35
+ const checkboxChecked = [...html.matchAll(/<input type="checkbox"[^>]*name="[^"]*choice(\d+)"[^>]*checked="checked"/g)];
36
+ if (checkboxChecked.length > 0)
37
+ return checkboxChecked.map(m => m[1]);
38
+ // Match <input> with both name="*_answer" and type="text" in any attribute order
39
+ const textMatch = html.match(/<input[^>]*(?:name="[^"]*:_answer"|type="text")[^>]*(?:name="[^"]*:_answer"|type="text")[^>]*value="([^"]*)"/);
40
+ if (textMatch && textMatch[1] !== "")
41
+ return textMatch[1];
42
+ return null;
43
+ }
11
44
  export function registerQuizzesCommand(program) {
12
45
  const quizzesCmd = program.command("quizzes");
13
46
  quizzesCmd.description("Quiz operations");
@@ -43,45 +76,11 @@ export function registerQuizzesCommand(program) {
43
76
  },
44
77
  };
45
78
  }
46
- // Helper function to create session context (for open command only)
47
- async function createSessionContext(options, command) {
48
- const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
49
- const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
50
- const silent = outputFormat === "json" && !opts.verbose;
51
- const log = createLogger(opts.verbose, silent);
52
- const baseDir = getBaseDir();
53
- const sessionPath = path.resolve(baseDir, ".auth", "storage-state.json");
54
- if (!fs.existsSync(sessionPath)) {
55
- log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
56
- return null;
57
- }
58
- const config = {
59
- username: "",
60
- password: "",
61
- courseUrl: "",
62
- moodleBaseUrl: "https://ilearning.cycu.edu.tw",
63
- headless: !options.headed,
64
- slowMo: 0,
65
- authStatePath: sessionPath,
66
- ollamaBaseUrl: "",
67
- };
68
- log.info("啟動瀏覽器...");
69
- const { browser, context, page } = await launchAuthenticated(config, log);
70
- try {
71
- const session = await extractSessionInfo(page, config, log);
72
- return { log, page, session, browser, context };
73
- }
74
- catch (err) {
75
- await context.close();
76
- await browser.close();
77
- throw err;
78
- }
79
- }
80
79
  quizzesCmd
81
80
  .command("list")
82
- .description("List quizzes in a course")
81
+ .description("List incomplete quizzes in a course")
83
82
  .argument("<course-id>", "Course ID")
84
- .option("--available-only", "Show only available quizzes")
83
+ .option("--all", "Include completed quizzes")
85
84
  .option("--output <format>", "Output format: json|csv|table|silent")
86
85
  .action(async (courseId, options, command) => {
87
86
  const output = getOutputFormat(command);
@@ -91,16 +90,19 @@ export function registerQuizzesCommand(program) {
91
90
  return;
92
91
  }
93
92
  const quizzes = await getQuizzesByCoursesApi(apiContext.session, [parseInt(courseId, 10)]);
94
- // Note: API doesn't provide completion status, so --available-only shows all
95
- if (options.availableOnly) {
96
- apiContext.log.warn("--available-only is not supported in API mode, showing all quizzes");
97
- }
98
- formatAndOutput(quizzes, output, apiContext.log);
93
+ // Default: only show incomplete quizzes
94
+ const filtered = options.all ? quizzes : quizzes.filter(q => !q.isComplete);
95
+ const formattedQuizzes = filtered.map(({ courseId, ...q }) => ({
96
+ ...q,
97
+ timeClose: q.timeClose ? formatTimestamp(q.timeClose) : null,
98
+ }));
99
+ formatAndOutput(formattedQuizzes, output, apiContext.log);
99
100
  });
100
101
  quizzesCmd
101
102
  .command("list-all")
102
- .description("List all available quizzes across all courses")
103
+ .description("List all incomplete quizzes across all courses")
103
104
  .option("--level <type>", "Course level: in_progress (default) | all", "in_progress")
105
+ .option("--all", "Include completed quizzes")
104
106
  .option("--output <format>", "Output format: json|csv|table|silent")
105
107
  .action(async (options, command) => {
106
108
  const output = getOutputFormat(command);
@@ -121,13 +123,17 @@ export function registerQuizzesCommand(program) {
121
123
  const allQuizzes = [];
122
124
  for (const q of apiQuizzes) {
123
125
  const course = courseMap.get(q.courseId);
124
- if (course) {
126
+ if (course && (options.all || !q.isComplete)) {
125
127
  allQuizzes.push({
126
128
  courseName: course.fullname,
129
+ courseId: q.courseId,
127
130
  name: q.name,
128
131
  url: q.url,
129
- cmid: q.cmid,
132
+ quizid: q.quizid,
130
133
  isComplete: q.isComplete,
134
+ attemptsUsed: q.attemptsUsed,
135
+ maxAttempts: q.maxAttempts,
136
+ timeClose: q.timeClose ? formatTimestamp(q.timeClose) : null,
131
137
  });
132
138
  }
133
139
  }
@@ -135,26 +141,120 @@ export function registerQuizzesCommand(program) {
135
141
  formatAndOutput(allQuizzes, output, apiContext.log);
136
142
  });
137
143
  quizzesCmd
138
- .command("open")
139
- .description("Open a quiz URL in browser (manual mode)")
140
- .argument("<quiz-url>", "Quiz URL")
141
- .option("--headed", "Run browser in visible mode (default: true)")
142
- .action(async (quizUrl, options, command) => {
143
- const context = await createSessionContext({ ...options, headed: true }, command);
144
- if (!context) {
144
+ .command("start")
145
+ .description("Start a new quiz attempt")
146
+ .argument("<quiz-id>", "Quiz ID")
147
+ .option("--output <format>", "Output format: json|csv|table|silent")
148
+ .action(async (quizCmid, options, command) => {
149
+ const output = getOutputFormat(command);
150
+ const apiContext = await createApiContext(options, command);
151
+ if (!apiContext) {
152
+ process.exitCode = 1;
153
+ return;
154
+ }
155
+ try {
156
+ const result = await startQuizAttemptApi(apiContext.session, quizCmid);
157
+ const outputData = [{
158
+ attemptId: result.attempt.attemptid,
159
+ quizId: result.attempt.quizid,
160
+ state: result.attempt.state,
161
+ timeStart: formatTimestamp(result.attempt.timestart),
162
+ timeFinish: result.attempt.timefinish
163
+ ? formatTimestamp(result.attempt.timefinish)
164
+ : null,
165
+ isPreview: result.attempt.preview,
166
+ }];
167
+ apiContext.log.success(`Quiz attempt ${result.attempt.attemptid} started.`);
168
+ formatAndOutput(outputData, output, apiContext.log);
169
+ }
170
+ catch (error) {
171
+ apiContext.log.error(`Failed to start quiz attempt: ${error instanceof Error ? error.message : String(error)}`);
172
+ process.exitCode = 1;
173
+ }
174
+ });
175
+ quizzesCmd
176
+ .command("info")
177
+ .description("Get quiz attempt data and questions")
178
+ .argument("<attempt-id>", "Quiz attempt ID")
179
+ .option("--page <number>", "Page number", "0")
180
+ .option("--output <format>", "Output format: json|csv|table|silent")
181
+ .action(async (attemptId, options, command) => {
182
+ const output = getOutputFormat(command);
183
+ const apiContext = await createApiContext(options, command);
184
+ if (!apiContext) {
145
185
  process.exitCode = 1;
146
186
  return;
147
187
  }
148
- const { log, page, browser, context: browserContext } = context;
149
188
  try {
150
- log.info(`導航至測驗頁面: ${quizUrl}`);
151
- await page.goto(quizUrl, { waitUntil: "domcontentloaded" });
152
- log.info("瀏覽器已開啟,請手動完成測驗。");
153
- log.info("按 Ctrl+C 關閉瀏覽器。");
154
- await new Promise(() => { });
155
- }
156
- finally {
157
- await closeBrowserSafely(browser, browserContext);
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
+ });
203
+ const outputData = [{
204
+ attemptId: data.attempt.attemptid,
205
+ quizId: data.attempt.quizid,
206
+ state: data.attempt.state,
207
+ totalQuestions: questions.length,
208
+ questions,
209
+ }];
210
+ apiContext.log.success(`Retrieved attempt ${data.attempt.attemptid}`);
211
+ formatAndOutput(outputData, output, apiContext.log);
212
+ }
213
+ catch (error) {
214
+ apiContext.log.error(`Failed to get attempt data: ${error instanceof Error ? error.message : String(error)}`);
215
+ process.exitCode = 1;
216
+ }
217
+ });
218
+ quizzesCmd
219
+ .command("save")
220
+ .description("Save answers for a quiz attempt")
221
+ .argument("<attempt-id>", "Quiz attempt ID")
222
+ .argument("<answers>", "Answers JSON: [{slot:1,answer:\"0\"}] multichoice=number, multichoices=\"0,2\", shortanswer=\"text\"")
223
+ .option("--submit", "Submit the attempt after saving")
224
+ .option("--output <format>", "Output format: json|csv|table|silent")
225
+ .action(async (attemptId, answersJson, options, command) => {
226
+ const output = getOutputFormat(command);
227
+ const apiContext = await createApiContext(options, command);
228
+ if (!apiContext) {
229
+ process.exitCode = 1;
230
+ return;
231
+ }
232
+ let answers;
233
+ try {
234
+ answers = JSON.parse(answersJson);
235
+ }
236
+ catch {
237
+ apiContext.log.error("Invalid answers JSON. Expected format: [{\"slot\":1,\"answer\":\"0\"},...]");
238
+ process.exitCode = 1;
239
+ return;
240
+ }
241
+ try {
242
+ // Get attempt data to find uniqueid and sequencecheck values
243
+ const attemptData = await getQuizAttemptDataApi(apiContext.session, parseInt(attemptId), 0);
244
+ const uniqueId = attemptData.attempt.uniqueid ?? attemptData.attempt.attemptid;
245
+ const sequenceChecks = new Map();
246
+ for (const q of Object.values(attemptData.questions)) {
247
+ if (q.sequencecheck !== undefined) {
248
+ sequenceChecks.set(q.slot, q.sequencecheck);
249
+ }
250
+ }
251
+ const result = await processQuizAttemptApi(apiContext.session, parseInt(attemptId), uniqueId, answers, sequenceChecks, !!options.submit);
252
+ apiContext.log.success(`Attempt ${attemptId} state: ${result.state}`);
253
+ formatAndOutput([result], output, apiContext.log);
254
+ }
255
+ catch (error) {
256
+ apiContext.log.error(`Failed to submit attempt: ${error instanceof Error ? error.message : String(error)}`);
257
+ process.exitCode = 1;
158
258
  }
159
259
  });
160
260
  }
@@ -1 +1 @@
1
- {"version":3,"file":"skills.d.ts","sourceRoot":"","sources":["../../../src/src/commands/skills.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AA6CpC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CA0E5D"}
1
+ {"version":3,"file":"skills.d.ts","sourceRoot":"","sources":["../../../src/src/commands/skills.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AA2CpC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAuE5D"}
@@ -23,12 +23,10 @@ async function readSkillContent() {
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
25
  const localPath = path.resolve(normalized, "..", "..", "skills", SKILL_NAME, "SKILL.md");
26
- if (fs.existsSync(localPath)) {
27
- return fs.readFileSync(localPath, "utf-8");
28
- }
26
+ return await fs.promises.readFile(localPath, "utf-8");
29
27
  }
30
28
  catch {
31
- // import.meta.url may be unavailable in some environments
29
+ // import.meta.url may be unavailable in some environments, or file doesn't exist
32
30
  }
33
31
  // Fallback: fetch from GitHub
34
32
  const res = await fetch(GITHUB_RAW_URL, { headers: { "User-Agent": "openape-cli" } });
@@ -81,10 +79,8 @@ export function registerSkillsCommand(program) {
81
79
  for (const target of targets) {
82
80
  console.log(`Installing to ${target.name} (${target.path})...`);
83
81
  const destDir = path.join(target.path, SKILL_NAME);
84
- if (!fs.existsSync(destDir)) {
85
- fs.mkdirSync(destDir, { recursive: true });
86
- }
87
- fs.writeFileSync(path.join(destDir, "SKILL.md"), content, "utf-8");
82
+ await fs.promises.mkdir(destDir, { recursive: true });
83
+ await fs.promises.writeFile(path.join(destDir, "SKILL.md"), content, "utf-8");
88
84
  console.log(` \x1b[32m✔\x1b[0m ${SKILL_NAME} installed!`);
89
85
  }
90
86
  console.log("\nDone!");
@@ -0,0 +1,3 @@
1
+ import { Command } from "commander";
2
+ export declare function registerUploadCommand(program: Command): void;
3
+ //# sourceMappingURL=upload.d.ts.map
@@ -0,0 +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"}