@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.
- package/README.md +30 -5
- package/esm/deno.js +1 -1
- package/esm/src/commands/announcements.d.ts.map +1 -1
- package/esm/src/commands/announcements.js +13 -14
- package/esm/src/commands/assignments.d.ts +3 -0
- package/esm/src/commands/assignments.d.ts.map +1 -0
- package/esm/src/commands/assignments.js +230 -0
- package/esm/src/commands/auth.d.ts.map +1 -1
- package/esm/src/commands/auth.js +21 -1
- package/esm/src/commands/calendar.d.ts.map +1 -1
- package/esm/src/commands/calendar.js +17 -18
- package/esm/src/commands/courses.js +3 -3
- package/esm/src/commands/forums.d.ts.map +1 -1
- package/esm/src/commands/forums.js +125 -33
- package/esm/src/commands/materials.d.ts.map +1 -1
- package/esm/src/commands/materials.js +6 -38
- package/esm/src/commands/quizzes.d.ts.map +1 -1
- package/esm/src/commands/quizzes.js +165 -65
- package/esm/src/commands/skills.d.ts.map +1 -1
- package/esm/src/commands/skills.js +4 -8
- package/esm/src/commands/upload.d.ts +3 -0
- package/esm/src/commands/upload.d.ts.map +1 -0
- package/esm/src/commands/upload.js +58 -0
- package/esm/src/commands/videos.d.ts.map +1 -1
- package/esm/src/commands/videos.js +5 -4
- package/esm/src/index.d.ts.map +1 -1
- package/esm/src/index.js +12 -1
- package/esm/src/lib/auth.d.ts +23 -1
- package/esm/src/lib/auth.d.ts.map +1 -1
- package/esm/src/lib/auth.js +36 -3
- package/esm/src/lib/moodle.d.ts +179 -1
- package/esm/src/lib/moodle.d.ts.map +1 -1
- package/esm/src/lib/moodle.js +481 -13
- package/esm/src/lib/types.d.ts +81 -164
- package/esm/src/lib/types.d.ts.map +1 -1
- package/esm/src/lib/types.js +1 -0
- package/esm/src/lib/utils.d.ts +20 -0
- package/esm/src/lib/utils.d.ts.map +1 -1
- package/esm/src/lib/utils.js +48 -1
- package/package.json +1 -1
- package/script/deno.js +1 -1
- package/script/src/commands/announcements.d.ts.map +1 -1
- package/script/src/commands/announcements.js +12 -13
- package/script/src/commands/assignments.d.ts +3 -0
- package/script/src/commands/assignments.d.ts.map +1 -0
- package/script/src/commands/assignments.js +269 -0
- package/script/src/commands/auth.d.ts.map +1 -1
- package/script/src/commands/auth.js +20 -0
- package/script/src/commands/calendar.d.ts.map +1 -1
- package/script/src/commands/calendar.js +16 -17
- package/script/src/commands/courses.js +2 -2
- package/script/src/commands/forums.d.ts.map +1 -1
- package/script/src/commands/forums.js +125 -33
- package/script/src/commands/materials.d.ts.map +1 -1
- package/script/src/commands/materials.js +7 -39
- package/script/src/commands/quizzes.d.ts.map +1 -1
- package/script/src/commands/quizzes.js +163 -63
- package/script/src/commands/skills.d.ts.map +1 -1
- package/script/src/commands/skills.js +4 -8
- package/script/src/commands/upload.d.ts +3 -0
- package/script/src/commands/upload.d.ts.map +1 -0
- package/script/src/commands/upload.js +64 -0
- package/script/src/commands/videos.d.ts.map +1 -1
- package/script/src/commands/videos.js +5 -4
- package/script/src/index.d.ts.map +1 -1
- package/script/src/index.js +12 -1
- package/script/src/lib/auth.d.ts +23 -1
- package/script/src/lib/auth.d.ts.map +1 -1
- package/script/src/lib/auth.js +70 -3
- package/script/src/lib/moodle.d.ts +179 -1
- package/script/src/lib/moodle.d.ts.map +1 -1
- package/script/src/lib/moodle.js +493 -13
- package/script/src/lib/types.d.ts +81 -164
- package/script/src/lib/types.d.ts.map +1 -1
- package/script/src/lib/types.js +1 -0
- package/script/src/lib/utils.d.ts +20 -0
- package/script/src/lib/utils.d.ts.map +1 -1
- package/script/src/lib/utils.js +52 -0
- 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
|
-
|
|
73
|
+
console.log(JSON.stringify({
|
|
77
74
|
status: "success",
|
|
78
75
|
timestamp: new Date().toISOString(),
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
116
|
+
console.log(JSON.stringify({
|
|
120
117
|
status: "success",
|
|
121
118
|
timestamp: new Date().toISOString(),
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
173
|
-
}))
|
|
174
|
-
|
|
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:
|
|
204
|
-
modified:
|
|
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,
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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.
|
|
130
|
-
log.success(` Downloaded: ${filename} (${(stats.size
|
|
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;
|
|
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(/ /g, " ")
|
|
14
|
+
.replace(/&/g, "&")
|
|
15
|
+
.replace(/</g, "<")
|
|
16
|
+
.replace(/>/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("--
|
|
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
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
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("
|
|
139
|
-
.description("
|
|
140
|
-
.argument("<quiz-
|
|
141
|
-
.option("--
|
|
142
|
-
.action(async (
|
|
143
|
-
const
|
|
144
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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 @@
|
|
|
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"}
|