moodle-cli 0.5.5 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -2
- package/SKILL.md +23 -154
- package/agents/openai.yaml +4 -0
- package/dist/moodle.js +907 -426
- package/package.json +7 -5
- package/references/command-reference.md +38 -0
- package/references/coursework-and-grades.md +42 -0
- package/references/deadlines-and-alerts.md +25 -0
- package/references/forums.md +58 -0
- package/references/maintenance.md +43 -0
- package/references/output-and-errors.md +30 -0
- package/references/profile-and-courses.md +27 -0
- package/references/setup-and-auth.md +73 -0
package/dist/moodle.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { Command, CommanderError } from "commander";
|
|
5
|
-
import { realpathSync } from "fs";
|
|
5
|
+
import { realpathSync as realpathSync2 } from "fs";
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
7
7
|
|
|
8
8
|
// src/client.ts
|
|
@@ -36,11 +36,16 @@ var FUNC_GET_POPUP_NOTIFICATIONS = "message_popup_get_popup_notifications";
|
|
|
36
36
|
var FUNC_GET_CONVERSATION_COUNTS = "core_message_get_conversation_counts";
|
|
37
37
|
var FUNC_GET_UNREAD_CONVERSATION_COUNTS = "core_message_get_unread_conversation_counts";
|
|
38
38
|
var FUNC_GET_DISCUSSION_POSTS = "mod_forum_get_discussion_posts";
|
|
39
|
+
var FUNC_SESSION_TOUCH = "core_session_touch";
|
|
40
|
+
var FUNC_SESSION_TIME_REMAINING = "core_session_time_remaining";
|
|
39
41
|
var CONFIG_FILENAME = "config.yaml";
|
|
40
42
|
var CONFIG_DIR_NAME = ".config/moodle-cli";
|
|
41
43
|
var CACHE_DIR_NAME = ".cache/moodle-cli";
|
|
42
44
|
var SESSION_CACHE_FILENAME = "session.json";
|
|
43
|
-
var DEFAULT_SESSION_CACHE_TTL_MS =
|
|
45
|
+
var DEFAULT_SESSION_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
46
|
+
var KEEPALIVE_LAUNCH_AGENT_LABEL = "com.moodle-cli.keepalive";
|
|
47
|
+
var KEEPALIVE_DEFAULT_INTERVAL_MINUTES = 30;
|
|
48
|
+
var KEEPALIVE_LOG_FILENAME = "keepalive.log";
|
|
44
49
|
var ENV_MOODLE_SESSION = "MOODLE_SESSION";
|
|
45
50
|
var ENV_MOODLE_BASE_URL = "MOODLE_BASE_URL";
|
|
46
51
|
var MOODLE_SESSION_COOKIE_PREFIX = "MoodleSession";
|
|
@@ -236,7 +241,7 @@ async function getAuthenticatedSession(baseUrl, options = {}) {
|
|
|
236
241
|
await refreshSessionCache(baseUrl, oktaSession.cookie, oktaSession.context, options);
|
|
237
242
|
return { baseUrl, cookie: oktaSession.cookie, ...oktaSession.context, fromCache: false };
|
|
238
243
|
}
|
|
239
|
-
if (!options.oktaCookieProvider && oktaCookies.length) {
|
|
244
|
+
if (!options.oktaCookieProvider && oktaCookies.length && !options.nonInteractive) {
|
|
240
245
|
const refreshed = matchingMoodleSessionCookies(
|
|
241
246
|
await loadSessionsFromOktaCli(baseUrl, { ...options, oktaCookieProvider: void 0, noCache: true }, true),
|
|
242
247
|
baseUrl
|
|
@@ -286,7 +291,7 @@ async function loadSessionsFromOktaCli(baseUrl, options = {}, forceLogin = false
|
|
|
286
291
|
return [];
|
|
287
292
|
}
|
|
288
293
|
const stored = await readOktaCookies(executable, baseUrl, execFile);
|
|
289
|
-
if (stored.length && !forceLogin) {
|
|
294
|
+
if (stored.length && !forceLogin || options.nonInteractive) {
|
|
290
295
|
return stored;
|
|
291
296
|
}
|
|
292
297
|
const login = await runOktaJson(executable, ["login", baseUrl], execFile);
|
|
@@ -304,6 +309,9 @@ function authFailureHint(baseUrl) {
|
|
|
304
309
|
`okta-auth: ${OKTA_AUTH_URL}`
|
|
305
310
|
].join("\n");
|
|
306
311
|
}
|
|
312
|
+
async function invalidateCachedSession(baseUrl, options = {}) {
|
|
313
|
+
await deleteCachedSession(baseUrl, cacheOptions(options));
|
|
314
|
+
}
|
|
307
315
|
function parseSessionContext(html) {
|
|
308
316
|
const sesskey = firstMatch(html, [
|
|
309
317
|
/"sesskey"\s*:\s*"([^"]+)"/,
|
|
@@ -630,6 +638,75 @@ function isRecord2(value) {
|
|
|
630
638
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
631
639
|
}
|
|
632
640
|
|
|
641
|
+
// src/html-utils.ts
|
|
642
|
+
import { parse } from "node-html-parser";
|
|
643
|
+
function htmlToStructuredContent(html, baseUrl) {
|
|
644
|
+
if (!html) {
|
|
645
|
+
return { text: "", image_urls: [], links: [], tables: [] };
|
|
646
|
+
}
|
|
647
|
+
const root = parse(html);
|
|
648
|
+
const image_urls = [];
|
|
649
|
+
const links = [];
|
|
650
|
+
const tables = [];
|
|
651
|
+
for (const br of root.querySelectorAll("br")) {
|
|
652
|
+
br.replaceWith("\n");
|
|
653
|
+
}
|
|
654
|
+
for (const img of root.querySelectorAll("img")) {
|
|
655
|
+
const src = (img.getAttribute("src") ?? "").trim();
|
|
656
|
+
if (!src) {
|
|
657
|
+
img.replaceWith("[image]");
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
const absolute = resolveUrl(baseUrl, src);
|
|
661
|
+
image_urls.push(absolute);
|
|
662
|
+
const label = (img.getAttribute("alt") ?? "").trim() || "image";
|
|
663
|
+
img.replaceWith(`[${label}] ${absolute}`);
|
|
664
|
+
}
|
|
665
|
+
for (const link of root.querySelectorAll("a[href]")) {
|
|
666
|
+
const href = (link.getAttribute("href") ?? "").trim();
|
|
667
|
+
if (!href) {
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
links.push({ text: cleanText(link.textContent), url: resolveUrl(baseUrl, href) });
|
|
671
|
+
}
|
|
672
|
+
for (const table of root.querySelectorAll("table")) {
|
|
673
|
+
const headers = [];
|
|
674
|
+
const rows = [];
|
|
675
|
+
for (const row of table.querySelectorAll("tr")) {
|
|
676
|
+
const headerCells = row.querySelectorAll("th");
|
|
677
|
+
const dataCells = row.querySelectorAll("td");
|
|
678
|
+
const cells = headerCells.length ? headerCells : dataCells;
|
|
679
|
+
if (!cells.length) {
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
const values = cells.map((cell) => cleanText(cell.textContent));
|
|
683
|
+
if (headerCells.length && !headers.length && !rows.length) {
|
|
684
|
+
headers.push(...values);
|
|
685
|
+
} else {
|
|
686
|
+
rows.push(values);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
if (headers.length || rows.length) {
|
|
690
|
+
tables.push({ headers, rows });
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
const text = root.textContent.split(/\r?\n/).map((line) => cleanText(line)).filter(Boolean).join("\n");
|
|
694
|
+
return { text, image_urls, links, tables };
|
|
695
|
+
}
|
|
696
|
+
function cleanText(value) {
|
|
697
|
+
return decodeHtml2(value ?? "").replace(/\s+/g, " ").trim();
|
|
698
|
+
}
|
|
699
|
+
function resolveUrl(baseUrl, href) {
|
|
700
|
+
try {
|
|
701
|
+
return new URL(href, baseUrl).toString();
|
|
702
|
+
} catch {
|
|
703
|
+
return href;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
function decodeHtml2(value) {
|
|
707
|
+
return value.replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'");
|
|
708
|
+
}
|
|
709
|
+
|
|
633
710
|
// src/parsers.ts
|
|
634
711
|
function schema(parser) {
|
|
635
712
|
return { parse: parser };
|
|
@@ -767,18 +844,20 @@ function parseForumPostAuthor(value) {
|
|
|
767
844
|
profile_image_url: stringValue(urls.profileimage)
|
|
768
845
|
};
|
|
769
846
|
}
|
|
770
|
-
function parseForumPost(value) {
|
|
847
|
+
function parseForumPost(value, baseUrl = "") {
|
|
771
848
|
const data = asRecord(value);
|
|
772
849
|
const urls = asRecord(data.urls);
|
|
850
|
+
const messageHtml = stringValue(data.message);
|
|
851
|
+
const structured = htmlToStructuredContent(messageHtml, stringValue(urls.view || urls.discuss) || baseUrl);
|
|
773
852
|
return {
|
|
774
853
|
id: numberValue(data.id),
|
|
775
854
|
discussion_id: numberValue(data.discussionid),
|
|
776
855
|
subject: stringValue(data.subject),
|
|
777
|
-
message_html:
|
|
778
|
-
message_text:
|
|
779
|
-
image_urls:
|
|
780
|
-
links:
|
|
781
|
-
tables:
|
|
856
|
+
message_html: messageHtml,
|
|
857
|
+
message_text: structured.text,
|
|
858
|
+
image_urls: structured.image_urls,
|
|
859
|
+
links: structured.links,
|
|
860
|
+
tables: structured.tables,
|
|
782
861
|
author: parseForumPostAuthor(data.author),
|
|
783
862
|
parent_id: numberValue(data.parentid),
|
|
784
863
|
time_created: numberValue(data.timecreated),
|
|
@@ -791,9 +870,9 @@ function parseForumPost(value) {
|
|
|
791
870
|
reply_url: stringValue(urls.reply)
|
|
792
871
|
};
|
|
793
872
|
}
|
|
794
|
-
function parseForumDiscussion(value, discussionId) {
|
|
873
|
+
function parseForumDiscussion(value, discussionId, baseUrl = "") {
|
|
795
874
|
const data = asRecord(value);
|
|
796
|
-
const posts = asArray(data.posts).map((item) => parseForumPost(item));
|
|
875
|
+
const posts = asArray(data.posts).map((item) => parseForumPost(item, baseUrl));
|
|
797
876
|
return {
|
|
798
877
|
id: discussionId,
|
|
799
878
|
subject: posts[0]?.subject ?? "",
|
|
@@ -832,77 +911,6 @@ function booleanValue(value, defaultValue = false) {
|
|
|
832
911
|
|
|
833
912
|
// src/scraper.ts
|
|
834
913
|
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
914
|
function parsePageContext(html, baseUrl) {
|
|
907
915
|
const root = parse2(html);
|
|
908
916
|
const config = parseMoodleConfig(html);
|
|
@@ -1422,33 +1430,453 @@ function cleanTableCell(node) {
|
|
|
1422
1430
|
if (!node) {
|
|
1423
1431
|
return "";
|
|
1424
1432
|
}
|
|
1425
|
-
const clone = parse2(node.toString());
|
|
1426
|
-
for (const unwanted of clone.querySelectorAll(".action-menu, .dropdown, script, style")) {
|
|
1427
|
-
unwanted.remove();
|
|
1433
|
+
const clone = parse2(node.toString());
|
|
1434
|
+
for (const unwanted of clone.querySelectorAll(".action-menu, .dropdown, script, style")) {
|
|
1435
|
+
unwanted.remove();
|
|
1436
|
+
}
|
|
1437
|
+
return cleanText(clone.textContent.replace("( Empty )", "(Empty)"));
|
|
1438
|
+
}
|
|
1439
|
+
function numberQueryValue(href, key) {
|
|
1440
|
+
try {
|
|
1441
|
+
return numericQueryValue(new URL(href, "https://moodle.invalid"), key);
|
|
1442
|
+
} catch {
|
|
1443
|
+
return null;
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
function numberValue2(value) {
|
|
1447
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1448
|
+
return value;
|
|
1449
|
+
}
|
|
1450
|
+
if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) {
|
|
1451
|
+
return Number(value);
|
|
1452
|
+
}
|
|
1453
|
+
return 0;
|
|
1454
|
+
}
|
|
1455
|
+
function stringValue2(value) {
|
|
1456
|
+
return typeof value === "string" ? value : value == null ? "" : String(value);
|
|
1457
|
+
}
|
|
1458
|
+
function unique(items) {
|
|
1459
|
+
return [...new Set(items)];
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// src/forum.ts
|
|
1463
|
+
var ForumModule = class {
|
|
1464
|
+
baseUrl;
|
|
1465
|
+
callMoodle;
|
|
1466
|
+
loadPage;
|
|
1467
|
+
loadCourses;
|
|
1468
|
+
loadCourseContents;
|
|
1469
|
+
forumDiscussionCache = /* @__PURE__ */ new Map();
|
|
1470
|
+
forumDiscussionRefsCache = /* @__PURE__ */ new Map();
|
|
1471
|
+
constructor(options) {
|
|
1472
|
+
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
1473
|
+
this.callMoodle = options.call;
|
|
1474
|
+
this.loadPage = options.getPage;
|
|
1475
|
+
this.loadCourses = options.getCourses;
|
|
1476
|
+
this.loadCourseContents = options.getCourseContents;
|
|
1477
|
+
}
|
|
1478
|
+
async getForumDiscussion(discussionId) {
|
|
1479
|
+
const cached = this.forumDiscussionCache.get(discussionId);
|
|
1480
|
+
if (cached) {
|
|
1481
|
+
return cached;
|
|
1482
|
+
}
|
|
1483
|
+
try {
|
|
1484
|
+
const data = await this.callMoodle(FUNC_GET_DISCUSSION_POSTS, {
|
|
1485
|
+
discussionid: discussionId,
|
|
1486
|
+
sortby: "created",
|
|
1487
|
+
sortdirection: "ASC",
|
|
1488
|
+
includeinlineattachments: true
|
|
1489
|
+
});
|
|
1490
|
+
const discussion2 = parseForumDiscussion(data, discussionId, this.baseUrl);
|
|
1491
|
+
if (discussion2.group_id <= 0) {
|
|
1492
|
+
const html2 = await this.loadPage(FORUM_DISCUSS_PATH, { d: discussionId }).catch(() => "");
|
|
1493
|
+
if (html2) {
|
|
1494
|
+
const [groupId, groupName] = parseForumDiscussionGroupHtml(html2);
|
|
1495
|
+
discussion2.group_id = groupId;
|
|
1496
|
+
discussion2.group_name = groupName;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
this.forumDiscussionCache.set(discussionId, discussion2);
|
|
1500
|
+
return discussion2;
|
|
1501
|
+
} catch (error) {
|
|
1502
|
+
if (!shouldFallbackForumAjax(error)) {
|
|
1503
|
+
throw error;
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
const html = await this.loadPage(FORUM_DISCUSS_PATH, { d: discussionId });
|
|
1507
|
+
const discussion = parseForumDiscussionHtml(html, this.baseUrl, discussionId);
|
|
1508
|
+
this.forumDiscussionCache.set(discussionId, discussion);
|
|
1509
|
+
return discussion;
|
|
1510
|
+
}
|
|
1511
|
+
async getForumViewCmid(discussionId) {
|
|
1512
|
+
const html = await this.loadPage(FORUM_DISCUSS_PATH, { d: discussionId });
|
|
1513
|
+
return parseForumViewCmidFromDiscussionHtml(html);
|
|
1514
|
+
}
|
|
1515
|
+
async getForumDiscussionRefs(forumCmid) {
|
|
1516
|
+
const cached = this.forumDiscussionRefsCache.get(forumCmid);
|
|
1517
|
+
if (cached) {
|
|
1518
|
+
return cached;
|
|
1519
|
+
}
|
|
1520
|
+
const html = await this.loadPage(FORUM_VIEW_PATH, { id: forumCmid });
|
|
1521
|
+
const groups = parseForumGroupsHtml(html);
|
|
1522
|
+
const refs = groups.length ? [] : parseForumDiscussionRefsHtml(html, this.baseUrl);
|
|
1523
|
+
const seenIds = new Set(refs.map((ref) => ref.id));
|
|
1524
|
+
for (const [groupId, groupName] of groups) {
|
|
1525
|
+
const groupHtml = await this.loadPage(FORUM_VIEW_PATH, { id: forumCmid, group: groupId });
|
|
1526
|
+
for (const ref of parseForumDiscussionRefsHtml(groupHtml, this.baseUrl)) {
|
|
1527
|
+
if (seenIds.has(ref.id)) {
|
|
1528
|
+
continue;
|
|
1529
|
+
}
|
|
1530
|
+
seenIds.add(ref.id);
|
|
1531
|
+
refs.push({ ...ref, group_id: groupId, group_name: groupName });
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
this.forumDiscussionRefsCache.set(forumCmid, refs);
|
|
1535
|
+
return refs;
|
|
1536
|
+
}
|
|
1537
|
+
async getCourseForums(courseId, courseName = "") {
|
|
1538
|
+
if (!this.loadCourseContents) {
|
|
1539
|
+
throw new Error("getCourseContents loader is required to list course forums");
|
|
1540
|
+
}
|
|
1541
|
+
const sections = await this.loadCourseContents(courseId);
|
|
1542
|
+
return sections.flatMap(
|
|
1543
|
+
(section) => section.activities.filter((activity) => activity.modname === "forum").map((activity) => ({
|
|
1544
|
+
id: activity.id,
|
|
1545
|
+
name: activity.name,
|
|
1546
|
+
course_id: courseId,
|
|
1547
|
+
course_name: courseName,
|
|
1548
|
+
url: activity.url
|
|
1549
|
+
}))
|
|
1550
|
+
);
|
|
1551
|
+
}
|
|
1552
|
+
async getForums(courseId) {
|
|
1553
|
+
if (courseId !== void 0) {
|
|
1554
|
+
const courseName = await this.courseName(courseId);
|
|
1555
|
+
return this.getCourseForums(courseId, courseName);
|
|
1556
|
+
}
|
|
1557
|
+
if (!this.loadCourses) {
|
|
1558
|
+
throw new Error("getCourses loader is required to list all forums");
|
|
1559
|
+
}
|
|
1560
|
+
const forums = [];
|
|
1561
|
+
for (const course of await this.loadCourses()) {
|
|
1562
|
+
forums.push(...await this.getCourseForums(course.id, course.fullname || course.shortname));
|
|
1563
|
+
}
|
|
1564
|
+
return forums;
|
|
1565
|
+
}
|
|
1566
|
+
async courseName(courseId) {
|
|
1567
|
+
if (!this.loadCourses) {
|
|
1568
|
+
return "";
|
|
1569
|
+
}
|
|
1570
|
+
const course = (await this.loadCourses()).find((item) => item.id === courseId);
|
|
1571
|
+
return course ? course.fullname || course.shortname : "";
|
|
1572
|
+
}
|
|
1573
|
+
};
|
|
1574
|
+
function parseDiscussionReference(value) {
|
|
1575
|
+
const raw = value.trim();
|
|
1576
|
+
if (/^\d+$/.test(raw)) {
|
|
1577
|
+
return { discussionId: Number(raw), postId: null };
|
|
1578
|
+
}
|
|
1579
|
+
let url;
|
|
1580
|
+
try {
|
|
1581
|
+
url = new URL(raw);
|
|
1582
|
+
} catch {
|
|
1583
|
+
throw new UsageError("DISCUSSION must be a numeric ID or a full discuss.php URL.");
|
|
1584
|
+
}
|
|
1585
|
+
const discussionValue = url.searchParams.get("d");
|
|
1586
|
+
if (!discussionValue || !/^\d+$/.test(discussionValue)) {
|
|
1587
|
+
throw new UsageError("Could not find discussion ID in URL query (expected ?d=...).");
|
|
1588
|
+
}
|
|
1589
|
+
const fragment = url.hash.replace(/^#/, "");
|
|
1590
|
+
const postId = /^p\d+$/.test(fragment) ? Number(fragment.slice(1)) : null;
|
|
1591
|
+
return { discussionId: Number(discussionValue), postId };
|
|
1592
|
+
}
|
|
1593
|
+
async function parseForumReference(value, resolveForumCmid) {
|
|
1594
|
+
const raw = value.trim();
|
|
1595
|
+
if (/^\d+$/.test(raw)) {
|
|
1596
|
+
return Number(raw);
|
|
1597
|
+
}
|
|
1598
|
+
let url;
|
|
1599
|
+
try {
|
|
1600
|
+
url = new URL(raw);
|
|
1601
|
+
} catch {
|
|
1602
|
+
throw new UsageError("FORUM must be a numeric ID or a full forum URL.");
|
|
1603
|
+
}
|
|
1604
|
+
if (url.pathname.endsWith("/mod/forum/view.php")) {
|
|
1605
|
+
const forumValue = url.searchParams.get("id");
|
|
1606
|
+
if (!forumValue || !/^\d+$/.test(forumValue)) {
|
|
1607
|
+
throw new UsageError("Could not find forum module ID in view.php URL (expected ?id=...).");
|
|
1608
|
+
}
|
|
1609
|
+
return Number(forumValue);
|
|
1610
|
+
}
|
|
1611
|
+
if (url.pathname.endsWith("/mod/forum/discuss.php")) {
|
|
1612
|
+
const discussionValue = url.searchParams.get("d");
|
|
1613
|
+
if (!discussionValue || !/^\d+$/.test(discussionValue)) {
|
|
1614
|
+
throw new UsageError("Could not find discussion ID in discuss.php URL (expected ?d=...).");
|
|
1615
|
+
}
|
|
1616
|
+
if (!resolveForumCmid) {
|
|
1617
|
+
throw new UsageError("A discuss.php URL needs a resolver to find the forum ID.");
|
|
1618
|
+
}
|
|
1619
|
+
const forumCmid = await resolveForumCmid(Number(discussionValue));
|
|
1620
|
+
if (!forumCmid) {
|
|
1621
|
+
throw new NotFoundError("Could not resolve forum ID from the discussion page.");
|
|
1622
|
+
}
|
|
1623
|
+
return forumCmid;
|
|
1624
|
+
}
|
|
1625
|
+
throw new UsageError("Unsupported forum URL. Use a view.php?id=... or discuss.php?d=... URL.");
|
|
1626
|
+
}
|
|
1627
|
+
function filterDiscussionToPost(discussion, postId) {
|
|
1628
|
+
if (postId == null) {
|
|
1629
|
+
return discussion;
|
|
1630
|
+
}
|
|
1631
|
+
const posts = discussion.posts.filter((post) => post.id === postId);
|
|
1632
|
+
if (!posts.length) {
|
|
1633
|
+
throw new NotFoundError(`Post ${postId} was not found in discussion ${discussion.id}.`);
|
|
1634
|
+
}
|
|
1635
|
+
return { ...discussion, posts };
|
|
1636
|
+
}
|
|
1637
|
+
function shouldFallbackForumAjax(error) {
|
|
1638
|
+
if (!(error instanceof MoodleAPIError)) {
|
|
1639
|
+
return false;
|
|
1640
|
+
}
|
|
1641
|
+
return error.moodleErrorCode === "servicenotavailable" || error.moodleErrorCode === "accessexception" || error.message.includes("Web service is not available");
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
// src/forum-search.ts
|
|
1645
|
+
async function searchForumContent(source, query, options = {}) {
|
|
1646
|
+
const cleanedQuery = query.trim();
|
|
1647
|
+
if (!cleanedQuery) {
|
|
1648
|
+
return [];
|
|
1649
|
+
}
|
|
1650
|
+
const includePostText = options.includePostText ?? true;
|
|
1651
|
+
const unreadOnly = options.unreadOnly ?? false;
|
|
1652
|
+
const sortBy = options.sortBy ?? "relevance";
|
|
1653
|
+
let forumRefs = await source.getForums(options.courseId);
|
|
1654
|
+
if (options.forumCmid !== void 0) {
|
|
1655
|
+
forumRefs = forumRefs.filter((ref) => ref.id === options.forumCmid);
|
|
1656
|
+
if (!forumRefs.length) {
|
|
1657
|
+
forumRefs = [
|
|
1658
|
+
{
|
|
1659
|
+
id: options.forumCmid,
|
|
1660
|
+
name: "",
|
|
1661
|
+
course_id: 0,
|
|
1662
|
+
course_name: "",
|
|
1663
|
+
url: `${(options.baseUrl ?? "").replace(/\/$/, "")}/mod/forum/view.php?id=${options.forumCmid}`
|
|
1664
|
+
}
|
|
1665
|
+
];
|
|
1666
|
+
}
|
|
1667
|
+
} else if (options.maxForums !== void 0) {
|
|
1668
|
+
forumRefs = forumRefs.slice(0, options.maxForums);
|
|
1669
|
+
}
|
|
1670
|
+
const hits = [];
|
|
1671
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1672
|
+
for (const forumRef of forumRefs) {
|
|
1673
|
+
let refs = await source.getForumDiscussionRefs(forumRef.id);
|
|
1674
|
+
if (options.maxDiscussionsPerForum !== void 0) {
|
|
1675
|
+
refs = refs.slice(0, options.maxDiscussionsPerForum);
|
|
1676
|
+
}
|
|
1677
|
+
for (const ref of refs) {
|
|
1678
|
+
let discussion = null;
|
|
1679
|
+
let latestPost = null;
|
|
1680
|
+
let discussionHasUnread = false;
|
|
1681
|
+
const matchingPostHits = [];
|
|
1682
|
+
if (includePostText || unreadOnly || sortBy === "recent") {
|
|
1683
|
+
discussion = await source.getForumDiscussion(ref.id);
|
|
1684
|
+
if (discussion.posts.length) {
|
|
1685
|
+
latestPost = discussion.posts.reduce((latest, post) => (post.time_created || 0) > (latest.time_created || 0) ? post : latest);
|
|
1686
|
+
discussionHasUnread = discussion.posts.some((post) => post.unread);
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
if (!includePostText) {
|
|
1690
|
+
addSubjectHit({
|
|
1691
|
+
hits,
|
|
1692
|
+
seen,
|
|
1693
|
+
score: matchScore(ref.subject, cleanedQuery),
|
|
1694
|
+
query: cleanedQuery,
|
|
1695
|
+
ref,
|
|
1696
|
+
forumRef,
|
|
1697
|
+
discussionHasUnread,
|
|
1698
|
+
unreadOnly,
|
|
1699
|
+
latestPost
|
|
1700
|
+
});
|
|
1701
|
+
continue;
|
|
1702
|
+
}
|
|
1703
|
+
discussion ??= await source.getForumDiscussion(ref.id);
|
|
1704
|
+
for (const post of discussion.posts) {
|
|
1705
|
+
const postSubjectScore = matchScore(post.subject, cleanedQuery);
|
|
1706
|
+
const postBodyScore = matchScore(post.message_text, cleanedQuery);
|
|
1707
|
+
if (postSubjectScore <= 0 && postBodyScore <= 0) {
|
|
1708
|
+
continue;
|
|
1709
|
+
}
|
|
1710
|
+
if (unreadOnly && !post.unread) {
|
|
1711
|
+
continue;
|
|
1712
|
+
}
|
|
1713
|
+
const matchedIn = postSubjectScore >= postBodyScore ? "post_subject" : "post_body";
|
|
1714
|
+
const matchedText = matchedIn === "post_subject" ? post.subject : post.message_text;
|
|
1715
|
+
matchingPostHits.push([
|
|
1716
|
+
300 + Math.max(postSubjectScore, postBodyScore),
|
|
1717
|
+
{
|
|
1718
|
+
course_id: forumRef.course_id,
|
|
1719
|
+
course_name: forumRef.course_name,
|
|
1720
|
+
forum_id: forumRef.id,
|
|
1721
|
+
forum_name: forumRef.name,
|
|
1722
|
+
group_id: discussion.group_id || ref.group_id,
|
|
1723
|
+
group_name: discussion.group_name || ref.group_name,
|
|
1724
|
+
discussion_id: ref.id,
|
|
1725
|
+
discussion_subject: discussion.subject || ref.subject,
|
|
1726
|
+
post_id: post.id,
|
|
1727
|
+
author_name: post.author.fullname,
|
|
1728
|
+
matched_in: matchedIn,
|
|
1729
|
+
snippet: snippetForText(matchedText, cleanedQuery),
|
|
1730
|
+
unread: post.unread,
|
|
1731
|
+
time_created: post.time_created,
|
|
1732
|
+
url: post.url || ref.url
|
|
1733
|
+
}
|
|
1734
|
+
]);
|
|
1735
|
+
}
|
|
1736
|
+
if (matchingPostHits.length) {
|
|
1737
|
+
for (const [score, hit] of matchingPostHits) {
|
|
1738
|
+
const key = hitKey(hit.discussion_id, hit.post_id);
|
|
1739
|
+
if (seen.has(key)) {
|
|
1740
|
+
continue;
|
|
1741
|
+
}
|
|
1742
|
+
seen.add(key);
|
|
1743
|
+
hits.push([score, hit]);
|
|
1744
|
+
}
|
|
1745
|
+
continue;
|
|
1746
|
+
}
|
|
1747
|
+
addSubjectHit({
|
|
1748
|
+
hits,
|
|
1749
|
+
seen,
|
|
1750
|
+
score: matchScore(ref.subject, cleanedQuery),
|
|
1751
|
+
query: cleanedQuery,
|
|
1752
|
+
ref,
|
|
1753
|
+
forumRef,
|
|
1754
|
+
discussionHasUnread,
|
|
1755
|
+
unreadOnly,
|
|
1756
|
+
latestPost
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
hits.sort(sortBy === "recent" ? sortRecent : sortRelevant);
|
|
1761
|
+
return hits.slice(0, options.limit ?? 20).map(([, hit]) => hit);
|
|
1762
|
+
}
|
|
1763
|
+
async function checkForumDiscussions(source, forumCmid, limit = 20) {
|
|
1764
|
+
const refs = (await source.getForumDiscussionRefs(forumCmid)).slice(0, limit);
|
|
1765
|
+
const results = [];
|
|
1766
|
+
for (const ref of refs) {
|
|
1767
|
+
try {
|
|
1768
|
+
const discussion = await source.getForumDiscussion(ref.id);
|
|
1769
|
+
results.push({
|
|
1770
|
+
discussion_id: ref.id,
|
|
1771
|
+
subject: ref.subject,
|
|
1772
|
+
ok: true,
|
|
1773
|
+
posts: discussion.posts.length,
|
|
1774
|
+
images: discussion.posts.reduce((count, post) => count + post.image_urls.length, 0)
|
|
1775
|
+
});
|
|
1776
|
+
} catch (error) {
|
|
1777
|
+
results.push({
|
|
1778
|
+
discussion_id: ref.id,
|
|
1779
|
+
subject: ref.subject,
|
|
1780
|
+
ok: false,
|
|
1781
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
return results;
|
|
1786
|
+
}
|
|
1787
|
+
function normalizeQuery(value) {
|
|
1788
|
+
const normalized = value.toLowerCase().split(/\s+/).filter(Boolean).join(" ");
|
|
1789
|
+
return { normalized, tokens: normalized ? normalized.split(/\s+/) : [] };
|
|
1790
|
+
}
|
|
1791
|
+
function matchScore(text, query) {
|
|
1792
|
+
const haystack = text.toLowerCase().split(/\s+/).filter(Boolean).join(" ");
|
|
1793
|
+
if (!haystack) {
|
|
1794
|
+
return 0;
|
|
1795
|
+
}
|
|
1796
|
+
const { normalized, tokens } = normalizeQuery(query);
|
|
1797
|
+
if (!normalized) {
|
|
1798
|
+
return 0;
|
|
1799
|
+
}
|
|
1800
|
+
if (haystack.includes(normalized)) {
|
|
1801
|
+
return 100 + normalized.length;
|
|
1802
|
+
}
|
|
1803
|
+
if (tokens.length && tokens.every((token) => haystack.includes(token))) {
|
|
1804
|
+
return 60 + tokens.length;
|
|
1805
|
+
}
|
|
1806
|
+
return 0;
|
|
1807
|
+
}
|
|
1808
|
+
function snippetForText(text, query, maxLen = 120) {
|
|
1809
|
+
const cleaned = text.split(/\s+/).filter(Boolean).join(" ");
|
|
1810
|
+
if (!cleaned) {
|
|
1811
|
+
return "";
|
|
1812
|
+
}
|
|
1813
|
+
const { normalized, tokens } = normalizeQuery(query);
|
|
1814
|
+
const lower = cleaned.toLowerCase();
|
|
1815
|
+
let start = normalized ? lower.indexOf(normalized) : -1;
|
|
1816
|
+
if (start < 0) {
|
|
1817
|
+
for (const token of tokens) {
|
|
1818
|
+
start = lower.indexOf(token);
|
|
1819
|
+
if (start >= 0) {
|
|
1820
|
+
break;
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
if (start < 0 || cleaned.length <= maxLen) {
|
|
1825
|
+
return cleaned.length <= maxLen ? cleaned : `${cleaned.slice(0, maxLen - 1)}\u2026`;
|
|
1826
|
+
}
|
|
1827
|
+
const half = Math.floor(maxLen / 2);
|
|
1828
|
+
const left = Math.max(0, start - half);
|
|
1829
|
+
const right = Math.min(cleaned.length, left + maxLen);
|
|
1830
|
+
let snippet = cleaned.slice(left, right);
|
|
1831
|
+
if (left > 0) {
|
|
1832
|
+
snippet = `\u2026${snippet}`;
|
|
1428
1833
|
}
|
|
1429
|
-
|
|
1430
|
-
}
|
|
1431
|
-
function numberQueryValue(href, key) {
|
|
1432
|
-
try {
|
|
1433
|
-
return numericQueryValue(new URL(href, "https://moodle.invalid"), key);
|
|
1434
|
-
} catch {
|
|
1435
|
-
return null;
|
|
1834
|
+
if (right < cleaned.length) {
|
|
1835
|
+
snippet = `${snippet}\u2026`;
|
|
1436
1836
|
}
|
|
1837
|
+
return snippet;
|
|
1437
1838
|
}
|
|
1438
|
-
function
|
|
1439
|
-
if (
|
|
1440
|
-
return
|
|
1839
|
+
function addSubjectHit(args) {
|
|
1840
|
+
if (args.score <= 0 || args.unreadOnly && !args.discussionHasUnread) {
|
|
1841
|
+
return;
|
|
1441
1842
|
}
|
|
1442
|
-
|
|
1443
|
-
|
|
1843
|
+
const key = hitKey(args.ref.id, 0);
|
|
1844
|
+
if (args.seen.has(key)) {
|
|
1845
|
+
return;
|
|
1444
1846
|
}
|
|
1445
|
-
|
|
1847
|
+
args.seen.add(key);
|
|
1848
|
+
args.hits.push([
|
|
1849
|
+
400 + args.score,
|
|
1850
|
+
{
|
|
1851
|
+
course_id: args.forumRef.course_id,
|
|
1852
|
+
course_name: args.forumRef.course_name,
|
|
1853
|
+
forum_id: args.forumRef.id,
|
|
1854
|
+
forum_name: args.forumRef.name,
|
|
1855
|
+
group_id: args.ref.group_id,
|
|
1856
|
+
group_name: args.ref.group_name,
|
|
1857
|
+
discussion_id: args.ref.id,
|
|
1858
|
+
discussion_subject: args.ref.subject,
|
|
1859
|
+
post_id: 0,
|
|
1860
|
+
author_name: "",
|
|
1861
|
+
matched_in: "discussion_subject",
|
|
1862
|
+
snippet: snippetForText(args.ref.subject, args.query),
|
|
1863
|
+
unread: args.discussionHasUnread,
|
|
1864
|
+
time_created: args.latestPost?.time_created ?? 0,
|
|
1865
|
+
url: args.ref.url
|
|
1866
|
+
}
|
|
1867
|
+
]);
|
|
1446
1868
|
}
|
|
1447
|
-
function
|
|
1448
|
-
return
|
|
1869
|
+
function sortRecent(a, b) {
|
|
1870
|
+
return (b[1].time_created || 0) - (a[1].time_created || 0) || b[0] - a[0] || compareText(a[1].course_name, b[1].course_name) || compareText(a[1].forum_name, b[1].forum_name) || a[1].discussion_id - b[1].discussion_id || a[1].post_id - b[1].post_id;
|
|
1449
1871
|
}
|
|
1450
|
-
function
|
|
1451
|
-
return [
|
|
1872
|
+
function sortRelevant(a, b) {
|
|
1873
|
+
return b[0] - a[0] || compareText(a[1].course_name, b[1].course_name) || compareText(a[1].forum_name, b[1].forum_name) || a[1].discussion_id - b[1].discussion_id || a[1].post_id - b[1].post_id;
|
|
1874
|
+
}
|
|
1875
|
+
function compareText(a, b) {
|
|
1876
|
+
return a.toLowerCase().localeCompare(b.toLowerCase());
|
|
1877
|
+
}
|
|
1878
|
+
function hitKey(discussionId, postId) {
|
|
1879
|
+
return `${discussionId}:${postId}`;
|
|
1452
1880
|
}
|
|
1453
1881
|
|
|
1454
1882
|
// src/client.ts
|
|
@@ -1472,8 +1900,7 @@ var MoodleClient = class {
|
|
|
1472
1900
|
cacheOptions;
|
|
1473
1901
|
onLoginRequired;
|
|
1474
1902
|
retryingLogin = false;
|
|
1475
|
-
|
|
1476
|
-
forumRefs = /* @__PURE__ */ new Map();
|
|
1903
|
+
forum;
|
|
1477
1904
|
constructor(baseUrl, options) {
|
|
1478
1905
|
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
1479
1906
|
const resolvedOptions = typeof options === "string" ? { cookie: { name: "MoodleSession", value: options } } : options;
|
|
@@ -1484,22 +1911,45 @@ var MoodleClient = class {
|
|
|
1484
1911
|
this.userInfo = resolvedOptions.pageContext?.user_info ?? null;
|
|
1485
1912
|
this.cacheOptions = resolvedOptions.cacheOptions;
|
|
1486
1913
|
this.onLoginRequired = resolvedOptions.onLoginRequired;
|
|
1914
|
+
this.forum = new ForumModule({
|
|
1915
|
+
baseUrl: this.baseUrl,
|
|
1916
|
+
call: async (functionName, args) => {
|
|
1917
|
+
await this.ensureSession();
|
|
1918
|
+
return this.call(functionName, args);
|
|
1919
|
+
},
|
|
1920
|
+
getPage: (path3, params) => this.get(path3, params),
|
|
1921
|
+
getCourses: () => this.getCourses(),
|
|
1922
|
+
getCourseContents: (courseId) => this.getCourseContents(courseId)
|
|
1923
|
+
});
|
|
1487
1924
|
}
|
|
1488
1925
|
async getSiteInfo() {
|
|
1489
1926
|
await this.ensureSession();
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
if (
|
|
1493
|
-
|
|
1927
|
+
try {
|
|
1928
|
+
const data = await this.call(FUNC_GET_SITE_INFO);
|
|
1929
|
+
if (isRecord3(data) && "userid" in data) {
|
|
1930
|
+
const info = parseUserInfo(data);
|
|
1931
|
+
this.sesskey = typeof data.sesskey === "string" ? data.sesskey : this.sesskey;
|
|
1932
|
+
this.userid = info.userid;
|
|
1933
|
+
this.userInfo = info;
|
|
1934
|
+
await this.writeCache();
|
|
1935
|
+
return info;
|
|
1936
|
+
}
|
|
1937
|
+
} catch (error) {
|
|
1938
|
+
if (!(error instanceof MoodleAPIError) || error.moodleErrorCode !== "servicenotavailable") {
|
|
1939
|
+
throw error;
|
|
1494
1940
|
}
|
|
1495
|
-
throw new NotFoundError("Session appears invalid: could not retrieve user info");
|
|
1496
1941
|
}
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1942
|
+
if (this.userInfo?.fullname) {
|
|
1943
|
+
return this.userInfo;
|
|
1944
|
+
}
|
|
1945
|
+
const html = await this.get(DASHBOARD_PATH);
|
|
1946
|
+
const context = parsePageContext(html, this.baseUrl);
|
|
1947
|
+
if (!context.user_info.fullname && this.userInfo) {
|
|
1948
|
+
return this.userInfo;
|
|
1949
|
+
}
|
|
1950
|
+
this.applyContext(context);
|
|
1501
1951
|
await this.writeCache();
|
|
1502
|
-
return
|
|
1952
|
+
return context.user_info;
|
|
1503
1953
|
}
|
|
1504
1954
|
async getCourses() {
|
|
1505
1955
|
await this.ensureSession();
|
|
@@ -1678,174 +2128,23 @@ var MoodleClient = class {
|
|
|
1678
2128
|
return parseFolderHtml(await this.get(FOLDER_VIEW_PATH, { id }), id, this.baseUrl);
|
|
1679
2129
|
}
|
|
1680
2130
|
async getForumDiscussion(discussionId) {
|
|
1681
|
-
|
|
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;
|
|
2131
|
+
return this.forum.getForumDiscussion(discussionId);
|
|
1711
2132
|
}
|
|
1712
2133
|
async getForumViewCmid(discussionId) {
|
|
1713
|
-
return
|
|
2134
|
+
return this.forum.getForumViewCmid(discussionId);
|
|
1714
2135
|
}
|
|
1715
2136
|
async resolveCourseIdForUrl(url) {
|
|
1716
2137
|
return parseCourseIdFromPageHtml(await this.getAbsolute(url));
|
|
1717
2138
|
}
|
|
1718
2139
|
async getForumDiscussionRefs(forumCmid) {
|
|
1719
|
-
|
|
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
|
-
);
|
|
2140
|
+
return this.forum.getForumDiscussionRefs(forumCmid);
|
|
1753
2141
|
}
|
|
1754
2142
|
async getForums(courseId) {
|
|
1755
|
-
|
|
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;
|
|
2143
|
+
return this.forum.getForums(courseId);
|
|
1764
2144
|
}
|
|
1765
2145
|
async searchForumContent(options) {
|
|
1766
|
-
const query = options
|
|
1767
|
-
|
|
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);
|
|
2146
|
+
const { query, ...searchOptions } = options;
|
|
2147
|
+
return searchForumContent(this.forum, query, { ...searchOptions, baseUrl: this.baseUrl });
|
|
1849
2148
|
}
|
|
1850
2149
|
async callBatch(requests) {
|
|
1851
2150
|
await this.ensureSession();
|
|
@@ -2030,85 +2329,11 @@ function authToClientSession(auth) {
|
|
|
2030
2329
|
}
|
|
2031
2330
|
};
|
|
2032
2331
|
}
|
|
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
2332
|
function queryMatches(text, query) {
|
|
2044
2333
|
const haystack = text.toLowerCase().split(/\s+/).join(" ");
|
|
2045
2334
|
const needle = query.toLowerCase().split(/\s+/).join(" ");
|
|
2046
2335
|
return needle ? haystack.includes(needle) || needle.split(" ").every((token) => haystack.includes(token)) : true;
|
|
2047
2336
|
}
|
|
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
2337
|
function isRecord3(value) {
|
|
2113
2338
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
2114
2339
|
}
|
|
@@ -2456,6 +2681,43 @@ function formatForumCheckResults(forumCmid, rows) {
|
|
|
2456
2681
|
}
|
|
2457
2682
|
return lines.join("\n");
|
|
2458
2683
|
}
|
|
2684
|
+
function formatAuthStatus(status) {
|
|
2685
|
+
return formatKeyValues([
|
|
2686
|
+
["Site", status.base_url],
|
|
2687
|
+
["Cached session", status.session_cached ? "yes" : "no"],
|
|
2688
|
+
["Cache age", status.cache_age_minutes === null ? "" : `${status.cache_age_minutes} min`],
|
|
2689
|
+
["Session alive", status.session_alive === null ? status.session_cached ? "unknown" : "" : status.session_alive ? "yes" : "no"],
|
|
2690
|
+
["Server timeout in", formatDuration(status.session_time_remaining_seconds)],
|
|
2691
|
+
["Keepalive agent", status.keepalive_installed ? `installed (${status.keepalive_plist_path})` : "not installed"]
|
|
2692
|
+
]);
|
|
2693
|
+
}
|
|
2694
|
+
function formatKeepaliveResult(result) {
|
|
2695
|
+
switch (result.status) {
|
|
2696
|
+
case "renewed":
|
|
2697
|
+
return `Session renewed${result.time_remaining_seconds ? `; server timeout in ${formatDuration(result.time_remaining_seconds)}` : ""}`;
|
|
2698
|
+
case "reauthenticated":
|
|
2699
|
+
return "Session was expired; re-authenticated from browser/okta cookies";
|
|
2700
|
+
case "expired":
|
|
2701
|
+
return "Session expired and could not be renewed. Log in to Moodle in your browser or run: moodle auth login";
|
|
2702
|
+
case "no_session":
|
|
2703
|
+
return "No cached session to renew. Run any moodle command once, or: moodle auth login";
|
|
2704
|
+
case "unreachable":
|
|
2705
|
+
return "Could not reach the Moodle site; session state unchanged";
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
function formatDuration(seconds) {
|
|
2709
|
+
if (seconds === null) {
|
|
2710
|
+
return "";
|
|
2711
|
+
}
|
|
2712
|
+
if (seconds < 90) {
|
|
2713
|
+
return `${seconds}s`;
|
|
2714
|
+
}
|
|
2715
|
+
const minutes = Math.round(seconds / 60);
|
|
2716
|
+
if (minutes < 90) {
|
|
2717
|
+
return `${minutes} min`;
|
|
2718
|
+
}
|
|
2719
|
+
return `${(minutes / 60).toFixed(1)} h`;
|
|
2720
|
+
}
|
|
2459
2721
|
function preview(value, maxLen = 100) {
|
|
2460
2722
|
const cleaned = value.split(/\s+/).filter(Boolean).join(" ");
|
|
2461
2723
|
return cleaned.length <= maxLen ? cleaned : `${cleaned.slice(0, maxLen - 1)}\u2026`;
|
|
@@ -2533,12 +2795,24 @@ function isPlainObject(value) {
|
|
|
2533
2795
|
|
|
2534
2796
|
// src/skills.ts
|
|
2535
2797
|
import { spawnSync } from "child_process";
|
|
2536
|
-
import { readFileSync, writeFileSync } from "fs";
|
|
2798
|
+
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2537
2799
|
import path from "path";
|
|
2538
2800
|
var SKILL_NAME = "moodle-cli";
|
|
2539
2801
|
var SKILL_SOURCE = "https://github.com/bunizao/moodle-cli";
|
|
2540
2802
|
var SKILLS_SPEC_URL = "https://github.com/vercel-labs/skills";
|
|
2541
|
-
var SKILL_DESCRIPTION = "
|
|
2803
|
+
var SKILL_DESCRIPTION = "Read Moodle data with the `moodle` CLI. Use for authenticated profile, course discovery, deadlines, alerts, sections, activities, grades, assignment or quiz detail, resources, forum search and discussions, supported Moodle URLs, authentication diagnostics, or CLI updates.";
|
|
2804
|
+
var SKILL_BUNDLE_TEMPLATES = [
|
|
2805
|
+
["SKILL.md", "skill.template.md"],
|
|
2806
|
+
["references/setup-and-auth.md", "skill-references/setup-and-auth.md"],
|
|
2807
|
+
["references/profile-and-courses.md", "skill-references/profile-and-courses.md"],
|
|
2808
|
+
["references/deadlines-and-alerts.md", "skill-references/deadlines-and-alerts.md"],
|
|
2809
|
+
["references/coursework-and-grades.md", "skill-references/coursework-and-grades.md"],
|
|
2810
|
+
["references/forums.md", "skill-references/forums.md"],
|
|
2811
|
+
["references/output-and-errors.md", "skill-references/output-and-errors.md"],
|
|
2812
|
+
["references/maintenance.md", "skill-references/maintenance.md"],
|
|
2813
|
+
["references/command-reference.md", "skill-references/command-reference.md"],
|
|
2814
|
+
["agents/openai.yaml", "skill-agents/openai.yaml"]
|
|
2815
|
+
];
|
|
2542
2816
|
function formatSkillSummary() {
|
|
2543
2817
|
return [
|
|
2544
2818
|
`Name: ${SKILL_NAME}`,
|
|
@@ -2547,7 +2821,7 @@ function formatSkillSummary() {
|
|
|
2547
2821
|
`Spec: ${SKILLS_SPEC_URL}`,
|
|
2548
2822
|
`Install: npx skills add ${SKILL_SOURCE}`,
|
|
2549
2823
|
"CLI alias: moodle skills add (falls back to npm exec)",
|
|
2550
|
-
"Generate: moodle skills generate"
|
|
2824
|
+
"Generate: moodle skills generate (writes SKILL.md, references/, and agents/)"
|
|
2551
2825
|
].join("\n");
|
|
2552
2826
|
}
|
|
2553
2827
|
function buildSkillsAddCommand(extraArgs = [], launcher = "npx") {
|
|
@@ -2585,15 +2859,15 @@ function extractCommanderCommands(program) {
|
|
|
2585
2859
|
flags: row.flags
|
|
2586
2860
|
}));
|
|
2587
2861
|
}
|
|
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
2862
|
function writeGeneratedSkill(program, target = "SKILL.md") {
|
|
2596
|
-
|
|
2863
|
+
const commands = extractCommanderCommands(program);
|
|
2864
|
+
const targetDir = path.dirname(target);
|
|
2865
|
+
for (const [relativeTarget, relativeTemplate] of SKILL_BUNDLE_TEMPLATES) {
|
|
2866
|
+
const outputPath = relativeTarget === "SKILL.md" ? target : path.join(targetDir, relativeTarget);
|
|
2867
|
+
mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
2868
|
+
const template = readSkillTemplate(relativeTemplate);
|
|
2869
|
+
writeFileSync(outputPath, renderSkillMarkdown(commands, template), "utf8");
|
|
2870
|
+
}
|
|
2597
2871
|
}
|
|
2598
2872
|
function renderSkillMarkdown(commands, template) {
|
|
2599
2873
|
const replacements = {
|
|
@@ -2667,6 +2941,8 @@ function renderIntentTable() {
|
|
|
2667
2941
|
["Show grades for a course", "moodle grades COURSE_ID --json"],
|
|
2668
2942
|
["Find the best forum match", "moodle forum find QUERY --json"],
|
|
2669
2943
|
["Open a forum discussion URL or ID", "moodle forum discussion DISCUSSION_OR_URL --json"],
|
|
2944
|
+
["Check session freshness or authentication state", "moodle auth status --json"],
|
|
2945
|
+
["Keep the session alive to avoid repeated logins", "moodle auth keepalive install"],
|
|
2670
2946
|
["Check whether the CLI has an update", "moodle update --json"],
|
|
2671
2947
|
["Install this agent skill", "moodle skills add"]
|
|
2672
2948
|
]
|
|
@@ -2751,11 +3027,8 @@ function isCommandAvailable(name, runCommand) {
|
|
|
2751
3027
|
function isHiddenCommand(command) {
|
|
2752
3028
|
return Boolean(command.hidden || command._hidden);
|
|
2753
3029
|
}
|
|
2754
|
-
function
|
|
2755
|
-
return "
|
|
2756
|
-
}
|
|
2757
|
-
function readSkillTemplate() {
|
|
2758
|
-
return readFileSync(path.join(process.cwd(), "src", "skill.template.md"), "utf8");
|
|
3030
|
+
function readSkillTemplate(relativePath = "skill.template.md") {
|
|
3031
|
+
return readFileSync(path.join(process.cwd(), "src", relativePath), "utf8");
|
|
2759
3032
|
}
|
|
2760
3033
|
function findLongFlag(flags) {
|
|
2761
3034
|
return flags.split(/[,\s]+/).find((part) => part.startsWith("--")) ?? "";
|
|
@@ -2916,8 +3189,227 @@ function errorMessage(error) {
|
|
|
2916
3189
|
return error instanceof Error ? error.message : String(error);
|
|
2917
3190
|
}
|
|
2918
3191
|
|
|
3192
|
+
// src/keepalive.ts
|
|
3193
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
3194
|
+
import { realpathSync } from "fs";
|
|
3195
|
+
import { mkdir as mkdir3, rm as rm3, stat as stat2, writeFile as writeFile3 } from "fs/promises";
|
|
3196
|
+
import { homedir as homedir4 } from "os";
|
|
3197
|
+
import { dirname as dirname3, join as join4 } from "path";
|
|
3198
|
+
async function touchMoodleSession(baseUrl, cookie, sesskey, fetchImpl = fetch, extend = true) {
|
|
3199
|
+
const methods = extend ? [FUNC_SESSION_TOUCH, FUNC_SESSION_TIME_REMAINING] : [FUNC_SESSION_TIME_REMAINING];
|
|
3200
|
+
const url = `${baseUrl.replace(/\/$/, "")}${AJAX_SERVICE_PATH}?sesskey=${encodeURIComponent(sesskey)}&info=${methods.join(",")}`;
|
|
3201
|
+
let response;
|
|
3202
|
+
try {
|
|
3203
|
+
response = await fetchImpl(url, {
|
|
3204
|
+
method: "POST",
|
|
3205
|
+
headers: {
|
|
3206
|
+
"content-type": "application/json",
|
|
3207
|
+
cookie: `${cookie.name}=${cookie.value}`
|
|
3208
|
+
},
|
|
3209
|
+
body: JSON.stringify(methods.map((methodname, index) => ({ index, methodname, args: {} })))
|
|
3210
|
+
});
|
|
3211
|
+
} catch {
|
|
3212
|
+
return { alive: null, timeRemainingSeconds: null };
|
|
3213
|
+
}
|
|
3214
|
+
if (!response.ok) {
|
|
3215
|
+
return { alive: null, timeRemainingSeconds: null };
|
|
3216
|
+
}
|
|
3217
|
+
let body;
|
|
3218
|
+
try {
|
|
3219
|
+
body = await response.json();
|
|
3220
|
+
} catch {
|
|
3221
|
+
return { alive: false, timeRemainingSeconds: null };
|
|
3222
|
+
}
|
|
3223
|
+
if (!Array.isArray(body) || !body.length) {
|
|
3224
|
+
return { alive: null, timeRemainingSeconds: null };
|
|
3225
|
+
}
|
|
3226
|
+
const first2 = body[0];
|
|
3227
|
+
if (first2?.error) {
|
|
3228
|
+
const exception = first2.exception;
|
|
3229
|
+
const errorcode = typeof exception?.errorcode === "string" ? exception.errorcode : "";
|
|
3230
|
+
if (errorcode === "servicerequireslogin" || errorcode === "sitepolicynotagreed") {
|
|
3231
|
+
return { alive: false, timeRemainingSeconds: null };
|
|
3232
|
+
}
|
|
3233
|
+
return { alive: null, timeRemainingSeconds: null };
|
|
3234
|
+
}
|
|
3235
|
+
const last = body[body.length - 1];
|
|
3236
|
+
const data = last?.data;
|
|
3237
|
+
const timeRemaining = typeof data?.timeremaining === "number" ? data.timeremaining : null;
|
|
3238
|
+
return { alive: true, timeRemainingSeconds: timeRemaining };
|
|
3239
|
+
}
|
|
3240
|
+
async function keepAliveOnce(baseUrl, options = {}) {
|
|
3241
|
+
const session = await readCachedSession(baseUrl, {
|
|
3242
|
+
homeDir: options.homeDir,
|
|
3243
|
+
ttlMs: Number.MAX_SAFE_INTEGER,
|
|
3244
|
+
now: options.now
|
|
3245
|
+
});
|
|
3246
|
+
if (!session) {
|
|
3247
|
+
return { status: "no_session", time_remaining_seconds: null };
|
|
3248
|
+
}
|
|
3249
|
+
const touch = await touchMoodleSession(
|
|
3250
|
+
baseUrl,
|
|
3251
|
+
{ name: session.cookieName, value: session.cookieValue },
|
|
3252
|
+
session.sesskey,
|
|
3253
|
+
options.fetchImpl ?? fetch
|
|
3254
|
+
);
|
|
3255
|
+
if (touch.alive === true) {
|
|
3256
|
+
await writeCachedSession({ ...session, savedAt: (options.now ?? Date.now)() }, { homeDir: options.homeDir });
|
|
3257
|
+
return { status: "renewed", time_remaining_seconds: touch.timeRemainingSeconds };
|
|
3258
|
+
}
|
|
3259
|
+
if (touch.alive === null) {
|
|
3260
|
+
return { status: "unreachable", time_remaining_seconds: null };
|
|
3261
|
+
}
|
|
3262
|
+
if (options.renewOnExpiry === false) {
|
|
3263
|
+
return { status: "expired", time_remaining_seconds: null };
|
|
3264
|
+
}
|
|
3265
|
+
const authenticate = options.authenticate ?? ((url) => getAuthenticatedSession(url, {
|
|
3266
|
+
homeDir: options.homeDir,
|
|
3267
|
+
fetch: options.fetchImpl,
|
|
3268
|
+
noCache: true,
|
|
3269
|
+
now: options.now,
|
|
3270
|
+
// Background runs must never block on an interactive Okta login.
|
|
3271
|
+
nonInteractive: true
|
|
3272
|
+
}));
|
|
3273
|
+
try {
|
|
3274
|
+
await authenticate(baseUrl);
|
|
3275
|
+
return { status: "reauthenticated", time_remaining_seconds: null };
|
|
3276
|
+
} catch {
|
|
3277
|
+
return { status: "expired", time_remaining_seconds: null };
|
|
3278
|
+
}
|
|
3279
|
+
}
|
|
3280
|
+
async function getAuthStatus(baseUrl, options = {}) {
|
|
3281
|
+
const now = options.now ?? Date.now;
|
|
3282
|
+
const keepalive = await keepaliveStatus(options.homeDir);
|
|
3283
|
+
const session = await readCachedSession(baseUrl, {
|
|
3284
|
+
homeDir: options.homeDir,
|
|
3285
|
+
ttlMs: Number.MAX_SAFE_INTEGER,
|
|
3286
|
+
now: options.now
|
|
3287
|
+
});
|
|
3288
|
+
if (!session) {
|
|
3289
|
+
return {
|
|
3290
|
+
base_url: baseUrl,
|
|
3291
|
+
session_cached: false,
|
|
3292
|
+
cache_age_minutes: null,
|
|
3293
|
+
session_alive: null,
|
|
3294
|
+
session_time_remaining_seconds: null,
|
|
3295
|
+
keepalive_installed: keepalive.installed,
|
|
3296
|
+
keepalive_plist_path: keepalive.plist_path
|
|
3297
|
+
};
|
|
3298
|
+
}
|
|
3299
|
+
const touch = await touchMoodleSession(
|
|
3300
|
+
baseUrl,
|
|
3301
|
+
{ name: session.cookieName, value: session.cookieValue },
|
|
3302
|
+
session.sesskey,
|
|
3303
|
+
options.fetchImpl ?? fetch,
|
|
3304
|
+
false
|
|
3305
|
+
);
|
|
3306
|
+
return {
|
|
3307
|
+
base_url: baseUrl,
|
|
3308
|
+
session_cached: true,
|
|
3309
|
+
cache_age_minutes: Math.max(0, Math.round((now() - session.savedAt) / 6e4)),
|
|
3310
|
+
session_alive: touch.alive,
|
|
3311
|
+
session_time_remaining_seconds: touch.timeRemainingSeconds,
|
|
3312
|
+
keepalive_installed: keepalive.installed,
|
|
3313
|
+
keepalive_plist_path: keepalive.plist_path
|
|
3314
|
+
};
|
|
3315
|
+
}
|
|
3316
|
+
function keepalivePlistPath(homeDir = homedir4()) {
|
|
3317
|
+
return join4(homeDir, "Library/LaunchAgents", `${KEEPALIVE_LAUNCH_AGENT_LABEL}.plist`);
|
|
3318
|
+
}
|
|
3319
|
+
function keepaliveLogPath(homeDir = homedir4()) {
|
|
3320
|
+
return join4(homeDir, CACHE_DIR_NAME, KEEPALIVE_LOG_FILENAME);
|
|
3321
|
+
}
|
|
3322
|
+
function keepaliveProgramArguments(execPath = process.execPath, argv1 = process.argv[1] ?? "") {
|
|
3323
|
+
const resolvedArgv1 = argv1 ? safeRealpath(argv1) : "";
|
|
3324
|
+
const tail = ["auth", "keepalive", "--json"];
|
|
3325
|
+
if (!resolvedArgv1 || resolvedArgv1 === safeRealpath(execPath)) {
|
|
3326
|
+
return [execPath, ...tail];
|
|
3327
|
+
}
|
|
3328
|
+
return [execPath, resolvedArgv1, ...tail];
|
|
3329
|
+
}
|
|
3330
|
+
function buildKeepalivePlist(programArguments, intervalMinutes, logPath) {
|
|
3331
|
+
const args = programArguments.map((arg) => ` <string>${escapeXml(arg)}</string>`).join("\n");
|
|
3332
|
+
return [
|
|
3333
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
3334
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
3335
|
+
'<plist version="1.0">',
|
|
3336
|
+
"<dict>",
|
|
3337
|
+
" <key>Label</key>",
|
|
3338
|
+
` <string>${KEEPALIVE_LAUNCH_AGENT_LABEL}</string>`,
|
|
3339
|
+
" <key>ProgramArguments</key>",
|
|
3340
|
+
" <array>",
|
|
3341
|
+
args,
|
|
3342
|
+
" </array>",
|
|
3343
|
+
" <key>StartInterval</key>",
|
|
3344
|
+
` <integer>${Math.round(intervalMinutes * 60)}</integer>`,
|
|
3345
|
+
" <key>RunAtLoad</key>",
|
|
3346
|
+
" <true/>",
|
|
3347
|
+
" <key>StandardOutPath</key>",
|
|
3348
|
+
` <string>${escapeXml(logPath)}</string>`,
|
|
3349
|
+
" <key>StandardErrPath</key>",
|
|
3350
|
+
` <string>${escapeXml(logPath)}</string>`,
|
|
3351
|
+
"</dict>",
|
|
3352
|
+
"</plist>",
|
|
3353
|
+
""
|
|
3354
|
+
].join("\n");
|
|
3355
|
+
}
|
|
3356
|
+
async function installKeepalive(options = {}) {
|
|
3357
|
+
const platform = options.platform ?? process.platform;
|
|
3358
|
+
if (platform !== "darwin") {
|
|
3359
|
+
throw new Error(
|
|
3360
|
+
"Automatic keepalive install requires macOS launchd. Add a cron entry instead: */30 * * * * moodle auth keepalive --json"
|
|
3361
|
+
);
|
|
3362
|
+
}
|
|
3363
|
+
const homeDir = options.homeDir ?? homedir4();
|
|
3364
|
+
const intervalMinutes = options.intervalMinutes ?? KEEPALIVE_DEFAULT_INTERVAL_MINUTES;
|
|
3365
|
+
const plistPath = keepalivePlistPath(homeDir);
|
|
3366
|
+
const logPath = keepaliveLogPath(homeDir);
|
|
3367
|
+
const command = keepaliveProgramArguments(options.execPath, options.argv1);
|
|
3368
|
+
await mkdir3(dirname3(plistPath), { recursive: true });
|
|
3369
|
+
await mkdir3(dirname3(logPath), { recursive: true, mode: 448 });
|
|
3370
|
+
await writeFile3(plistPath, buildKeepalivePlist(command, intervalMinutes, logPath), "utf8");
|
|
3371
|
+
const runCommand = options.runCommand ?? spawnSync3;
|
|
3372
|
+
const uid = options.uid ?? (typeof process.getuid === "function" ? process.getuid() : 0);
|
|
3373
|
+
runCommand("launchctl", ["bootout", `gui/${uid}/${KEEPALIVE_LAUNCH_AGENT_LABEL}`], { stdio: "ignore" });
|
|
3374
|
+
const bootstrap = runCommand("launchctl", ["bootstrap", `gui/${uid}`, plistPath], { stdio: "ignore" });
|
|
3375
|
+
if (bootstrap.error || bootstrap.status !== 0) {
|
|
3376
|
+
const legacy = runCommand("launchctl", ["load", "-w", plistPath], { stdio: "ignore" });
|
|
3377
|
+
if (legacy.error || legacy.status !== 0) {
|
|
3378
|
+
throw new Error(`Failed to register the launch agent. Try manually: launchctl bootstrap gui/${uid} ${plistPath}`);
|
|
3379
|
+
}
|
|
3380
|
+
}
|
|
3381
|
+
return { plist_path: plistPath, interval_minutes: intervalMinutes, log_path: logPath, command };
|
|
3382
|
+
}
|
|
3383
|
+
async function uninstallKeepalive(options = {}) {
|
|
3384
|
+
const homeDir = options.homeDir ?? homedir4();
|
|
3385
|
+
const plistPath = keepalivePlistPath(homeDir);
|
|
3386
|
+
const runCommand = options.runCommand ?? spawnSync3;
|
|
3387
|
+
const uid = options.uid ?? (typeof process.getuid === "function" ? process.getuid() : 0);
|
|
3388
|
+
runCommand("launchctl", ["bootout", `gui/${uid}/${KEEPALIVE_LAUNCH_AGENT_LABEL}`], { stdio: "ignore" });
|
|
3389
|
+
await rm3(plistPath, { force: true });
|
|
3390
|
+
return { installed: false, plist_path: plistPath };
|
|
3391
|
+
}
|
|
3392
|
+
async function keepaliveStatus(homeDir = homedir4()) {
|
|
3393
|
+
const plistPath = keepalivePlistPath(homeDir);
|
|
3394
|
+
try {
|
|
3395
|
+
return { installed: (await stat2(plistPath)).isFile(), plist_path: plistPath };
|
|
3396
|
+
} catch {
|
|
3397
|
+
return { installed: false, plist_path: plistPath };
|
|
3398
|
+
}
|
|
3399
|
+
}
|
|
3400
|
+
function safeRealpath(path3) {
|
|
3401
|
+
try {
|
|
3402
|
+
return realpathSync(path3);
|
|
3403
|
+
} catch {
|
|
3404
|
+
return path3;
|
|
3405
|
+
}
|
|
3406
|
+
}
|
|
3407
|
+
function escapeXml(value) {
|
|
3408
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3409
|
+
}
|
|
3410
|
+
|
|
2919
3411
|
// src/version.ts
|
|
2920
|
-
var VERSION = "0.
|
|
3412
|
+
var VERSION = "0.6.0";
|
|
2921
3413
|
|
|
2922
3414
|
// src/url-resolver.ts
|
|
2923
3415
|
function looksLikeUrl(value) {
|
|
@@ -3026,24 +3518,6 @@ function parseActivityReference(value, labelOrOptions, expectedPathValue) {
|
|
|
3026
3518
|
}
|
|
3027
3519
|
return Number(id);
|
|
3028
3520
|
}
|
|
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
3521
|
|
|
3048
3522
|
// src/cli.ts
|
|
3049
3523
|
function buildProgram(io = {}) {
|
|
@@ -3147,7 +3621,7 @@ ${overview.alerts ? formatAlerts(overview.alerts) : ""}`, options);
|
|
|
3147
3621
|
});
|
|
3148
3622
|
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
3623
|
const client = await runtime.getClient();
|
|
3150
|
-
const forumId = await parseForumReference(
|
|
3624
|
+
const forumId = await parseForumReference(forumRef, (discussionId) => client.getForumViewCmid(discussionId));
|
|
3151
3625
|
let refs = await client.getForumDiscussionRefs(forumId);
|
|
3152
3626
|
if (options.query) {
|
|
3153
3627
|
refs = refs.filter((ref) => queryMatches2(ref.subject, options.query));
|
|
@@ -3169,25 +3643,55 @@ ${overview.alerts ? formatAlerts(overview.alerts) : ""}`, options);
|
|
|
3169
3643
|
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
3644
|
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
3645
|
const client = await runtime.getClient();
|
|
3172
|
-
const forumId = await parseForumReference(
|
|
3173
|
-
const
|
|
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
|
-
}
|
|
3646
|
+
const forumId = await parseForumReference(forumRef, (discussionId) => client.getForumViewCmid(discussionId));
|
|
3647
|
+
const results = await checkForumDiscussions(client, forumId, options.limit);
|
|
3189
3648
|
runtime.output(results, () => formatForumCheckResults(forumId, results), options);
|
|
3190
3649
|
});
|
|
3650
|
+
const auth = program.command("auth").description("Session and keepalive utilities.");
|
|
3651
|
+
addOutputOptions(auth.command("status").description("Show cached session freshness and keepalive state.")).action(
|
|
3652
|
+
async (options) => {
|
|
3653
|
+
const baseUrl = await runtime.baseUrl();
|
|
3654
|
+
const status = await getAuthStatus(baseUrl, { homeDir: io.homeDir, fetchImpl: io.fetchImpl });
|
|
3655
|
+
runtime.output(status, () => formatAuthStatus(status), options);
|
|
3656
|
+
}
|
|
3657
|
+
);
|
|
3658
|
+
addOutputOptions(auth.command("login").description("Force a fresh login and refresh the session cache.")).action(
|
|
3659
|
+
async (options) => {
|
|
3660
|
+
const baseUrl = await runtime.baseUrl();
|
|
3661
|
+
await invalidateCachedSession(baseUrl, { homeDir: io.homeDir });
|
|
3662
|
+
const session = await getAuthenticatedSession(baseUrl, { env: io.env, fetch: io.fetchImpl, homeDir: io.homeDir, noCache: true });
|
|
3663
|
+
const result = { base_url: baseUrl, userid: session.userid, cookie_source: session.cookie.source ?? "unknown" };
|
|
3664
|
+
runtime.output(result, () => `Authenticated as userid ${result.userid} via ${result.cookie_source}`, options);
|
|
3665
|
+
}
|
|
3666
|
+
);
|
|
3667
|
+
const keepalive = addOutputOptions(
|
|
3668
|
+
auth.command("keepalive").description("Renew the Moodle session once; used by the background keepalive agent.").option("--no-renew", "Only touch the session; skip re-login when it is expired.")
|
|
3669
|
+
).action(async (options) => {
|
|
3670
|
+
const baseUrl = await runtime.baseUrl();
|
|
3671
|
+
const result = await keepAliveOnce(baseUrl, { homeDir: io.homeDir, fetchImpl: io.fetchImpl, renewOnExpiry: options.renew });
|
|
3672
|
+
runtime.output(result, () => formatKeepaliveResult(result), options);
|
|
3673
|
+
});
|
|
3674
|
+
addOutputOptions(
|
|
3675
|
+
keepalive.command("install").description("Install a macOS launch agent that renews the session periodically.").option("--interval <minutes>", "Renewal interval in minutes.", parsePositiveInt)
|
|
3676
|
+
).action(async (options) => {
|
|
3677
|
+
await runtime.baseUrl();
|
|
3678
|
+
const result = await installKeepalive({ homeDir: io.homeDir, intervalMinutes: options.interval });
|
|
3679
|
+
runtime.output(result, () => `Keepalive installed: renews every ${result.interval_minutes} min
|
|
3680
|
+
Agent: ${result.plist_path}
|
|
3681
|
+
Log: ${result.log_path}`, options);
|
|
3682
|
+
});
|
|
3683
|
+
addOutputOptions(keepalive.command("uninstall").description("Remove the keepalive launch agent.")).action(
|
|
3684
|
+
async (options) => {
|
|
3685
|
+
const result = await uninstallKeepalive({ homeDir: io.homeDir });
|
|
3686
|
+
runtime.output(result, () => `Keepalive removed (${result.plist_path})`, options);
|
|
3687
|
+
}
|
|
3688
|
+
);
|
|
3689
|
+
addOutputOptions(keepalive.command("status").description("Show whether the keepalive launch agent is installed.")).action(
|
|
3690
|
+
async (options) => {
|
|
3691
|
+
const result = await keepaliveStatus(io.homeDir);
|
|
3692
|
+
runtime.output(result, () => result.installed ? `Keepalive installed (${result.plist_path})` : "Keepalive not installed", options);
|
|
3693
|
+
}
|
|
3694
|
+
);
|
|
3191
3695
|
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
3696
|
try {
|
|
3193
3697
|
const info = await checkForUpdates(VERSION, io.fetchImpl);
|
|
@@ -3219,9 +3723,9 @@ ${overview.alerts ? formatAlerts(overview.alerts) : ""}`, options);
|
|
|
3219
3723
|
stdout.write(`${formatSkillSummary()}
|
|
3220
3724
|
`);
|
|
3221
3725
|
});
|
|
3222
|
-
skills.command("generate").description("Regenerate
|
|
3726
|
+
skills.command("generate").description("Regenerate the agent skill bundle from the CLI command tree.").action(() => {
|
|
3223
3727
|
writeGeneratedSkill(program);
|
|
3224
|
-
stdout.write("Generated
|
|
3728
|
+
stdout.write("Generated Moodle skill bundle\n");
|
|
3225
3729
|
});
|
|
3226
3730
|
skills.command("add").description("Install the published skill through npx skills add.").allowUnknownOption(true).action((_options, command) => installSkill(command.args));
|
|
3227
3731
|
hideCommand(skills.command("install").allowUnknownOption(true)).action((_options, command) => installSkill(command.args));
|
|
@@ -3334,7 +3838,7 @@ function addForumSearchCommand(command, runtime, defaultLimit, findMode) {
|
|
|
3334
3838
|
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
3839
|
const client = await runtime.getClient();
|
|
3336
3840
|
const courseId = options.course ? await client.resolveCourseReference(options.course) : void 0;
|
|
3337
|
-
const forumCmid = options.forum ? await parseForumReference(
|
|
3841
|
+
const forumCmid = options.forum ? await parseForumReference(options.forum, (discussionId) => client.getForumViewCmid(discussionId)) : void 0;
|
|
3338
3842
|
const limit = findMode && !options.list ? 1 : options.limit;
|
|
3339
3843
|
const hits = await client.searchForumContent({
|
|
3340
3844
|
query,
|
|
@@ -3356,29 +3860,6 @@ function addForumSearchCommand(command, runtime, defaultLimit, findMode) {
|
|
|
3356
3860
|
runtime.output(output, () => formatForumSearchHits(Array.isArray(output) ? output : output ? [output] : []), options);
|
|
3357
3861
|
});
|
|
3358
3862
|
}
|
|
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
3863
|
function addOutputOptions(command) {
|
|
3383
3864
|
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
3865
|
}
|
|
@@ -3462,7 +3943,7 @@ function queryMatches2(text, query) {
|
|
|
3462
3943
|
const needle = query.toLowerCase().split(/\s+/).join(" ");
|
|
3463
3944
|
return needle ? haystack.includes(needle) || needle.split(" ").every((token) => haystack.includes(token)) : true;
|
|
3464
3945
|
}
|
|
3465
|
-
var isMain = process.argv[1] ?
|
|
3946
|
+
var isMain = process.argv[1] ? realpathSync2(fileURLToPath(import.meta.url)) === realpathSync2(process.argv[1]) : false;
|
|
3466
3947
|
if (isMain) {
|
|
3467
3948
|
runCli().then((code) => {
|
|
3468
3949
|
process.exitCode = code;
|