@mo7yw4ng/openape 1.0.0 → 1.0.2

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 (133) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +15 -5
  3. package/esm/_dnt.polyfills.d.ts +83 -6
  4. package/esm/_dnt.polyfills.d.ts.map +1 -0
  5. package/esm/_dnt.polyfills.js +127 -1
  6. package/esm/_dnt.shims.d.ts +1 -0
  7. package/esm/_dnt.shims.d.ts.map +1 -0
  8. package/esm/deno.d.ts +2 -0
  9. package/esm/deno.d.ts.map +1 -0
  10. package/esm/deno.js +2 -1
  11. package/esm/src/commands/announcements.d.ts +3 -0
  12. package/esm/src/commands/announcements.d.ts.map +1 -0
  13. package/esm/src/commands/announcements.js +135 -0
  14. package/esm/src/commands/auth.d.ts +3 -0
  15. package/esm/src/commands/auth.d.ts.map +1 -0
  16. package/esm/src/commands/auth.js +264 -0
  17. package/esm/src/commands/calendar.d.ts +3 -0
  18. package/esm/src/commands/calendar.d.ts.map +1 -0
  19. package/esm/src/commands/calendar.js +180 -0
  20. package/esm/src/commands/courses.d.ts +3 -0
  21. package/esm/src/commands/courses.d.ts.map +1 -0
  22. package/esm/src/commands/courses.js +348 -0
  23. package/esm/src/commands/forums.d.ts +3 -0
  24. package/esm/src/commands/forums.d.ts.map +1 -0
  25. package/esm/src/commands/forums.js +231 -0
  26. package/esm/src/commands/grades.d.ts +3 -0
  27. package/esm/src/commands/grades.d.ts.map +1 -0
  28. package/esm/src/commands/grades.js +121 -0
  29. package/esm/src/commands/materials.d.ts +3 -0
  30. package/esm/src/commands/materials.d.ts.map +1 -0
  31. package/esm/src/commands/materials.js +362 -0
  32. package/esm/src/commands/quizzes.d.ts +3 -0
  33. package/esm/src/commands/quizzes.d.ts.map +1 -0
  34. package/esm/src/commands/quizzes.js +160 -0
  35. package/esm/src/commands/skills.d.ts +3 -0
  36. package/esm/src/commands/skills.d.ts.map +1 -0
  37. package/esm/src/commands/skills.js +110 -0
  38. package/esm/src/commands/videos.d.ts +3 -0
  39. package/esm/src/commands/videos.d.ts.map +1 -0
  40. package/esm/src/commands/videos.js +302 -0
  41. package/esm/src/index.d.ts +27 -0
  42. package/esm/src/index.d.ts.map +1 -0
  43. package/esm/src/index.js +149 -0
  44. package/esm/src/lib/auth.d.ts +25 -0
  45. package/esm/src/lib/auth.d.ts.map +1 -0
  46. package/esm/src/lib/auth.js +194 -0
  47. package/esm/src/lib/config.d.ts +6 -0
  48. package/esm/src/lib/config.d.ts.map +1 -0
  49. package/esm/src/lib/config.js +36 -0
  50. package/esm/src/lib/logger.d.ts +3 -0
  51. package/esm/src/lib/logger.d.ts.map +1 -0
  52. package/esm/src/lib/logger.js +24 -0
  53. package/esm/src/lib/moodle.d.ts +205 -0
  54. package/esm/src/lib/moodle.d.ts.map +1 -0
  55. package/esm/src/lib/moodle.js +690 -0
  56. package/esm/src/lib/session.d.ts +8 -0
  57. package/esm/src/lib/session.d.ts.map +1 -0
  58. package/esm/src/lib/session.js +42 -0
  59. package/esm/src/lib/token.d.ts +38 -0
  60. package/esm/src/lib/token.d.ts.map +1 -0
  61. package/esm/src/lib/token.js +178 -0
  62. package/esm/src/lib/types.d.ts +271 -0
  63. package/esm/src/lib/types.d.ts.map +1 -0
  64. package/esm/src/lib/types.js +1 -0
  65. package/esm/src/lib/utils.d.ts +17 -0
  66. package/esm/src/lib/utils.d.ts.map +1 -0
  67. package/esm/src/lib/utils.js +51 -0
  68. package/package.json +7 -3
  69. package/script/_dnt.polyfills.d.ts +83 -6
  70. package/script/_dnt.polyfills.d.ts.map +1 -0
  71. package/script/_dnt.polyfills.js +128 -0
  72. package/script/_dnt.shims.d.ts +1 -0
  73. package/script/_dnt.shims.d.ts.map +1 -0
  74. package/script/deno.d.ts +2 -0
  75. package/script/deno.d.ts.map +1 -0
  76. package/script/deno.js +2 -1
  77. package/script/src/commands/announcements.d.ts +1 -0
  78. package/script/src/commands/announcements.d.ts.map +1 -0
  79. package/script/src/commands/announcements.js +75 -222
  80. package/script/src/commands/auth.d.ts +1 -0
  81. package/script/src/commands/auth.d.ts.map +1 -0
  82. package/script/src/commands/auth.js +49 -17
  83. package/script/src/commands/calendar.d.ts +1 -0
  84. package/script/src/commands/calendar.d.ts.map +1 -0
  85. package/script/src/commands/calendar.js +112 -301
  86. package/script/src/commands/courses.d.ts +1 -0
  87. package/script/src/commands/courses.d.ts.map +1 -0
  88. package/script/src/commands/courses.js +43 -173
  89. package/script/src/commands/forums.d.ts +1 -0
  90. package/script/src/commands/forums.d.ts.map +1 -0
  91. package/script/src/commands/forums.js +145 -311
  92. package/script/src/commands/grades.d.ts +1 -0
  93. package/script/src/commands/grades.d.ts.map +1 -0
  94. package/script/src/commands/grades.js +62 -194
  95. package/script/src/commands/materials.d.ts +1 -0
  96. package/script/src/commands/materials.d.ts.map +1 -0
  97. package/script/src/commands/materials.js +111 -166
  98. package/script/src/commands/quizzes.d.ts +1 -0
  99. package/script/src/commands/quizzes.d.ts.map +1 -0
  100. package/script/src/commands/quizzes.js +40 -102
  101. package/script/src/commands/skills.d.ts +1 -0
  102. package/script/src/commands/skills.d.ts.map +1 -0
  103. package/script/src/commands/skills.js +17 -18
  104. package/script/src/commands/videos.d.ts +1 -0
  105. package/script/src/commands/videos.d.ts.map +1 -0
  106. package/script/src/commands/videos.js +26 -52
  107. package/script/src/index.d.ts +1 -0
  108. package/script/src/index.d.ts.map +1 -0
  109. package/script/src/index.js +4 -4
  110. package/script/src/lib/auth.d.ts +1 -0
  111. package/script/src/lib/auth.d.ts.map +1 -0
  112. package/script/src/lib/auth.js +9 -10
  113. package/script/src/lib/config.d.ts +1 -0
  114. package/script/src/lib/config.d.ts.map +1 -0
  115. package/script/src/lib/config.js +6 -7
  116. package/script/src/lib/logger.d.ts +1 -0
  117. package/script/src/lib/logger.d.ts.map +1 -0
  118. package/script/src/lib/logger.js +1 -2
  119. package/script/src/lib/moodle.d.ts +25 -54
  120. package/script/src/lib/moodle.d.ts.map +1 -0
  121. package/script/src/lib/moodle.js +103 -324
  122. package/script/src/lib/session.d.ts +1 -0
  123. package/script/src/lib/session.d.ts.map +1 -0
  124. package/script/src/lib/session.js +3 -29
  125. package/script/src/lib/token.d.ts +16 -5
  126. package/script/src/lib/token.d.ts.map +1 -0
  127. package/script/src/lib/token.js +71 -36
  128. package/script/src/lib/types.d.ts +10 -0
  129. package/script/src/lib/types.d.ts.map +1 -0
  130. package/script/src/lib/utils.d.ts +12 -0
  131. package/script/src/lib/utils.d.ts.map +1 -0
  132. package/script/src/lib/utils.js +57 -11
  133. package/skills/openape/SKILL.md +331 -328
