moodle-cli 0.5.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/LICENSE +21 -0
- package/README.md +155 -0
- package/SKILL.md +167 -0
- package/dist/moodle.js +3474 -0
- package/package.json +52 -0
package/dist/moodle.js
ADDED
|
@@ -0,0 +1,3474 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command, CommanderError } from "commander";
|
|
5
|
+
import { realpathSync } from "fs";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
|
|
8
|
+
// src/client.ts
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
|
|
11
|
+
// src/constants.ts
|
|
12
|
+
var PACKAGE_NAME = "moodle-cli";
|
|
13
|
+
var NPM_LATEST_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
14
|
+
var GITHUB_RELEASES_URL = "https://github.com/bunizao/moodle-cli/releases/latest";
|
|
15
|
+
var AJAX_SERVICE_PATH = "/lib/ajax/service.php";
|
|
16
|
+
var DASHBOARD_PATH = "/my/";
|
|
17
|
+
var COURSE_PATH = "/course/view.php";
|
|
18
|
+
var ASSIGN_VIEW_PATH = "/mod/assign/view.php";
|
|
19
|
+
var QUIZ_VIEW_PATH = "/mod/quiz/view.php";
|
|
20
|
+
var RESOURCE_VIEW_PATH = "/mod/resource/view.php";
|
|
21
|
+
var URL_VIEW_PATH = "/mod/url/view.php";
|
|
22
|
+
var PAGE_VIEW_PATH = "/mod/page/view.php";
|
|
23
|
+
var FOLDER_VIEW_PATH = "/mod/folder/view.php";
|
|
24
|
+
var FORUM_DISCUSS_PATH = "/mod/forum/discuss.php";
|
|
25
|
+
var FORUM_VIEW_PATH = "/mod/forum/view.php";
|
|
26
|
+
var GRADE_REPORT_INDEX_PATH = "/grade/report/index.php";
|
|
27
|
+
var GRADE_REPORT_OVERVIEW_PATH = "/grade/report/overview/index.php";
|
|
28
|
+
var GRADE_REPORT_PATH = "/grade/report/user/index.php";
|
|
29
|
+
var LOGIN_PATH = "/login/index.php";
|
|
30
|
+
var FUNC_GET_SITE_INFO = "core_webservice_get_site_info";
|
|
31
|
+
var FUNC_GET_COURSES = "core_enrol_get_users_courses";
|
|
32
|
+
var FUNC_GET_COURSES_BY_TIMELINE = "core_course_get_enrolled_courses_by_timeline_classification";
|
|
33
|
+
var FUNC_GET_COURSE_CONTENTS = "core_course_get_contents";
|
|
34
|
+
var FUNC_GET_ACTION_EVENTS = "core_calendar_get_action_events_by_timesort";
|
|
35
|
+
var FUNC_GET_POPUP_NOTIFICATIONS = "message_popup_get_popup_notifications";
|
|
36
|
+
var FUNC_GET_CONVERSATION_COUNTS = "core_message_get_conversation_counts";
|
|
37
|
+
var FUNC_GET_UNREAD_CONVERSATION_COUNTS = "core_message_get_unread_conversation_counts";
|
|
38
|
+
var FUNC_GET_DISCUSSION_POSTS = "mod_forum_get_discussion_posts";
|
|
39
|
+
var CONFIG_FILENAME = "config.yaml";
|
|
40
|
+
var CONFIG_DIR_NAME = ".config/moodle-cli";
|
|
41
|
+
var CACHE_DIR_NAME = ".cache/moodle-cli";
|
|
42
|
+
var SESSION_CACHE_FILENAME = "session.json";
|
|
43
|
+
var DEFAULT_SESSION_CACHE_TTL_MS = 2 * 60 * 60 * 1e3;
|
|
44
|
+
var ENV_MOODLE_SESSION = "MOODLE_SESSION";
|
|
45
|
+
var ENV_MOODLE_BASE_URL = "MOODLE_BASE_URL";
|
|
46
|
+
var MOODLE_SESSION_COOKIE_PREFIX = "MoodleSession";
|
|
47
|
+
var OKTA_AUTH_URL = "https://github.com/bunizao/okta-auth";
|
|
48
|
+
var OKTA_AUTH_INSTALL_COMMAND = "uv tool install okta-auth-cli";
|
|
49
|
+
var OKTA_AUTH_CONFIG_COMMAND = "okta config";
|
|
50
|
+
|
|
51
|
+
// src/auth.ts
|
|
52
|
+
import { execFile as execFileCallback } from "child_process";
|
|
53
|
+
import { copyFile, mkdtemp, readdir, rm as rm2, stat } from "fs/promises";
|
|
54
|
+
import { homedir as homedir2, tmpdir } from "os";
|
|
55
|
+
import { basename, join as join2 } from "path";
|
|
56
|
+
|
|
57
|
+
// src/errors.ts
|
|
58
|
+
var CliError = class extends Error {
|
|
59
|
+
code;
|
|
60
|
+
exitCode;
|
|
61
|
+
hint;
|
|
62
|
+
constructor(message, code = "unexpected_error", exitCode = 1, hint) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.name = this.constructor.name;
|
|
65
|
+
this.code = code;
|
|
66
|
+
this.exitCode = exitCode;
|
|
67
|
+
this.hint = hint;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
var AuthError = class extends CliError {
|
|
71
|
+
constructor(message, hint) {
|
|
72
|
+
super(message, "auth_failed", 2, hint);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
var ConfigError = class extends CliError {
|
|
76
|
+
constructor(message, hint) {
|
|
77
|
+
super(message, "config_error", 2, hint);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
var UsageError = class extends CliError {
|
|
81
|
+
constructor(message, hint) {
|
|
82
|
+
super(message, "usage_error", 3, hint);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
var NotFoundError = class extends CliError {
|
|
86
|
+
constructor(message, hint) {
|
|
87
|
+
super(message, "not_found", 4, hint);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
var MoodleAPIError = class extends CliError {
|
|
91
|
+
moodleErrorCode;
|
|
92
|
+
constructor(message, moodleErrorCode) {
|
|
93
|
+
super(message, "api_error", 1);
|
|
94
|
+
this.moodleErrorCode = moodleErrorCode;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
function isLoginRequiredError(error) {
|
|
98
|
+
if (!(error instanceof MoodleAPIError)) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
return ["servicerequireslogin", "sitepolicynotagreed"].includes(error.moodleErrorCode ?? "");
|
|
102
|
+
}
|
|
103
|
+
function toCliError(error) {
|
|
104
|
+
if (error instanceof CliError) {
|
|
105
|
+
return error;
|
|
106
|
+
}
|
|
107
|
+
if (error instanceof Error) {
|
|
108
|
+
return new CliError(error.message);
|
|
109
|
+
}
|
|
110
|
+
return new CliError(String(error));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// src/session-cache.ts
|
|
114
|
+
import { chmod, mkdir, readFile, rm, writeFile } from "fs/promises";
|
|
115
|
+
import { homedir } from "os";
|
|
116
|
+
import { dirname, join } from "path";
|
|
117
|
+
var nodeFs = { readFile, writeFile, mkdir, rm, chmod };
|
|
118
|
+
function sessionCachePath(homeDir = homedir()) {
|
|
119
|
+
return join(homeDir, CACHE_DIR_NAME, SESSION_CACHE_FILENAME);
|
|
120
|
+
}
|
|
121
|
+
function isCachedSessionFresh(session, ttlMs = DEFAULT_SESSION_CACHE_TTL_MS, now = Date.now) {
|
|
122
|
+
const age = now() - session.savedAt;
|
|
123
|
+
return age >= 0 && age <= ttlMs;
|
|
124
|
+
}
|
|
125
|
+
async function readCachedSession(baseUrl, options = {}) {
|
|
126
|
+
if (options.noCache) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
const fs = options.fs ?? nodeFs;
|
|
130
|
+
const path3 = sessionCachePath(options.homeDir);
|
|
131
|
+
let raw;
|
|
132
|
+
try {
|
|
133
|
+
raw = await fs.readFile(path3, "utf8");
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (isMissingFileError(error)) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
const session = parseCachedSession(raw);
|
|
141
|
+
if (!session || !sameBaseUrl(session.baseUrl, baseUrl)) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
const ttlMs = options.ttlMs ?? DEFAULT_SESSION_CACHE_TTL_MS;
|
|
145
|
+
return isCachedSessionFresh(session, ttlMs, options.now ?? Date.now) ? session : null;
|
|
146
|
+
}
|
|
147
|
+
async function writeCachedSession(session, options = {}) {
|
|
148
|
+
const fs = options.fs ?? nodeFs;
|
|
149
|
+
const path3 = sessionCachePath(options.homeDir);
|
|
150
|
+
await fs.mkdir(dirname(path3), { recursive: true, mode: 448 });
|
|
151
|
+
await fs.writeFile(path3, `${JSON.stringify(session, null, 2)}
|
|
152
|
+
`, { encoding: "utf8", mode: 384 });
|
|
153
|
+
await fs.chmod(path3, 384);
|
|
154
|
+
}
|
|
155
|
+
async function deleteCachedSession(baseUrl, options = {}) {
|
|
156
|
+
const current = await readCachedSession(baseUrl, { ...options, noCache: false, ttlMs: Number.MAX_SAFE_INTEGER });
|
|
157
|
+
if (!current) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const fs = options.fs ?? nodeFs;
|
|
161
|
+
try {
|
|
162
|
+
await fs.rm(sessionCachePath(options.homeDir), { force: true });
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (!isMissingFileError(error)) {
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function parseCachedSession(raw) {
|
|
170
|
+
let value;
|
|
171
|
+
try {
|
|
172
|
+
value = JSON.parse(raw);
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
if (!isRecord(value)) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
const session = value;
|
|
180
|
+
if (typeof session.baseUrl !== "string" || typeof session.cookieName !== "string" || typeof session.cookieValue !== "string" || typeof session.sesskey !== "string" || typeof session.userid !== "number" || typeof session.savedAt !== "number") {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
baseUrl: session.baseUrl,
|
|
185
|
+
cookieName: session.cookieName,
|
|
186
|
+
cookieValue: session.cookieValue,
|
|
187
|
+
sesskey: session.sesskey,
|
|
188
|
+
userid: session.userid,
|
|
189
|
+
savedAt: session.savedAt
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
function sameBaseUrl(left, right) {
|
|
193
|
+
try {
|
|
194
|
+
return new URL(left).origin === new URL(right).origin;
|
|
195
|
+
} catch {
|
|
196
|
+
return left === right;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function isRecord(value) {
|
|
200
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
201
|
+
}
|
|
202
|
+
function isMissingFileError(error) {
|
|
203
|
+
return isRecord(error) && error.code === "ENOENT";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// src/auth.ts
|
|
207
|
+
async function getAuthenticatedSession(baseUrl, options = {}) {
|
|
208
|
+
const envSession = loadSessionFromEnv(options.env);
|
|
209
|
+
const validate = options.validateSession ?? validateSessionWithFetch(options);
|
|
210
|
+
if (envSession) {
|
|
211
|
+
const context = await validate(baseUrl, envSession);
|
|
212
|
+
if (!context) {
|
|
213
|
+
throw new AuthError(
|
|
214
|
+
`${ENV_MOODLE_SESSION} is set but did not authenticate for ${baseUrl}.`,
|
|
215
|
+
authFailureHint(baseUrl)
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
await refreshSessionCache(baseUrl, envSession, context, options);
|
|
219
|
+
return { baseUrl, cookie: envSession, ...context, fromCache: false };
|
|
220
|
+
}
|
|
221
|
+
const cached = await readCache(baseUrl, options);
|
|
222
|
+
if (cached) {
|
|
223
|
+
return cached;
|
|
224
|
+
}
|
|
225
|
+
const browserProvider = options.browserCookieProvider ?? defaultBrowserCookieProvider;
|
|
226
|
+
const browserCookies = matchingMoodleSessionCookies(await browserProvider(baseUrl, options), baseUrl);
|
|
227
|
+
const browserSession = await firstValidSession(baseUrl, browserCookies, validate);
|
|
228
|
+
if (browserSession) {
|
|
229
|
+
await refreshSessionCache(baseUrl, browserSession.cookie, browserSession.context, options);
|
|
230
|
+
return { baseUrl, cookie: browserSession.cookie, ...browserSession.context, fromCache: false };
|
|
231
|
+
}
|
|
232
|
+
const oktaProvider = options.oktaCookieProvider ?? loadSessionsFromOktaCli;
|
|
233
|
+
const oktaCookies = matchingMoodleSessionCookies(await oktaProvider(baseUrl, options), baseUrl);
|
|
234
|
+
const oktaSession = await firstValidSession(baseUrl, oktaCookies, validate);
|
|
235
|
+
if (oktaSession) {
|
|
236
|
+
await refreshSessionCache(baseUrl, oktaSession.cookie, oktaSession.context, options);
|
|
237
|
+
return { baseUrl, cookie: oktaSession.cookie, ...oktaSession.context, fromCache: false };
|
|
238
|
+
}
|
|
239
|
+
if (!options.oktaCookieProvider && oktaCookies.length) {
|
|
240
|
+
const refreshed = matchingMoodleSessionCookies(
|
|
241
|
+
await loadSessionsFromOktaCli(baseUrl, { ...options, oktaCookieProvider: void 0, noCache: true }, true),
|
|
242
|
+
baseUrl
|
|
243
|
+
);
|
|
244
|
+
const refreshedSession = await firstValidSession(baseUrl, refreshed, validate);
|
|
245
|
+
if (refreshedSession) {
|
|
246
|
+
await refreshSessionCache(baseUrl, refreshedSession.cookie, refreshedSession.context, options);
|
|
247
|
+
return { baseUrl, cookie: refreshedSession.cookie, ...refreshedSession.context, fromCache: false };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
throw new AuthError(`No usable MoodleSession found for ${baseUrl}.`, authFailureHint(baseUrl));
|
|
251
|
+
}
|
|
252
|
+
function loadSessionFromEnv(env = process.env) {
|
|
253
|
+
const value = env[ENV_MOODLE_SESSION]?.trim();
|
|
254
|
+
return value ? { name: MOODLE_SESSION_COOKIE_PREFIX, value, source: "env" } : null;
|
|
255
|
+
}
|
|
256
|
+
function matchingMoodleSessionCookies(cookies, baseUrl) {
|
|
257
|
+
const host = new URL(baseUrl).hostname.toLowerCase();
|
|
258
|
+
const ranked = [];
|
|
259
|
+
const seen = /* @__PURE__ */ new Set();
|
|
260
|
+
cookies.forEach((cookie, index) => {
|
|
261
|
+
if (!cookie.name.startsWith(MOODLE_SESSION_COOKIE_PREFIX) || !cookie.value) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const rank = cookieHostRank(cookie.domain, host);
|
|
265
|
+
if (rank === null) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const key = `${cookie.name}\0${cookie.value}`;
|
|
269
|
+
if (seen.has(key)) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
seen.add(key);
|
|
273
|
+
ranked.push({ cookie, rank, index });
|
|
274
|
+
});
|
|
275
|
+
return ranked.sort((left, right) => left.rank - right.rank || left.index - right.index).map(({ cookie }) => cookie);
|
|
276
|
+
}
|
|
277
|
+
async function defaultBrowserCookieProvider(baseUrl, options = {}) {
|
|
278
|
+
const chromiumCookies = await loadChromiumCookies(baseUrl, options);
|
|
279
|
+
const firefoxCookies = await loadFirefoxCookies(options);
|
|
280
|
+
return [...chromiumCookies, ...firefoxCookies];
|
|
281
|
+
}
|
|
282
|
+
async function loadSessionsFromOktaCli(baseUrl, options = {}, forceLogin = false) {
|
|
283
|
+
const execFile = options.execFile ?? defaultExecFile;
|
|
284
|
+
const executable = await findExecutable("okta", execFile, options.platform);
|
|
285
|
+
if (!executable) {
|
|
286
|
+
return [];
|
|
287
|
+
}
|
|
288
|
+
const stored = await readOktaCookies(executable, baseUrl, execFile);
|
|
289
|
+
if (stored.length && !forceLogin) {
|
|
290
|
+
return stored;
|
|
291
|
+
}
|
|
292
|
+
const login = await runOktaJson(executable, ["login", baseUrl], execFile);
|
|
293
|
+
if (!login) {
|
|
294
|
+
return stored;
|
|
295
|
+
}
|
|
296
|
+
const refreshed = await readOktaCookies(executable, baseUrl, execFile);
|
|
297
|
+
return refreshed.length ? refreshed : stored;
|
|
298
|
+
}
|
|
299
|
+
function authFailureHint(baseUrl) {
|
|
300
|
+
return [
|
|
301
|
+
`Log in to ${loginUrl(baseUrl)} in your browser, then rerun the command.`,
|
|
302
|
+
`Or set ${ENV_MOODLE_SESSION} to a valid MoodleSession cookie value.`,
|
|
303
|
+
`For automatic login, install okta-auth: ${OKTA_AUTH_INSTALL_COMMAND}, then run ${OKTA_AUTH_CONFIG_COMMAND}.`,
|
|
304
|
+
`okta-auth: ${OKTA_AUTH_URL}`
|
|
305
|
+
].join("\n");
|
|
306
|
+
}
|
|
307
|
+
function parseSessionContext(html) {
|
|
308
|
+
const sesskey = firstMatch(html, [
|
|
309
|
+
/"sesskey"\s*:\s*"([^"]+)"/,
|
|
310
|
+
/\bsesskey\s*:\s*'([^']+)'/,
|
|
311
|
+
/name=["']sesskey["'][^>]*value=["']([^"']+)["']/i,
|
|
312
|
+
/value=["']([^"']+)["'][^>]*name=["']sesskey["']/i
|
|
313
|
+
]);
|
|
314
|
+
if (!sesskey) {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
const useridRaw = firstMatch(html, [
|
|
318
|
+
/"userid"\s*:\s*(\d+)/,
|
|
319
|
+
/\buserid\s*:\s*(\d+)/,
|
|
320
|
+
/data-userid=["'](\d+)["']/i
|
|
321
|
+
]);
|
|
322
|
+
return { sesskey: decodeHtml(sesskey), userid: useridRaw ? Number(useridRaw) : 0 };
|
|
323
|
+
}
|
|
324
|
+
function validateSessionWithFetch(options) {
|
|
325
|
+
return async (baseUrl, cookie) => {
|
|
326
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
327
|
+
if (!fetcher) {
|
|
328
|
+
throw new AuthError("fetch is not available in this runtime.", authFailureHint(baseUrl));
|
|
329
|
+
}
|
|
330
|
+
let response;
|
|
331
|
+
try {
|
|
332
|
+
response = await fetcher(`${baseUrl}${DASHBOARD_PATH}`, {
|
|
333
|
+
redirect: "follow",
|
|
334
|
+
headers: { cookie: `${cookie.name}=${cookie.value}` }
|
|
335
|
+
});
|
|
336
|
+
} catch {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
if (response.status >= 400 || isLoginRedirect(response.url, baseUrl)) {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
const html = await response.text();
|
|
343
|
+
if (looksLikeLoginPage(html)) {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
return parseSessionContext(html);
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
async function loadChromiumCookies(baseUrl, options) {
|
|
350
|
+
const chrome = await importChromeCookiesSecure();
|
|
351
|
+
if (!chrome) {
|
|
352
|
+
return [];
|
|
353
|
+
}
|
|
354
|
+
const cookies = [];
|
|
355
|
+
for (const browser of ["Chrome", "Brave", "Edge"]) {
|
|
356
|
+
for (const cookieFile of await chromiumCookieFiles(browser, options)) {
|
|
357
|
+
try {
|
|
358
|
+
const items = await chrome.getCookiesPromised(baseUrl, "puppeteer", cookieFile);
|
|
359
|
+
cookies.push(
|
|
360
|
+
...items.map((cookie) => ({
|
|
361
|
+
name: cookie.name,
|
|
362
|
+
value: cookie.value,
|
|
363
|
+
domain: cookie.domain,
|
|
364
|
+
path: cookie.path,
|
|
365
|
+
source: `${browser}:${basename(cookieFile)}`
|
|
366
|
+
}))
|
|
367
|
+
);
|
|
368
|
+
} catch {
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return cookies;
|
|
374
|
+
}
|
|
375
|
+
async function loadFirefoxCookies(options) {
|
|
376
|
+
const execFile = options.execFile ?? defaultExecFile;
|
|
377
|
+
const sqlite = await findExecutable("sqlite3", execFile, options.platform);
|
|
378
|
+
if (!sqlite) {
|
|
379
|
+
return [];
|
|
380
|
+
}
|
|
381
|
+
const cookies = [];
|
|
382
|
+
for (const cookieFile of await firefoxCookieFiles(options)) {
|
|
383
|
+
const tempDir = await mkdtemp(join2(tmpdir(), "moodle-cli-firefox-"));
|
|
384
|
+
const tempDb = join2(tempDir, "cookies.sqlite");
|
|
385
|
+
try {
|
|
386
|
+
await copyFile(cookieFile, tempDb);
|
|
387
|
+
const result = await execFile(sqlite, [
|
|
388
|
+
"-json",
|
|
389
|
+
tempDb,
|
|
390
|
+
"select name, value, host as domain, path from moz_cookies where name like 'MoodleSession%';"
|
|
391
|
+
]);
|
|
392
|
+
if (result.exitCode !== 0 || !result.stdout.trim()) {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const rows = JSON.parse(result.stdout);
|
|
396
|
+
if (!Array.isArray(rows)) {
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
cookies.push(
|
|
400
|
+
...rows.filter(isRecord2).map((row) => ({
|
|
401
|
+
name: String(row.name ?? ""),
|
|
402
|
+
value: String(row.value ?? ""),
|
|
403
|
+
domain: typeof row.domain === "string" ? row.domain : void 0,
|
|
404
|
+
path: typeof row.path === "string" ? row.path : void 0,
|
|
405
|
+
source: `Firefox:${basename(cookieFile)}`
|
|
406
|
+
}))
|
|
407
|
+
);
|
|
408
|
+
} catch {
|
|
409
|
+
continue;
|
|
410
|
+
} finally {
|
|
411
|
+
await rm2(tempDir, { recursive: true, force: true });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return cookies;
|
|
415
|
+
}
|
|
416
|
+
async function chromiumCookieFiles(browser, options) {
|
|
417
|
+
const files = [];
|
|
418
|
+
for (const root of chromiumUserDataDirs(browser, options)) {
|
|
419
|
+
const profiles = await profileDirs(root);
|
|
420
|
+
for (const profile of profiles) {
|
|
421
|
+
for (const relative of ["Cookies", "Network/Cookies"]) {
|
|
422
|
+
const file = join2(root, profile, relative);
|
|
423
|
+
if (await isFile(file)) {
|
|
424
|
+
files.push(file);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return files;
|
|
430
|
+
}
|
|
431
|
+
function chromiumUserDataDirs(browser, options) {
|
|
432
|
+
const home = options.homeDir ?? homedir2();
|
|
433
|
+
const platform = options.platform ?? process.platform;
|
|
434
|
+
const dirs = {
|
|
435
|
+
darwin: {
|
|
436
|
+
Chrome: ["Library/Application Support/Google/Chrome"],
|
|
437
|
+
Brave: ["Library/Application Support/BraveSoftware/Brave-Browser"],
|
|
438
|
+
Edge: ["Library/Application Support/Microsoft Edge"]
|
|
439
|
+
},
|
|
440
|
+
linux: {
|
|
441
|
+
Chrome: [".config/google-chrome", ".var/app/com.google.Chrome/config/google-chrome"],
|
|
442
|
+
Brave: [".config/BraveSoftware/Brave-Browser", ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser"],
|
|
443
|
+
Edge: [".config/microsoft-edge"]
|
|
444
|
+
},
|
|
445
|
+
win32: {
|
|
446
|
+
Chrome: ["AppData/Local/Google/Chrome/User Data"],
|
|
447
|
+
Brave: ["AppData/Local/BraveSoftware/Brave-Browser/User Data"],
|
|
448
|
+
Edge: ["AppData/Local/Microsoft/Edge/User Data"]
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
return [...dirs[platform]?.[browser] ?? []].map((part) => join2(home, part));
|
|
452
|
+
}
|
|
453
|
+
async function firefoxCookieFiles(options) {
|
|
454
|
+
const home = options.homeDir ?? homedir2();
|
|
455
|
+
const platform = options.platform ?? process.platform;
|
|
456
|
+
const roots = {
|
|
457
|
+
darwin: ["Library/Application Support/Firefox/Profiles"],
|
|
458
|
+
linux: [".mozilla/firefox"],
|
|
459
|
+
win32: ["AppData/Roaming/Mozilla/Firefox/Profiles"]
|
|
460
|
+
};
|
|
461
|
+
const files = [];
|
|
462
|
+
for (const rootPart of roots[platform] ?? []) {
|
|
463
|
+
const root = join2(home, rootPart);
|
|
464
|
+
for (const profile of await profileDirs(root, true)) {
|
|
465
|
+
const file = join2(root, profile, "cookies.sqlite");
|
|
466
|
+
if (await isFile(file)) {
|
|
467
|
+
files.push(file);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return files;
|
|
472
|
+
}
|
|
473
|
+
async function profileDirs(root, allowAnyDirectory = false) {
|
|
474
|
+
let entries;
|
|
475
|
+
try {
|
|
476
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
477
|
+
} catch {
|
|
478
|
+
return [];
|
|
479
|
+
}
|
|
480
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).filter((name) => allowAnyDirectory || name === "Default" || name === "Guest Profile" || name.startsWith("Profile "));
|
|
481
|
+
}
|
|
482
|
+
async function isFile(path3) {
|
|
483
|
+
try {
|
|
484
|
+
return (await stat(path3)).isFile();
|
|
485
|
+
} catch {
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
async function importChromeCookiesSecure() {
|
|
490
|
+
try {
|
|
491
|
+
const dynamicImport = new Function("specifier", "return import(specifier)");
|
|
492
|
+
const module = await dynamicImport("chrome-cookies-secure");
|
|
493
|
+
return module.default ?? module;
|
|
494
|
+
} catch {
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
async function readOktaCookies(executable, baseUrl, execFile) {
|
|
499
|
+
const payload = await runOktaJson(executable, ["cookies", baseUrl], execFile);
|
|
500
|
+
if (!payload) {
|
|
501
|
+
return [];
|
|
502
|
+
}
|
|
503
|
+
const cookies = Array.isArray(payload.cookies) ? payload.cookies : Array.isArray(payload) ? payload : [];
|
|
504
|
+
return cookies.filter(isRecord2).map((cookie) => ({
|
|
505
|
+
name: String(cookie.name ?? ""),
|
|
506
|
+
value: String(cookie.value ?? ""),
|
|
507
|
+
domain: typeof cookie.domain === "string" ? cookie.domain : void 0,
|
|
508
|
+
path: typeof cookie.path === "string" ? cookie.path : void 0,
|
|
509
|
+
source: "okta"
|
|
510
|
+
}));
|
|
511
|
+
}
|
|
512
|
+
async function runOktaJson(executable, args, execFile) {
|
|
513
|
+
const result = await execFile(executable, [...args, "--json"]);
|
|
514
|
+
if (result.exitCode !== 0 || !result.stdout.trim()) {
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
try {
|
|
518
|
+
const payload = JSON.parse(result.stdout);
|
|
519
|
+
return isRecord2(payload) ? payload : null;
|
|
520
|
+
} catch {
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
async function findExecutable(name, execFile, platform = process.platform) {
|
|
525
|
+
const command = platform === "win32" ? "where" : "which";
|
|
526
|
+
const result = await execFile(command, [name]);
|
|
527
|
+
if (result.exitCode !== 0) {
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
return result.stdout.split(/\r?\n/, 1)[0]?.trim() || null;
|
|
531
|
+
}
|
|
532
|
+
async function firstValidSession(baseUrl, cookies, validate) {
|
|
533
|
+
for (const cookie of cookies) {
|
|
534
|
+
const context = await validate(baseUrl, cookie);
|
|
535
|
+
if (context) {
|
|
536
|
+
return { cookie, context };
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
async function readCache(baseUrl, options) {
|
|
542
|
+
try {
|
|
543
|
+
const cached = await readCachedSession(baseUrl, cacheOptions(options));
|
|
544
|
+
return cached ? cachedSessionToAuth(baseUrl, cached) : null;
|
|
545
|
+
} catch {
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
async function refreshSessionCache(baseUrl, cookie, context, options) {
|
|
550
|
+
const session = {
|
|
551
|
+
baseUrl,
|
|
552
|
+
cookieName: cookie.name,
|
|
553
|
+
cookieValue: cookie.value,
|
|
554
|
+
sesskey: context.sesskey,
|
|
555
|
+
userid: context.userid,
|
|
556
|
+
savedAt: (options.now ?? Date.now)()
|
|
557
|
+
};
|
|
558
|
+
try {
|
|
559
|
+
await writeCachedSession(session, cacheOptions(options));
|
|
560
|
+
} catch {
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
function cachedSessionToAuth(baseUrl, cached) {
|
|
565
|
+
return {
|
|
566
|
+
baseUrl,
|
|
567
|
+
cookie: { name: cached.cookieName, value: cached.cookieValue, source: "cache" },
|
|
568
|
+
sesskey: cached.sesskey,
|
|
569
|
+
userid: cached.userid,
|
|
570
|
+
fromCache: true
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
function cacheOptions(options) {
|
|
574
|
+
return {
|
|
575
|
+
homeDir: options.homeDir,
|
|
576
|
+
ttlMs: options.cacheTtlMs,
|
|
577
|
+
now: options.now,
|
|
578
|
+
noCache: options.noCache
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
function cookieHostRank(domain, host) {
|
|
582
|
+
if (!domain) {
|
|
583
|
+
return 2;
|
|
584
|
+
}
|
|
585
|
+
const normalized = domain.replace(/^\./, "").toLowerCase();
|
|
586
|
+
if (normalized === host) {
|
|
587
|
+
return 0;
|
|
588
|
+
}
|
|
589
|
+
if (host.endsWith(`.${normalized}`)) {
|
|
590
|
+
return 1;
|
|
591
|
+
}
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
function loginUrl(baseUrl) {
|
|
595
|
+
return new URL(LOGIN_PATH, `${baseUrl.replace(/\/+$/, "")}/`).toString();
|
|
596
|
+
}
|
|
597
|
+
function isLoginRedirect(responseUrl, baseUrl) {
|
|
598
|
+
if (!responseUrl) {
|
|
599
|
+
return false;
|
|
600
|
+
}
|
|
601
|
+
const path3 = new URL(responseUrl, baseUrl).pathname;
|
|
602
|
+
return path3 === LOGIN_PATH || path3.startsWith("/login/");
|
|
603
|
+
}
|
|
604
|
+
function looksLikeLoginPage(html) {
|
|
605
|
+
return /name=["']username["']/i.test(html) && /name=["']password["']/i.test(html);
|
|
606
|
+
}
|
|
607
|
+
function firstMatch(value, patterns) {
|
|
608
|
+
for (const pattern of patterns) {
|
|
609
|
+
const match = value.match(pattern);
|
|
610
|
+
if (match?.[1]) {
|
|
611
|
+
return match[1];
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
function decodeHtml(value) {
|
|
617
|
+
return value.replace(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">");
|
|
618
|
+
}
|
|
619
|
+
var defaultExecFile = (file, args) => new Promise((resolve) => {
|
|
620
|
+
execFileCallback(file, args, { encoding: "utf8" }, (error, stdout, stderr) => {
|
|
621
|
+
const errorWithCode = error;
|
|
622
|
+
resolve({
|
|
623
|
+
stdout: String(stdout ?? ""),
|
|
624
|
+
stderr: String(stderr ?? ""),
|
|
625
|
+
exitCode: errorWithCode ? Number(errorWithCode.code) || 1 : 0
|
|
626
|
+
});
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
function isRecord2(value) {
|
|
630
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// src/parsers.ts
|
|
634
|
+
function schema(parser) {
|
|
635
|
+
return { parse: parser };
|
|
636
|
+
}
|
|
637
|
+
var UserInfoSchema = schema(parseUserInfo);
|
|
638
|
+
var CourseSchema = schema(parseCourse);
|
|
639
|
+
var CoursesSchema = schema(parseCourses);
|
|
640
|
+
var ActivitySchema = schema(parseActivity);
|
|
641
|
+
var SectionSchema = schema(parseSection);
|
|
642
|
+
var CourseContentsSchema = schema(parseCourseContents);
|
|
643
|
+
var TodoItemSchema = schema(parseTodoItem);
|
|
644
|
+
function parseUserInfo(value) {
|
|
645
|
+
const data = asRecord(value);
|
|
646
|
+
return {
|
|
647
|
+
userid: numberValue(data.userid),
|
|
648
|
+
username: stringValue(data.username),
|
|
649
|
+
fullname: stringValue(data.fullname),
|
|
650
|
+
sitename: stringValue(data.sitename),
|
|
651
|
+
siteurl: stringValue(data.siteurl),
|
|
652
|
+
lang: stringValue(data.lang)
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
function parseCourse(value, nowSeconds = Math.floor(Date.now() / 1e3)) {
|
|
656
|
+
const data = asRecord(value);
|
|
657
|
+
const course = {
|
|
658
|
+
id: numberValue(data.id),
|
|
659
|
+
shortname: stringValue(data.shortname),
|
|
660
|
+
fullname: stringValue(data.fullname),
|
|
661
|
+
category: numberValue(data.category),
|
|
662
|
+
visible: booleanValue(data.visible, true),
|
|
663
|
+
startdate: numberValue(data.startdate)
|
|
664
|
+
};
|
|
665
|
+
const enddate = numberValue(data.enddate);
|
|
666
|
+
if (enddate > nowSeconds) {
|
|
667
|
+
course.enddate = enddate;
|
|
668
|
+
}
|
|
669
|
+
return course;
|
|
670
|
+
}
|
|
671
|
+
function parseCourses(value) {
|
|
672
|
+
return asArray(value).map((item) => parseCourse(item));
|
|
673
|
+
}
|
|
674
|
+
function parseActivity(value) {
|
|
675
|
+
const data = asRecord(value);
|
|
676
|
+
return {
|
|
677
|
+
id: numberValue(data.id),
|
|
678
|
+
name: stringValue(data.name),
|
|
679
|
+
modname: stringValue(data.modname),
|
|
680
|
+
url: stringValue(data.url),
|
|
681
|
+
visible: booleanValue(data.visible, true),
|
|
682
|
+
description: stringValue(data.description)
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
function parseSection(value) {
|
|
686
|
+
const data = asRecord(value);
|
|
687
|
+
return {
|
|
688
|
+
id: numberValue(data.id),
|
|
689
|
+
name: stringValue(data.name),
|
|
690
|
+
section: numberValue(data.section),
|
|
691
|
+
visible: booleanValue(data.visible, true),
|
|
692
|
+
summary: stringValue(data.summary),
|
|
693
|
+
activities: asArray(data.modules).map((item) => parseActivity(item))
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
function parseCourseContents(value) {
|
|
697
|
+
return asArray(value).map((item) => parseSection(item));
|
|
698
|
+
}
|
|
699
|
+
function parseTodoItem(value) {
|
|
700
|
+
const data = asRecord(value);
|
|
701
|
+
const course = asRecord(data.course);
|
|
702
|
+
const action = asRecord(data.action);
|
|
703
|
+
const progress = course.progress;
|
|
704
|
+
return {
|
|
705
|
+
id: numberValue(data.id),
|
|
706
|
+
name: stringValue(data.name),
|
|
707
|
+
activity_name: stringValue(data.activityname),
|
|
708
|
+
modname: stringValue(data.modulename),
|
|
709
|
+
course_id: numberValue(course.id),
|
|
710
|
+
course_name: stringValue(course.fullname),
|
|
711
|
+
due_at: numberValue(data.timesort) || numberValue(data.timestart),
|
|
712
|
+
overdue: booleanValue(data.overdue),
|
|
713
|
+
actionable: booleanValue(action.actionable),
|
|
714
|
+
action_name: stringValue(action.name),
|
|
715
|
+
action_url: stringValue(action.url),
|
|
716
|
+
url: stringValue(data.url),
|
|
717
|
+
event_type: stringValue(data.eventtype),
|
|
718
|
+
course_progress: typeof progress === "number" ? progress : void 0
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
function parseTodoItems(value) {
|
|
722
|
+
return asArray(value).map((item) => parseTodoItem(item));
|
|
723
|
+
}
|
|
724
|
+
function parseAlertNotification(value) {
|
|
725
|
+
const data = asRecord(value);
|
|
726
|
+
return {
|
|
727
|
+
id: numberValue(data.id),
|
|
728
|
+
subject: stringValue(data.subject),
|
|
729
|
+
short_subject: stringValue(data.shortenedsubject),
|
|
730
|
+
event_type: stringValue(data.eventtype),
|
|
731
|
+
component: stringValue(data.component),
|
|
732
|
+
created_at: numberValue(data.timecreated),
|
|
733
|
+
created_pretty: stringValue(data.timecreatedpretty),
|
|
734
|
+
read: booleanValue(data.read),
|
|
735
|
+
context_url: stringValue(data.contexturl),
|
|
736
|
+
context_name: stringValue(data.contexturlname)
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
function parseAlertSummary(notificationsData, countsData, unreadCountsData) {
|
|
740
|
+
const notificationsRecord = asRecord(notificationsData);
|
|
741
|
+
const counts = asRecord(countsData);
|
|
742
|
+
const unreadCounts = asRecord(unreadCountsData);
|
|
743
|
+
const types = asRecord(counts.types);
|
|
744
|
+
const unreadTypes = asRecord(unreadCounts.types);
|
|
745
|
+
const notifications = asArray(notificationsRecord.notifications).map((item) => parseAlertNotification(item));
|
|
746
|
+
return {
|
|
747
|
+
notifications,
|
|
748
|
+
notification_count: notifications.length,
|
|
749
|
+
unread_notification_count: notifications.filter((notification) => !notification.read).length,
|
|
750
|
+
starred_message_count: numberValue(counts.favourites),
|
|
751
|
+
direct_message_count: numberValue(types["1"]),
|
|
752
|
+
group_message_count: numberValue(types["2"]),
|
|
753
|
+
self_message_count: numberValue(types["3"]),
|
|
754
|
+
unread_starred_message_count: numberValue(unreadCounts.favourites),
|
|
755
|
+
unread_direct_message_count: numberValue(unreadTypes["1"]),
|
|
756
|
+
unread_group_message_count: numberValue(unreadTypes["2"]),
|
|
757
|
+
unread_self_message_count: numberValue(unreadTypes["3"])
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
function parseForumPostAuthor(value) {
|
|
761
|
+
const data = asRecord(value);
|
|
762
|
+
const urls = asRecord(data.urls);
|
|
763
|
+
return {
|
|
764
|
+
id: numberValue(data.id),
|
|
765
|
+
fullname: stringValue(data.fullname),
|
|
766
|
+
profile_url: stringValue(urls.profile),
|
|
767
|
+
profile_image_url: stringValue(urls.profileimage)
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
function parseForumPost(value) {
|
|
771
|
+
const data = asRecord(value);
|
|
772
|
+
const urls = asRecord(data.urls);
|
|
773
|
+
return {
|
|
774
|
+
id: numberValue(data.id),
|
|
775
|
+
discussion_id: numberValue(data.discussionid),
|
|
776
|
+
subject: stringValue(data.subject),
|
|
777
|
+
message_html: stringValue(data.message),
|
|
778
|
+
message_text: stringValue(data.message).replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(),
|
|
779
|
+
image_urls: [],
|
|
780
|
+
links: [],
|
|
781
|
+
tables: [],
|
|
782
|
+
author: parseForumPostAuthor(data.author),
|
|
783
|
+
parent_id: numberValue(data.parentid),
|
|
784
|
+
time_created: numberValue(data.timecreated),
|
|
785
|
+
time_modified: numberValue(data.timemodified),
|
|
786
|
+
created_pretty: "",
|
|
787
|
+
unread: booleanValue(data.unread),
|
|
788
|
+
is_deleted: booleanValue(data.isdeleted),
|
|
789
|
+
is_private_reply: booleanValue(data.isprivatereply),
|
|
790
|
+
url: stringValue(urls.view || urls.viewisolated),
|
|
791
|
+
reply_url: stringValue(urls.reply)
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
function parseForumDiscussion(value, discussionId) {
|
|
795
|
+
const data = asRecord(value);
|
|
796
|
+
const posts = asArray(data.posts).map((item) => parseForumPost(item));
|
|
797
|
+
return {
|
|
798
|
+
id: discussionId,
|
|
799
|
+
subject: posts[0]?.subject ?? "",
|
|
800
|
+
course_id: numberValue(data.courseid),
|
|
801
|
+
forum_id: numberValue(data.forumid),
|
|
802
|
+
group_id: numberValue(data.groupid),
|
|
803
|
+
group_name: stringValue(data.groupname),
|
|
804
|
+
url: posts[0]?.url ? posts[0].url.split("#", 1)[0] : "",
|
|
805
|
+
posts
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
function asRecord(value) {
|
|
809
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
810
|
+
}
|
|
811
|
+
function asArray(value) {
|
|
812
|
+
return Array.isArray(value) ? value : [];
|
|
813
|
+
}
|
|
814
|
+
function stringValue(value) {
|
|
815
|
+
return typeof value === "string" ? value : value == null ? "" : String(value);
|
|
816
|
+
}
|
|
817
|
+
function numberValue(value) {
|
|
818
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
819
|
+
return value;
|
|
820
|
+
}
|
|
821
|
+
if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) {
|
|
822
|
+
return Number(value);
|
|
823
|
+
}
|
|
824
|
+
return 0;
|
|
825
|
+
}
|
|
826
|
+
function booleanValue(value, defaultValue = false) {
|
|
827
|
+
if (value === void 0 || value === null) {
|
|
828
|
+
return defaultValue;
|
|
829
|
+
}
|
|
830
|
+
return Boolean(value);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// src/scraper.ts
|
|
834
|
+
import { parse as parse2 } from "node-html-parser";
|
|
835
|
+
|
|
836
|
+
// src/html-utils.ts
|
|
837
|
+
import { parse } from "node-html-parser";
|
|
838
|
+
function htmlToStructuredContent(html, baseUrl) {
|
|
839
|
+
if (!html) {
|
|
840
|
+
return { text: "", image_urls: [], links: [], tables: [] };
|
|
841
|
+
}
|
|
842
|
+
const root = parse(html);
|
|
843
|
+
const image_urls = [];
|
|
844
|
+
const links = [];
|
|
845
|
+
const tables = [];
|
|
846
|
+
for (const br of root.querySelectorAll("br")) {
|
|
847
|
+
br.replaceWith("\n");
|
|
848
|
+
}
|
|
849
|
+
for (const img of root.querySelectorAll("img")) {
|
|
850
|
+
const src = (img.getAttribute("src") ?? "").trim();
|
|
851
|
+
if (!src) {
|
|
852
|
+
img.replaceWith("[image]");
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
const absolute = resolveUrl(baseUrl, src);
|
|
856
|
+
image_urls.push(absolute);
|
|
857
|
+
const label = (img.getAttribute("alt") ?? "").trim() || "image";
|
|
858
|
+
img.replaceWith(`[${label}] ${absolute}`);
|
|
859
|
+
}
|
|
860
|
+
for (const link of root.querySelectorAll("a[href]")) {
|
|
861
|
+
const href = (link.getAttribute("href") ?? "").trim();
|
|
862
|
+
if (!href) {
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
links.push({ text: cleanText(link.textContent), url: resolveUrl(baseUrl, href) });
|
|
866
|
+
}
|
|
867
|
+
for (const table of root.querySelectorAll("table")) {
|
|
868
|
+
const headers = [];
|
|
869
|
+
const rows = [];
|
|
870
|
+
for (const row of table.querySelectorAll("tr")) {
|
|
871
|
+
const headerCells = row.querySelectorAll("th");
|
|
872
|
+
const dataCells = row.querySelectorAll("td");
|
|
873
|
+
const cells = headerCells.length ? headerCells : dataCells;
|
|
874
|
+
if (!cells.length) {
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
const values = cells.map((cell) => cleanText(cell.textContent));
|
|
878
|
+
if (headerCells.length && !headers.length && !rows.length) {
|
|
879
|
+
headers.push(...values);
|
|
880
|
+
} else {
|
|
881
|
+
rows.push(values);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
if (headers.length || rows.length) {
|
|
885
|
+
tables.push({ headers, rows });
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
const text = root.textContent.split(/\r?\n/).map((line) => cleanText(line)).filter(Boolean).join("\n");
|
|
889
|
+
return { text, image_urls, links, tables };
|
|
890
|
+
}
|
|
891
|
+
function cleanText(value) {
|
|
892
|
+
return decodeHtml2(value ?? "").replace(/\s+/g, " ").trim();
|
|
893
|
+
}
|
|
894
|
+
function resolveUrl(baseUrl, href) {
|
|
895
|
+
try {
|
|
896
|
+
return new URL(href, baseUrl).toString();
|
|
897
|
+
} catch {
|
|
898
|
+
return href;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
function decodeHtml2(value) {
|
|
902
|
+
return value.replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'");
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// src/scraper.ts
|
|
906
|
+
function parsePageContext(html, baseUrl) {
|
|
907
|
+
const root = parse2(html);
|
|
908
|
+
const config = parseMoodleConfig(html);
|
|
909
|
+
const sesskey = stringValue2(config.sesskey).trim();
|
|
910
|
+
const userid = numberValue2(config.userId) || numberValue2(root.querySelector("[data-user-id]")?.getAttribute("data-user-id"));
|
|
911
|
+
if (!sesskey || !userid) {
|
|
912
|
+
throw new Error("Session appears invalid: could not load authenticated Moodle context");
|
|
913
|
+
}
|
|
914
|
+
return {
|
|
915
|
+
sesskey,
|
|
916
|
+
user_info: {
|
|
917
|
+
userid,
|
|
918
|
+
username: "",
|
|
919
|
+
fullname: cleanNodeText(root.querySelector(".userfullname")),
|
|
920
|
+
sitename: extractSitename(root),
|
|
921
|
+
siteurl: baseUrl,
|
|
922
|
+
lang: stringValue2(config.language) || root.querySelector("html")?.getAttribute("lang") || ""
|
|
923
|
+
}
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
function parseCourseContentsHtml(html, baseUrl) {
|
|
927
|
+
const root = parse2(html);
|
|
928
|
+
const sections = [];
|
|
929
|
+
for (const sectionElement of root.querySelectorAll('li[data-for="section"]')) {
|
|
930
|
+
const sectionId = safeInt(sectionElement.getAttribute("data-id"));
|
|
931
|
+
const sectionNumber = safeInt(sectionElement.getAttribute("data-number") ?? sectionElement.getAttribute("data-sectionnum"));
|
|
932
|
+
const positionName = cleanNodeText(sectionElement.querySelector(".course-section-position-name"));
|
|
933
|
+
const mainName = cleanNodeText(
|
|
934
|
+
firstDefined([
|
|
935
|
+
first(sectionElement, ["h1.sectionname", "h2.sectionname", "h3.sectionname"]),
|
|
936
|
+
sectionElement.querySelector('[data-for="section_title"] a'),
|
|
937
|
+
sectionElement.querySelector('[data-for="section_title"]')
|
|
938
|
+
])
|
|
939
|
+
);
|
|
940
|
+
const name = positionName && mainName && positionName !== mainName ? `${positionName} - ${mainName}` : mainName || positionName || `Section ${sectionNumber}`;
|
|
941
|
+
const visible = !(sectionElement.getAttribute("class") ?? "").split(/\s+/).includes("hidden");
|
|
942
|
+
const activities = [];
|
|
943
|
+
const seenActivities = /* @__PURE__ */ new Set();
|
|
944
|
+
for (const activityElement of sectionElement.querySelectorAll('li[data-for="cmitem"]')) {
|
|
945
|
+
const id = safeInt(activityElement.getAttribute("data-id"));
|
|
946
|
+
if (id && seenActivities.has(id)) {
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
if (id) {
|
|
950
|
+
seenActivities.add(id);
|
|
951
|
+
}
|
|
952
|
+
const classes = (activityElement.getAttribute("class") ?? "").split(/\s+/).filter(Boolean);
|
|
953
|
+
const modname = classes.find((item) => item.startsWith("modtype_"))?.slice("modtype_".length) ?? "";
|
|
954
|
+
const name2 = cleanNodeText(
|
|
955
|
+
firstDefined([
|
|
956
|
+
activityElement.querySelector(".activityname .instancename"),
|
|
957
|
+
activityElement.querySelector(".activityname"),
|
|
958
|
+
activityElement.querySelector("a.aalink")
|
|
959
|
+
])
|
|
960
|
+
);
|
|
961
|
+
if (!name2) {
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
const href = activityElement.querySelector(".activityname a, a.aalink, a[href]")?.getAttribute("href") ?? "";
|
|
965
|
+
activities.push({
|
|
966
|
+
id,
|
|
967
|
+
name: name2,
|
|
968
|
+
modname,
|
|
969
|
+
url: href ? resolveUrl(baseUrl, href) : "",
|
|
970
|
+
visible: !classes.some((item) => ["hidden", "stealth", "dimmed"].includes(item)),
|
|
971
|
+
description: cleanNodeText(first(activityElement, ["[data-region='activity-description']", ".contentafterlink", ".description"]))
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
sections.push({
|
|
975
|
+
id: sectionId,
|
|
976
|
+
name,
|
|
977
|
+
section: sectionNumber,
|
|
978
|
+
visible,
|
|
979
|
+
summary: cleanNodeText(first(sectionElement, [".summarytext", "[data-for='sectioninfo']"])),
|
|
980
|
+
activities
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
return sections;
|
|
984
|
+
}
|
|
985
|
+
function parseCourseSectionNumbers(html, courseId) {
|
|
986
|
+
const sections = [];
|
|
987
|
+
const pattern = /href=["']([^"']*\/course\/view\.php\?[^"']*)["']/g;
|
|
988
|
+
for (const match of html.matchAll(pattern)) {
|
|
989
|
+
const url = parseMaybeUrl(match[1], "https://moodle.invalid");
|
|
990
|
+
const id = url?.searchParams.get("id");
|
|
991
|
+
const sectionValue = url?.searchParams.get("section");
|
|
992
|
+
if (id === String(courseId) && sectionValue && /^\d+$/.test(sectionValue)) {
|
|
993
|
+
const section = Number(sectionValue);
|
|
994
|
+
if (!sections.includes(section)) {
|
|
995
|
+
sections.push(section);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
return sections;
|
|
1000
|
+
}
|
|
1001
|
+
function parseCourseGradesUrl(html, baseUrl) {
|
|
1002
|
+
const root = parse2(html);
|
|
1003
|
+
const link = root.querySelector('li[data-key="grades"] a[href]') ?? root.querySelector('.secondary-navigation a[href*="mode=grade"]') ?? root.querySelector('.secondary-navigation a[href*="/grade/report/"]') ?? root.querySelector('a[href*="mode=grade"], a[href*="/grade/report/"]');
|
|
1004
|
+
const href = link?.getAttribute("href") ?? "";
|
|
1005
|
+
return href ? resolveUrl(baseUrl, href) : "";
|
|
1006
|
+
}
|
|
1007
|
+
function parseCourseIdFromPageHtml(html) {
|
|
1008
|
+
const root = parse2(html);
|
|
1009
|
+
for (const link of root.querySelectorAll('a[href*="/course/view.php?id="]')) {
|
|
1010
|
+
const courseId = numberQueryValue(link.getAttribute("href") ?? "", "id");
|
|
1011
|
+
if (courseId !== null) {
|
|
1012
|
+
return courseId;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
return null;
|
|
1016
|
+
}
|
|
1017
|
+
function hasCourseGradesHtml(html) {
|
|
1018
|
+
return parse2(html).querySelector("table.user-grade") !== null;
|
|
1019
|
+
}
|
|
1020
|
+
function parseCourseGradesHtml(html, courseId, baseUrl) {
|
|
1021
|
+
const root = parse2(html);
|
|
1022
|
+
const report = {
|
|
1023
|
+
course_id: courseId,
|
|
1024
|
+
course_name: cleanNodeText(root.querySelector("h1")),
|
|
1025
|
+
learner_name: cleanNodeText(
|
|
1026
|
+
firstDefined([
|
|
1027
|
+
root.querySelector(".grade-report-user .page-header-headings h2"),
|
|
1028
|
+
root.querySelector(".page-header-headings h2"),
|
|
1029
|
+
root.querySelector(".grade-report-user h2 a"),
|
|
1030
|
+
root.querySelector("h2 a"),
|
|
1031
|
+
root.querySelector("h2")
|
|
1032
|
+
])
|
|
1033
|
+
),
|
|
1034
|
+
total_grade: "",
|
|
1035
|
+
total_range: "",
|
|
1036
|
+
total_percentage: "",
|
|
1037
|
+
items: []
|
|
1038
|
+
};
|
|
1039
|
+
const table = root.querySelector("table.user-grade");
|
|
1040
|
+
if (!table) {
|
|
1041
|
+
return report;
|
|
1042
|
+
}
|
|
1043
|
+
for (const row of table.querySelectorAll("tr")) {
|
|
1044
|
+
const title = cleanNodeText(row.querySelector(".rowtitle"));
|
|
1045
|
+
if (!title || row.querySelector(".toggle-category")) {
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
if (title === "Course total") {
|
|
1049
|
+
report.total_grade = cleanTableCell(row.querySelector("td.column-grade"));
|
|
1050
|
+
report.total_range = cleanTableCell(row.querySelector("td.column-range"));
|
|
1051
|
+
report.total_percentage = cleanTableCell(row.querySelector("td.column-percentage"));
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
1054
|
+
const link = row.querySelector(".rowtitle a.gradeitemheader, .rowtitle a");
|
|
1055
|
+
if (!link) {
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
const statusIcon = row.querySelector("td.column-grade i[aria-label], td.column-grade i[title]");
|
|
1059
|
+
const item = {
|
|
1060
|
+
name: title,
|
|
1061
|
+
item_type: cleanText(row.querySelector(".item img.itemicon, .courseitem img.itemicon, img.itemicon")?.getAttribute("alt") ?? ""),
|
|
1062
|
+
grade: cleanTableCell(row.querySelector("td.column-grade")),
|
|
1063
|
+
range: cleanTableCell(row.querySelector("td.column-range")),
|
|
1064
|
+
percentage: cleanTableCell(row.querySelector("td.column-percentage")),
|
|
1065
|
+
weight: cleanTableCell(row.querySelector("td.column-weight")),
|
|
1066
|
+
contribution: cleanTableCell(row.querySelector("td.column-contributiontocoursetotal")),
|
|
1067
|
+
feedback: cleanTableCell(row.querySelector("td.column-feedback")),
|
|
1068
|
+
url: resolveUrl(baseUrl, link.getAttribute("href") ?? ""),
|
|
1069
|
+
status: statusIcon?.getAttribute("aria-label") ?? statusIcon?.getAttribute("title") ?? ""
|
|
1070
|
+
};
|
|
1071
|
+
report.items.push(item);
|
|
1072
|
+
}
|
|
1073
|
+
return report;
|
|
1074
|
+
}
|
|
1075
|
+
function parseGradeOverviewRows(html, baseUrl) {
|
|
1076
|
+
const rows = {};
|
|
1077
|
+
const table = parse2(html).querySelector("table#overview-grade");
|
|
1078
|
+
if (!table) {
|
|
1079
|
+
return rows;
|
|
1080
|
+
}
|
|
1081
|
+
for (const row of table.querySelectorAll("tbody tr, tr")) {
|
|
1082
|
+
const link = row.querySelector("td a[href]");
|
|
1083
|
+
if (!link) {
|
|
1084
|
+
continue;
|
|
1085
|
+
}
|
|
1086
|
+
const href = resolveUrl(baseUrl, link.getAttribute("href") ?? "");
|
|
1087
|
+
const courseId = numberQueryValue(href, "id");
|
|
1088
|
+
if (courseId === null) {
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
const cells = row.querySelectorAll("td");
|
|
1092
|
+
rows[courseId] = {
|
|
1093
|
+
course_name: cleanNodeText(link),
|
|
1094
|
+
grade: cleanNodeText(cells[1]),
|
|
1095
|
+
url: href
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
return rows;
|
|
1099
|
+
}
|
|
1100
|
+
function parseAssignmentHtml(html, assignmentId, baseUrl) {
|
|
1101
|
+
return {
|
|
1102
|
+
id: assignmentId,
|
|
1103
|
+
name: pageTitle(html),
|
|
1104
|
+
...activityContext(html),
|
|
1105
|
+
due_pretty: extractLabeledText(html, "Due:"),
|
|
1106
|
+
submission_status: findTableValue(html, "Submission status"),
|
|
1107
|
+
grading_status: findTableValue(html, "Grading status"),
|
|
1108
|
+
time_remaining: findTableValue(html, "Time remaining"),
|
|
1109
|
+
grade: findTableValue(html, "Grade"),
|
|
1110
|
+
url: `${baseUrl.replace(/\/$/, "")}/mod/assign/view.php?id=${assignmentId}`
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
function parseQuizHtml(html, quizId, baseUrl) {
|
|
1114
|
+
const root = parse2(html);
|
|
1115
|
+
return {
|
|
1116
|
+
id: quizId,
|
|
1117
|
+
name: pageTitle(html),
|
|
1118
|
+
...activityContext(html),
|
|
1119
|
+
opens_pretty: extractLabeledText(html, "Opens:"),
|
|
1120
|
+
closes_pretty: extractLabeledText(html, "Closes:"),
|
|
1121
|
+
attempts_allowed: cleanText(root.textContent.match(/Attempts allowed:\s*([^\n]+)/i)?.[1] ?? ""),
|
|
1122
|
+
availability: cleanText(root.textContent.match(/This quiz is currently[^\n]+/i)?.[0] ?? ""),
|
|
1123
|
+
grade: findTableValue(html, "Grade"),
|
|
1124
|
+
url: `${baseUrl.replace(/\/$/, "")}/mod/quiz/view.php?id=${quizId}`
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
function parseResourceHtml(html, resourceId, baseUrl) {
|
|
1128
|
+
const root = parse2(html);
|
|
1129
|
+
const link = root.querySelector(".resourceworkaround a[href], .resourcecontent a[href], a.resourceworkaround[href]");
|
|
1130
|
+
return {
|
|
1131
|
+
id: resourceId,
|
|
1132
|
+
name: pageTitle(html),
|
|
1133
|
+
...activityContext(html),
|
|
1134
|
+
target_name: cleanNodeText(link),
|
|
1135
|
+
target_url: link ? resolveUrl(baseUrl, link.getAttribute("href") ?? "") : "",
|
|
1136
|
+
url: `${baseUrl.replace(/\/$/, "")}/mod/resource/view.php?id=${resourceId}`
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
function parseLinkHtml(html, linkId, baseUrl) {
|
|
1140
|
+
const root = parse2(html);
|
|
1141
|
+
const link = root.querySelector(".urlworkaround a[href], .mod_url-content a[href], .externalurl a[href]");
|
|
1142
|
+
return {
|
|
1143
|
+
id: linkId,
|
|
1144
|
+
name: pageTitle(html),
|
|
1145
|
+
...activityContext(html),
|
|
1146
|
+
target_url: link?.getAttribute("href") ?? "",
|
|
1147
|
+
url: `${baseUrl.replace(/\/$/, "")}/mod/url/view.php?id=${linkId}`
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
function parsePageHtml(html, pageId, baseUrl) {
|
|
1151
|
+
const root = parse2(html);
|
|
1152
|
+
const content = first(root, [".box.generalbox", ".activity-description", "[data-region='page-content']", "main"]);
|
|
1153
|
+
return {
|
|
1154
|
+
id: pageId,
|
|
1155
|
+
name: pageTitle(html),
|
|
1156
|
+
...activityContext(html),
|
|
1157
|
+
content_text: content ? htmlToStructuredContent(content.innerHTML, baseUrl).text : "",
|
|
1158
|
+
url: `${baseUrl.replace(/\/$/, "")}/mod/page/view.php?id=${pageId}`
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
function parseFolderHtml(html, folderId, baseUrl) {
|
|
1162
|
+
const root = parse2(html);
|
|
1163
|
+
const files = unique(root.querySelectorAll(".foldertree a[href], .fp-filename-icon a[href]").map((link) => cleanNodeText(link)).filter(Boolean));
|
|
1164
|
+
return {
|
|
1165
|
+
id: folderId,
|
|
1166
|
+
name: pageTitle(html),
|
|
1167
|
+
...activityContext(html),
|
|
1168
|
+
files,
|
|
1169
|
+
url: `${baseUrl.replace(/\/$/, "")}/mod/folder/view.php?id=${folderId}`
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
function parseForumDiscussionHtml(html, baseUrl, discussionId) {
|
|
1173
|
+
const root = parse2(html);
|
|
1174
|
+
let postElements = root.querySelectorAll("div.forumpost[data-post-id]");
|
|
1175
|
+
if (!postElements.length) {
|
|
1176
|
+
postElements = root.querySelectorAll("article[data-post-id]");
|
|
1177
|
+
}
|
|
1178
|
+
const [groupId, groupName] = parseForumDiscussionGroupHtml(html);
|
|
1179
|
+
const posts = [];
|
|
1180
|
+
for (const element of postElements) {
|
|
1181
|
+
const postId = safeInt(element.getAttribute("data-post-id"));
|
|
1182
|
+
if (!postId) {
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
1185
|
+
const header = first(element, ["header", ".header"]);
|
|
1186
|
+
const subject = cleanNodeText(
|
|
1187
|
+
firstDefined([
|
|
1188
|
+
header ? first(header, ["h3"]) : null,
|
|
1189
|
+
first(element, ["h3", "[data-region='post-title']"])
|
|
1190
|
+
])
|
|
1191
|
+
);
|
|
1192
|
+
const authorLink = firstDefined([
|
|
1193
|
+
header ? first(header, ['a[href*="/user/"]']) : null,
|
|
1194
|
+
first(element, ['a[href*="/user/"]', 'a[href*="/user/profile.php"]'])
|
|
1195
|
+
]) ?? null;
|
|
1196
|
+
const messageElement = first(element, [
|
|
1197
|
+
".post-content-container",
|
|
1198
|
+
".content",
|
|
1199
|
+
"[data-region='post-content']",
|
|
1200
|
+
"[data-region-content='forum-post-core']"
|
|
1201
|
+
]);
|
|
1202
|
+
const messageHtml = messageElement?.innerHTML ?? "";
|
|
1203
|
+
const structured = htmlToStructuredContent(messageHtml, baseUrl);
|
|
1204
|
+
posts.push({
|
|
1205
|
+
id: postId,
|
|
1206
|
+
discussion_id: discussionId,
|
|
1207
|
+
subject,
|
|
1208
|
+
message_html: messageHtml,
|
|
1209
|
+
message_text: structured.text,
|
|
1210
|
+
image_urls: structured.image_urls,
|
|
1211
|
+
links: structured.links,
|
|
1212
|
+
tables: structured.tables,
|
|
1213
|
+
author: {
|
|
1214
|
+
id: 0,
|
|
1215
|
+
fullname: cleanNodeText(authorLink),
|
|
1216
|
+
profile_url: authorLink ? resolveUrl(baseUrl, authorLink.getAttribute("href") ?? "") : "",
|
|
1217
|
+
profile_image_url: ""
|
|
1218
|
+
},
|
|
1219
|
+
parent_id: 0,
|
|
1220
|
+
time_created: 0,
|
|
1221
|
+
time_modified: 0,
|
|
1222
|
+
created_pretty: cleanNodeText(header ? first(header, [".date", "time"]) : null),
|
|
1223
|
+
unread: false,
|
|
1224
|
+
is_deleted: false,
|
|
1225
|
+
is_private_reply: false,
|
|
1226
|
+
url: `${baseUrl.replace(/\/$/, "")}/mod/forum/discuss.php?d=${discussionId}#p${postId}`,
|
|
1227
|
+
reply_url: `${baseUrl.replace(/\/$/, "")}/mod/forum/post.php?reply=${postId}#mformforum`
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
return {
|
|
1231
|
+
id: discussionId,
|
|
1232
|
+
subject: posts[0]?.subject ?? "",
|
|
1233
|
+
course_id: 0,
|
|
1234
|
+
forum_id: 0,
|
|
1235
|
+
group_id: groupId,
|
|
1236
|
+
group_name: groupName,
|
|
1237
|
+
url: `${baseUrl.replace(/\/$/, "")}/mod/forum/discuss.php?d=${discussionId}`,
|
|
1238
|
+
posts
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
function parseForumViewCmidFromDiscussionHtml(html) {
|
|
1242
|
+
const root = parse2(html);
|
|
1243
|
+
const link = first(root, ['a[href*="/mod/forum/view.php?id="]', 'a[href*="mod/forum/view.php?id="]']);
|
|
1244
|
+
const href = link?.getAttribute("href") ?? "";
|
|
1245
|
+
if (!href) {
|
|
1246
|
+
return null;
|
|
1247
|
+
}
|
|
1248
|
+
const url = parseMaybeUrl(href, "https://moodle.invalid");
|
|
1249
|
+
if (!url || !url.pathname.endsWith("/mod/forum/view.php")) {
|
|
1250
|
+
return null;
|
|
1251
|
+
}
|
|
1252
|
+
return numericQueryValue(url, "id");
|
|
1253
|
+
}
|
|
1254
|
+
function parseForumDiscussionRefsHtml(html, baseUrl) {
|
|
1255
|
+
const root = parse2(html);
|
|
1256
|
+
const refs = [];
|
|
1257
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1258
|
+
const links = [
|
|
1259
|
+
...root.querySelectorAll('a[href*="/mod/forum/discuss.php?d="]'),
|
|
1260
|
+
...root.querySelectorAll('a[href*="mod/forum/discuss.php?d="]'),
|
|
1261
|
+
...root.querySelectorAll('a[href*="discuss.php?d="]')
|
|
1262
|
+
];
|
|
1263
|
+
for (const link of links) {
|
|
1264
|
+
const href = link.getAttribute("href") ?? "";
|
|
1265
|
+
const url = parseMaybeUrl(href, baseUrl);
|
|
1266
|
+
if (!url || !url.pathname.endsWith("/mod/forum/discuss.php")) {
|
|
1267
|
+
continue;
|
|
1268
|
+
}
|
|
1269
|
+
const discussionId = numericQueryValue(url, "d");
|
|
1270
|
+
if (!discussionId || seen.has(discussionId)) {
|
|
1271
|
+
continue;
|
|
1272
|
+
}
|
|
1273
|
+
const subject = cleanNodeText(link);
|
|
1274
|
+
if (!subject || ["permalink", "discuss"].includes(subject.toLowerCase())) {
|
|
1275
|
+
continue;
|
|
1276
|
+
}
|
|
1277
|
+
seen.add(discussionId);
|
|
1278
|
+
refs.push({
|
|
1279
|
+
id: discussionId,
|
|
1280
|
+
subject,
|
|
1281
|
+
group_id: 0,
|
|
1282
|
+
group_name: "",
|
|
1283
|
+
url: resolveUrl(baseUrl, href)
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
return refs;
|
|
1287
|
+
}
|
|
1288
|
+
function parseForumGroupsHtml(html) {
|
|
1289
|
+
const root = parse2(html);
|
|
1290
|
+
const select = first(root, ["form#selectgroup select[name='group']", "select[name='group']"]);
|
|
1291
|
+
if (!select) {
|
|
1292
|
+
return [];
|
|
1293
|
+
}
|
|
1294
|
+
const groups = [];
|
|
1295
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1296
|
+
for (const option of select.querySelectorAll("option")) {
|
|
1297
|
+
const groupId = safeInt(option.getAttribute("value"));
|
|
1298
|
+
if (!groupId) {
|
|
1299
|
+
continue;
|
|
1300
|
+
}
|
|
1301
|
+
const groupName = cleanNodeText(option);
|
|
1302
|
+
const key = `${groupId}:${groupName}`;
|
|
1303
|
+
if (seen.has(key)) {
|
|
1304
|
+
continue;
|
|
1305
|
+
}
|
|
1306
|
+
seen.add(key);
|
|
1307
|
+
groups.push([groupId, groupName]);
|
|
1308
|
+
}
|
|
1309
|
+
return groups;
|
|
1310
|
+
}
|
|
1311
|
+
function parseForumDiscussionGroupHtml(html) {
|
|
1312
|
+
const root = parse2(html);
|
|
1313
|
+
const groupId = safeInt(root.querySelector("form#mformforum input[name='groupid']")?.getAttribute("value"));
|
|
1314
|
+
return [groupId, selectedGroupName(root, groupId)];
|
|
1315
|
+
}
|
|
1316
|
+
function selectedGroupName(root, groupId) {
|
|
1317
|
+
if (groupId <= 0) {
|
|
1318
|
+
return "";
|
|
1319
|
+
}
|
|
1320
|
+
for (const selector of ["select[name='groupinfo']", "select[name='group']"]) {
|
|
1321
|
+
const option = root.querySelector(selector)?.querySelector(`option[value='${groupId}']`) ?? null;
|
|
1322
|
+
if (option) {
|
|
1323
|
+
return cleanNodeText(option);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
return "";
|
|
1327
|
+
}
|
|
1328
|
+
function cleanNodeText(node) {
|
|
1329
|
+
return cleanText(node?.textContent ?? "");
|
|
1330
|
+
}
|
|
1331
|
+
function first(root, selectors) {
|
|
1332
|
+
for (const selector of selectors) {
|
|
1333
|
+
const match = root.querySelector(selector);
|
|
1334
|
+
if (match) {
|
|
1335
|
+
return match;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
return null;
|
|
1339
|
+
}
|
|
1340
|
+
function firstDefined(items) {
|
|
1341
|
+
return items.find((item) => item !== null && item !== void 0) ?? null;
|
|
1342
|
+
}
|
|
1343
|
+
function safeInt(value) {
|
|
1344
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1345
|
+
return Math.trunc(value);
|
|
1346
|
+
}
|
|
1347
|
+
if (typeof value === "string" && /^\d+$/.test(value.trim())) {
|
|
1348
|
+
return Number(value.trim());
|
|
1349
|
+
}
|
|
1350
|
+
return 0;
|
|
1351
|
+
}
|
|
1352
|
+
function parseMaybeUrl(href, baseUrl) {
|
|
1353
|
+
try {
|
|
1354
|
+
return new URL(href, baseUrl);
|
|
1355
|
+
} catch {
|
|
1356
|
+
return null;
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
function numericQueryValue(url, key) {
|
|
1360
|
+
const value = url.searchParams.get(key);
|
|
1361
|
+
if (!value || !/^\d+$/.test(value)) {
|
|
1362
|
+
return null;
|
|
1363
|
+
}
|
|
1364
|
+
return Number(value);
|
|
1365
|
+
}
|
|
1366
|
+
function parseMoodleConfig(html) {
|
|
1367
|
+
const match = html.match(/M\.cfg\s*=\s*({[\s\S]*?});/);
|
|
1368
|
+
if (!match) {
|
|
1369
|
+
return {};
|
|
1370
|
+
}
|
|
1371
|
+
try {
|
|
1372
|
+
return JSON.parse(match[1]);
|
|
1373
|
+
} catch {
|
|
1374
|
+
return {};
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
function extractSitename(root) {
|
|
1378
|
+
const title = cleanNodeText(root.querySelector("title"));
|
|
1379
|
+
return title.includes("|") ? title.split("|").at(-1)?.trim() ?? title : title;
|
|
1380
|
+
}
|
|
1381
|
+
function pageTitle(html) {
|
|
1382
|
+
return cleanNodeText(parse2(html).querySelector("h1"));
|
|
1383
|
+
}
|
|
1384
|
+
function activityContext(html) {
|
|
1385
|
+
const root = parse2(html);
|
|
1386
|
+
const context = { course_id: parseCourseIdFromPageHtml(html) ?? 0, course_name: "", section_name: "" };
|
|
1387
|
+
for (const link of root.querySelectorAll('nav[aria-label="Breadcrumb"] a[href], #page-navbar .breadcrumb a[href], a[href*="/course/view.php?id="]')) {
|
|
1388
|
+
const href = link.getAttribute("href") ?? "";
|
|
1389
|
+
const courseId = numberQueryValue(href, "id");
|
|
1390
|
+
if (courseId !== null) {
|
|
1391
|
+
context.course_id = courseId;
|
|
1392
|
+
context.course_name ||= cleanNodeText(link);
|
|
1393
|
+
}
|
|
1394
|
+
if (numberQueryValue(href, "section") !== null) {
|
|
1395
|
+
context.section_name ||= cleanNodeText(link);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
return context;
|
|
1399
|
+
}
|
|
1400
|
+
function extractLabeledText(html, label) {
|
|
1401
|
+
const root = parse2(html);
|
|
1402
|
+
for (const node of root.querySelectorAll("strong, b")) {
|
|
1403
|
+
if (cleanNodeText(node) !== label) {
|
|
1404
|
+
continue;
|
|
1405
|
+
}
|
|
1406
|
+
const parent = node.parentNode;
|
|
1407
|
+
return cleanText(parent?.textContent.replace(label, "") ?? "");
|
|
1408
|
+
}
|
|
1409
|
+
return "";
|
|
1410
|
+
}
|
|
1411
|
+
function findTableValue(html, label) {
|
|
1412
|
+
const root = parse2(html);
|
|
1413
|
+
for (const row of root.querySelectorAll("tr")) {
|
|
1414
|
+
const cells = row.querySelectorAll("th, td");
|
|
1415
|
+
if (cleanNodeText(cells[0]) === label) {
|
|
1416
|
+
return cleanTableCell(cells[1]);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
return "";
|
|
1420
|
+
}
|
|
1421
|
+
function cleanTableCell(node) {
|
|
1422
|
+
if (!node) {
|
|
1423
|
+
return "";
|
|
1424
|
+
}
|
|
1425
|
+
const clone = parse2(node.toString());
|
|
1426
|
+
for (const unwanted of clone.querySelectorAll(".action-menu, .dropdown, script, style")) {
|
|
1427
|
+
unwanted.remove();
|
|
1428
|
+
}
|
|
1429
|
+
return cleanText(clone.textContent.replace("( Empty )", "(Empty)"));
|
|
1430
|
+
}
|
|
1431
|
+
function numberQueryValue(href, key) {
|
|
1432
|
+
try {
|
|
1433
|
+
return numericQueryValue(new URL(href, "https://moodle.invalid"), key);
|
|
1434
|
+
} catch {
|
|
1435
|
+
return null;
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
function numberValue2(value) {
|
|
1439
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1440
|
+
return value;
|
|
1441
|
+
}
|
|
1442
|
+
if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) {
|
|
1443
|
+
return Number(value);
|
|
1444
|
+
}
|
|
1445
|
+
return 0;
|
|
1446
|
+
}
|
|
1447
|
+
function stringValue2(value) {
|
|
1448
|
+
return typeof value === "string" ? value : value == null ? "" : String(value);
|
|
1449
|
+
}
|
|
1450
|
+
function unique(items) {
|
|
1451
|
+
return [...new Set(items)];
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// src/client.ts
|
|
1455
|
+
var AjaxEnvelopeSchema = z.array(
|
|
1456
|
+
z.object({
|
|
1457
|
+
error: z.boolean().optional(),
|
|
1458
|
+
data: z.unknown().optional(),
|
|
1459
|
+
exception: z.object({
|
|
1460
|
+
message: z.string().optional(),
|
|
1461
|
+
errorcode: z.string().optional()
|
|
1462
|
+
}).passthrough().optional()
|
|
1463
|
+
}).passthrough()
|
|
1464
|
+
);
|
|
1465
|
+
var MoodleClient = class {
|
|
1466
|
+
baseUrl;
|
|
1467
|
+
fetchImpl;
|
|
1468
|
+
cookie;
|
|
1469
|
+
sesskey;
|
|
1470
|
+
userid;
|
|
1471
|
+
userInfo;
|
|
1472
|
+
cacheOptions;
|
|
1473
|
+
onLoginRequired;
|
|
1474
|
+
retryingLogin = false;
|
|
1475
|
+
forumDiscussions = /* @__PURE__ */ new Map();
|
|
1476
|
+
forumRefs = /* @__PURE__ */ new Map();
|
|
1477
|
+
constructor(baseUrl, options) {
|
|
1478
|
+
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
1479
|
+
const resolvedOptions = typeof options === "string" ? { cookie: { name: "MoodleSession", value: options } } : options;
|
|
1480
|
+
this.fetchImpl = resolvedOptions.fetchImpl ?? fetch;
|
|
1481
|
+
this.cookie = resolvedOptions.cookie;
|
|
1482
|
+
this.sesskey = resolvedOptions.pageContext?.sesskey ?? null;
|
|
1483
|
+
this.userid = resolvedOptions.pageContext?.user_info.userid ?? null;
|
|
1484
|
+
this.userInfo = resolvedOptions.pageContext?.user_info ?? null;
|
|
1485
|
+
this.cacheOptions = resolvedOptions.cacheOptions;
|
|
1486
|
+
this.onLoginRequired = resolvedOptions.onLoginRequired;
|
|
1487
|
+
}
|
|
1488
|
+
async getSiteInfo() {
|
|
1489
|
+
await this.ensureSession();
|
|
1490
|
+
const data = await this.call(FUNC_GET_SITE_INFO);
|
|
1491
|
+
if (!isRecord3(data) || !("userid" in data)) {
|
|
1492
|
+
if (this.userInfo) {
|
|
1493
|
+
return this.userInfo;
|
|
1494
|
+
}
|
|
1495
|
+
throw new NotFoundError("Session appears invalid: could not retrieve user info");
|
|
1496
|
+
}
|
|
1497
|
+
const info = parseUserInfo(data);
|
|
1498
|
+
this.sesskey = typeof data.sesskey === "string" ? data.sesskey : this.sesskey;
|
|
1499
|
+
this.userid = info.userid;
|
|
1500
|
+
this.userInfo = info;
|
|
1501
|
+
await this.writeCache();
|
|
1502
|
+
return info;
|
|
1503
|
+
}
|
|
1504
|
+
async getCourses() {
|
|
1505
|
+
await this.ensureSession();
|
|
1506
|
+
try {
|
|
1507
|
+
const data = await this.call(FUNC_GET_COURSES, { userid: this.userid });
|
|
1508
|
+
return parseCourses(data);
|
|
1509
|
+
} catch (error) {
|
|
1510
|
+
if (!(error instanceof MoodleAPIError) || error.moodleErrorCode !== "servicenotavailable") {
|
|
1511
|
+
throw error;
|
|
1512
|
+
}
|
|
1513
|
+
return this.getCoursesTimeline();
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
async resolveCourseReference(value) {
|
|
1517
|
+
const raw = value.trim();
|
|
1518
|
+
if (/^\d+$/.test(raw)) {
|
|
1519
|
+
return Number(raw);
|
|
1520
|
+
}
|
|
1521
|
+
const courses = await this.getCourses();
|
|
1522
|
+
const matches = courses.filter((course) => queryMatches(course.fullname, raw) || queryMatches(course.shortname, raw));
|
|
1523
|
+
if (matches.length === 1) {
|
|
1524
|
+
return matches[0].id;
|
|
1525
|
+
}
|
|
1526
|
+
if (!matches.length) {
|
|
1527
|
+
throw new NotFoundError(`Could not find a course matching '${raw}'. Run 'moodle courses' to inspect course IDs.`);
|
|
1528
|
+
}
|
|
1529
|
+
throw new NotFoundError(`Course '${raw}' is ambiguous. Matches: ${matches.slice(0, 5).map((course) => `${course.id}:${course.fullname || course.shortname}`).join(", ")}`);
|
|
1530
|
+
}
|
|
1531
|
+
async getCourseContents(courseId) {
|
|
1532
|
+
await this.ensureSession();
|
|
1533
|
+
try {
|
|
1534
|
+
return parseCourseContents(await this.call(FUNC_GET_COURSE_CONTENTS, { courseid: courseId }));
|
|
1535
|
+
} catch (error) {
|
|
1536
|
+
if (!(error instanceof MoodleAPIError) || error.moodleErrorCode !== "servicenotavailable") {
|
|
1537
|
+
throw error;
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
const response = await this.get(COURSE_PATH, { id: courseId });
|
|
1541
|
+
return this.scrapeCourseContents(courseId, response);
|
|
1542
|
+
}
|
|
1543
|
+
async getActivities(courseId) {
|
|
1544
|
+
return (await this.getCourseContents(courseId)).flatMap((section) => section.activities);
|
|
1545
|
+
}
|
|
1546
|
+
async getTodo(limit = 20, days) {
|
|
1547
|
+
await this.ensureSession();
|
|
1548
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1549
|
+
const data = await this.call(FUNC_GET_ACTION_EVENTS, {
|
|
1550
|
+
limitnum: limit,
|
|
1551
|
+
timesortfrom: now,
|
|
1552
|
+
timesortto: days ? now + days * 24 * 60 * 60 : 0,
|
|
1553
|
+
aftereventid: 0,
|
|
1554
|
+
limittononsuspendedevents: true
|
|
1555
|
+
});
|
|
1556
|
+
const events = isRecord3(data) && Array.isArray(data.events) ? data.events : [];
|
|
1557
|
+
return parseTodoItems(events);
|
|
1558
|
+
}
|
|
1559
|
+
async getAlerts(limit = 20) {
|
|
1560
|
+
await this.ensureSession();
|
|
1561
|
+
const [notifications, counts, unread] = await this.callBatchValues([
|
|
1562
|
+
{ methodname: FUNC_GET_POPUP_NOTIFICATIONS, args: { useridto: this.userid, limit, offset: 0 } },
|
|
1563
|
+
{ methodname: FUNC_GET_CONVERSATION_COUNTS, args: { userid: this.userid } },
|
|
1564
|
+
{ methodname: FUNC_GET_UNREAD_CONVERSATION_COUNTS, args: { userid: this.userid } }
|
|
1565
|
+
]);
|
|
1566
|
+
return parseAlertSummary(notifications, counts, unread);
|
|
1567
|
+
}
|
|
1568
|
+
async getOverview(todoLimit = 5, todoDays, alertsLimit = 5) {
|
|
1569
|
+
await this.ensureSession();
|
|
1570
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1571
|
+
const results = await this.callBatch([
|
|
1572
|
+
{ methodname: FUNC_GET_COURSES, args: { userid: this.userid } },
|
|
1573
|
+
{
|
|
1574
|
+
methodname: FUNC_GET_ACTION_EVENTS,
|
|
1575
|
+
args: {
|
|
1576
|
+
limitnum: todoLimit,
|
|
1577
|
+
timesortfrom: now,
|
|
1578
|
+
timesortto: todoDays ? now + todoDays * 24 * 60 * 60 : 0,
|
|
1579
|
+
aftereventid: 0,
|
|
1580
|
+
limittononsuspendedevents: true
|
|
1581
|
+
}
|
|
1582
|
+
},
|
|
1583
|
+
{ methodname: FUNC_GET_POPUP_NOTIFICATIONS, args: { useridto: this.userid, limit: alertsLimit, offset: 0 } },
|
|
1584
|
+
{ methodname: FUNC_GET_CONVERSATION_COUNTS, args: { userid: this.userid } },
|
|
1585
|
+
{ methodname: FUNC_GET_UNREAD_CONVERSATION_COUNTS, args: { userid: this.userid } }
|
|
1586
|
+
]);
|
|
1587
|
+
const [coursesData, todoData, notifications, counts, unread] = results;
|
|
1588
|
+
const errors = results.flatMap((result, index) => {
|
|
1589
|
+
if (result.ok) {
|
|
1590
|
+
return [];
|
|
1591
|
+
}
|
|
1592
|
+
const labels = ["courses", "todo", "notifications", "conversation counts", "unread conversation counts"];
|
|
1593
|
+
return [`${labels[index]}: ${result.error.message}`];
|
|
1594
|
+
});
|
|
1595
|
+
const todoPayload = todoData?.ok ? todoData.data : {};
|
|
1596
|
+
return {
|
|
1597
|
+
user: this.userInfo,
|
|
1598
|
+
courses: coursesData?.ok ? parseCourses(coursesData.data) : [],
|
|
1599
|
+
todo: parseTodoItems(isRecord3(todoPayload) && Array.isArray(todoPayload.events) ? todoPayload.events : []),
|
|
1600
|
+
alerts: notifications?.ok && counts?.ok && unread?.ok ? parseAlertSummary(notifications.data, counts.data, unread.data) : void 0,
|
|
1601
|
+
errors
|
|
1602
|
+
};
|
|
1603
|
+
}
|
|
1604
|
+
async getCourseGrades(courseId) {
|
|
1605
|
+
await this.ensureSession();
|
|
1606
|
+
const courseHtml = await this.get(COURSE_PATH, { id: courseId });
|
|
1607
|
+
const candidates = [
|
|
1608
|
+
parseCourseGradesUrl(courseHtml, this.baseUrl),
|
|
1609
|
+
`${this.baseUrl}/course/user.php?mode=grade&id=${courseId}&user=${this.userid}`,
|
|
1610
|
+
`${this.baseUrl}${GRADE_REPORT_OVERVIEW_PATH}`,
|
|
1611
|
+
`${this.baseUrl}${GRADE_REPORT_INDEX_PATH}?id=${courseId}`,
|
|
1612
|
+
`${this.baseUrl}${GRADE_REPORT_PATH}?id=${courseId}`
|
|
1613
|
+
].filter(Boolean);
|
|
1614
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1615
|
+
let overviewRows = {};
|
|
1616
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
1617
|
+
const url = candidates[index];
|
|
1618
|
+
if (seen.has(url)) {
|
|
1619
|
+
continue;
|
|
1620
|
+
}
|
|
1621
|
+
seen.add(url);
|
|
1622
|
+
let html = "";
|
|
1623
|
+
try {
|
|
1624
|
+
html = await this.getAbsolute(url);
|
|
1625
|
+
} catch (error) {
|
|
1626
|
+
if (error instanceof MoodleAPIError && error.message.startsWith("HTTP 404")) {
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
throw error;
|
|
1630
|
+
}
|
|
1631
|
+
if (hasCourseGradesHtml(html)) {
|
|
1632
|
+
return parseCourseGradesHtml(html, courseId, this.baseUrl);
|
|
1633
|
+
}
|
|
1634
|
+
overviewRows = parseGradeOverviewRows(html, this.baseUrl);
|
|
1635
|
+
const row = overviewRows[courseId];
|
|
1636
|
+
if (row) {
|
|
1637
|
+
if (row.url && !seen.has(row.url)) {
|
|
1638
|
+
candidates.push(row.url);
|
|
1639
|
+
continue;
|
|
1640
|
+
}
|
|
1641
|
+
return {
|
|
1642
|
+
course_id: courseId,
|
|
1643
|
+
course_name: row.course_name,
|
|
1644
|
+
learner_name: "",
|
|
1645
|
+
total_grade: row.grade,
|
|
1646
|
+
total_range: "",
|
|
1647
|
+
total_percentage: "",
|
|
1648
|
+
items: []
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
return {
|
|
1653
|
+
course_id: courseId,
|
|
1654
|
+
course_name: "",
|
|
1655
|
+
learner_name: "",
|
|
1656
|
+
total_grade: "",
|
|
1657
|
+
total_range: "",
|
|
1658
|
+
total_percentage: "",
|
|
1659
|
+
items: []
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1662
|
+
async getAssignment(id) {
|
|
1663
|
+
return parseAssignmentHtml(await this.get(ASSIGN_VIEW_PATH, { id }), id, this.baseUrl);
|
|
1664
|
+
}
|
|
1665
|
+
async getQuiz(id) {
|
|
1666
|
+
return parseQuizHtml(await this.get(QUIZ_VIEW_PATH, { id }), id, this.baseUrl);
|
|
1667
|
+
}
|
|
1668
|
+
async getResource(id) {
|
|
1669
|
+
return parseResourceHtml(await this.get(RESOURCE_VIEW_PATH, { id }), id, this.baseUrl);
|
|
1670
|
+
}
|
|
1671
|
+
async getLink(id) {
|
|
1672
|
+
return parseLinkHtml(await this.get(URL_VIEW_PATH, { id }), id, this.baseUrl);
|
|
1673
|
+
}
|
|
1674
|
+
async getPage(id) {
|
|
1675
|
+
return parsePageHtml(await this.get(PAGE_VIEW_PATH, { id }), id, this.baseUrl);
|
|
1676
|
+
}
|
|
1677
|
+
async getFolder(id) {
|
|
1678
|
+
return parseFolderHtml(await this.get(FOLDER_VIEW_PATH, { id }), id, this.baseUrl);
|
|
1679
|
+
}
|
|
1680
|
+
async getForumDiscussion(discussionId) {
|
|
1681
|
+
const cached = this.forumDiscussions.get(discussionId);
|
|
1682
|
+
if (cached) {
|
|
1683
|
+
return cached;
|
|
1684
|
+
}
|
|
1685
|
+
await this.ensureSession();
|
|
1686
|
+
try {
|
|
1687
|
+
const data = await this.call(FUNC_GET_DISCUSSION_POSTS, {
|
|
1688
|
+
discussionid: discussionId,
|
|
1689
|
+
sortby: "created",
|
|
1690
|
+
sortdirection: "ASC",
|
|
1691
|
+
includeinlineattachments: true
|
|
1692
|
+
});
|
|
1693
|
+
const discussion2 = parseForumDiscussion(data, discussionId);
|
|
1694
|
+
if (discussion2.group_id <= 0) {
|
|
1695
|
+
try {
|
|
1696
|
+
const html = await this.get(FORUM_DISCUSS_PATH, { d: discussionId });
|
|
1697
|
+
[discussion2.group_id, discussion2.group_name] = parseForumDiscussionGroupHtml(html);
|
|
1698
|
+
} catch {
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
this.forumDiscussions.set(discussionId, discussion2);
|
|
1702
|
+
return discussion2;
|
|
1703
|
+
} catch (error) {
|
|
1704
|
+
if (!(error instanceof MoodleAPIError) || !["servicenotavailable", "accessexception"].includes(error.moodleErrorCode ?? "")) {
|
|
1705
|
+
throw error;
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
const discussion = parseForumDiscussionHtml(await this.get(FORUM_DISCUSS_PATH, { d: discussionId }), this.baseUrl, discussionId);
|
|
1709
|
+
this.forumDiscussions.set(discussionId, discussion);
|
|
1710
|
+
return discussion;
|
|
1711
|
+
}
|
|
1712
|
+
async getForumViewCmid(discussionId) {
|
|
1713
|
+
return parseForumViewCmidFromDiscussionHtml(await this.get(FORUM_DISCUSS_PATH, { d: discussionId }));
|
|
1714
|
+
}
|
|
1715
|
+
async resolveCourseIdForUrl(url) {
|
|
1716
|
+
return parseCourseIdFromPageHtml(await this.getAbsolute(url));
|
|
1717
|
+
}
|
|
1718
|
+
async getForumDiscussionRefs(forumCmid) {
|
|
1719
|
+
const cached = this.forumRefs.get(forumCmid);
|
|
1720
|
+
if (cached) {
|
|
1721
|
+
return cached;
|
|
1722
|
+
}
|
|
1723
|
+
const rootHtml = await this.get(FORUM_VIEW_PATH, { id: forumCmid });
|
|
1724
|
+
const groups = parseForumGroupsHtml(rootHtml);
|
|
1725
|
+
const refs = groups.length ? [] : parseForumDiscussionRefsHtml(rootHtml, this.baseUrl);
|
|
1726
|
+
const seen = new Set(refs.map((ref) => ref.id));
|
|
1727
|
+
for (const [groupId, groupName] of groups) {
|
|
1728
|
+
const html = await this.get(FORUM_VIEW_PATH, { id: forumCmid, group: groupId });
|
|
1729
|
+
for (const ref of parseForumDiscussionRefsHtml(html, this.baseUrl)) {
|
|
1730
|
+
if (seen.has(ref.id)) {
|
|
1731
|
+
continue;
|
|
1732
|
+
}
|
|
1733
|
+
ref.group_id = groupId;
|
|
1734
|
+
ref.group_name = groupName;
|
|
1735
|
+
seen.add(ref.id);
|
|
1736
|
+
refs.push(ref);
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
this.forumRefs.set(forumCmid, refs);
|
|
1740
|
+
return refs;
|
|
1741
|
+
}
|
|
1742
|
+
async getCourseForums(courseId, courseName = "") {
|
|
1743
|
+
const sections = await this.getCourseContents(courseId);
|
|
1744
|
+
return sections.flatMap(
|
|
1745
|
+
(section) => section.activities.filter((activity) => activity.modname === "forum").map((activity) => ({
|
|
1746
|
+
id: activity.id,
|
|
1747
|
+
name: activity.name,
|
|
1748
|
+
course_id: courseId,
|
|
1749
|
+
course_name: courseName,
|
|
1750
|
+
url: activity.url
|
|
1751
|
+
}))
|
|
1752
|
+
);
|
|
1753
|
+
}
|
|
1754
|
+
async getForums(courseId) {
|
|
1755
|
+
if (courseId !== void 0) {
|
|
1756
|
+
const courseName = (await this.getCourses()).find((course) => course.id === courseId);
|
|
1757
|
+
return this.getCourseForums(courseId, courseName?.fullname || courseName?.shortname || "");
|
|
1758
|
+
}
|
|
1759
|
+
const refs = [];
|
|
1760
|
+
for (const course of await this.getCourses()) {
|
|
1761
|
+
refs.push(...await this.getCourseForums(course.id, course.fullname || course.shortname));
|
|
1762
|
+
}
|
|
1763
|
+
return refs;
|
|
1764
|
+
}
|
|
1765
|
+
async searchForumContent(options) {
|
|
1766
|
+
const query = options.query.trim();
|
|
1767
|
+
if (!query) {
|
|
1768
|
+
return [];
|
|
1769
|
+
}
|
|
1770
|
+
let forums = await this.getForums(options.courseId);
|
|
1771
|
+
if (options.forumCmid !== void 0) {
|
|
1772
|
+
forums = forums.filter((forum) => forum.id === options.forumCmid);
|
|
1773
|
+
if (!forums.length) {
|
|
1774
|
+
forums = [{ id: options.forumCmid, name: "", course_id: 0, course_name: "", url: `${this.baseUrl}${FORUM_VIEW_PATH}?id=${options.forumCmid}` }];
|
|
1775
|
+
}
|
|
1776
|
+
} else if (options.maxForums !== void 0) {
|
|
1777
|
+
forums = forums.slice(0, options.maxForums);
|
|
1778
|
+
}
|
|
1779
|
+
const hits = [];
|
|
1780
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1781
|
+
for (const forum of forums) {
|
|
1782
|
+
let refs = await this.getForumDiscussionRefs(forum.id);
|
|
1783
|
+
if (options.maxDiscussionsPerForum !== void 0) {
|
|
1784
|
+
refs = refs.slice(0, options.maxDiscussionsPerForum);
|
|
1785
|
+
}
|
|
1786
|
+
for (const ref of refs) {
|
|
1787
|
+
let discussion = null;
|
|
1788
|
+
let latest = 0;
|
|
1789
|
+
let discussionHasUnread = false;
|
|
1790
|
+
if (options.includePostText !== false || options.unreadOnly || options.sortBy === "recent") {
|
|
1791
|
+
discussion = await this.getForumDiscussion(ref.id);
|
|
1792
|
+
latest = Math.max(0, ...discussion.posts.map((post) => post.time_created));
|
|
1793
|
+
discussionHasUnread = discussion.posts.some((post) => post.unread);
|
|
1794
|
+
}
|
|
1795
|
+
if (options.includePostText === false) {
|
|
1796
|
+
const score = matchScore(ref.subject, query);
|
|
1797
|
+
if (score > 0 && (!options.unreadOnly || discussionHasUnread)) {
|
|
1798
|
+
addHit(hits, seen, 400 + score, makeHit(forum, ref, { matched_in: "discussion_subject", snippet: snippetForText(ref.subject, query), unread: discussionHasUnread, time_created: latest }));
|
|
1799
|
+
}
|
|
1800
|
+
continue;
|
|
1801
|
+
}
|
|
1802
|
+
discussion ??= await this.getForumDiscussion(ref.id);
|
|
1803
|
+
let postMatched = false;
|
|
1804
|
+
for (const post of discussion.posts) {
|
|
1805
|
+
const subjectScore = matchScore(post.subject, query);
|
|
1806
|
+
const bodyScore = matchScore(post.message_text, query);
|
|
1807
|
+
if (subjectScore <= 0 && bodyScore <= 0) {
|
|
1808
|
+
continue;
|
|
1809
|
+
}
|
|
1810
|
+
if (options.unreadOnly && !post.unread) {
|
|
1811
|
+
continue;
|
|
1812
|
+
}
|
|
1813
|
+
postMatched = true;
|
|
1814
|
+
const matched_in = subjectScore >= bodyScore ? "post_subject" : "post_body";
|
|
1815
|
+
const matchedText = matched_in === "post_subject" ? post.subject : post.message_text;
|
|
1816
|
+
addHit(
|
|
1817
|
+
hits,
|
|
1818
|
+
seen,
|
|
1819
|
+
300 + Math.max(subjectScore, bodyScore),
|
|
1820
|
+
makeHit(forum, ref, {
|
|
1821
|
+
group_id: discussion.group_id || ref.group_id,
|
|
1822
|
+
group_name: discussion.group_name || ref.group_name,
|
|
1823
|
+
discussion_subject: discussion.subject || ref.subject,
|
|
1824
|
+
post_id: post.id,
|
|
1825
|
+
author_name: post.author.fullname,
|
|
1826
|
+
matched_in,
|
|
1827
|
+
snippet: snippetForText(matchedText, query),
|
|
1828
|
+
unread: post.unread,
|
|
1829
|
+
time_created: post.time_created,
|
|
1830
|
+
url: post.url || ref.url
|
|
1831
|
+
})
|
|
1832
|
+
);
|
|
1833
|
+
}
|
|
1834
|
+
if (!postMatched) {
|
|
1835
|
+
const score = matchScore(ref.subject, query);
|
|
1836
|
+
if (score > 0 && (!options.unreadOnly || discussionHasUnread)) {
|
|
1837
|
+
addHit(hits, seen, 400 + score, makeHit(forum, ref, { matched_in: "discussion_subject", snippet: snippetForText(ref.subject, query), unread: discussionHasUnread, time_created: latest }));
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
hits.sort((a, b) => {
|
|
1843
|
+
if (options.sortBy === "recent") {
|
|
1844
|
+
return b[1].time_created - a[1].time_created || b[0] - a[0] || compareHit(a[1], b[1]);
|
|
1845
|
+
}
|
|
1846
|
+
return b[0] - a[0] || compareHit(a[1], b[1]);
|
|
1847
|
+
});
|
|
1848
|
+
return hits.slice(0, options.limit ?? 20).map(([, hit]) => hit);
|
|
1849
|
+
}
|
|
1850
|
+
async callBatch(requests) {
|
|
1851
|
+
await this.ensureSession();
|
|
1852
|
+
return this.callBatchInternal(requests, true);
|
|
1853
|
+
}
|
|
1854
|
+
async call(functionName, args = {}) {
|
|
1855
|
+
const [result] = await this.callBatchValues([{ methodname: functionName, args }]);
|
|
1856
|
+
return result;
|
|
1857
|
+
}
|
|
1858
|
+
async callBatchValues(requests) {
|
|
1859
|
+
const results = await this.callBatchInternal(requests, true);
|
|
1860
|
+
const failed = results.find((result) => !result.ok);
|
|
1861
|
+
if (failed && !failed.ok) {
|
|
1862
|
+
throw failed.error;
|
|
1863
|
+
}
|
|
1864
|
+
return results.map((result) => result.ok ? result.data : void 0);
|
|
1865
|
+
}
|
|
1866
|
+
async callBatchInternal(requests, allowRetry) {
|
|
1867
|
+
const payload = requests.map((request, index) => ({ index, methodname: request.methodname, args: request.args ?? {} }));
|
|
1868
|
+
const response = await this.fetchImpl(`${this.baseUrl}${AJAX_SERVICE_PATH}?sesskey=${encodeURIComponent(this.sesskey ?? "")}&info=${requests.map((request) => request.methodname).join(",")}`, {
|
|
1869
|
+
method: "POST",
|
|
1870
|
+
headers: {
|
|
1871
|
+
"content-type": "application/json",
|
|
1872
|
+
cookie: `${this.cookie.name}=${this.cookie.value}`
|
|
1873
|
+
},
|
|
1874
|
+
body: JSON.stringify(payload)
|
|
1875
|
+
});
|
|
1876
|
+
if (response.url.includes("/login/") && this.onLoginRequired && allowRetry && !this.retryingLogin) {
|
|
1877
|
+
await this.reauthenticate();
|
|
1878
|
+
return this.callBatchInternal(requests, false);
|
|
1879
|
+
}
|
|
1880
|
+
const body = await response.json();
|
|
1881
|
+
const envelope = AjaxEnvelopeSchema.parse(body);
|
|
1882
|
+
const results = envelope.map((item) => {
|
|
1883
|
+
if (item.error) {
|
|
1884
|
+
return {
|
|
1885
|
+
ok: false,
|
|
1886
|
+
error: new MoodleAPIError(item.exception?.message ?? "Unknown API error", item.exception?.errorcode)
|
|
1887
|
+
};
|
|
1888
|
+
}
|
|
1889
|
+
return { ok: true, data: item.data ?? item };
|
|
1890
|
+
});
|
|
1891
|
+
if (allowRetry && !this.retryingLogin && this.onLoginRequired && results.some((result) => !result.ok && isLoginRequiredError(result.error))) {
|
|
1892
|
+
await this.reauthenticate();
|
|
1893
|
+
return this.callBatchInternal(requests, false);
|
|
1894
|
+
}
|
|
1895
|
+
return results;
|
|
1896
|
+
}
|
|
1897
|
+
async ensureSession() {
|
|
1898
|
+
if (this.sesskey && this.userid) {
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
const html = await this.get(DASHBOARD_PATH);
|
|
1902
|
+
const context = parsePageContext(html, this.baseUrl);
|
|
1903
|
+
this.applyContext(context);
|
|
1904
|
+
await this.writeCache();
|
|
1905
|
+
}
|
|
1906
|
+
async get(pathname, params = {}) {
|
|
1907
|
+
const query = new URLSearchParams(Object.entries(params).map(([key, value]) => [key, String(value)])).toString();
|
|
1908
|
+
return this.getAbsolute(`${this.baseUrl}${pathname}${query ? `?${query}` : ""}`);
|
|
1909
|
+
}
|
|
1910
|
+
async getAbsolute(url) {
|
|
1911
|
+
const response = await this.fetchImpl(url, { headers: { cookie: `${this.cookie.name}=${this.cookie.value}` }, redirect: "follow" });
|
|
1912
|
+
if (response.url.includes("/login/") && this.onLoginRequired && !this.retryingLogin) {
|
|
1913
|
+
await this.reauthenticate();
|
|
1914
|
+
return this.getAbsolute(url);
|
|
1915
|
+
}
|
|
1916
|
+
if (!response.ok) {
|
|
1917
|
+
throw new MoodleAPIError(`HTTP ${response.status} loading ${url}`);
|
|
1918
|
+
}
|
|
1919
|
+
return response.text();
|
|
1920
|
+
}
|
|
1921
|
+
async getCoursesTimeline() {
|
|
1922
|
+
const courses = [];
|
|
1923
|
+
let offset = 0;
|
|
1924
|
+
while (true) {
|
|
1925
|
+
const data = await this.call(FUNC_GET_COURSES_BY_TIMELINE, { classification: "all", limit: 100, offset });
|
|
1926
|
+
if (!isRecord3(data) || !Array.isArray(data.courses) || !data.courses.length) {
|
|
1927
|
+
break;
|
|
1928
|
+
}
|
|
1929
|
+
courses.push(...data.courses);
|
|
1930
|
+
const nextOffset = typeof data.nextoffset === "number" ? data.nextoffset : offset;
|
|
1931
|
+
if (nextOffset <= offset) {
|
|
1932
|
+
break;
|
|
1933
|
+
}
|
|
1934
|
+
offset = nextOffset;
|
|
1935
|
+
}
|
|
1936
|
+
return parseCourses(courses);
|
|
1937
|
+
}
|
|
1938
|
+
async scrapeCourseContents(courseId, rootHtml) {
|
|
1939
|
+
const pages = [rootHtml];
|
|
1940
|
+
for (const section of parseCourseSectionNumbers(rootHtml, courseId)) {
|
|
1941
|
+
if (section !== 0) {
|
|
1942
|
+
pages.push(await this.get(COURSE_PATH, { id: courseId, section }));
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1946
|
+
const sections = [];
|
|
1947
|
+
for (const html of pages) {
|
|
1948
|
+
for (const section of parseCourseContentsHtml(html, this.baseUrl)) {
|
|
1949
|
+
const key = section.section || section.id;
|
|
1950
|
+
if (seen.has(key)) {
|
|
1951
|
+
continue;
|
|
1952
|
+
}
|
|
1953
|
+
seen.add(key);
|
|
1954
|
+
sections.push(section);
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
return sections;
|
|
1958
|
+
}
|
|
1959
|
+
async reauthenticate() {
|
|
1960
|
+
if (!this.onLoginRequired) {
|
|
1961
|
+
throw new MoodleAPIError("Session expired", "servicerequireslogin");
|
|
1962
|
+
}
|
|
1963
|
+
this.retryingLogin = true;
|
|
1964
|
+
await deleteCachedSession(this.baseUrl, this.cacheOptions);
|
|
1965
|
+
try {
|
|
1966
|
+
const auth = await this.onLoginRequired();
|
|
1967
|
+
this.cookie = auth.cookie;
|
|
1968
|
+
this.applyContext(auth.pageContext);
|
|
1969
|
+
await this.writeCache();
|
|
1970
|
+
} finally {
|
|
1971
|
+
this.retryingLogin = false;
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
applyContext(context) {
|
|
1975
|
+
this.sesskey = context.sesskey;
|
|
1976
|
+
this.userid = context.user_info.userid;
|
|
1977
|
+
this.userInfo = context.user_info;
|
|
1978
|
+
}
|
|
1979
|
+
async writeCache() {
|
|
1980
|
+
if (this.cacheOptions && this.sesskey && this.userid) {
|
|
1981
|
+
try {
|
|
1982
|
+
await writeCachedSession({ baseUrl: this.baseUrl, cookieName: this.cookie.name, cookieValue: this.cookie.value, sesskey: this.sesskey, userid: this.userid, savedAt: (this.cacheOptions.now ?? Date.now)() }, this.cacheOptions);
|
|
1983
|
+
} catch {
|
|
1984
|
+
return;
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
};
|
|
1989
|
+
async function createMoodleClient(baseUrl, options = {}) {
|
|
1990
|
+
const cacheOptions2 = { homeDir: options.homeDir, now: options.now, ttlMs: options.ttlMs };
|
|
1991
|
+
const authOptions = { ...options, fetch: options.fetch ?? options.fetchImpl };
|
|
1992
|
+
if (!options.noCache) {
|
|
1993
|
+
const cached = await readCachedSession(baseUrl, cacheOptions2);
|
|
1994
|
+
if (cached) {
|
|
1995
|
+
return new MoodleClient(baseUrl, {
|
|
1996
|
+
fetchImpl: options.fetchImpl,
|
|
1997
|
+
cookie: { name: cached.cookieName, value: cached.cookieValue },
|
|
1998
|
+
pageContext: {
|
|
1999
|
+
sesskey: cached.sesskey,
|
|
2000
|
+
user_info: { userid: cached.userid, username: "", fullname: "", sitename: "", siteurl: baseUrl, lang: "" }
|
|
2001
|
+
},
|
|
2002
|
+
cacheOptions: cacheOptions2,
|
|
2003
|
+
onLoginRequired: async () => authToClientSession(await getAuthenticatedSession(baseUrl, authOptions))
|
|
2004
|
+
});
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
const auth = await getAuthenticatedSession(baseUrl, authOptions);
|
|
2008
|
+
const session = authToClientSession(auth);
|
|
2009
|
+
return new MoodleClient(baseUrl, {
|
|
2010
|
+
fetchImpl: options.fetchImpl,
|
|
2011
|
+
cookie: session.cookie,
|
|
2012
|
+
pageContext: session.pageContext,
|
|
2013
|
+
cacheOptions: cacheOptions2,
|
|
2014
|
+
onLoginRequired: async () => authToClientSession(await getAuthenticatedSession(baseUrl, authOptions))
|
|
2015
|
+
});
|
|
2016
|
+
}
|
|
2017
|
+
function authToClientSession(auth) {
|
|
2018
|
+
return {
|
|
2019
|
+
cookie: auth.cookie,
|
|
2020
|
+
pageContext: {
|
|
2021
|
+
sesskey: auth.sesskey,
|
|
2022
|
+
user_info: {
|
|
2023
|
+
userid: auth.userid,
|
|
2024
|
+
username: "",
|
|
2025
|
+
fullname: "",
|
|
2026
|
+
sitename: "",
|
|
2027
|
+
siteurl: auth.baseUrl,
|
|
2028
|
+
lang: ""
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
};
|
|
2032
|
+
}
|
|
2033
|
+
function filterDiscussionToPost(discussion, postId) {
|
|
2034
|
+
if (postId === null) {
|
|
2035
|
+
return discussion;
|
|
2036
|
+
}
|
|
2037
|
+
const posts = discussion.posts.filter((post) => post.id === postId);
|
|
2038
|
+
if (!posts.length) {
|
|
2039
|
+
throw new NotFoundError(`Post ${postId} was not found in discussion ${discussion.id}.`);
|
|
2040
|
+
}
|
|
2041
|
+
return { ...discussion, posts };
|
|
2042
|
+
}
|
|
2043
|
+
function queryMatches(text, query) {
|
|
2044
|
+
const haystack = text.toLowerCase().split(/\s+/).join(" ");
|
|
2045
|
+
const needle = query.toLowerCase().split(/\s+/).join(" ");
|
|
2046
|
+
return needle ? haystack.includes(needle) || needle.split(" ").every((token) => haystack.includes(token)) : true;
|
|
2047
|
+
}
|
|
2048
|
+
function matchScore(text, query) {
|
|
2049
|
+
const haystack = text.toLowerCase().split(/\s+/).join(" ");
|
|
2050
|
+
const normalized = query.toLowerCase().split(/\s+/).join(" ");
|
|
2051
|
+
const tokens = normalized.split(/\s+/).filter(Boolean);
|
|
2052
|
+
if (!haystack || !normalized) {
|
|
2053
|
+
return 0;
|
|
2054
|
+
}
|
|
2055
|
+
if (haystack.includes(normalized)) {
|
|
2056
|
+
return 100 + normalized.length;
|
|
2057
|
+
}
|
|
2058
|
+
if (tokens.length && tokens.every((token) => haystack.includes(token))) {
|
|
2059
|
+
return 60 + tokens.length;
|
|
2060
|
+
}
|
|
2061
|
+
return 0;
|
|
2062
|
+
}
|
|
2063
|
+
function snippetForText(text, query, maxLen = 120) {
|
|
2064
|
+
const cleaned = text.split(/\s+/).join(" ").trim();
|
|
2065
|
+
if (!cleaned || cleaned.length <= maxLen) {
|
|
2066
|
+
return cleaned;
|
|
2067
|
+
}
|
|
2068
|
+
const normalized = query.toLowerCase().split(/\s+/).join(" ");
|
|
2069
|
+
const lower = cleaned.toLowerCase();
|
|
2070
|
+
let start = lower.indexOf(normalized);
|
|
2071
|
+
if (start < 0) {
|
|
2072
|
+
start = normalized.split(/\s+/).map((token) => lower.indexOf(token)).find((index) => index >= 0) ?? -1;
|
|
2073
|
+
}
|
|
2074
|
+
if (start < 0) {
|
|
2075
|
+
return `${cleaned.slice(0, maxLen - 1)}...`;
|
|
2076
|
+
}
|
|
2077
|
+
const left = Math.max(0, start - Math.floor(maxLen / 2));
|
|
2078
|
+
const right = Math.min(cleaned.length, left + maxLen);
|
|
2079
|
+
return `${left > 0 ? "..." : ""}${cleaned.slice(left, right)}${right < cleaned.length ? "..." : ""}`;
|
|
2080
|
+
}
|
|
2081
|
+
function makeHit(forum, ref, override) {
|
|
2082
|
+
return {
|
|
2083
|
+
course_id: forum.course_id,
|
|
2084
|
+
course_name: forum.course_name,
|
|
2085
|
+
forum_id: forum.id,
|
|
2086
|
+
forum_name: forum.name,
|
|
2087
|
+
group_id: ref.group_id,
|
|
2088
|
+
group_name: ref.group_name,
|
|
2089
|
+
discussion_id: ref.id,
|
|
2090
|
+
discussion_subject: ref.subject,
|
|
2091
|
+
post_id: 0,
|
|
2092
|
+
author_name: "",
|
|
2093
|
+
matched_in: "",
|
|
2094
|
+
snippet: "",
|
|
2095
|
+
unread: false,
|
|
2096
|
+
time_created: 0,
|
|
2097
|
+
url: ref.url,
|
|
2098
|
+
...override
|
|
2099
|
+
};
|
|
2100
|
+
}
|
|
2101
|
+
function addHit(hits, seen, score, hit) {
|
|
2102
|
+
const key = `${hit.discussion_id}:${hit.post_id}`;
|
|
2103
|
+
if (seen.has(key)) {
|
|
2104
|
+
return;
|
|
2105
|
+
}
|
|
2106
|
+
seen.add(key);
|
|
2107
|
+
hits.push([score, hit]);
|
|
2108
|
+
}
|
|
2109
|
+
function compareHit(a, b) {
|
|
2110
|
+
return a.course_name.localeCompare(b.course_name) || a.forum_name.localeCompare(b.forum_name) || a.discussion_id - b.discussion_id || a.post_id - b.post_id;
|
|
2111
|
+
}
|
|
2112
|
+
function isRecord3(value) {
|
|
2113
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
// src/config.ts
|
|
2117
|
+
import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
2118
|
+
import { homedir as homedir3 } from "os";
|
|
2119
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
2120
|
+
import { createInterface } from "readline/promises";
|
|
2121
|
+
import YAML from "yaml";
|
|
2122
|
+
var nodeFs2 = { readFile: readFile2, writeFile: writeFile2, mkdir: mkdir2 };
|
|
2123
|
+
function cwdConfigPath(cwd = process.cwd()) {
|
|
2124
|
+
return join3(cwd, CONFIG_FILENAME);
|
|
2125
|
+
}
|
|
2126
|
+
function userConfigPath(homeDir = homedir3()) {
|
|
2127
|
+
return join3(homeDir, CONFIG_DIR_NAME, CONFIG_FILENAME);
|
|
2128
|
+
}
|
|
2129
|
+
function normalizeBaseUrl(value) {
|
|
2130
|
+
const raw = value.trim();
|
|
2131
|
+
if (!raw) {
|
|
2132
|
+
throw new ConfigError("Base URL cannot be empty.");
|
|
2133
|
+
}
|
|
2134
|
+
if (!raw.includes("://")) {
|
|
2135
|
+
throw new ConfigError("Base URL must include the scheme, for example https://school.example.edu");
|
|
2136
|
+
}
|
|
2137
|
+
let parsed;
|
|
2138
|
+
try {
|
|
2139
|
+
parsed = new URL(raw);
|
|
2140
|
+
} catch {
|
|
2141
|
+
throw new ConfigError("Base URL must be a valid URL");
|
|
2142
|
+
}
|
|
2143
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
2144
|
+
throw new ConfigError("Base URL must start with http:// or https://");
|
|
2145
|
+
}
|
|
2146
|
+
if (!parsed.hostname) {
|
|
2147
|
+
throw new ConfigError("Base URL must include a hostname");
|
|
2148
|
+
}
|
|
2149
|
+
if (parsed.search || parsed.hash) {
|
|
2150
|
+
throw new ConfigError("Base URL must not include query parameters or fragments");
|
|
2151
|
+
}
|
|
2152
|
+
if (parsed.pathname !== "/" && parsed.pathname !== "") {
|
|
2153
|
+
throw new ConfigError(
|
|
2154
|
+
"Base URL must be the site root, for example https://school.example.edu. Do not include paths like /login/index.php or /my/."
|
|
2155
|
+
);
|
|
2156
|
+
}
|
|
2157
|
+
return parsed.origin;
|
|
2158
|
+
}
|
|
2159
|
+
async function loadConfig(options = {}) {
|
|
2160
|
+
const env = options.env ?? process.env;
|
|
2161
|
+
if (env[ENV_MOODLE_BASE_URL]) {
|
|
2162
|
+
return { baseUrl: normalizeBaseUrl(env[ENV_MOODLE_BASE_URL]) };
|
|
2163
|
+
}
|
|
2164
|
+
const loaded = await loadExistingConfig(options);
|
|
2165
|
+
if (loaded.config.base_url) {
|
|
2166
|
+
return toMoodleConfig(loaded.config, normalizeBaseUrl(String(loaded.config.base_url)));
|
|
2167
|
+
}
|
|
2168
|
+
if (!isInteractive(options)) {
|
|
2169
|
+
throw new ConfigError(missingBaseUrlMessage(loaded.path, options), "Set MOODLE_BASE_URL or run `moodle` once in an interactive shell.");
|
|
2170
|
+
}
|
|
2171
|
+
const baseUrl = await promptForBaseUrl(options);
|
|
2172
|
+
const targetPath = loaded.path ?? userConfigPath(options.homeDir);
|
|
2173
|
+
await saveConfigFile(targetPath, { ...loaded.config, base_url: baseUrl }, options);
|
|
2174
|
+
return toMoodleConfig(loaded.config, baseUrl);
|
|
2175
|
+
}
|
|
2176
|
+
async function promptForBaseUrl(options = {}) {
|
|
2177
|
+
const prompt = options.prompt ?? defaultPrompt(options);
|
|
2178
|
+
const output = options.stderr ?? process.stderr;
|
|
2179
|
+
output.write("Configuration required\n");
|
|
2180
|
+
output.write("Moodle base URL is not configured yet.\n");
|
|
2181
|
+
output.write("Required format: https://school.example.edu\n");
|
|
2182
|
+
output.write("Use the site root only. Do not include paths like /login/index.php or /my/.\n");
|
|
2183
|
+
while (true) {
|
|
2184
|
+
let baseUrl;
|
|
2185
|
+
try {
|
|
2186
|
+
baseUrl = normalizeBaseUrl(await prompt("Moodle base URL"));
|
|
2187
|
+
} catch (error) {
|
|
2188
|
+
output.write(`Invalid URL: ${error instanceof Error ? error.message : String(error)}
|
|
2189
|
+
`);
|
|
2190
|
+
continue;
|
|
2191
|
+
}
|
|
2192
|
+
const probe = await probeBaseUrl(baseUrl, options);
|
|
2193
|
+
if (probe.ok) {
|
|
2194
|
+
return baseUrl;
|
|
2195
|
+
}
|
|
2196
|
+
output.write(`Validation failed: ${probe.message ?? "site did not look like Moodle"}
|
|
2197
|
+
`);
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
async function probeBaseUrl(baseUrl, options = {}) {
|
|
2201
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
2202
|
+
if (!fetcher) {
|
|
2203
|
+
return { ok: false, message: "fetch is not available in this runtime" };
|
|
2204
|
+
}
|
|
2205
|
+
let response;
|
|
2206
|
+
try {
|
|
2207
|
+
response = await fetcher(`${baseUrl}/login/token.php`, { redirect: "follow" });
|
|
2208
|
+
} catch (error) {
|
|
2209
|
+
return { ok: false, message: `Could not reach ${baseUrl}: ${error instanceof Error ? error.message : String(error)}` };
|
|
2210
|
+
}
|
|
2211
|
+
const body = (await response.text()).slice(0, 5e3).toLowerCase();
|
|
2212
|
+
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
2213
|
+
const looksJson = contentType.includes("application/json") || body.startsWith("{");
|
|
2214
|
+
const looksMoodleTokenError = [
|
|
2215
|
+
'"errorcode":"missingparam"',
|
|
2216
|
+
'"errorcode":"invalidparameter"',
|
|
2217
|
+
'"errorcode":"invalidlogin"',
|
|
2218
|
+
"a required parameter (username) was missing"
|
|
2219
|
+
].some((marker) => body.includes(marker));
|
|
2220
|
+
const looksMoodleHtml = body.includes("moodle") && (body.includes("login") || body.includes("sesskey"));
|
|
2221
|
+
if (response.status >= 400) {
|
|
2222
|
+
return { ok: false, message: `${baseUrl} returned HTTP ${response.status}` };
|
|
2223
|
+
}
|
|
2224
|
+
if (looksJson && looksMoodleTokenError || looksMoodleHtml) {
|
|
2225
|
+
return { ok: true };
|
|
2226
|
+
}
|
|
2227
|
+
return { ok: false, message: `${baseUrl} does not expose the expected Moodle token endpoint` };
|
|
2228
|
+
}
|
|
2229
|
+
function missingBaseUrlMessage(configPath, options = {}) {
|
|
2230
|
+
const targetPath = configPath ?? userConfigPath(options.homeDir);
|
|
2231
|
+
return [
|
|
2232
|
+
"No base_url configured.",
|
|
2233
|
+
`Add base_url to ${targetPath} or set MOODLE_BASE_URL.`,
|
|
2234
|
+
"Required format:",
|
|
2235
|
+
" base_url: https://school.example.edu",
|
|
2236
|
+
"Use the site root only. Do not include paths like /login/index.php or /my/."
|
|
2237
|
+
].join("\n");
|
|
2238
|
+
}
|
|
2239
|
+
async function loadExistingConfig(options) {
|
|
2240
|
+
for (const path3 of [cwdConfigPath(options.cwd), userConfigPath(options.homeDir)]) {
|
|
2241
|
+
const config = await readConfigFile(path3, options);
|
|
2242
|
+
if (config) {
|
|
2243
|
+
return { config, path: path3 };
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
return { config: {}, path: null };
|
|
2247
|
+
}
|
|
2248
|
+
async function readConfigFile(path3, options) {
|
|
2249
|
+
const fs = options.fs ?? nodeFs2;
|
|
2250
|
+
let raw;
|
|
2251
|
+
try {
|
|
2252
|
+
raw = await fs.readFile(path3, "utf8");
|
|
2253
|
+
} catch (error) {
|
|
2254
|
+
if (isMissingFileError2(error)) {
|
|
2255
|
+
return null;
|
|
2256
|
+
}
|
|
2257
|
+
throw error;
|
|
2258
|
+
}
|
|
2259
|
+
const parsed = YAML.parse(raw) ?? {};
|
|
2260
|
+
if (!isRecord4(parsed)) {
|
|
2261
|
+
throw new ConfigError(`${path3} must contain a YAML object.`);
|
|
2262
|
+
}
|
|
2263
|
+
return parsed;
|
|
2264
|
+
}
|
|
2265
|
+
async function saveConfigFile(path3, config, options) {
|
|
2266
|
+
const fs = options.fs ?? nodeFs2;
|
|
2267
|
+
await fs.mkdir(dirname2(path3), { recursive: true });
|
|
2268
|
+
await fs.writeFile(path3, YAML.stringify(config, { sortMapEntries: true }), "utf8");
|
|
2269
|
+
}
|
|
2270
|
+
function toMoodleConfig(config, baseUrl) {
|
|
2271
|
+
const { base_url: _baseUrl, ...rest } = config;
|
|
2272
|
+
return { ...rest, baseUrl };
|
|
2273
|
+
}
|
|
2274
|
+
function defaultPrompt(options) {
|
|
2275
|
+
return async (label) => {
|
|
2276
|
+
const rl = createInterface({
|
|
2277
|
+
input: process.stdin,
|
|
2278
|
+
output: options.stdout ?? process.stdout
|
|
2279
|
+
});
|
|
2280
|
+
try {
|
|
2281
|
+
return await rl.question(`${label} > `);
|
|
2282
|
+
} finally {
|
|
2283
|
+
rl.close();
|
|
2284
|
+
}
|
|
2285
|
+
};
|
|
2286
|
+
}
|
|
2287
|
+
function isInteractive(options) {
|
|
2288
|
+
return Boolean((options.stdin ?? process.stdin).isTTY);
|
|
2289
|
+
}
|
|
2290
|
+
function isRecord4(value) {
|
|
2291
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
2292
|
+
}
|
|
2293
|
+
function isMissingFileError2(error) {
|
|
2294
|
+
return isRecord4(error) && error.code === "ENOENT";
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
// src/formatters.ts
|
|
2298
|
+
function formatUser(user) {
|
|
2299
|
+
return formatKeyValues([
|
|
2300
|
+
["User", user.fullname],
|
|
2301
|
+
["Username", user.username],
|
|
2302
|
+
["User ID", String(user.userid)],
|
|
2303
|
+
["Site", user.sitename],
|
|
2304
|
+
["URL", user.siteurl],
|
|
2305
|
+
["Language", user.lang ?? ""]
|
|
2306
|
+
]);
|
|
2307
|
+
}
|
|
2308
|
+
function formatCourses(courses) {
|
|
2309
|
+
return formatColumns([["ID", "Short Name", "Full Name"], ...courses.map((course) => [String(course.id), course.shortname, course.fullname])]);
|
|
2310
|
+
}
|
|
2311
|
+
function formatCourseSections(sections) {
|
|
2312
|
+
const lines = ["Course"];
|
|
2313
|
+
for (const section of sections) {
|
|
2314
|
+
lines.push(` ${section.name || `Section ${section.section}`}${section.visible ? "" : " (hidden)"}`);
|
|
2315
|
+
if (!section.activities.length) {
|
|
2316
|
+
lines.push(" No activities");
|
|
2317
|
+
continue;
|
|
2318
|
+
}
|
|
2319
|
+
for (const activity of section.activities) {
|
|
2320
|
+
lines.push(` ${activity.name}${activity.visible ? "" : " (hidden)"} (${activity.modname})`);
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
return lines.join("\n");
|
|
2324
|
+
}
|
|
2325
|
+
function formatActivityList(value) {
|
|
2326
|
+
const activities = Array.isArray(value) && value[0] && "activities" in value[0] ? value.flatMap((section) => section.activities) : value;
|
|
2327
|
+
return formatColumns([["ID", "Type", "Name"], ...activities.map((activity) => [String(activity.id), activity.modname, activity.name])]);
|
|
2328
|
+
}
|
|
2329
|
+
function formatTodo(items) {
|
|
2330
|
+
if (!items.length) {
|
|
2331
|
+
return "No upcoming items";
|
|
2332
|
+
}
|
|
2333
|
+
return formatColumns([
|
|
2334
|
+
["Due", "Course", "Activity", "Type", "Action"],
|
|
2335
|
+
...items.map((item) => [
|
|
2336
|
+
item.due_at ? String(item.due_at) : "-",
|
|
2337
|
+
item.course_name,
|
|
2338
|
+
item.activity_name || item.name,
|
|
2339
|
+
item.modname || item.event_type,
|
|
2340
|
+
item.actionable ? item.action_name : ""
|
|
2341
|
+
])
|
|
2342
|
+
]);
|
|
2343
|
+
}
|
|
2344
|
+
function formatAlerts(alerts) {
|
|
2345
|
+
const lines = [
|
|
2346
|
+
`Notifications: ${alerts.notification_count}`,
|
|
2347
|
+
`Unread notifications: ${alerts.unread_notification_count}`,
|
|
2348
|
+
`Direct messages: ${alerts.direct_message_count}`,
|
|
2349
|
+
`Unread direct messages: ${alerts.unread_direct_message_count}`
|
|
2350
|
+
];
|
|
2351
|
+
for (const notification of alerts.notifications) {
|
|
2352
|
+
lines.push(`${notification.created_pretty || notification.created_at} ${notification.short_subject || notification.subject}`);
|
|
2353
|
+
}
|
|
2354
|
+
return lines.join("\n");
|
|
2355
|
+
}
|
|
2356
|
+
function formatGrades(grades) {
|
|
2357
|
+
return formatColumns([
|
|
2358
|
+
["Item", "Grade", "Range", "Percent", "Feedback"],
|
|
2359
|
+
...grades.items.map((item) => [item.name, item.grade, item.range, item.percentage, item.feedback])
|
|
2360
|
+
]);
|
|
2361
|
+
}
|
|
2362
|
+
function formatActivityDetail(activity) {
|
|
2363
|
+
const rows = Object.entries(activity).filter(([, value]) => value !== "" && value !== void 0 && !(Array.isArray(value) && value.length === 0)).map(([key, value]) => [key, Array.isArray(value) ? value.join("\n") : String(value)]);
|
|
2364
|
+
return formatKeyValues(rows);
|
|
2365
|
+
}
|
|
2366
|
+
function formatForumDiscussion(discussion, options = {}) {
|
|
2367
|
+
const lines = [`Discussion: ${discussion.id}`];
|
|
2368
|
+
if (discussion.subject) {
|
|
2369
|
+
lines.push(`Subject: ${discussion.subject}`);
|
|
2370
|
+
}
|
|
2371
|
+
if (discussion.url) {
|
|
2372
|
+
lines.push(`URL: ${discussion.url}`);
|
|
2373
|
+
}
|
|
2374
|
+
if (discussion.course_id) {
|
|
2375
|
+
lines.push(`Course ID: ${discussion.course_id}`);
|
|
2376
|
+
}
|
|
2377
|
+
if (discussion.forum_id) {
|
|
2378
|
+
lines.push(`Forum ID: ${discussion.forum_id}`);
|
|
2379
|
+
}
|
|
2380
|
+
if (!discussion.posts.length) {
|
|
2381
|
+
lines.push("", "No posts");
|
|
2382
|
+
return lines.join("\n");
|
|
2383
|
+
}
|
|
2384
|
+
for (const post of discussion.posts) {
|
|
2385
|
+
const marker = options.highlightPostId === post.id ? "*" : "-";
|
|
2386
|
+
lines.push("", `${marker} Post ${post.id}`);
|
|
2387
|
+
lines.push(` Author: ${post.author.fullname || "-"}`);
|
|
2388
|
+
lines.push(` When: ${post.created_pretty || (post.time_created ? String(post.time_created) : "-")}`);
|
|
2389
|
+
if (post.subject) {
|
|
2390
|
+
lines.push(` Subject: ${post.subject}`);
|
|
2391
|
+
}
|
|
2392
|
+
if (post.url) {
|
|
2393
|
+
lines.push(` URL: ${post.url}`);
|
|
2394
|
+
}
|
|
2395
|
+
if (options.showBody) {
|
|
2396
|
+
if (post.message_text) {
|
|
2397
|
+
lines.push("", post.message_text);
|
|
2398
|
+
}
|
|
2399
|
+
if (post.image_urls.length) {
|
|
2400
|
+
lines.push("", "Images:", ...post.image_urls.map((url) => `- ${url}`));
|
|
2401
|
+
}
|
|
2402
|
+
} else {
|
|
2403
|
+
lines.push(` Preview: ${preview(post.message_text)}`);
|
|
2404
|
+
lines.push(` Images: ${post.image_urls.length}`);
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
return lines.join("\n");
|
|
2408
|
+
}
|
|
2409
|
+
function formatForumDiscussionRefs(forumCmid, refs) {
|
|
2410
|
+
const lines = [`Forum ${forumCmid}: Discussions`];
|
|
2411
|
+
if (!refs.length) {
|
|
2412
|
+
return `${lines[0]}
|
|
2413
|
+
No discussions`;
|
|
2414
|
+
}
|
|
2415
|
+
for (const ref of refs) {
|
|
2416
|
+
lines.push([ref.id, ref.subject, ref.group_name, ref.url].filter(Boolean).join(" | "));
|
|
2417
|
+
}
|
|
2418
|
+
return lines.join("\n");
|
|
2419
|
+
}
|
|
2420
|
+
function formatForumActivities(forums) {
|
|
2421
|
+
if (!forums.length) {
|
|
2422
|
+
return "Forums\nNo forums";
|
|
2423
|
+
}
|
|
2424
|
+
return [
|
|
2425
|
+
"Forums",
|
|
2426
|
+
...forums.map((forum) => [forum.id, forum.name, forum.course_name, forum.course_id || "", forum.url].filter(Boolean).join(" | "))
|
|
2427
|
+
].join("\n");
|
|
2428
|
+
}
|
|
2429
|
+
function formatForumSearchHits(hits) {
|
|
2430
|
+
if (!hits.length) {
|
|
2431
|
+
return "Forum Search\nNo matches";
|
|
2432
|
+
}
|
|
2433
|
+
return [
|
|
2434
|
+
"Forum Search",
|
|
2435
|
+
...hits.map(
|
|
2436
|
+
(hit) => [
|
|
2437
|
+
hit.course_name,
|
|
2438
|
+
hit.forum_name,
|
|
2439
|
+
hit.discussion_subject,
|
|
2440
|
+
hit.discussion_id || "",
|
|
2441
|
+
hit.post_id || "",
|
|
2442
|
+
hit.matched_in,
|
|
2443
|
+
hit.author_name,
|
|
2444
|
+
hit.snippet || hit.discussion_subject,
|
|
2445
|
+
hit.url
|
|
2446
|
+
].filter(Boolean).join(" | ")
|
|
2447
|
+
)
|
|
2448
|
+
].join("\n");
|
|
2449
|
+
}
|
|
2450
|
+
function formatForumCheckResults(forumCmid, rows) {
|
|
2451
|
+
const lines = [`Forum ${forumCmid}: Discussion Check (first ${rows.length})`];
|
|
2452
|
+
for (const row of rows) {
|
|
2453
|
+
lines.push(
|
|
2454
|
+
row.ok ? `${row.discussion_id} | Yes | ${row.posts ?? ""} | ${row.images ?? ""} | ${row.subject}` : `${row.discussion_id} | No | ${row.subject} | ${row.error ?? ""}`
|
|
2455
|
+
);
|
|
2456
|
+
}
|
|
2457
|
+
return lines.join("\n");
|
|
2458
|
+
}
|
|
2459
|
+
function preview(value, maxLen = 100) {
|
|
2460
|
+
const cleaned = value.split(/\s+/).filter(Boolean).join(" ");
|
|
2461
|
+
return cleaned.length <= maxLen ? cleaned : `${cleaned.slice(0, maxLen - 1)}\u2026`;
|
|
2462
|
+
}
|
|
2463
|
+
function formatKeyValues(rows) {
|
|
2464
|
+
const present = rows.filter(([, value]) => value);
|
|
2465
|
+
const width = Math.max(0, ...present.map(([key]) => key.length));
|
|
2466
|
+
return present.map(([key, value]) => `${key.padEnd(width)} ${value}`).join("\n");
|
|
2467
|
+
}
|
|
2468
|
+
function formatColumns(rows) {
|
|
2469
|
+
if (!rows.length) {
|
|
2470
|
+
return "";
|
|
2471
|
+
}
|
|
2472
|
+
const widths = rows[0].map((_, index) => Math.max(...rows.map((row) => (row[index] ?? "").length)));
|
|
2473
|
+
return rows.map((row) => row.map((cell, index) => cell.padEnd(widths[index])).join(" ").trimEnd()).join("\n");
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
// src/output.ts
|
|
2477
|
+
import YAML2 from "yaml";
|
|
2478
|
+
function stripEmpty(value) {
|
|
2479
|
+
if (value === null || value === void 0 || value === "") {
|
|
2480
|
+
return void 0;
|
|
2481
|
+
}
|
|
2482
|
+
if (Array.isArray(value)) {
|
|
2483
|
+
const items = value.map(stripEmpty).filter((item) => item !== void 0);
|
|
2484
|
+
return items.length ? items : void 0;
|
|
2485
|
+
}
|
|
2486
|
+
if (typeof value === "object") {
|
|
2487
|
+
const entries = Object.entries(value).map(([key, child]) => [key, stripEmpty(child)]).filter(([, child]) => child !== void 0);
|
|
2488
|
+
return entries.length ? Object.fromEntries(entries) : void 0;
|
|
2489
|
+
}
|
|
2490
|
+
return value;
|
|
2491
|
+
}
|
|
2492
|
+
function applyFields(data, fieldsValue) {
|
|
2493
|
+
if (!fieldsValue) {
|
|
2494
|
+
return data;
|
|
2495
|
+
}
|
|
2496
|
+
const requested = fieldsValue.split(",").map((field) => field.trim()).filter(Boolean);
|
|
2497
|
+
if (!requested.length) {
|
|
2498
|
+
throw new UsageError("--fields must include at least one field");
|
|
2499
|
+
}
|
|
2500
|
+
const objects = Array.isArray(data) ? data : [data];
|
|
2501
|
+
const firstObject = objects.find(isPlainObject);
|
|
2502
|
+
if (!firstObject) {
|
|
2503
|
+
throw new UsageError("--fields can only be used with object or object-array output");
|
|
2504
|
+
}
|
|
2505
|
+
const valid = Object.keys(firstObject);
|
|
2506
|
+
const invalid = requested.filter((field) => !valid.includes(field));
|
|
2507
|
+
if (invalid.length) {
|
|
2508
|
+
throw new UsageError(`Unknown field '${invalid[0]}'. Valid fields: ${valid.join(", ")}`);
|
|
2509
|
+
}
|
|
2510
|
+
const pick = (item) => {
|
|
2511
|
+
if (!isPlainObject(item)) {
|
|
2512
|
+
return item;
|
|
2513
|
+
}
|
|
2514
|
+
return Object.fromEntries(requested.map((field) => [field, item[field]]));
|
|
2515
|
+
};
|
|
2516
|
+
return Array.isArray(data) ? data.map(pick) : pick(data);
|
|
2517
|
+
}
|
|
2518
|
+
function serializeStructured(data, options) {
|
|
2519
|
+
const filtered = applyFields(data, options.fields);
|
|
2520
|
+
const optimized = stripEmpty(filtered);
|
|
2521
|
+
const value = optimized === void 0 ? null : optimized;
|
|
2522
|
+
if (options.format === "yaml") {
|
|
2523
|
+
return YAML2.stringify(value).trimEnd();
|
|
2524
|
+
}
|
|
2525
|
+
return JSON.stringify(value);
|
|
2526
|
+
}
|
|
2527
|
+
function errorJson(code, message, hint) {
|
|
2528
|
+
return JSON.stringify({ error: true, code, message, ...hint ? { hint } : {} });
|
|
2529
|
+
}
|
|
2530
|
+
function isPlainObject(value) {
|
|
2531
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
// src/skills.ts
|
|
2535
|
+
import { spawnSync } from "child_process";
|
|
2536
|
+
import { readFileSync, writeFileSync } from "fs";
|
|
2537
|
+
import path from "path";
|
|
2538
|
+
var SKILL_NAME = "moodle-cli";
|
|
2539
|
+
var SKILL_SOURCE = "https://github.com/bunizao/moodle-cli";
|
|
2540
|
+
var SKILLS_SPEC_URL = "https://github.com/vercel-labs/skills";
|
|
2541
|
+
var SKILL_DESCRIPTION = "Inspect Moodle data from the terminal with the `moodle` CLI. Use when an agent needs courses, deadlines, grades, alerts, activities, or forum discussions. Prefer JSON output for agent workflows.";
|
|
2542
|
+
function formatSkillSummary() {
|
|
2543
|
+
return [
|
|
2544
|
+
`Name: ${SKILL_NAME}`,
|
|
2545
|
+
`Description: ${SKILL_DESCRIPTION}`,
|
|
2546
|
+
`Source: ${SKILL_SOURCE}`,
|
|
2547
|
+
`Spec: ${SKILLS_SPEC_URL}`,
|
|
2548
|
+
`Install: npx skills add ${SKILL_SOURCE}`,
|
|
2549
|
+
"CLI alias: moodle skills add (falls back to npm exec)",
|
|
2550
|
+
"Generate: moodle skills generate"
|
|
2551
|
+
].join("\n");
|
|
2552
|
+
}
|
|
2553
|
+
function buildSkillsAddCommand(extraArgs = [], launcher = "npx") {
|
|
2554
|
+
if (launcher === "npx") {
|
|
2555
|
+
return ["npx", "skills", "add", SKILL_SOURCE, ...extraArgs];
|
|
2556
|
+
}
|
|
2557
|
+
return ["npm", "exec", "--yes", "--", "skills", "add", SKILL_SOURCE, ...extraArgs];
|
|
2558
|
+
}
|
|
2559
|
+
function addSkill(extraArgs = [], options = {}) {
|
|
2560
|
+
const runCommand = options.runCommand ?? spawnSync;
|
|
2561
|
+
const commandExists = options.commandExists ?? ((name) => isCommandAvailable(name, runCommand));
|
|
2562
|
+
const command = commandExists("npx") ? buildSkillsAddCommand(extraArgs, "npx") : commandExists("npm") ? buildSkillsAddCommand(extraArgs, "npm") : void 0;
|
|
2563
|
+
if (!command) {
|
|
2564
|
+
throw new Error(`npx or npm is required to install agent skills. Install Node.js, then run npx skills add ${SKILL_SOURCE}.`);
|
|
2565
|
+
}
|
|
2566
|
+
const [program, ...args] = command;
|
|
2567
|
+
const result = runCommand(program, args, { stdio: "inherit" });
|
|
2568
|
+
if (result.error) {
|
|
2569
|
+
throw new Error(`Failed to launch ${command.join(" ")}: ${result.error.message}`);
|
|
2570
|
+
}
|
|
2571
|
+
if (result.status !== 0) {
|
|
2572
|
+
throw new Error(`${command.join(" ")} exited with status ${result.status ?? "unknown"}.`);
|
|
2573
|
+
}
|
|
2574
|
+
return command;
|
|
2575
|
+
}
|
|
2576
|
+
function installSkill(extraArgs = [], options = {}) {
|
|
2577
|
+
addSkill(extraArgs, options);
|
|
2578
|
+
}
|
|
2579
|
+
function extractCommanderCommands(program) {
|
|
2580
|
+
return collectCommandRows(program).map((row) => ({
|
|
2581
|
+
name: row.path.at(-1) ?? "",
|
|
2582
|
+
path: row.path,
|
|
2583
|
+
description: row.description,
|
|
2584
|
+
arguments: row.arguments,
|
|
2585
|
+
flags: row.flags
|
|
2586
|
+
}));
|
|
2587
|
+
}
|
|
2588
|
+
function generateSkillMarkdown(input) {
|
|
2589
|
+
if (isGenerateOptions(input)) {
|
|
2590
|
+
return renderSkillMarkdown(input.commands, input.template);
|
|
2591
|
+
}
|
|
2592
|
+
const template = readSkillTemplate();
|
|
2593
|
+
return renderSkillMarkdown(extractCommanderCommands(input), template);
|
|
2594
|
+
}
|
|
2595
|
+
function writeGeneratedSkill(program, target = "SKILL.md") {
|
|
2596
|
+
writeFileSync(target, generateSkillMarkdown(program), "utf8");
|
|
2597
|
+
}
|
|
2598
|
+
function renderSkillMarkdown(commands, template) {
|
|
2599
|
+
const replacements = {
|
|
2600
|
+
generated_frontmatter: renderFrontmatter(),
|
|
2601
|
+
generated_intent_table: renderIntentTable(),
|
|
2602
|
+
generated_command_reference: renderCommandReference(commands),
|
|
2603
|
+
generated_output_contract: renderOutputContract()
|
|
2604
|
+
};
|
|
2605
|
+
let markdown = template;
|
|
2606
|
+
for (const [key, value] of Object.entries(replacements)) {
|
|
2607
|
+
markdown = markdown.replaceAll(`{{${key}}}`, value.trimEnd());
|
|
2608
|
+
}
|
|
2609
|
+
return `${markdown.trimEnd()}
|
|
2610
|
+
`;
|
|
2611
|
+
}
|
|
2612
|
+
function collectCommandRows(command, parentPath = []) {
|
|
2613
|
+
const isRoot = !command.parent;
|
|
2614
|
+
const commandPath2 = isRoot ? parentPath : [...parentPath, command.name()];
|
|
2615
|
+
const ownRows = isRoot || isHiddenCommand(command) ? [] : [{
|
|
2616
|
+
name: command.name(),
|
|
2617
|
+
path: commandPath2,
|
|
2618
|
+
description: command.description() || "",
|
|
2619
|
+
arguments: readArguments(command),
|
|
2620
|
+
flags: readFlags(command)
|
|
2621
|
+
}];
|
|
2622
|
+
const childRows = command.commands.filter((child) => !isHiddenCommand(child) && child.name() !== "help").flatMap((child) => collectCommandRows(child, commandPath2));
|
|
2623
|
+
return [...ownRows, ...childRows];
|
|
2624
|
+
}
|
|
2625
|
+
function readArguments(command) {
|
|
2626
|
+
const args = command.registeredArguments ?? command._args ?? [];
|
|
2627
|
+
return args.map((arg) => {
|
|
2628
|
+
const nameValue = typeof arg.name === "function" ? arg.name.call(arg) : arg.name;
|
|
2629
|
+
return {
|
|
2630
|
+
name: String(nameValue ?? ""),
|
|
2631
|
+
required: Boolean(arg.required),
|
|
2632
|
+
variadic: Boolean(arg.variadic)
|
|
2633
|
+
};
|
|
2634
|
+
}).filter((arg) => arg.name);
|
|
2635
|
+
}
|
|
2636
|
+
function readFlags(command) {
|
|
2637
|
+
return command.options.map((option) => {
|
|
2638
|
+
const value = option;
|
|
2639
|
+
return {
|
|
2640
|
+
name: typeof value.long === "string" ? value.long : findLongFlag(option.flags),
|
|
2641
|
+
alias: typeof value.short === "string" ? value.short : findShortFlag(option.flags),
|
|
2642
|
+
description: option.description ?? "",
|
|
2643
|
+
defaultValue: value.defaultValue,
|
|
2644
|
+
required: Boolean(value.required ?? value.mandatory)
|
|
2645
|
+
};
|
|
2646
|
+
}).filter((flag) => flag.name);
|
|
2647
|
+
}
|
|
2648
|
+
function renderFrontmatter() {
|
|
2649
|
+
return [
|
|
2650
|
+
"---",
|
|
2651
|
+
`name: ${SKILL_NAME}`,
|
|
2652
|
+
`description: ${SKILL_DESCRIPTION}`,
|
|
2653
|
+
"---"
|
|
2654
|
+
].join("\n");
|
|
2655
|
+
}
|
|
2656
|
+
function renderIntentTable() {
|
|
2657
|
+
return renderMarkdownTable(
|
|
2658
|
+
["User intent", "Command"],
|
|
2659
|
+
[
|
|
2660
|
+
["Show my profile or account info", "moodle user --json"],
|
|
2661
|
+
["List my courses", "moodle courses --json"],
|
|
2662
|
+
["Find nearest deadlines or upcoming actions", "moodle todo --limit 5 --days 14 --json"],
|
|
2663
|
+
["List alerts or unread notifications", "moodle alerts --limit 10 --json"],
|
|
2664
|
+
["Show a compact dashboard", "moodle overview --todo-limit 5 --alerts-limit 5 --json"],
|
|
2665
|
+
["Show activities in a course", "moodle activities COURSE_ID --json"],
|
|
2666
|
+
["Show course sections", "moodle course COURSE_ID --json"],
|
|
2667
|
+
["Show grades for a course", "moodle grades COURSE_ID --json"],
|
|
2668
|
+
["Find the best forum match", "moodle forum find QUERY --json"],
|
|
2669
|
+
["Open a forum discussion URL or ID", "moodle forum discussion DISCUSSION_OR_URL --json"],
|
|
2670
|
+
["Check whether the CLI has an update", "moodle update --json"],
|
|
2671
|
+
["Install this agent skill", "moodle skills add"]
|
|
2672
|
+
]
|
|
2673
|
+
);
|
|
2674
|
+
}
|
|
2675
|
+
function renderCommandReference(commands) {
|
|
2676
|
+
const rows = flattenCommands(commands).sort((left, right) => commandPath(left).localeCompare(commandPath(right))).map((command) => [
|
|
2677
|
+
`moodle ${commandPath(command)}`.trim(),
|
|
2678
|
+
command.description ?? "",
|
|
2679
|
+
renderArguments(command.arguments ?? []),
|
|
2680
|
+
renderFlags(command.flags ?? [])
|
|
2681
|
+
]);
|
|
2682
|
+
return renderMarkdownTable(["Command", "Description", "Arguments", "Flags"], rows);
|
|
2683
|
+
}
|
|
2684
|
+
function renderOutputContract() {
|
|
2685
|
+
return [
|
|
2686
|
+
"### Output Contract",
|
|
2687
|
+
"",
|
|
2688
|
+
"- `--json` writes JSON to stdout.",
|
|
2689
|
+
"- `--yaml` writes YAML to stdout when supported.",
|
|
2690
|
+
"- `--table` forces human-readable table/tree output.",
|
|
2691
|
+
"- When stdout is not a TTY, commands default to JSON unless `--table` is set.",
|
|
2692
|
+
"- `--fields a,b,c` keeps only listed top-level fields. Arrays apply the field filter to each item.",
|
|
2693
|
+
"- Invalid `--fields` values are usage errors and list valid fields.",
|
|
2694
|
+
'- With JSON output enabled, errors are one JSON line on stderr: `{"error":true,"code":"auth_failed","message":"...","hint":"..."}`.',
|
|
2695
|
+
"",
|
|
2696
|
+
"Exit codes:",
|
|
2697
|
+
"",
|
|
2698
|
+
renderMarkdownTable(
|
|
2699
|
+
["Code", "Meaning"],
|
|
2700
|
+
[
|
|
2701
|
+
["0", "Success"],
|
|
2702
|
+
["1", "Unexpected error"],
|
|
2703
|
+
["2", "Authentication or configuration error"],
|
|
2704
|
+
["3", "Usage error"],
|
|
2705
|
+
["4", "Requested course, activity, forum, or discussion was not found"]
|
|
2706
|
+
]
|
|
2707
|
+
)
|
|
2708
|
+
].join("\n");
|
|
2709
|
+
}
|
|
2710
|
+
function flattenCommands(commands) {
|
|
2711
|
+
return commands.flatMap((command) => [command, ...flattenCommands(command.children ?? [])]);
|
|
2712
|
+
}
|
|
2713
|
+
function commandPath(command) {
|
|
2714
|
+
return (command.path?.length ? command.path : [command.name]).join(" ");
|
|
2715
|
+
}
|
|
2716
|
+
function renderArguments(args) {
|
|
2717
|
+
return args.map((arg) => {
|
|
2718
|
+
const name = arg.variadic ? `${arg.name}...` : arg.name;
|
|
2719
|
+
return arg.required ? `<${name}>` : `[${name}]`;
|
|
2720
|
+
}).join(" ");
|
|
2721
|
+
}
|
|
2722
|
+
function renderFlags(flags) {
|
|
2723
|
+
return flags.map((flag) => {
|
|
2724
|
+
const names = [flag.alias, flag.name].filter(Boolean).join(", ");
|
|
2725
|
+
const defaultValue = formatDefault(flag.defaultValue);
|
|
2726
|
+
const required = flag.required ? "value required" : "";
|
|
2727
|
+
const suffix = [defaultValue, required].filter(Boolean).join("; ");
|
|
2728
|
+
return suffix ? `${names} (${suffix})` : names;
|
|
2729
|
+
}).join("<br>");
|
|
2730
|
+
}
|
|
2731
|
+
function formatDefault(value) {
|
|
2732
|
+
if (value === void 0 || value === false) {
|
|
2733
|
+
return "";
|
|
2734
|
+
}
|
|
2735
|
+
return `default: ${Array.isArray(value) ? value.join(",") : String(value)}`;
|
|
2736
|
+
}
|
|
2737
|
+
function renderMarkdownTable(headers, rows) {
|
|
2738
|
+
return [
|
|
2739
|
+
`| ${headers.map(escapeCell).join(" | ")} |`,
|
|
2740
|
+
`| ${headers.map(() => "---").join(" | ")} |`,
|
|
2741
|
+
...rows.map((row) => `| ${row.map(escapeCell).join(" | ")} |`)
|
|
2742
|
+
].join("\n");
|
|
2743
|
+
}
|
|
2744
|
+
function escapeCell(value) {
|
|
2745
|
+
return value.replace(/\|/g, "\\|").replace(/\n/g, "<br>");
|
|
2746
|
+
}
|
|
2747
|
+
function isCommandAvailable(name, runCommand) {
|
|
2748
|
+
const result = runCommand(name, ["--version"], { stdio: "ignore" });
|
|
2749
|
+
return !result.error && result.status === 0;
|
|
2750
|
+
}
|
|
2751
|
+
function isHiddenCommand(command) {
|
|
2752
|
+
return Boolean(command.hidden || command._hidden);
|
|
2753
|
+
}
|
|
2754
|
+
function isGenerateOptions(value) {
|
|
2755
|
+
return "template" in value && "commands" in value;
|
|
2756
|
+
}
|
|
2757
|
+
function readSkillTemplate() {
|
|
2758
|
+
return readFileSync(path.join(process.cwd(), "src", "skill.template.md"), "utf8");
|
|
2759
|
+
}
|
|
2760
|
+
function findLongFlag(flags) {
|
|
2761
|
+
return flags.split(/[,\s]+/).find((part) => part.startsWith("--")) ?? "";
|
|
2762
|
+
}
|
|
2763
|
+
function findShortFlag(flags) {
|
|
2764
|
+
return flags.split(/[,\s]+/).find((part) => /^-[^-]/.test(part));
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
// src/update.ts
|
|
2768
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
2769
|
+
import path2 from "path";
|
|
2770
|
+
var UpdateCheckError = class extends Error {
|
|
2771
|
+
cause;
|
|
2772
|
+
constructor(message, options) {
|
|
2773
|
+
super(message);
|
|
2774
|
+
this.name = "UpdateCheckError";
|
|
2775
|
+
this.cause = options?.cause;
|
|
2776
|
+
}
|
|
2777
|
+
};
|
|
2778
|
+
async function checkForUpdates(currentVersion, fetchImpl = fetch) {
|
|
2779
|
+
const latestVersion = await fetchLatestVersion(fetchImpl);
|
|
2780
|
+
return {
|
|
2781
|
+
package_name: PACKAGE_NAME,
|
|
2782
|
+
current_version: currentVersion,
|
|
2783
|
+
latest_version: latestVersion,
|
|
2784
|
+
update_available: compareSemver(latestVersion, currentVersion) > 0,
|
|
2785
|
+
upgrade_commands: [
|
|
2786
|
+
`npm install -g ${PACKAGE_NAME}@latest`,
|
|
2787
|
+
`Download a standalone binary: ${GITHUB_RELEASES_URL}`
|
|
2788
|
+
],
|
|
2789
|
+
npm_url: `https://www.npmjs.com/package/${PACKAGE_NAME}`,
|
|
2790
|
+
release_url: GITHUB_RELEASES_URL
|
|
2791
|
+
};
|
|
2792
|
+
}
|
|
2793
|
+
async function fetchLatestVersion(fetchImpl = fetch, registryUrl = NPM_LATEST_URL) {
|
|
2794
|
+
let response;
|
|
2795
|
+
try {
|
|
2796
|
+
response = await fetchImpl(registryUrl, { headers: { accept: "application/json" } });
|
|
2797
|
+
} catch (error) {
|
|
2798
|
+
throw new UpdateCheckError(errorMessage(error), { cause: error });
|
|
2799
|
+
}
|
|
2800
|
+
if (!response.ok) {
|
|
2801
|
+
const statusText = response.statusText ? ` ${response.statusText}` : "";
|
|
2802
|
+
throw new UpdateCheckError(`npm registry returned HTTP ${response.status}${statusText}`);
|
|
2803
|
+
}
|
|
2804
|
+
let payload;
|
|
2805
|
+
try {
|
|
2806
|
+
payload = await response.json();
|
|
2807
|
+
} catch (error) {
|
|
2808
|
+
throw new UpdateCheckError("invalid response from npm registry", { cause: error });
|
|
2809
|
+
}
|
|
2810
|
+
const latestVersion = typeof payload === "object" && payload !== null ? payload.version : void 0;
|
|
2811
|
+
if (typeof latestVersion !== "string" || latestVersion.trim() === "") {
|
|
2812
|
+
throw new UpdateCheckError("missing version in npm registry response");
|
|
2813
|
+
}
|
|
2814
|
+
return latestVersion.trim();
|
|
2815
|
+
}
|
|
2816
|
+
function detectInstallKind(argv1 = process.argv[1]) {
|
|
2817
|
+
return detectInstallMethod({ argv1 }) === "npm" ? "npm" : "binary";
|
|
2818
|
+
}
|
|
2819
|
+
function detectInstallMethod(context = {}) {
|
|
2820
|
+
const env = context.env ?? process.env;
|
|
2821
|
+
const explicit = env.MOODLE_CLI_INSTALL_METHOD?.toLowerCase();
|
|
2822
|
+
if (explicit === "npm" || explicit === "binary" || explicit === "source") {
|
|
2823
|
+
return explicit;
|
|
2824
|
+
}
|
|
2825
|
+
const argv1 = normalizePath(context.argv1 ?? process.argv[1] ?? "");
|
|
2826
|
+
const execPath = normalizePath(context.execPath ?? process.execPath ?? "");
|
|
2827
|
+
const scriptName = path2.basename(argv1).toLowerCase();
|
|
2828
|
+
const executableName = path2.basename(execPath).toLowerCase();
|
|
2829
|
+
if (argv1.includes("/node_modules/moodle-cli/") || argv1.includes("/node_modules/.bin/")) {
|
|
2830
|
+
return "npm";
|
|
2831
|
+
}
|
|
2832
|
+
if (scriptName === "moodle" || scriptName === "moodle.cmd" || scriptName === "moodle-cli") {
|
|
2833
|
+
return "npm";
|
|
2834
|
+
}
|
|
2835
|
+
if (!argv1 || executableName === "moodle" || executableName === "moodle.exe" || executableName === "moodle-cli") {
|
|
2836
|
+
return "binary";
|
|
2837
|
+
}
|
|
2838
|
+
if (argv1.includes("/src/") || argv1.endsWith("/src/cli.ts")) {
|
|
2839
|
+
return "source";
|
|
2840
|
+
}
|
|
2841
|
+
return "unknown";
|
|
2842
|
+
}
|
|
2843
|
+
function applySelfUpdate(kind = detectInstallKind(), options = {}) {
|
|
2844
|
+
if (kind !== "npm") {
|
|
2845
|
+
return GITHUB_RELEASES_URL;
|
|
2846
|
+
}
|
|
2847
|
+
const runCommand = options.runCommand ?? spawnSync2;
|
|
2848
|
+
const available = runCommand("npm", ["--version"], { stdio: "ignore" });
|
|
2849
|
+
if (available.error || available.status !== 0) {
|
|
2850
|
+
throw new Error(`npm is required to update ${PACKAGE_NAME}. Download a binary from ${GITHUB_RELEASES_URL}`);
|
|
2851
|
+
}
|
|
2852
|
+
const command = ["npm", "install", "-g", `${PACKAGE_NAME}@latest`];
|
|
2853
|
+
const result = runCommand(command[0], command.slice(1), { stdio: "inherit" });
|
|
2854
|
+
if (result.error) {
|
|
2855
|
+
throw new Error(`Failed to run ${command.join(" ")}: ${result.error.message}`);
|
|
2856
|
+
}
|
|
2857
|
+
if (result.status !== 0) {
|
|
2858
|
+
throw new Error(`${command.join(" ")} exited with status ${result.status ?? "unknown"}`);
|
|
2859
|
+
}
|
|
2860
|
+
return command.join(" ");
|
|
2861
|
+
}
|
|
2862
|
+
function compareSemver(left, right) {
|
|
2863
|
+
const a = parseSemver(left);
|
|
2864
|
+
const b = parseSemver(right);
|
|
2865
|
+
for (const key of ["major", "minor", "patch"]) {
|
|
2866
|
+
if (a[key] !== b[key]) {
|
|
2867
|
+
return a[key] > b[key] ? 1 : -1;
|
|
2868
|
+
}
|
|
2869
|
+
}
|
|
2870
|
+
return comparePrerelease(a.prerelease, b.prerelease);
|
|
2871
|
+
}
|
|
2872
|
+
function parseSemver(value) {
|
|
2873
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value.trim());
|
|
2874
|
+
if (!match) {
|
|
2875
|
+
throw new UpdateCheckError(`invalid semver '${value}'`);
|
|
2876
|
+
}
|
|
2877
|
+
return {
|
|
2878
|
+
major: Number(match[1]),
|
|
2879
|
+
minor: Number(match[2]),
|
|
2880
|
+
patch: Number(match[3]),
|
|
2881
|
+
prerelease: match[4] ? match[4].split(".") : []
|
|
2882
|
+
};
|
|
2883
|
+
}
|
|
2884
|
+
function comparePrerelease(left, right) {
|
|
2885
|
+
if (left.length === 0 && right.length === 0) {
|
|
2886
|
+
return 0;
|
|
2887
|
+
}
|
|
2888
|
+
if (left.length === 0) {
|
|
2889
|
+
return 1;
|
|
2890
|
+
}
|
|
2891
|
+
if (right.length === 0) {
|
|
2892
|
+
return -1;
|
|
2893
|
+
}
|
|
2894
|
+
const length = Math.max(left.length, right.length);
|
|
2895
|
+
for (let index = 0; index < length; index += 1) {
|
|
2896
|
+
const a = left[index];
|
|
2897
|
+
const b = right[index];
|
|
2898
|
+
if (a === void 0) return -1;
|
|
2899
|
+
if (b === void 0) return 1;
|
|
2900
|
+
if (a === b) continue;
|
|
2901
|
+
const numericA = /^\d+$/.test(a);
|
|
2902
|
+
const numericB = /^\d+$/.test(b);
|
|
2903
|
+
if (numericA && numericB) {
|
|
2904
|
+
return Number(a) > Number(b) ? 1 : -1;
|
|
2905
|
+
}
|
|
2906
|
+
if (numericA) return -1;
|
|
2907
|
+
if (numericB) return 1;
|
|
2908
|
+
return a > b ? 1 : -1;
|
|
2909
|
+
}
|
|
2910
|
+
return 0;
|
|
2911
|
+
}
|
|
2912
|
+
function normalizePath(value) {
|
|
2913
|
+
return value.replace(/\\/g, "/");
|
|
2914
|
+
}
|
|
2915
|
+
function errorMessage(error) {
|
|
2916
|
+
return error instanceof Error ? error.message : String(error);
|
|
2917
|
+
}
|
|
2918
|
+
|
|
2919
|
+
// src/version.ts
|
|
2920
|
+
var VERSION = "0.5.5";
|
|
2921
|
+
|
|
2922
|
+
// src/url-resolver.ts
|
|
2923
|
+
function looksLikeUrl(value) {
|
|
2924
|
+
try {
|
|
2925
|
+
const parsed = new URL(value);
|
|
2926
|
+
return Boolean(parsed.protocol && parsed.host);
|
|
2927
|
+
} catch {
|
|
2928
|
+
return false;
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
function resolveTopLevelUrl(baseUrlOrOptions, targetValue, resolveCourseIdForUrlValue) {
|
|
2932
|
+
const objectMode = typeof baseUrlOrOptions !== "string";
|
|
2933
|
+
const baseUrl = objectMode ? baseUrlOrOptions.baseUrl : baseUrlOrOptions;
|
|
2934
|
+
const target = objectMode ? baseUrlOrOptions.target : targetValue ?? "";
|
|
2935
|
+
const resolveCourseIdForUrl = objectMode ? baseUrlOrOptions.resolveCourseIdForUrl : resolveCourseIdForUrlValue;
|
|
2936
|
+
let parsed;
|
|
2937
|
+
try {
|
|
2938
|
+
parsed = new URL(target.trim());
|
|
2939
|
+
} catch {
|
|
2940
|
+
throw new UsageError(`No such command '${target}'.`);
|
|
2941
|
+
}
|
|
2942
|
+
const configuredHost = new URL(baseUrl).host.toLowerCase();
|
|
2943
|
+
if (parsed.host.toLowerCase() !== configuredHost) {
|
|
2944
|
+
throw new UsageError(`URL host '${parsed.host.toLowerCase()}' does not match configured Moodle site '${configuredHost}'.`);
|
|
2945
|
+
}
|
|
2946
|
+
const path3 = parsed.pathname.replace(/\/$/, "");
|
|
2947
|
+
const intParam = (key, label) => {
|
|
2948
|
+
const value = parsed.searchParams.get(key);
|
|
2949
|
+
if (!value || !/^\d+$/.test(value)) {
|
|
2950
|
+
throw new UsageError(`Could not find ${label} in URL query (expected ?${key}=...).`);
|
|
2951
|
+
}
|
|
2952
|
+
return value;
|
|
2953
|
+
};
|
|
2954
|
+
if (path3.endsWith("/mod/forum/discuss.php")) {
|
|
2955
|
+
return objectMode ? { commandName: "forum_discussion", kwargs: { discussion: intParam("d", "discussion ID"), postId: parsed.hash, asJson: false, asYaml: false } } : { commandName: "forum:discussion", args: [intParam("d", "discussion ID"), parsed.hash] };
|
|
2956
|
+
}
|
|
2957
|
+
if (path3.endsWith("/mod/forum/view.php")) {
|
|
2958
|
+
return objectMode ? { commandName: "forum_discussions", kwargs: { forum: intParam("id", "forum module ID"), asJson: false, asYaml: false } } : { commandName: "forum:discussions", args: [intParam("id", "forum module ID")] };
|
|
2959
|
+
}
|
|
2960
|
+
if (path3.endsWith("/mod/assign/view.php")) {
|
|
2961
|
+
const id = intParam("id", "assignment module ID");
|
|
2962
|
+
return objectMode ? { commandName: "assign", kwargs: { assign: id, asJson: false, asYaml: false } } : { commandName: "assign", args: [id] };
|
|
2963
|
+
}
|
|
2964
|
+
if (path3.endsWith("/mod/quiz/view.php")) {
|
|
2965
|
+
const id = intParam("id", "quiz module ID");
|
|
2966
|
+
return objectMode ? { commandName: "quiz", kwargs: { quiz: id, asJson: false, asYaml: false } } : { commandName: "quiz", args: [id] };
|
|
2967
|
+
}
|
|
2968
|
+
if (path3.endsWith("/mod/resource/view.php")) {
|
|
2969
|
+
const id = intParam("id", "resource module ID");
|
|
2970
|
+
return objectMode ? { commandName: "resource", kwargs: { resource: id, asJson: false, asYaml: false } } : { commandName: "resource", args: [id] };
|
|
2971
|
+
}
|
|
2972
|
+
if (path3.endsWith("/mod/url/view.php")) {
|
|
2973
|
+
const id = intParam("id", "link module ID");
|
|
2974
|
+
return objectMode ? { commandName: "link", kwargs: { link: id, asJson: false, asYaml: false } } : { commandName: "link", args: [id] };
|
|
2975
|
+
}
|
|
2976
|
+
if (path3.endsWith("/mod/page/view.php")) {
|
|
2977
|
+
const id = intParam("id", "page module ID");
|
|
2978
|
+
return objectMode ? { commandName: "page", kwargs: { page: id, asJson: false, asYaml: false } } : { commandName: "page", args: [id] };
|
|
2979
|
+
}
|
|
2980
|
+
if (path3.endsWith("/mod/folder/view.php")) {
|
|
2981
|
+
const id = intParam("id", "folder module ID");
|
|
2982
|
+
return objectMode ? { commandName: "folder", kwargs: { folder: id, asJson: false, asYaml: false } } : { commandName: "folder", args: [id] };
|
|
2983
|
+
}
|
|
2984
|
+
if (path3.endsWith("/course/view.php")) {
|
|
2985
|
+
const id = intParam("id", "course ID");
|
|
2986
|
+
return objectMode ? { commandName: "course", kwargs: { course: id, asJson: false, asYaml: false } } : { commandName: "course", args: [id] };
|
|
2987
|
+
}
|
|
2988
|
+
if (path3.endsWith("/course/user.php") && parsed.searchParams.get("mode") === "grade" || path3.includes("/grade/report/")) {
|
|
2989
|
+
const id = intParam("id", "course ID");
|
|
2990
|
+
return objectMode ? { commandName: "grades", kwargs: { course: id, asJson: false, asYaml: false } } : { commandName: "grades", args: [id] };
|
|
2991
|
+
}
|
|
2992
|
+
if (path3.includes("/mod/") && path3.endsWith("/view.php")) {
|
|
2993
|
+
if (!resolveCourseIdForUrl) {
|
|
2994
|
+
throw new UsageError("Could not resolve course ID from the activity page.");
|
|
2995
|
+
}
|
|
2996
|
+
const finish = (courseId2) => {
|
|
2997
|
+
if (!courseId2) {
|
|
2998
|
+
throw new UsageError("Could not resolve course ID from the activity page.");
|
|
2999
|
+
}
|
|
3000
|
+
return objectMode ? { commandName: "course", kwargs: { course: String(courseId2), asJson: false, asYaml: false } } : { commandName: "course", args: [String(courseId2)] };
|
|
3001
|
+
};
|
|
3002
|
+
const courseId = resolveCourseIdForUrl(target);
|
|
3003
|
+
return courseId instanceof Promise ? courseId.then(finish) : finish(courseId);
|
|
3004
|
+
}
|
|
3005
|
+
throw new UsageError("Unsupported Moodle URL. Supported paths: forum, activity, course, and grade report URLs.");
|
|
3006
|
+
}
|
|
3007
|
+
function parseActivityReference(value, labelOrOptions, expectedPathValue) {
|
|
3008
|
+
const label = typeof labelOrOptions === "string" ? labelOrOptions : labelOrOptions.label;
|
|
3009
|
+
const expectedPath = typeof labelOrOptions === "string" ? expectedPathValue ?? "" : labelOrOptions.path;
|
|
3010
|
+
const raw = value.trim();
|
|
3011
|
+
if (/^\d+$/.test(raw)) {
|
|
3012
|
+
return Number(raw);
|
|
3013
|
+
}
|
|
3014
|
+
let parsed;
|
|
3015
|
+
try {
|
|
3016
|
+
parsed = new URL(raw);
|
|
3017
|
+
} catch {
|
|
3018
|
+
throw new UsageError(`${label} must be a numeric ID or a full ${label.toLowerCase()} URL.`);
|
|
3019
|
+
}
|
|
3020
|
+
if (!parsed.pathname.endsWith(expectedPath)) {
|
|
3021
|
+
throw new UsageError(`Unsupported ${label.toLowerCase()} URL. Use a view.php?id=... URL.`);
|
|
3022
|
+
}
|
|
3023
|
+
const id = parsed.searchParams.get("id");
|
|
3024
|
+
if (!id || !/^\d+$/.test(id)) {
|
|
3025
|
+
throw new UsageError(`Could not find ${label.toLowerCase()} module ID in view.php URL (expected ?id=...).`);
|
|
3026
|
+
}
|
|
3027
|
+
return Number(id);
|
|
3028
|
+
}
|
|
3029
|
+
function parseDiscussionReference(value) {
|
|
3030
|
+
const raw = value.trim();
|
|
3031
|
+
if (/^\d+$/.test(raw)) {
|
|
3032
|
+
return { discussionId: Number(raw), postId: null };
|
|
3033
|
+
}
|
|
3034
|
+
let parsed;
|
|
3035
|
+
try {
|
|
3036
|
+
parsed = new URL(raw);
|
|
3037
|
+
} catch {
|
|
3038
|
+
throw new UsageError("DISCUSSION must be a numeric ID or a full discuss.php URL.");
|
|
3039
|
+
}
|
|
3040
|
+
const discussion = parsed.searchParams.get("d");
|
|
3041
|
+
if (!discussion || !/^\d+$/.test(discussion)) {
|
|
3042
|
+
throw new UsageError("Could not find discussion ID in URL query (expected ?d=...).");
|
|
3043
|
+
}
|
|
3044
|
+
const postId = parsed.hash.startsWith("#p") && /^\d+$/.test(parsed.hash.slice(2)) ? Number(parsed.hash.slice(2)) : null;
|
|
3045
|
+
return { discussionId: Number(discussion), postId };
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
// src/cli.ts
|
|
3049
|
+
function buildProgram(io = {}) {
|
|
3050
|
+
const stdout = io.stdout ?? process.stdout;
|
|
3051
|
+
const stderr = io.stderr ?? process.stderr;
|
|
3052
|
+
const program = new Command("moodle");
|
|
3053
|
+
program.version(VERSION);
|
|
3054
|
+
program.description("Terminal-first CLI for Moodle LMS.");
|
|
3055
|
+
program.exitOverride();
|
|
3056
|
+
program.allowUnknownOption(true);
|
|
3057
|
+
program.allowExcessArguments(true);
|
|
3058
|
+
program.configureOutput({
|
|
3059
|
+
writeOut: (text) => stdout.write(text),
|
|
3060
|
+
writeErr: (text) => stderr.write(text)
|
|
3061
|
+
});
|
|
3062
|
+
program.option("-v, --verbose", "Enable debug logging.");
|
|
3063
|
+
program.option("--no-cache", "Bypass session cache reads.");
|
|
3064
|
+
program.argument("[target]", "Supported Moodle URL");
|
|
3065
|
+
const runtime = {
|
|
3066
|
+
client: null,
|
|
3067
|
+
baseUrl: async () => (await loadConfig({ env: io.env, cwd: io.cwd, homeDir: io.homeDir, stdin: io.stdin, fetch: io.fetchImpl })).baseUrl,
|
|
3068
|
+
getClient: async () => {
|
|
3069
|
+
if (!runtime.client) {
|
|
3070
|
+
const baseUrl = await runtime.baseUrl();
|
|
3071
|
+
runtime.client = await createMoodleClient(baseUrl, {
|
|
3072
|
+
env: io.env,
|
|
3073
|
+
fetchImpl: io.fetchImpl,
|
|
3074
|
+
homeDir: io.homeDir,
|
|
3075
|
+
noCache: Boolean(program.opts().cache === false)
|
|
3076
|
+
});
|
|
3077
|
+
}
|
|
3078
|
+
return runtime.client;
|
|
3079
|
+
},
|
|
3080
|
+
output: (data, formatter, options) => {
|
|
3081
|
+
const format = outputFormat(options, stdout);
|
|
3082
|
+
if (format === "table") {
|
|
3083
|
+
stdout.write(`${formatter()}
|
|
3084
|
+
`);
|
|
3085
|
+
} else {
|
|
3086
|
+
stdout.write(`${serializeStructured(data, { format, fields: options.fields })}
|
|
3087
|
+
`);
|
|
3088
|
+
}
|
|
3089
|
+
}
|
|
3090
|
+
};
|
|
3091
|
+
program.action(async (target, options, command) => {
|
|
3092
|
+
if (!target) {
|
|
3093
|
+
command.help();
|
|
3094
|
+
return;
|
|
3095
|
+
}
|
|
3096
|
+
if (!looksLikeUrl(target)) {
|
|
3097
|
+
throw new UsageError(`No such command '${target}'.`);
|
|
3098
|
+
}
|
|
3099
|
+
await dispatchUrl(runtime, target, { ...parseRootOutputOptions(io.rootArgs ?? []), ...options });
|
|
3100
|
+
});
|
|
3101
|
+
addOutputOptions(program.command("user").description("Show authenticated user info.")).action(async (options) => {
|
|
3102
|
+
const user = await (await runtime.getClient()).getSiteInfo();
|
|
3103
|
+
runtime.output(user, () => formatUser(user), options);
|
|
3104
|
+
});
|
|
3105
|
+
addOutputOptions(program.command("courses").description("List enrolled courses.")).action(async (options) => {
|
|
3106
|
+
const courses = await (await runtime.getClient()).getCourses();
|
|
3107
|
+
runtime.output(courses, () => formatCourses(courses), options);
|
|
3108
|
+
});
|
|
3109
|
+
addOutputOptions(program.command("todo").description("List upcoming actionable timeline items.")).option("--limit <number>", "Maximum number of items.", parsePositiveInt, 20).option("--days <number>", "Only include items due within the next N days.", parsePositiveInt).action(async (options) => {
|
|
3110
|
+
const items = await (await runtime.getClient()).getTodo(options.limit, options.days);
|
|
3111
|
+
runtime.output(items, () => formatTodo(items), options);
|
|
3112
|
+
});
|
|
3113
|
+
addOutputOptions(program.command("alerts").description("List notifications and message counts.")).option("--limit <number>", "Maximum number of notifications.", parsePositiveInt, 20).action(async (options) => {
|
|
3114
|
+
const alerts = await (await runtime.getClient()).getAlerts(options.limit);
|
|
3115
|
+
runtime.output(alerts, () => formatAlerts(alerts), options);
|
|
3116
|
+
});
|
|
3117
|
+
addOutputOptions(program.command("overview").description("Show a compact multi-source overview.")).option("--todo-limit <number>", "Maximum number of todo items.", parsePositiveInt, 5).option("--todo-days <number>", "Only include todo items due within the next N days.", parsePositiveInt).option("--alerts-limit <number>", "Maximum number of notifications.", parsePositiveInt, 5).action(async (options) => {
|
|
3118
|
+
const overview = await (await runtime.getClient()).getOverview(options.todoLimit, options.todoDays, options.alertsLimit);
|
|
3119
|
+
runtime.output(overview, () => `${formatUser(overview.user)}
|
|
3120
|
+
|
|
3121
|
+
${formatTodo(overview.todo)}
|
|
3122
|
+
|
|
3123
|
+
${overview.alerts ? formatAlerts(overview.alerts) : ""}`, options);
|
|
3124
|
+
});
|
|
3125
|
+
addCourseCommand(program, runtime, "course", "Show course detail with sections.", async (client, courseId) => client.getCourseContents(courseId), formatCourseSections);
|
|
3126
|
+
addCourseCommand(program, runtime, "activities", "List activities in a course.", async (client, courseId) => client.getActivities(courseId), formatActivityList);
|
|
3127
|
+
addOutputOptions(program.command("grades").description("Show grade details for a course.").argument("<course>", "Course ID or unique name")).action(
|
|
3128
|
+
async (course, options) => {
|
|
3129
|
+
const client = await runtime.getClient();
|
|
3130
|
+
const courseId = await client.resolveCourseReference(course);
|
|
3131
|
+
const grades = await client.getCourseGrades(courseId);
|
|
3132
|
+
runtime.output(grades, () => formatGrades(grades), options);
|
|
3133
|
+
}
|
|
3134
|
+
);
|
|
3135
|
+
addActivityCommand(program, runtime, "assign", "Assignment", ASSIGN_VIEW_PATH, (client, id) => client.getAssignment(id));
|
|
3136
|
+
addActivityCommand(program, runtime, "quiz", "Quiz", QUIZ_VIEW_PATH, (client, id) => client.getQuiz(id));
|
|
3137
|
+
addActivityCommand(program, runtime, "resource", "Resource", RESOURCE_VIEW_PATH, (client, id) => client.getResource(id));
|
|
3138
|
+
addActivityCommand(program, runtime, "link", "Link", URL_VIEW_PATH, (client, id) => client.getLink(id));
|
|
3139
|
+
addActivityCommand(program, runtime, "page", "Page", PAGE_VIEW_PATH, (client, id) => client.getPage(id));
|
|
3140
|
+
addActivityCommand(program, runtime, "folder", "Folder", FOLDER_VIEW_PATH, (client, id) => client.getFolder(id));
|
|
3141
|
+
const forum = program.command("forum").description("Forum utilities.");
|
|
3142
|
+
addOutputOptions(forum.command("discussion").description("Show posts in a forum discussion.").argument("<discussion>", "Discussion ID or URL")).option("--post <id>", "Show a specific post ID.", parsePositiveInt).option("--body", "Show full post body.").action(async (discussion, options) => {
|
|
3143
|
+
const parsed = parseDiscussionReference(discussion);
|
|
3144
|
+
const postId = options.post ?? parsed.postId;
|
|
3145
|
+
const thread = filterDiscussionToPost(await (await runtime.getClient()).getForumDiscussion(parsed.discussionId), postId);
|
|
3146
|
+
runtime.output(thread, () => formatForumDiscussion(thread, { showBody: options.body }), options);
|
|
3147
|
+
});
|
|
3148
|
+
addOutputOptions(forum.command("discussions").description("List discussions from a forum.").argument("<forum>", "Forum ID or URL")).option("--limit <number>", "Maximum number of discussions.", parsePositiveInt, 50).option("--query <query>", "Filter discussion titles by query.").action(async (forumRef, options) => {
|
|
3149
|
+
const client = await runtime.getClient();
|
|
3150
|
+
const forumId = await parseForumReference(client, forumRef);
|
|
3151
|
+
let refs = await client.getForumDiscussionRefs(forumId);
|
|
3152
|
+
if (options.query) {
|
|
3153
|
+
refs = refs.filter((ref) => queryMatches2(ref.subject, options.query));
|
|
3154
|
+
}
|
|
3155
|
+
refs = refs.slice(0, options.limit);
|
|
3156
|
+
runtime.output(refs, () => formatForumDiscussionRefs(forumId, refs), options);
|
|
3157
|
+
});
|
|
3158
|
+
addOutputOptions(forum.command("forums").description("List forum activities.").argument("[query]", "Optional forum/course query")).option("--course <course>", "Restrict to a course ID or unique course name match.").option("--limit <number>", "Maximum number of forums.", parsePositiveInt, 50).action(async (query, options) => {
|
|
3159
|
+
const client = await runtime.getClient();
|
|
3160
|
+
const courseId = options.course ? await client.resolveCourseReference(options.course) : void 0;
|
|
3161
|
+
let forums = await client.getForums(courseId);
|
|
3162
|
+
if (query) {
|
|
3163
|
+
forums = forums.filter((forum2) => queryMatches2(forum2.name, query) || queryMatches2(forum2.course_name, query));
|
|
3164
|
+
}
|
|
3165
|
+
forums = forums.slice(0, options.limit);
|
|
3166
|
+
runtime.output(forums, () => formatForumActivities(forums), options);
|
|
3167
|
+
});
|
|
3168
|
+
addForumSearchCommand(forum.command("search").description("Search forum discussion titles and post text."), runtime, 20, false);
|
|
3169
|
+
addForumSearchCommand(forum.command("find").description("Find the best forum match.").option("--list", "Return a shortlist.").option("--body", "Resolve the target body."), runtime, 5, true);
|
|
3170
|
+
addOutputOptions(forum.command("check").description("Validate discussion rendering.").argument("<forum>", "Forum ID or URL")).option("--limit <number>", "Maximum number of discussions.", parsePositiveInt, 20).action(async (forumRef, options) => {
|
|
3171
|
+
const client = await runtime.getClient();
|
|
3172
|
+
const forumId = await parseForumReference(client, forumRef);
|
|
3173
|
+
const refs = (await client.getForumDiscussionRefs(forumId)).slice(0, options.limit);
|
|
3174
|
+
const results = [];
|
|
3175
|
+
for (const ref of refs) {
|
|
3176
|
+
try {
|
|
3177
|
+
const discussion = await client.getForumDiscussion(ref.id);
|
|
3178
|
+
results.push({
|
|
3179
|
+
discussion_id: ref.id,
|
|
3180
|
+
subject: ref.subject,
|
|
3181
|
+
ok: true,
|
|
3182
|
+
posts: discussion.posts.length,
|
|
3183
|
+
images: discussion.posts.reduce((total, post) => total + post.image_urls.length, 0)
|
|
3184
|
+
});
|
|
3185
|
+
} catch (error) {
|
|
3186
|
+
results.push({ discussion_id: ref.id, subject: ref.subject, ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
3187
|
+
}
|
|
3188
|
+
}
|
|
3189
|
+
runtime.output(results, () => formatForumCheckResults(forumId, results), options);
|
|
3190
|
+
});
|
|
3191
|
+
addOutputOptions(program.command("update").description("Check for updates and upgrade the installed CLI.")).option("--check-only", "Only check for updates; do not install.").action(async (options) => {
|
|
3192
|
+
try {
|
|
3193
|
+
const info = await checkForUpdates(VERSION, io.fetchImpl);
|
|
3194
|
+
if (outputFormat(options, stdout) !== "table") {
|
|
3195
|
+
runtime.output(info, () => "", options);
|
|
3196
|
+
return;
|
|
3197
|
+
}
|
|
3198
|
+
if (!info.update_available) {
|
|
3199
|
+
stdout.write(`${info.package_name} is up to date (${info.current_version})
|
|
3200
|
+
`);
|
|
3201
|
+
return;
|
|
3202
|
+
}
|
|
3203
|
+
stdout.write(`Update available: ${info.latest_version} (installed: ${info.current_version})
|
|
3204
|
+
`);
|
|
3205
|
+
if (options.checkOnly) {
|
|
3206
|
+
stdout.write(`Upgrade with: ${info.upgrade_commands.join(" && ")}
|
|
3207
|
+
`);
|
|
3208
|
+
return;
|
|
3209
|
+
}
|
|
3210
|
+
stdout.write(`Updated with: ${applySelfUpdate()}
|
|
3211
|
+
`);
|
|
3212
|
+
} catch (error) {
|
|
3213
|
+
stdout.write(`Could not check for updates: ${error instanceof Error ? error.message : String(error)}
|
|
3214
|
+
`);
|
|
3215
|
+
}
|
|
3216
|
+
});
|
|
3217
|
+
const skills = program.command("skills").description("Show skill metadata or delegate to the shared skills CLI.");
|
|
3218
|
+
skills.action(() => {
|
|
3219
|
+
stdout.write(`${formatSkillSummary()}
|
|
3220
|
+
`);
|
|
3221
|
+
});
|
|
3222
|
+
skills.command("generate").description("Regenerate SKILL.md from the CLI command tree.").action(() => {
|
|
3223
|
+
writeGeneratedSkill(program);
|
|
3224
|
+
stdout.write("Generated SKILL.md\n");
|
|
3225
|
+
});
|
|
3226
|
+
skills.command("add").description("Install the published skill through npx skills add.").allowUnknownOption(true).action((_options, command) => installSkill(command.args));
|
|
3227
|
+
hideCommand(skills.command("install").allowUnknownOption(true)).action((_options, command) => installSkill(command.args));
|
|
3228
|
+
hideCommand(skills.command("i").allowUnknownOption(true)).action((_options, command) => installSkill(command.args));
|
|
3229
|
+
return program;
|
|
3230
|
+
}
|
|
3231
|
+
async function runCli(argv = process.argv, io = {}) {
|
|
3232
|
+
const stderr = io.stderr ?? process.stderr;
|
|
3233
|
+
const stdout = io.stdout ?? process.stdout;
|
|
3234
|
+
const program = buildProgram({ ...io, rootArgs: argv.slice(2) });
|
|
3235
|
+
try {
|
|
3236
|
+
await program.parseAsync(argv, { from: "node" });
|
|
3237
|
+
return 0;
|
|
3238
|
+
} catch (error) {
|
|
3239
|
+
if (error instanceof CommanderError) {
|
|
3240
|
+
if (error.code === "commander.helpDisplayed" || error.code === "commander.version") {
|
|
3241
|
+
return 0;
|
|
3242
|
+
}
|
|
3243
|
+
const cliError2 = new UsageError(error.message);
|
|
3244
|
+
writeError(cliError2, stderr, wantsJsonFromArgs(argv, stdout));
|
|
3245
|
+
return cliError2.exitCode;
|
|
3246
|
+
}
|
|
3247
|
+
const cliError = toCliError(error);
|
|
3248
|
+
writeError(cliError, stderr, wantsJsonFromArgs(argv, stdout));
|
|
3249
|
+
return cliError.exitCode;
|
|
3250
|
+
}
|
|
3251
|
+
}
|
|
3252
|
+
async function dispatchUrl(runtime, target, options) {
|
|
3253
|
+
const client = await runtime.getClient();
|
|
3254
|
+
const resolved = await resolveTopLevelUrl(client.baseUrl, target, (url) => client.resolveCourseIdForUrl(url));
|
|
3255
|
+
const [first2] = resolved.args ?? [];
|
|
3256
|
+
if (!first2) {
|
|
3257
|
+
throw new UsageError("Unsupported Moodle URL.");
|
|
3258
|
+
}
|
|
3259
|
+
switch (resolved.commandName) {
|
|
3260
|
+
case "assign": {
|
|
3261
|
+
const item = await client.getAssignment(Number(first2));
|
|
3262
|
+
runtime.output(item, () => formatActivityDetail(item), options);
|
|
3263
|
+
return;
|
|
3264
|
+
}
|
|
3265
|
+
case "quiz": {
|
|
3266
|
+
const item = await client.getQuiz(Number(first2));
|
|
3267
|
+
runtime.output(item, () => formatActivityDetail(item), options);
|
|
3268
|
+
return;
|
|
3269
|
+
}
|
|
3270
|
+
case "resource": {
|
|
3271
|
+
const item = await client.getResource(Number(first2));
|
|
3272
|
+
runtime.output(item, () => formatActivityDetail(item), options);
|
|
3273
|
+
return;
|
|
3274
|
+
}
|
|
3275
|
+
case "link": {
|
|
3276
|
+
const item = await client.getLink(Number(first2));
|
|
3277
|
+
runtime.output(item, () => formatActivityDetail(item), options);
|
|
3278
|
+
return;
|
|
3279
|
+
}
|
|
3280
|
+
case "page": {
|
|
3281
|
+
const item = await client.getPage(Number(first2));
|
|
3282
|
+
runtime.output(item, () => formatActivityDetail(item), options);
|
|
3283
|
+
return;
|
|
3284
|
+
}
|
|
3285
|
+
case "folder": {
|
|
3286
|
+
const item = await client.getFolder(Number(first2));
|
|
3287
|
+
runtime.output(item, () => formatActivityDetail(item), options);
|
|
3288
|
+
return;
|
|
3289
|
+
}
|
|
3290
|
+
case "course": {
|
|
3291
|
+
const sections = await client.getCourseContents(Number(first2));
|
|
3292
|
+
runtime.output(sections, () => formatCourseSections(sections), options);
|
|
3293
|
+
return;
|
|
3294
|
+
}
|
|
3295
|
+
case "grades": {
|
|
3296
|
+
const grades = await client.getCourseGrades(Number(first2));
|
|
3297
|
+
runtime.output(grades, () => formatGrades(grades), options);
|
|
3298
|
+
return;
|
|
3299
|
+
}
|
|
3300
|
+
case "forum:discussion": {
|
|
3301
|
+
const postHash = resolved.args?.[1] ?? "";
|
|
3302
|
+
const postId = postHash.startsWith("#p") ? Number(postHash.slice(2)) : null;
|
|
3303
|
+
const discussion = filterDiscussionToPost(await client.getForumDiscussion(Number(first2)), Number.isFinite(postId) ? postId : null);
|
|
3304
|
+
runtime.output(discussion, () => formatForumDiscussion(discussion), options);
|
|
3305
|
+
return;
|
|
3306
|
+
}
|
|
3307
|
+
case "forum:discussions": {
|
|
3308
|
+
const refs = await client.getForumDiscussionRefs(Number(first2));
|
|
3309
|
+
runtime.output(refs, () => formatForumDiscussionRefs(Number(first2), refs), options);
|
|
3310
|
+
return;
|
|
3311
|
+
}
|
|
3312
|
+
default:
|
|
3313
|
+
throw new UsageError("Unsupported Moodle URL.");
|
|
3314
|
+
}
|
|
3315
|
+
}
|
|
3316
|
+
function addCourseCommand(program, runtime, name, description, load, format) {
|
|
3317
|
+
addOutputOptions(program.command(name).description(description).argument("<course>", "Course ID or unique name")).action(async (course, options) => {
|
|
3318
|
+
const client = await runtime.getClient();
|
|
3319
|
+
const courseId = await client.resolveCourseReference(course);
|
|
3320
|
+
const value = await load(client, courseId);
|
|
3321
|
+
runtime.output(value, () => format(value), options);
|
|
3322
|
+
});
|
|
3323
|
+
}
|
|
3324
|
+
function addActivityCommand(program, runtime, name, label, path3, load) {
|
|
3325
|
+
return addOutputOptions(program.command(name).description(`Show ${label.toLowerCase()} details.`).argument(`<${name}>`, `${label} ID or URL`)).action(
|
|
3326
|
+
async (value, options) => {
|
|
3327
|
+
const id = parseActivityReference(value, label, path3);
|
|
3328
|
+
const item = await load(await runtime.getClient(), id);
|
|
3329
|
+
runtime.output(item, () => formatActivityDetail(item), options);
|
|
3330
|
+
}
|
|
3331
|
+
);
|
|
3332
|
+
}
|
|
3333
|
+
function addForumSearchCommand(command, runtime, defaultLimit, findMode) {
|
|
3334
|
+
addOutputOptions(command.argument("<query>", "Search query")).option("--course <course>", "Restrict to a course ID or unique course name match.").option("--forum <forum>", "Restrict to a forum ID or forum URL.").option("--titles-only", "Only search discussion titles.").option("--unread-only", "Only include unread matches.").option("--recent", "Sort matches by newest activity.").option("--limit-forums <number>", "Maximum number of forums to scan.", parsePositiveInt).option("--limit-discussions <number>", "Maximum number of discussions per forum.", parsePositiveInt).option("--limit <number>", "Maximum number of matches.", parsePositiveInt, defaultLimit).action(async (query, options) => {
|
|
3335
|
+
const client = await runtime.getClient();
|
|
3336
|
+
const courseId = options.course ? await client.resolveCourseReference(options.course) : void 0;
|
|
3337
|
+
const forumCmid = options.forum ? await parseForumReference(client, options.forum) : void 0;
|
|
3338
|
+
const limit = findMode && !options.list ? 1 : options.limit;
|
|
3339
|
+
const hits = await client.searchForumContent({
|
|
3340
|
+
query,
|
|
3341
|
+
limit,
|
|
3342
|
+
courseId,
|
|
3343
|
+
forumCmid,
|
|
3344
|
+
includePostText: !options.titlesOnly,
|
|
3345
|
+
unreadOnly: options.unreadOnly,
|
|
3346
|
+
sortBy: options.recent || findMode ? "recent" : "relevance",
|
|
3347
|
+
maxForums: options.limitForums,
|
|
3348
|
+
maxDiscussionsPerForum: options.limitDiscussions
|
|
3349
|
+
});
|
|
3350
|
+
if (findMode && options.body && hits[0]) {
|
|
3351
|
+
const discussion = filterDiscussionToPost(await client.getForumDiscussion(hits[0].discussion_id), hits[0].post_id || null);
|
|
3352
|
+
runtime.output(discussion, () => formatForumDiscussion(discussion, { showBody: true }), options);
|
|
3353
|
+
return;
|
|
3354
|
+
}
|
|
3355
|
+
const output = findMode && !options.list ? hits[0] ?? null : hits;
|
|
3356
|
+
runtime.output(output, () => formatForumSearchHits(Array.isArray(output) ? output : output ? [output] : []), options);
|
|
3357
|
+
});
|
|
3358
|
+
}
|
|
3359
|
+
async function parseForumReference(client, value) {
|
|
3360
|
+
const raw = value.trim();
|
|
3361
|
+
if (/^\d+$/.test(raw)) {
|
|
3362
|
+
return Number(raw);
|
|
3363
|
+
}
|
|
3364
|
+
const parsed = new URL(raw);
|
|
3365
|
+
if (parsed.pathname.endsWith("/mod/forum/view.php")) {
|
|
3366
|
+
const id = parsed.searchParams.get("id");
|
|
3367
|
+
if (id && /^\d+$/.test(id)) {
|
|
3368
|
+
return Number(id);
|
|
3369
|
+
}
|
|
3370
|
+
}
|
|
3371
|
+
if (parsed.pathname.endsWith("/mod/forum/discuss.php")) {
|
|
3372
|
+
const id = parsed.searchParams.get("d");
|
|
3373
|
+
if (id && /^\d+$/.test(id)) {
|
|
3374
|
+
const forumId = await client.getForumViewCmid(Number(id));
|
|
3375
|
+
if (forumId) {
|
|
3376
|
+
return forumId;
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
throw new UsageError("Unsupported forum URL. Use a view.php?id=... or discuss.php?d=... URL.");
|
|
3381
|
+
}
|
|
3382
|
+
function addOutputOptions(command) {
|
|
3383
|
+
return command.option("--json", "Output as JSON.").option("--yaml", "Output as YAML.").option("--table", "Force human output.").option("--fields <fields>", "Keep only listed top-level fields in structured output.");
|
|
3384
|
+
}
|
|
3385
|
+
function hideCommand(command) {
|
|
3386
|
+
command.hidden = true;
|
|
3387
|
+
return command;
|
|
3388
|
+
}
|
|
3389
|
+
function outputFormat(options, stdout) {
|
|
3390
|
+
if (options.yaml) {
|
|
3391
|
+
return "yaml";
|
|
3392
|
+
}
|
|
3393
|
+
if (options.json) {
|
|
3394
|
+
return "json";
|
|
3395
|
+
}
|
|
3396
|
+
if (options.table) {
|
|
3397
|
+
return "table";
|
|
3398
|
+
}
|
|
3399
|
+
return "isTTY" in stdout && stdout.isTTY ? "table" : "json";
|
|
3400
|
+
}
|
|
3401
|
+
function parsePositiveInt(value) {
|
|
3402
|
+
const parsed = Number(value);
|
|
3403
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
3404
|
+
throw new UsageError("Expected a positive integer.");
|
|
3405
|
+
}
|
|
3406
|
+
return parsed;
|
|
3407
|
+
}
|
|
3408
|
+
function writeError(error, stderr, asJson) {
|
|
3409
|
+
if (asJson) {
|
|
3410
|
+
stderr?.write(`${errorJson(error.code, error.message, error.hint)}
|
|
3411
|
+
`);
|
|
3412
|
+
return;
|
|
3413
|
+
}
|
|
3414
|
+
stderr?.write(`${labelFor(error)}: ${error.message}
|
|
3415
|
+
`);
|
|
3416
|
+
if (error instanceof ConfigError && error.hint) {
|
|
3417
|
+
stderr?.write(`${error.hint}
|
|
3418
|
+
`);
|
|
3419
|
+
} else if (error.hint) {
|
|
3420
|
+
stderr?.write(`${error.hint}
|
|
3421
|
+
`);
|
|
3422
|
+
}
|
|
3423
|
+
if (error instanceof MoodleAPIError && error.moodleErrorCode) {
|
|
3424
|
+
stderr?.write(`Error code: ${error.moodleErrorCode}
|
|
3425
|
+
`);
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
function wantsJsonFromArgs(argv, stdout) {
|
|
3429
|
+
return argv.includes("--json") || !argv.includes("--table") && !("isTTY" in stdout && stdout.isTTY);
|
|
3430
|
+
}
|
|
3431
|
+
function parseRootOutputOptions(args) {
|
|
3432
|
+
const fieldsIndex = args.findIndex((arg) => arg === "--fields" || arg.startsWith("--fields="));
|
|
3433
|
+
const fieldsArg = fieldsIndex >= 0 ? args[fieldsIndex] : "";
|
|
3434
|
+
const fields = fieldsArg.startsWith("--fields=") ? fieldsArg.slice("--fields=".length) : fieldsIndex >= 0 ? args[fieldsIndex + 1] : void 0;
|
|
3435
|
+
if (fieldsIndex >= 0 && (!fields || fields.startsWith("--"))) {
|
|
3436
|
+
throw new UsageError("--fields requires a value.");
|
|
3437
|
+
}
|
|
3438
|
+
return {
|
|
3439
|
+
json: args.includes("--json"),
|
|
3440
|
+
yaml: args.includes("--yaml"),
|
|
3441
|
+
table: args.includes("--table"),
|
|
3442
|
+
fields
|
|
3443
|
+
};
|
|
3444
|
+
}
|
|
3445
|
+
function labelFor(error) {
|
|
3446
|
+
if (error.code === "auth_failed") {
|
|
3447
|
+
return "Auth error";
|
|
3448
|
+
}
|
|
3449
|
+
if (error.code === "config_error") {
|
|
3450
|
+
return "Config error";
|
|
3451
|
+
}
|
|
3452
|
+
if (error.code === "usage_error") {
|
|
3453
|
+
return "Usage error";
|
|
3454
|
+
}
|
|
3455
|
+
if (error.code === "not_found") {
|
|
3456
|
+
return "Not found";
|
|
3457
|
+
}
|
|
3458
|
+
return "Error";
|
|
3459
|
+
}
|
|
3460
|
+
function queryMatches2(text, query) {
|
|
3461
|
+
const haystack = text.toLowerCase().split(/\s+/).join(" ");
|
|
3462
|
+
const needle = query.toLowerCase().split(/\s+/).join(" ");
|
|
3463
|
+
return needle ? haystack.includes(needle) || needle.split(" ").every((token) => haystack.includes(token)) : true;
|
|
3464
|
+
}
|
|
3465
|
+
var isMain = process.argv[1] ? realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]) : false;
|
|
3466
|
+
if (isMain) {
|
|
3467
|
+
runCli().then((code) => {
|
|
3468
|
+
process.exitCode = code;
|
|
3469
|
+
});
|
|
3470
|
+
}
|
|
3471
|
+
export {
|
|
3472
|
+
buildProgram,
|
|
3473
|
+
runCli
|
|
3474
|
+
};
|