@mo7yw4ng/openape 1.0.3 → 1.0.5
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 +16 -17
- 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 +45 -15
- package/esm/src/commands/calendar.d.ts.map +1 -1
- package/esm/src/commands/calendar.js +20 -21
- package/esm/src/commands/courses.js +6 -6
- package/esm/src/commands/forums.d.ts.map +1 -1
- package/esm/src/commands/forums.js +128 -36
- package/esm/src/commands/grades.js +3 -3
- package/esm/src/commands/materials.d.ts.map +1 -1
- package/esm/src/commands/materials.js +115 -224
- package/esm/src/commands/quizzes.d.ts.map +1 -1
- package/esm/src/commands/quizzes.js +179 -68
- 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 +10 -9
- 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/logger.d.ts +1 -1
- package/esm/src/lib/logger.d.ts.map +1 -1
- package/esm/src/lib/logger.js +7 -4
- package/esm/src/lib/moodle.d.ts +183 -1
- package/esm/src/lib/moodle.d.ts.map +1 -1
- package/esm/src/lib/moodle.js +498 -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 +15 -16
- 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 +44 -14
- package/script/src/commands/calendar.d.ts.map +1 -1
- package/script/src/commands/calendar.js +19 -20
- package/script/src/commands/courses.js +5 -5
- package/script/src/commands/forums.d.ts.map +1 -1
- package/script/src/commands/forums.js +128 -36
- package/script/src/commands/grades.js +3 -3
- package/script/src/commands/materials.d.ts.map +1 -1
- package/script/src/commands/materials.js +115 -224
- package/script/src/commands/quizzes.d.ts.map +1 -1
- package/script/src/commands/quizzes.js +177 -66
- 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 +10 -9
- 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/logger.d.ts +1 -1
- package/script/src/lib/logger.d.ts.map +1 -1
- package/script/src/lib/logger.js +7 -4
- package/script/src/lib/moodle.d.ts +183 -1
- package/script/src/lib/moodle.d.ts.map +1 -1
- package/script/src/lib/moodle.js +511 -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 +74 -270
|
@@ -1,13 +1,61 @@
|
|
|
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, getAllQuizAttemptDataApi, 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
|
+
}
|
|
44
|
+
function parseQuizQuestions(questions) {
|
|
45
|
+
return Object.values(questions).map((q) => {
|
|
46
|
+
const parsed = parseQuestionHtml(q.html ?? "");
|
|
47
|
+
const savedAnswer = parseSavedAnswer(q.html ?? "");
|
|
48
|
+
return {
|
|
49
|
+
slot: q.slot,
|
|
50
|
+
type: q.type,
|
|
51
|
+
status: q.status,
|
|
52
|
+
stateclass: q.stateclass,
|
|
53
|
+
savedAnswer,
|
|
54
|
+
question: parsed.text,
|
|
55
|
+
options: parsed.options,
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
}
|
|
11
59
|
export function registerQuizzesCommand(program) {
|
|
12
60
|
const quizzesCmd = program.command("quizzes");
|
|
13
61
|
quizzesCmd.description("Quiz operations");
|
|
@@ -20,19 +68,19 @@ export function registerQuizzesCommand(program) {
|
|
|
20
68
|
const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
|
|
21
69
|
const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
|
|
22
70
|
const silent = outputFormat === "json" && !opts.verbose;
|
|
23
|
-
const log = createLogger(opts.verbose, silent);
|
|
71
|
+
const log = createLogger(opts.verbose, silent, outputFormat);
|
|
24
72
|
const baseDir = getBaseDir();
|
|
25
73
|
const sessionPath = path.resolve(baseDir, ".auth", "storage-state.json");
|
|
26
74
|
// Check if session exists
|
|
27
75
|
if (!fs.existsSync(sessionPath)) {
|
|
28
|
-
log.error("未找到登入 session。請先執行 'openape
|
|
76
|
+
log.error("未找到登入 session。請先執行 'openape login' 進行登入。");
|
|
29
77
|
log.info(`Session 預期位置: ${sessionPath}`);
|
|
30
78
|
return null;
|
|
31
79
|
}
|
|
32
80
|
// Try to load WS token
|
|
33
81
|
const wsToken = loadWsToken(sessionPath);
|
|
34
82
|
if (!wsToken) {
|
|
35
|
-
log.error("未找到 WS token。請先執行 'openape
|
|
83
|
+
log.error("未找到 WS token。請先執行 'openape login' 進行登入。");
|
|
36
84
|
return null;
|
|
37
85
|
}
|
|
38
86
|
return {
|
|
@@ -43,45 +91,11 @@ export function registerQuizzesCommand(program) {
|
|
|
43
91
|
},
|
|
44
92
|
};
|
|
45
93
|
}
|
|
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
94
|
quizzesCmd
|
|
81
95
|
.command("list")
|
|
82
|
-
.description("List quizzes in a course")
|
|
96
|
+
.description("List incomplete quizzes in a course")
|
|
83
97
|
.argument("<course-id>", "Course ID")
|
|
84
|
-
.option("--
|
|
98
|
+
.option("--all", "Include completed quizzes")
|
|
85
99
|
.option("--output <format>", "Output format: json|csv|table|silent")
|
|
86
100
|
.action(async (courseId, options, command) => {
|
|
87
101
|
const output = getOutputFormat(command);
|
|
@@ -91,16 +105,19 @@ export function registerQuizzesCommand(program) {
|
|
|
91
105
|
return;
|
|
92
106
|
}
|
|
93
107
|
const quizzes = await getQuizzesByCoursesApi(apiContext.session, [parseInt(courseId, 10)]);
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
108
|
+
// Default: only show incomplete quizzes
|
|
109
|
+
const filtered = options.all ? quizzes : quizzes.filter(q => !q.isComplete);
|
|
110
|
+
const formattedQuizzes = filtered.map(({ courseId, ...q }) => ({
|
|
111
|
+
...q,
|
|
112
|
+
timeClose: q.timeClose ? formatTimestamp(q.timeClose) : null,
|
|
113
|
+
}));
|
|
114
|
+
formatAndOutput(formattedQuizzes, output, apiContext.log);
|
|
99
115
|
});
|
|
100
116
|
quizzesCmd
|
|
101
117
|
.command("list-all")
|
|
102
|
-
.description("List all
|
|
118
|
+
.description("List all incomplete quizzes across all courses")
|
|
103
119
|
.option("--level <type>", "Course level: in_progress (default) | all", "in_progress")
|
|
120
|
+
.option("--all", "Include completed quizzes")
|
|
104
121
|
.option("--output <format>", "Output format: json|csv|table|silent")
|
|
105
122
|
.action(async (options, command) => {
|
|
106
123
|
const output = getOutputFormat(command);
|
|
@@ -121,13 +138,17 @@ export function registerQuizzesCommand(program) {
|
|
|
121
138
|
const allQuizzes = [];
|
|
122
139
|
for (const q of apiQuizzes) {
|
|
123
140
|
const course = courseMap.get(q.courseId);
|
|
124
|
-
if (course) {
|
|
141
|
+
if (course && (options.all || !q.isComplete)) {
|
|
125
142
|
allQuizzes.push({
|
|
126
143
|
courseName: course.fullname,
|
|
144
|
+
courseId: q.courseId,
|
|
127
145
|
name: q.name,
|
|
128
146
|
url: q.url,
|
|
129
|
-
|
|
147
|
+
quizid: q.quizid,
|
|
130
148
|
isComplete: q.isComplete,
|
|
149
|
+
attemptsUsed: q.attemptsUsed,
|
|
150
|
+
maxAttempts: q.maxAttempts,
|
|
151
|
+
timeClose: q.timeClose ? formatTimestamp(q.timeClose) : null,
|
|
131
152
|
});
|
|
132
153
|
}
|
|
133
154
|
}
|
|
@@ -135,26 +156,116 @@ export function registerQuizzesCommand(program) {
|
|
|
135
156
|
formatAndOutput(allQuizzes, output, apiContext.log);
|
|
136
157
|
});
|
|
137
158
|
quizzesCmd
|
|
138
|
-
.command("
|
|
139
|
-
.description("
|
|
140
|
-
.argument("<quiz-
|
|
141
|
-
.option("--
|
|
142
|
-
.action(async (
|
|
143
|
-
const
|
|
144
|
-
|
|
159
|
+
.command("start")
|
|
160
|
+
.description("Start a new quiz attempt")
|
|
161
|
+
.argument("<quiz-id>", "Quiz ID")
|
|
162
|
+
.option("--output <format>", "Output format: json|csv|table|silent")
|
|
163
|
+
.action(async (quizCmid, options, command) => {
|
|
164
|
+
const output = getOutputFormat(command);
|
|
165
|
+
const apiContext = await createApiContext(options, command);
|
|
166
|
+
if (!apiContext) {
|
|
167
|
+
process.exitCode = 1;
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const result = await startQuizAttemptApi(apiContext.session, quizCmid);
|
|
172
|
+
apiContext.log.success(`Quiz attempt ${result.attempt.attemptid} started.`);
|
|
173
|
+
const attemptId = result.attempt.attemptid;
|
|
174
|
+
const data = await getAllQuizAttemptDataApi(apiContext.session, attemptId);
|
|
175
|
+
const questions = parseQuizQuestions(data.questions);
|
|
176
|
+
const outputData = [{
|
|
177
|
+
attemptId,
|
|
178
|
+
quizId: result.attempt.quizid,
|
|
179
|
+
state: result.attempt.state,
|
|
180
|
+
timeStart: formatTimestamp(result.attempt.timestart),
|
|
181
|
+
timeFinish: result.attempt.timefinish
|
|
182
|
+
? formatTimestamp(result.attempt.timefinish)
|
|
183
|
+
: null,
|
|
184
|
+
isPreview: result.attempt.preview,
|
|
185
|
+
totalQuestions: questions.length,
|
|
186
|
+
questions,
|
|
187
|
+
}];
|
|
188
|
+
formatAndOutput(outputData, output, apiContext.log);
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
apiContext.log.error(`Failed to start quiz attempt: ${error instanceof Error ? error.message : String(error)}`);
|
|
192
|
+
process.exitCode = 1;
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
quizzesCmd
|
|
196
|
+
.command("info")
|
|
197
|
+
.description("Get quiz attempt data and questions")
|
|
198
|
+
.argument("<attempt-id>", "Quiz attempt ID")
|
|
199
|
+
.option("--page <number>", "Page number (-1 for all pages)", "-1")
|
|
200
|
+
.option("--output <format>", "Output format: json|csv|table|silent")
|
|
201
|
+
.action(async (attemptId, options, command) => {
|
|
202
|
+
const output = getOutputFormat(command);
|
|
203
|
+
const apiContext = await createApiContext(options, command);
|
|
204
|
+
if (!apiContext) {
|
|
205
|
+
process.exitCode = 1;
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
const pageNumber = parseInt(options.page);
|
|
210
|
+
const data = pageNumber === -1
|
|
211
|
+
? await getAllQuizAttemptDataApi(apiContext.session, parseInt(attemptId))
|
|
212
|
+
: await getQuizAttemptDataApi(apiContext.session, parseInt(attemptId), pageNumber);
|
|
213
|
+
const questions = parseQuizQuestions(data.questions);
|
|
214
|
+
const outputData = [{
|
|
215
|
+
attemptId: data.attempt.attemptid,
|
|
216
|
+
quizId: data.attempt.quizid,
|
|
217
|
+
state: data.attempt.state,
|
|
218
|
+
totalQuestions: questions.length,
|
|
219
|
+
questions,
|
|
220
|
+
}];
|
|
221
|
+
apiContext.log.success(`Retrieved attempt ${data.attempt.attemptid}`);
|
|
222
|
+
formatAndOutput(outputData, output, apiContext.log);
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
apiContext.log.error(`Failed to get attempt data: ${error instanceof Error ? error.message : String(error)}`);
|
|
226
|
+
process.exitCode = 1;
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
quizzesCmd
|
|
230
|
+
.command("save")
|
|
231
|
+
.description("Save answers for a quiz attempt")
|
|
232
|
+
.argument("<attempt-id>", "Quiz attempt ID")
|
|
233
|
+
.argument("<answers>", "Answers JSON: [{slot:1,answer:\"0\"}] multichoice=number, multichoices=\"0,2\", shortanswer=\"text\"")
|
|
234
|
+
.option("--submit", "Submit the attempt after saving")
|
|
235
|
+
.option("--output <format>", "Output format: json|csv|table|silent")
|
|
236
|
+
.action(async (attemptId, answersJson, options, command) => {
|
|
237
|
+
const output = getOutputFormat(command);
|
|
238
|
+
const apiContext = await createApiContext(options, command);
|
|
239
|
+
if (!apiContext) {
|
|
145
240
|
process.exitCode = 1;
|
|
146
241
|
return;
|
|
147
242
|
}
|
|
148
|
-
|
|
243
|
+
let answers;
|
|
149
244
|
try {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
log.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
245
|
+
answers = JSON.parse(answersJson);
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
apiContext.log.error("Invalid answers JSON. Expected format: [{\"slot\":1,\"answer\":\"0\"},...]");
|
|
249
|
+
process.exitCode = 1;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
// Get attempt data to find uniqueid and sequencecheck values
|
|
254
|
+
const attemptData = await getAllQuizAttemptDataApi(apiContext.session, parseInt(attemptId));
|
|
255
|
+
const uniqueId = attemptData.attempt.uniqueid ?? attemptData.attempt.attemptid;
|
|
256
|
+
const sequenceChecks = new Map();
|
|
257
|
+
for (const q of Object.values(attemptData.questions)) {
|
|
258
|
+
if (q.sequencecheck !== undefined) {
|
|
259
|
+
sequenceChecks.set(q.slot, q.sequencecheck);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const result = await processQuizAttemptApi(apiContext.session, parseInt(attemptId), uniqueId, answers, sequenceChecks, !!options.submit);
|
|
263
|
+
apiContext.log.success(`Attempt ${attemptId} state: ${result.state}`);
|
|
264
|
+
formatAndOutput([result], output, apiContext.log);
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
apiContext.log.error(`Failed to submit attempt: ${error instanceof Error ? error.message : String(error)}`);
|
|
268
|
+
process.exitCode = 1;
|
|
158
269
|
}
|
|
159
270
|
});
|
|
160
271
|
}
|
|
@@ -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"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { getOutputFormat, formatFileSize } from "../lib/utils.js";
|
|
2
|
+
import { uploadFileApi } from "../lib/moodle.js";
|
|
3
|
+
import { createApiContext } from "../lib/auth.js";
|
|
4
|
+
import { formatAndOutput } from "../index.js";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
export function registerUploadCommand(program) {
|
|
8
|
+
const uploadCmd = program.command("upload");
|
|
9
|
+
uploadCmd.description("Upload files to Moodle draft area");
|
|
10
|
+
uploadCmd
|
|
11
|
+
.command("file")
|
|
12
|
+
.description("Upload a file to Moodle draft area")
|
|
13
|
+
.argument("<file-path>", "Path to the file to upload")
|
|
14
|
+
.option("--filename <name>", "Custom filename (default: original filename)")
|
|
15
|
+
.option("--output <format>", "Output format: json|csv|table|silent")
|
|
16
|
+
.action(async (filePath, options, command) => {
|
|
17
|
+
const output = getOutputFormat(command);
|
|
18
|
+
const apiContext = await createApiContext(options, command);
|
|
19
|
+
if (!apiContext) {
|
|
20
|
+
process.exitCode = 1;
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
// Resolve file path
|
|
24
|
+
const resolvedPath = path.resolve(filePath);
|
|
25
|
+
// Check if file exists
|
|
26
|
+
try {
|
|
27
|
+
await fs.access(resolvedPath);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
apiContext.log.error(`檔案不存在: ${filePath}`);
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
// Get file size
|
|
35
|
+
const stats = await fs.stat(resolvedPath);
|
|
36
|
+
const fileSizeKB = formatFileSize(stats.size);
|
|
37
|
+
apiContext.log.info(`上傳檔案: ${path.basename(resolvedPath)} (${fileSizeKB} KB)`);
|
|
38
|
+
// Upload file
|
|
39
|
+
const result = await uploadFileApi(apiContext.session, resolvedPath, {
|
|
40
|
+
filename: options.filename,
|
|
41
|
+
});
|
|
42
|
+
if (!result.success) {
|
|
43
|
+
apiContext.log.error(`上傳失敗: ${result.error}`);
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
apiContext.log.info(`✓ 上傳成功!Draft ID: ${result.draftId}`);
|
|
48
|
+
const uploadResult = {
|
|
49
|
+
success: true,
|
|
50
|
+
draft_id: result.draftId,
|
|
51
|
+
filename: path.basename(resolvedPath),
|
|
52
|
+
filesize: stats.size,
|
|
53
|
+
filesize_kb: formatFileSize(stats.size),
|
|
54
|
+
message: "Use this draft ID for assignment submission or forum posts",
|
|
55
|
+
};
|
|
56
|
+
formatAndOutput(uploadResult, output, apiContext.log);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"videos.d.ts","sourceRoot":"","sources":["../../../src/src/commands/videos.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAYpC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"videos.d.ts","sourceRoot":"","sources":["../../../src/src/commands/videos.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAYpC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAgY5D"}
|
|
@@ -17,19 +17,19 @@ export function registerVideosCommand(program) {
|
|
|
17
17
|
// Don't silence logs for commands that don't have explicit output format control
|
|
18
18
|
const outputFormat = command && command.optsWithGlobals ? getOutputFormat(command) : "table";
|
|
19
19
|
const silent = outputFormat === "json" && !opts.verbose;
|
|
20
|
-
const log = createLogger(opts.verbose, silent);
|
|
20
|
+
const log = createLogger(opts.verbose, silent, outputFormat);
|
|
21
21
|
const baseDir = getBaseDir();
|
|
22
22
|
const sessionPath = path.resolve(baseDir, ".auth", "storage-state.json");
|
|
23
23
|
// Check if session exists
|
|
24
24
|
if (!fs.existsSync(sessionPath)) {
|
|
25
|
-
|
|
25
|
+
console.error("未找到登入 session。請先執行 'openape login' 進行登入。");
|
|
26
26
|
log.info(`Session 預期位置: ${sessionPath}`);
|
|
27
27
|
return null;
|
|
28
28
|
}
|
|
29
29
|
// Try to load WS token
|
|
30
30
|
const wsToken = loadWsToken(sessionPath);
|
|
31
31
|
if (!wsToken) {
|
|
32
|
-
|
|
32
|
+
console.error("未找到 WS token。請先執行 'openape login' 進行登入。");
|
|
33
33
|
return null;
|
|
34
34
|
}
|
|
35
35
|
return {
|
|
@@ -45,11 +45,11 @@ export function registerVideosCommand(program) {
|
|
|
45
45
|
const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
|
|
46
46
|
const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
|
|
47
47
|
const silent = outputFormat === "json" && !opts.verbose;
|
|
48
|
-
const log = createLogger(opts.verbose, silent);
|
|
48
|
+
const log = createLogger(opts.verbose, silent, outputFormat);
|
|
49
49
|
const baseDir = getBaseDir();
|
|
50
50
|
const sessionPath = path.resolve(baseDir, ".auth", "storage-state.json");
|
|
51
51
|
if (!fs.existsSync(sessionPath)) {
|
|
52
|
-
|
|
52
|
+
console.error("未找到登入 session。請先執行 'openape login' 進行登入。");
|
|
53
53
|
return null;
|
|
54
54
|
}
|
|
55
55
|
const config = {
|
|
@@ -316,7 +316,7 @@ export function registerVideosCommand(program) {
|
|
|
316
316
|
const completed = downloaded.filter(d => d.success).length;
|
|
317
317
|
const failed = downloaded.filter(d => !d.success).length;
|
|
318
318
|
log.info(`\n執行結果: ${completed} 成功, ${failed} 失敗`);
|
|
319
|
-
|
|
319
|
+
console.log(JSON.stringify({
|
|
320
320
|
status: "success",
|
|
321
321
|
timestamp: new Date().toISOString(),
|
|
322
322
|
course_id: courseId,
|
|
@@ -324,9 +324,10 @@ export function registerVideosCommand(program) {
|
|
|
324
324
|
total_videos: videos.length,
|
|
325
325
|
downloaded: completed,
|
|
326
326
|
failed,
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
327
|
+
}));
|
|
328
|
+
for (const v of downloaded) {
|
|
329
|
+
console.log(JSON.stringify(v));
|
|
330
|
+
}
|
|
330
331
|
}
|
|
331
332
|
finally {
|
|
332
333
|
await closeBrowserSafely(browser, browserContext);
|
package/esm/src/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/src/index.ts"],"names":[],"mappings":";AACA,OAAO,sBAAsB,CAAC;AAO9B,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/src/index.ts"],"names":[],"mappings":";AACA,OAAO,sBAAsB,CAAC;AAO9B,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAwEnF;;GAEG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE;IACP,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB,GACA,OAAO,CAAC;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,OAAO,iBAAiB,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,WAAW,CAAA;CAAE,GAAG,IAAI,CAAC,CAoBhH;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/D,IAAI,EAAE,CAAC,GAAG,CAAC,EAAE,EACb,MAAM,EAAE,YAAY,EACpB,GAAG,EAAE,MAAM,GACV,IAAI,CAyBN;AA2CD,OAAO,EAAE,YAAY,EAAE,KAAK,SAAS,EAAE,KAAK,MAAM,EAAE,KAAK,WAAW,EAAE,KAAK,YAAY,EAAE,CAAC"}
|
package/esm/src/index.js
CHANGED
|
@@ -18,6 +18,8 @@ import { registerForumsCommand } from "./commands/forums.js";
|
|
|
18
18
|
import { registerAnnouncementsCommand } from "./commands/announcements.js";
|
|
19
19
|
import { registerCalendarCommand } from "./commands/calendar.js";
|
|
20
20
|
import { registerSkillsCommand } from "./commands/skills.js";
|
|
21
|
+
import { registerAssignmentsCommand } from "./commands/assignments.js";
|
|
22
|
+
import { registerUploadCommand } from "./commands/upload.js";
|
|
21
23
|
const program = new Command();
|
|
22
24
|
program
|
|
23
25
|
.name("openape")
|
|
@@ -42,6 +44,8 @@ registerForumsCommand(program);
|
|
|
42
44
|
registerAnnouncementsCommand(program);
|
|
43
45
|
registerCalendarCommand(program);
|
|
44
46
|
registerSkillsCommand(program);
|
|
47
|
+
registerAssignmentsCommand(program);
|
|
48
|
+
registerUploadCommand(program);
|
|
45
49
|
/**
|
|
46
50
|
* Load configuration and authenticate, returning the context for commands.
|
|
47
51
|
*/
|
|
@@ -84,7 +88,14 @@ export async function createSessionContext(options) {
|
|
|
84
88
|
*/
|
|
85
89
|
export function formatAndOutput(data, format, log) {
|
|
86
90
|
if (format === "json") {
|
|
87
|
-
|
|
91
|
+
if (Array.isArray(data)) {
|
|
92
|
+
for (const item of data) {
|
|
93
|
+
console.log(JSON.stringify(item));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
console.log(JSON.stringify(data));
|
|
98
|
+
}
|
|
88
99
|
// Exit immediately for AI agent - no need to wait for browser cleanup
|
|
89
100
|
process.exit(0);
|
|
90
101
|
}
|
package/esm/src/lib/auth.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Browser, type BrowserContext, type Page } from "playwright-core";
|
|
2
|
-
import type { AppConfig, Logger } from "./types.js";
|
|
2
|
+
import type { AppConfig, Logger, OutputFormat } from "./types.js";
|
|
3
3
|
/**
|
|
4
4
|
* Find a Chromium-based browser executable on Windows.
|
|
5
5
|
* Priority: Edge → Chrome → Brave
|
|
@@ -20,6 +20,28 @@ export declare function launchAuthenticated(config: AppConfig, log: Logger): Pro
|
|
|
20
20
|
* Safely close browser and context with timeout.
|
|
21
21
|
* Designed for AI agent usage - no human interaction needed.
|
|
22
22
|
* If noWait is true, initiates cleanup but doesn't wait for completion.
|
|
23
|
+
*
|
|
24
|
+
* Note: Closes sequentially (context first, then browser) to avoid libuv
|
|
25
|
+
* assertion failures on Windows when handles are closed concurrently.
|
|
23
26
|
*/
|
|
24
27
|
export declare function closeBrowserSafely(browser: Browser, context?: BrowserContext, timeoutMs?: number, noWait?: boolean): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Create API context for WS token operations (no browser required).
|
|
30
|
+
* Returns null if session is invalid or WS token is missing.
|
|
31
|
+
*/
|
|
32
|
+
export declare function createApiContext(options: {
|
|
33
|
+
verbose?: boolean;
|
|
34
|
+
headed?: boolean;
|
|
35
|
+
}, command?: {
|
|
36
|
+
optsWithGlobals(): {
|
|
37
|
+
output?: OutputFormat;
|
|
38
|
+
verbose?: boolean;
|
|
39
|
+
};
|
|
40
|
+
}): Promise<{
|
|
41
|
+
log: Logger;
|
|
42
|
+
session: {
|
|
43
|
+
wsToken: string;
|
|
44
|
+
moodleBaseUrl: string;
|
|
45
|
+
};
|
|
46
|
+
} | null>;
|
|
25
47
|
//# sourceMappingURL=auth.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../../src/src/lib/auth.ts"],"names":[],"mappings":"AAEA,OAAO,EAAY,KAAK,OAAO,EAAE,KAAK,cAAc,EAAE,KAAK,IAAI,EAAE,MAAM,iBAAiB,CAAC;AACzF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../../src/src/lib/auth.ts"],"names":[],"mappings":"AAEA,OAAO,EAAY,KAAK,OAAO,EAAE,KAAK,cAAc,EAAE,KAAK,IAAI,EAAE,MAAM,iBAAiB,CAAC;AACzF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAGlE;;;GAGG;AACH,wBAAgB,YAAY,IAAI,MAAM,CAuBrC;AAED;;;;GAIG;AACH,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,SAAS,EACjB,GAAG,EAAE,MAAM,GACV,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,cAAc,CAAC;IAAC,IAAI,EAAE,IAAI,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CA4DtF;AAED;;;;;;;GAOG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,OAAO,EAChB,OAAO,CAAC,EAAE,cAAc,EACxB,SAAS,GAAE,MAAa,EACxB,MAAM,GAAE,OAAe,GACtB,OAAO,CAAC,IAAI,CAAC,CA+Bf;AAoHD;;;GAGG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,EAChD,OAAO,CAAC,EAAE;IAAE,eAAe,IAAI;QAAE,MAAM,CAAC,EAAE,YAAY,CAAC;QAAC,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,CAAA;CAAE,GAC5E,OAAO,CAAC;IACT,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAA;KAAE,CAAC;CACrD,GAAG,IAAI,CAAC,CA0BR"}
|
package/esm/src/lib/auth.js
CHANGED
|
@@ -88,17 +88,20 @@ export async function launchAuthenticated(config, log) {
|
|
|
88
88
|
* Safely close browser and context with timeout.
|
|
89
89
|
* Designed for AI agent usage - no human interaction needed.
|
|
90
90
|
* If noWait is true, initiates cleanup but doesn't wait for completion.
|
|
91
|
+
*
|
|
92
|
+
* Note: Closes sequentially (context first, then browser) to avoid libuv
|
|
93
|
+
* assertion failures on Windows when handles are closed concurrently.
|
|
91
94
|
*/
|
|
92
95
|
export async function closeBrowserSafely(browser, context, timeoutMs = 5000, noWait = false) {
|
|
93
96
|
const closePromises = [];
|
|
94
|
-
// Close context with error handling
|
|
97
|
+
// Close context first with error handling
|
|
95
98
|
if (context) {
|
|
96
99
|
closePromises.push(Promise.race([
|
|
97
100
|
context.close().catch(() => { }),
|
|
98
101
|
new Promise(resolve => setTimeout(() => resolve(), timeoutMs))
|
|
99
102
|
]));
|
|
100
103
|
}
|
|
101
|
-
// Close browser with error handling
|
|
104
|
+
// Close browser after context with error handling
|
|
102
105
|
closePromises.push(Promise.race([
|
|
103
106
|
browser.close().catch(() => { }),
|
|
104
107
|
new Promise(resolve => setTimeout(() => resolve(), timeoutMs))
|
|
@@ -108,7 +111,10 @@ export async function closeBrowserSafely(browser, context, timeoutMs = 5000, noW
|
|
|
108
111
|
Promise.allSettled(closePromises);
|
|
109
112
|
return;
|
|
110
113
|
}
|
|
111
|
-
|
|
114
|
+
// Wait sequentially to avoid libuv issues on Windows
|
|
115
|
+
for (const promise of closePromises) {
|
|
116
|
+
await promise.catch(() => { });
|
|
117
|
+
}
|
|
112
118
|
}
|
|
113
119
|
/**
|
|
114
120
|
* Attempt to restore a session from stored state.
|
|
@@ -192,3 +198,30 @@ async function login(page, config, log) {
|
|
|
192
198
|
}
|
|
193
199
|
log.success("Login completed successfully.");
|
|
194
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* Create API context for WS token operations (no browser required).
|
|
203
|
+
* Returns null if session is invalid or WS token is missing.
|
|
204
|
+
*/
|
|
205
|
+
export async function createApiContext(options, command) {
|
|
206
|
+
const { createLogger } = await import("./logger.js");
|
|
207
|
+
const { loadWsToken } = await import("./token.js");
|
|
208
|
+
const { getOutputFormat, getSessionPath } = await import("./utils.js");
|
|
209
|
+
const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
|
|
210
|
+
const outputFormat = command ? getOutputFormat(command) : "json";
|
|
211
|
+
const silent = outputFormat === "json" && !opts.verbose;
|
|
212
|
+
const log = createLogger(opts.verbose, silent, outputFormat);
|
|
213
|
+
const sessionPath = getSessionPath();
|
|
214
|
+
// Try to load WS token
|
|
215
|
+
const wsToken = loadWsToken(sessionPath);
|
|
216
|
+
if (!wsToken) {
|
|
217
|
+
console.error("未找到 WS token。請先執行 'openape login' 進行登入。");
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
log,
|
|
222
|
+
session: {
|
|
223
|
+
wsToken,
|
|
224
|
+
moodleBaseUrl: "https://ilearning.cycu.edu.tw",
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
package/esm/src/lib/logger.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../../../src/src/lib/logger.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAOzC,wBAAgB,YAAY,CAAC,OAAO,UAAQ,EAAE,MAAM,UAAQ,GAAG,MAAM,
|
|
1
|
+
{"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../../../src/src/lib/logger.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAOzC,wBAAgB,YAAY,CAAC,OAAO,UAAQ,EAAE,MAAM,UAAQ,EAAE,YAAY,GAAE,MAAe,GAAG,MAAM,CAwBnG"}
|