@@ -0,0 +1,348 @@
1
+ import { getBaseDir } from "../lib/utils.js";
2
+ import { getEnrolledCoursesApi } from "../lib/moodle.js";
3
+ import { createLogger } from "../lib/logger.js";
4
+ import { loadWsToken } from "../lib/token.js";
5
+ import { formatAndOutput } from "../index.js";
6
+ import path from "node:path";
7
+ import fs from "node:fs";
8
+ export function registerCoursesCommand(program) {
9
+ const coursesCmd = program.command("courses");
10
+ coursesCmd.description("Course operations");
11
+ // Helper to get output format from global or local options
12
+ function getOutputFormat(command) {
13
+ const opts = command.optsWithGlobals();
14
+ return opts.output || "json";
15
+ }
16
+ // Pure API context - no browser required (fast!)
17
+ async function createApiContext(options, command) {
18
+ const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
19
+ const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
20
+ const silent = outputFormat === "json" && !opts.verbose;
21
+ const log = createLogger(opts.verbose, silent);
22
+ const baseDir = getBaseDir();
23
+ const sessionPath = path.resolve(baseDir, ".auth", "storage-state.json");
24
+ // Check if session exists
25
+ if (!fs.existsSync(sessionPath)) {
26
+ log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
27
+ log.info(`Session 預期位置: ${sessionPath}`);
28
+ return null;
29
+ }
30
+ // Try to load WS token
31
+ const wsToken = loadWsToken(sessionPath);
32
+ if (!wsToken) {
33
+ log.error("未找到 WS token。請先執行 'openape auth login' 進行登入。");
34
+ return null;
35
+ }
36
+ return {
37
+ log,
38
+ session: {
39
+ wsToken,
40
+ moodleBaseUrl: "https://ilearning.cycu.edu.tw",
41
+ },
42
+ };
43
+ }
44
+ coursesCmd
45
+ .command("list")
46
+ .description("List enrolled courses")
47
+ .option("--incomplete-only", "Show only incomplete courses")
48
+ .option("--output <format>", "Output format: json|csv|table|silent")
49
+ .option("--level <type>", "Course level: in_progress (default) | past | future | all", "in_progress")
50
+ .action(async (options, command) => {
51
+ const output = getOutputFormat(command);
52
+ const apiContext = await createApiContext(options, command);
53
+ if (!apiContext) {
54
+ process.exitCode = 1;
55
+ return;
56
+ }
57
+ // Map level to classification
58
+ const classification = options.level === "all" ? undefined :
59
+ options.level === "past" ? "past" :
60
+ options.level === "future" ? "future" : "inprogress";
61
+ const courses = await getEnrolledCoursesApi(apiContext.session, {
62
+ classification,
63
+ });
64
+ let filteredCourses = courses;
65
+ if (options.incompleteOnly) {
66
+ filteredCourses = courses.filter(c => (c.progress ?? 0) < 100);
67
+ }
68
+ formatAndOutput(filteredCourses, output, apiContext.log);
69
+ });
70
+ coursesCmd
71
+ .command("info")
72
+ .description("Show detailed course information")
73
+ .argument("<course-id>", "Course ID")
74
+ .option("--output <format>", "Output format: json|csv|table|silent")
75
+ .action(async (courseId, options, command) => {
76
+ const output = getOutputFormat(command);
77
+ const apiContext = await createApiContext(options, command);
78
+ if (!apiContext) {
79
+ process.exitCode = 1;
80
+ return;
81
+ }
82
+ const courses = await getEnrolledCoursesApi(apiContext.session);
83
+ const course = courses.find(c => c.id === parseInt(courseId, 10));
84
+ if (!course) {
85
+ apiContext.log.error(`Course not found: ${courseId}`);
86
+ process.exitCode = 1;
87
+ return;
88
+ }
89
+ formatAndOutput(course, output, apiContext.log);
90
+ });
91
+ coursesCmd
92
+ .command("progress")
93
+ .description("Show course progress")
94
+ .argument("<course-id>", "Course ID")
95
+ .option("--output <format>", "Output format: json|csv|table|silent")
96
+ .action(async (courseId, options, command) => {
97
+ const output = getOutputFormat(command);
98
+ const apiContext = await createApiContext(options, command);
99
+ if (!apiContext) {
100
+ process.exitCode = 1;
101
+ return;
102
+ }
103
+ const courses = await getEnrolledCoursesApi(apiContext.session);
104
+ const course = courses.find(c => c.id === parseInt(courseId, 10));
105
+ if (!course) {
106
+ apiContext.log.error(`Course not found: ${courseId}`);
107
+ process.exitCode = 1;
108
+ return;
109
+ }
110
+ const progressData = {
111
+ courseId: course.id,
112
+ courseName: course.fullname,
113
+ progress: course.progress ?? 0,
114
+ startDate: course.startdate ? new Date(course.startdate * 1000).toISOString() : null,
115
+ endDate: course.enddate ? new Date(course.enddate * 1000).toISOString() : null,
116
+ };
117
+ formatAndOutput(progressData, output, apiContext.log);
118
+ });
119
+ // Helper function to fetch syllabus from CMAP using GWT-RPC API
120
+ async function fetchSyllabus(shortname) {
121
+ try {
122
+ const parts = shortname.split("_");
123
+ if (parts.length < 2) {
124
+ return { error: "Invalid course shortname format" };
125
+ }
126
+ const [yearTerm, opCode] = parts;
127
+ // Build GWT-RPC request body
128
+ // Format: 7|0|8|<base_url>|<permutation>|<service>|<method>|<param_types>|<params>...|1|2|3|4|3|5|5|5|6|7|8|
129
+ const gwtBody = `7|0|8|https://cmap.cycu.edu.tw:8443/Syllabus/syllabus/|339796D6E7B561A6465F5E9B5F4943FA|com.sanfong.syllabus.shared.SyllabusClientService|findClassTargetByYearAndOpCode|java.lang.String/2004016611|${yearTerm}|${opCode}|zh_TW|1|2|3|4|3|5|5|5|6|7|8|`;
130
+ const response = await fetch("https://cmap.cycu.edu.tw:8443/Syllabus/syllabus/syllabusClientService", {
131
+ method: "POST",
132
+ headers: {
133
+ "X-GWT-Permutation": "339796D6E7B561A6465F5E9B5F4943FA",
134
+ "Accept": "text/x-gwt-rpc, */*; q=0.01",
135
+ "Content-Type": "text/x-gwt-rpc; charset=UTF-8",
136
+ },
137
+ body: gwtBody,
138
+ });
139
+ if (!response.ok) {
140
+ return { error: `HTTP ${response.status}`, url: "https://cmap.cycu.edu.tw:8443/Syllabus/syllabus/syllabusClientService" };
141
+ }
142
+ const rawText = await response.text();
143
+ // GWT-RPC response format: //OK[...data...]
144
+ if (!rawText.startsWith("//OK")) {
145
+ return { error: "Invalid GWT-RPC response", rawResponse: rawText.slice(0, 200) };
146
+ }
147
+ // Extract the JSON array part from the GWT response
148
+ // Response format: //OK[data1,data2,...]
149
+ const content = rawText.slice(4); // Remove "//OK"
150
+ // Parse the GWT string table - GWT uses a special format where strings are escaped
151
+ // Format: ["string1","string2",...] or [123,"string2",...]
152
+ const stringTable = [];
153
+ // Simple parser for GWT string table
154
+ let current = "";
155
+ let inString = false;
156
+ let escaped = false;
157
+ for (let i = 0; i < content.length; i++) {
158
+ const char = content[i];
159
+ if (escaped) {
160
+ // Handle escape sequences
161
+ switch (char) {
162
+ case 'n':
163
+ current += '\n';
164
+ break;
165
+ case 'r':
166
+ current += '\r';
167
+ break;
168
+ case 't':
169
+ current += '\t';
170
+ break;
171
+ case '"':
172
+ current += '"';
173
+ break;
174
+ case '\\':
175
+ current += '\\';
176
+ break;
177
+ case '0':
178
+ current += '\0';
179
+ break;
180
+ default:
181
+ // Unknown escape, just append the char
182
+ current += char;
183
+ }
184
+ escaped = false;
185
+ continue;
186
+ }
187
+ if (char === "\\") {
188
+ escaped = true;
189
+ continue;
190
+ }
191
+ if (char === '"') {
192
+ inString = !inString;
193
+ if (!inString && current.length > 0) {
194
+ stringTable.push(current);
195
+ current = "";
196
+ }
197
+ continue;
198
+ }
199
+ if (inString) {
200
+ current += char;
201
+ }
202
+ }
203
+ // Parse schedule from string table
204
+ // Strategy: Find week numbers (1-18), extract title (previous field) and date
205
+ const schedule = [];
206
+ const datePattern = /^\d{4}-\d{2}-\d{2}$/;
207
+ // Track processed indices to avoid duplicates
208
+ const processedIndices = new Set();
209
+ for (let i = 0; i < stringTable.length; i++) {
210
+ const s = stringTable[i];
211
+ // Look for week numbers (1-18)
212
+ if (/^[1-9]$|^1[0-8]$/.test(s) && !processedIndices.has(i)) {
213
+ const week = s;
214
+ let date = "";
215
+ let title = "";
216
+ // Previous field is the title
217
+ if (i - 1 >= 0 && !processedIndices.has(i - 1)) {
218
+ title = stringTable[i - 1];
219
+ }
220
+ // For week 1 & 2, find date before the week number
221
+ // For week 18, also look before (last week has no "next week")
222
+ // For other weeks (3-17), next field is next week's date
223
+ if (week === "1" || week === "2" || week === "18") {
224
+ // Look backwards for date pattern (search further back for week 18)
225
+ const maxLookback = week === "18" ? 15 : 6;
226
+ for (let j = i - 1; j >= Math.max(0, i - maxLookback); j--) {
227
+ if (datePattern.test(stringTable[j]) && !processedIndices.has(j)) {
228
+ date = stringTable[j];
229
+ processedIndices.add(j);
230
+ break;
231
+ }
232
+ }
233
+ }
234
+ else {
235
+ // Week 3-17: look for next week number, then get date before it
236
+ for (let j = i + 1; j < Math.min(i + 10, stringTable.length); j++) {
237
+ if (/^[1-9]$|^1[0-8]$/.test(stringTable[j]) && !processedIndices.has(j)) {
238
+ // Found next week, look before it for date
239
+ for (let k = j - 1; k >= Math.max(0, j - 6); k--) {
240
+ if (datePattern.test(stringTable[k]) && !processedIndices.has(k)) {
241
+ date = stringTable[k];
242
+ processedIndices.add(k);
243
+ break;
244
+ }
245
+ }
246
+ break;
247
+ }
248
+ }
249
+ }
250
+ // Clean up title
251
+ title = title.trim()
252
+ .replace(/[\r\n]+/g, ' ')
253
+ .replace(/,+$/, '')
254
+ .trim()
255
+ .slice(0, 200);
256
+ // Only add if we have title (date is optional, will be inferred if missing)
257
+ if (title.length > 1) {
258
+ // If no date found, try to infer from the last added date
259
+ if (date.length === 0 && schedule.length > 0) {
260
+ const lastEntry = schedule[schedule.length - 1];
261
+ const lastDate = new Date(lastEntry.date);
262
+ const nextDate = new Date(lastDate.getTime() + 7 * 24 * 60 * 60 * 1000);
263
+ const year = nextDate.getFullYear();
264
+ const month = String(nextDate.getMonth() + 1).padStart(2, "0");
265
+ const day = String(nextDate.getDate()).padStart(2, "0");
266
+ date = `${year}-${month}-${day}`;
267
+ }
268
+ schedule.push({
269
+ week,
270
+ date,
271
+ title,
272
+ });
273
+ }
274
+ processedIndices.add(i);
275
+ }
276
+ }
277
+ // Sort by date to maintain order
278
+ schedule.sort((a, b) => a.date.localeCompare(b.date));
279
+ // Extract course info from string table
280
+ const result = {
281
+ yearTerm,
282
+ opCode,
283
+ url: `https://cmap.cycu.edu.tw:8443/Syllabus/CoursePreview.html?yearTerm=${yearTerm}&opCode=${opCode}&locale=zh_TW`,
284
+ schedule,
285
+ };
286
+ // Try to find instructor (look for common patterns)
287
+ for (let i = 0; i < stringTable.length; i++) {
288
+ const s = stringTable[i];
289
+ if (s.includes("教授") || s.includes("老師") || s.includes("教師") || s.includes("Instructor")) {
290
+ result.instructor = s;
291
+ break;
292
+ }
293
+ }
294
+ return result;
295
+ }
296
+ catch (e) {
297
+ return { error: e instanceof Error ? e.message : String(e) };
298
+ }
299
+ }
300
+ coursesCmd
301
+ .command("syllabus")
302
+ .description("Show course syllabus (from CMAP)")
303
+ .argument("<course-id>", "Course ID")
304
+ .option("--output <format>", "Output format: json|csv|table|silent")
305
+ .action(async (courseId, options, command) => {
306
+ const output = getOutputFormat(command);
307
+ const apiContext = await createApiContext(options, command);
308
+ if (!apiContext) {
309
+ process.exitCode = 1;
310
+ return;
311
+ }
312
+ try {
313
+ const courses = await getEnrolledCoursesApi(apiContext.session);
314
+ const course = courses.find(c => c.id === parseInt(courseId, 10));
315
+ if (!course) {
316
+ apiContext.log.error(`Course not found: ${courseId}`);
317
+ process.exitCode = 1;
318
+ return;
319
+ }
320
+ // Fetch syllabus from CMAP
321
+ const syllabus = await fetchSyllabus(course.shortname);
322
+ if (!syllabus) {
323
+ apiContext.log.warn(`Syllabus not found for course: ${course.shortname}`);
324
+ // Return course info at least
325
+ formatAndOutput({
326
+ courseId: course.id,
327
+ shortname: course.shortname,
328
+ fullname: course.fullname,
329
+ note: "Syllabus not available from CMAP",
330
+ }, output, apiContext.log);
331
+ return;
332
+ }
333
+ // Combine course info with syllabus
334
+ const result = {
335
+ courseId: course.id,
336
+ shortname: course.shortname,
337
+ fullname: course.fullname,
338
+ ...syllabus,
339
+ };
340
+ formatAndOutput(result, output, apiContext.log);
341
+ }
342
+ catch (e) {
343
+ const msg = e instanceof Error ? e.message : String(e);
344
+ apiContext.log.error(`Error fetching syllabus: ${msg}`);
345
+ process.exitCode = 1;
346
+ }
347
+ });
348
+ }
@@ -0,0 +1,3 @@
1
+ import { Command } from "commander";
2
+ export declare function registerForumsCommand(program: Command): void;
3
+ //# sourceMappingURL=forums.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"forums.d.ts","sourceRoot":"","sources":["../../../src/src/commands/forums.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAiBpC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CA0Q5D"}
@@ -0,0 +1,231 @@
1
+ import { getBaseDir, stripHtmlTags } from "../lib/utils.js";
2
+ import { getEnrolledCoursesApi, getForumsApi, getForumDiscussionsApi, getDiscussionPostsApi } from "../lib/moodle.js";
3
+ import { createLogger } from "../lib/logger.js";
4
+ import { loadWsToken, loadSesskey } from "../lib/token.js";
5
+ import path from "node:path";
6
+ import fs from "node:fs";
7
+ export function registerForumsCommand(program) {
8
+ const forumsCmd = program.command("forums");
9
+ forumsCmd.description("Forum operations");
10
+ function getOutputFormat(command) {
11
+ const opts = command.optsWithGlobals();
12
+ return opts.output || "json";
13
+ }
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
+ // Try to load sesskey from cache
35
+ const sesskey = loadSesskey(sessionPath) || undefined;
36
+ return {
37
+ log,
38
+ session: {
39
+ wsToken,
40
+ moodleBaseUrl: "https://ilearning.cycu.edu.tw",
41
+ sesskey,
42
+ },
43
+ };
44
+ }
45
+ forumsCmd
46
+ .command("list")
47
+ .description("List forums from in-progress courses")
48
+ .option("--output <format>", "Output format: json|csv|table|silent")
49
+ .action(async (options, command) => {
50
+ const apiContext = await createApiContext(options, command);
51
+ if (!apiContext) {
52
+ process.exitCode = 1;
53
+ return;
54
+ }
55
+ const courses = await getEnrolledCoursesApi(apiContext.session, {
56
+ classification: "inprogress",
57
+ });
58
+ // Get forums via WS API (no browser needed!)
59
+ const courseIds = courses.map(c => c.id);
60
+ const wsForums = await getForumsApi(apiContext.session, courseIds);
61
+ const allForums = [];
62
+ for (const wsForum of wsForums) {
63
+ const course = courses.find(c => c.id === wsForum.courseid);
64
+ if (course) {
65
+ allForums.push({
66
+ course_id: wsForum.courseid,
67
+ course_name: course.fullname,
68
+ cmid: wsForum.cmid.toString(),
69
+ forum_id: wsForum.id,
70
+ name: wsForum.name,
71
+ // url: `https://ilearning.cycu.edu.tw/mod/forum/view.php?id=${wsForum.cmid}`,
72
+ });
73
+ }
74
+ }
75
+ const result = {
76
+ status: "success",
77
+ timestamp: new Date().toISOString(),
78
+ forums: allForums,
79
+ summary: {
80
+ total_courses: courses.length,
81
+ total_forums: allForums.length,
82
+ },
83
+ };
84
+ console.log(JSON.stringify(result));
85
+ });
86
+ forumsCmd
87
+ .command("list-all")
88
+ .description("List all forums across all courses")
89
+ .option("--level <type>", "Course level: in_progress (default) | all", "in_progress")
90
+ .option("--output <format>", "Output format: json|csv|table|silent")
91
+ .action(async (options, command) => {
92
+ const apiContext = await createApiContext(options, command);
93
+ if (!apiContext) {
94
+ process.exitCode = 1;
95
+ return;
96
+ }
97
+ const classification = options.level === "all" ? undefined : "inprogress";
98
+ const courses = await getEnrolledCoursesApi(apiContext.session, {
99
+ classification,
100
+ });
101
+ // Get forums via WS API (no browser needed!)
102
+ const courseIds = courses.map(c => c.id);
103
+ const wsForums = await getForumsApi(apiContext.session, courseIds);
104
+ const allForums = [];
105
+ for (const wsForum of wsForums) {
106
+ const course = courses.find(c => c.id === wsForum.courseid);
107
+ if (course) {
108
+ allForums.push({
109
+ course_id: wsForum.courseid,
110
+ course_name: course.fullname,
111
+ cmid: wsForum.cmid.toString(),
112
+ forum_id: wsForum.id,
113
+ name: wsForum.name,
114
+ url: `https://ilearning.cycu.edu.tw/mod/forum/view.php?id=${wsForum.cmid}`,
115
+ });
116
+ }
117
+ }
118
+ const result = {
119
+ status: "success",
120
+ timestamp: new Date().toISOString(),
121
+ forums: allForums,
122
+ summary: {
123
+ total_courses: courses.length,
124
+ total_forums: allForums.length,
125
+ },
126
+ };
127
+ console.log(JSON.stringify(result));
128
+ });
129
+ forumsCmd
130
+ .command("discussions")
131
+ .description("List discussions in a forum (use forum ID)")
132
+ .argument("<forum-id>", "Forum ID")
133
+ .option("--output <format>", "Output format: json|csv|table|silent")
134
+ .option("--msg", "Include message content in response")
135
+ .action(async (forumId, options, command) => {
136
+ const apiContext = await createApiContext(options, command);
137
+ if (!apiContext) {
138
+ process.exitCode = 1;
139
+ return;
140
+ }
141
+ // Get courses via WS API
142
+ const courses = await getEnrolledCoursesApi(apiContext.session, {
143
+ classification: "inprogress",
144
+ });
145
+ // Get forums via WS API
146
+ const courseIds = courses.map(c => c.id);
147
+ const wsForums = await getForumsApi(apiContext.session, courseIds);
148
+ // Find forum by cmid or instance ID
149
+ const targetForum = wsForums.find(f => f.cmid.toString() === forumId || f.id === parseInt(forumId, 10));
150
+ if (!targetForum) {
151
+ console.log(JSON.stringify({ status: "error", error: "Forum not found" }));
152
+ process.exitCode = 1;
153
+ return;
154
+ }
155
+ const course = courses.find(c => c.id === targetForum.courseid);
156
+ // Get discussions via WS API
157
+ const discussions = await getForumDiscussionsApi(apiContext.session, targetForum.id);
158
+ const result = {
159
+ status: "success",
160
+ timestamp: new Date().toISOString(),
161
+ forum_id: targetForum.id,
162
+ forum_name: targetForum.name,
163
+ course_id: course?.id,
164
+ course_name: course?.fullname,
165
+ discussions: discussions.map(d => {
166
+ const discussion = {
167
+ id: d.id,
168
+ name: d.name,
169
+ user_id: d.userId,
170
+ time_modified: d.timeModified,
171
+ post_count: d.postCount,
172
+ unread: d.unread,
173
+ };
174
+ if (options.msg) {
175
+ discussion.message = stripHtmlTags(d.message || "");
176
+ }
177
+ return discussion;
178
+ }),
179
+ summary: {
180
+ total_discussions: discussions.length,
181
+ },
182
+ };
183
+ console.log(JSON.stringify(result));
184
+ });
185
+ forumsCmd
186
+ .command("posts")
187
+ .description("Show posts in a discussion")
188
+ .argument("<discussion-id>", "Discussion ID")
189
+ .option("--output <format>", "Output format: json|csv|table|silent")
190
+ .action(async (discussionId, options, command) => {
191
+ const output = getOutputFormat(command);
192
+ const apiContext = await createApiContext(options, command);
193
+ if (!apiContext) {
194
+ process.exitCode = 1;
195
+ return;
196
+ }
197
+ const posts = await getDiscussionPostsApi(apiContext.session, parseInt(discussionId, 10));
198
+ if (output === "json") {
199
+ const result = {
200
+ status: "success",
201
+ timestamp: new Date().toISOString(),
202
+ discussion_id: discussionId,
203
+ posts: posts.map(p => ({
204
+ id: p.id,
205
+ subject: p.subject,
206
+ author: p.author,
207
+ author_id: p.authorId,
208
+ created: new Date(p.created * 1000).toISOString(),
209
+ modified: new Date(p.modified * 1000).toISOString(),
210
+ message: p.message,
211
+ unread: p.unread,
212
+ })),
213
+ summary: {
214
+ total_posts: posts.length,
215
+ },
216
+ };
217
+ console.log(JSON.stringify(result));
218
+ }
219
+ else if (output === "table") {
220
+ console.log(`Discussion ${discussionId} - ${posts.length} posts`);
221
+ console.log("Use --output json to see full post content");
222
+ const tablePosts = posts.map(p => ({
223
+ id: p.id,
224
+ subject: p.subject.substring(0, 50) + (p.subject.length > 50 ? "..." : ""),
225
+ author: p.author,
226
+ created: new Date(p.created * 1000).toLocaleString(),
227
+ }));
228
+ console.table(tablePosts);
229
+ }
230
+ });
231
+ }
@@ -0,0 +1,3 @@
1
+ import { Command } from "commander";
2
+ export declare function registerGradesCommand(program: Command): void;
3
+ //# sourceMappingURL=grades.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"grades.d.ts","sourceRoot":"","sources":["../../../src/src/commands/grades.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAkBpC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAsI5D"}