moodle-cli 0.9.1 → 0.9.3
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 +18 -3
- package/SKILL.md +3 -2
- package/dist/moodle.js +1197 -251
- package/dist/worker/recovery.js +622 -103
- package/dist/worker/worker.js +622 -103
- package/package.json +2 -2
- package/references/command-reference.md +7 -0
package/dist/worker/recovery.js
CHANGED
|
@@ -3582,7 +3582,7 @@ var require_parse = __commonJS({
|
|
|
3582
3582
|
var whitespace = /* @__PURE__ */ new Set([9, 10, 12, 13, 32]);
|
|
3583
3583
|
var ZERO = "0".charCodeAt(0);
|
|
3584
3584
|
var NINE = "9".charCodeAt(0);
|
|
3585
|
-
function
|
|
3585
|
+
function parse9(formula) {
|
|
3586
3586
|
formula = formula.trim().toLowerCase();
|
|
3587
3587
|
if (formula === "even") {
|
|
3588
3588
|
return [2, 0];
|
|
@@ -3634,7 +3634,7 @@ var require_parse = __commonJS({
|
|
|
3634
3634
|
}
|
|
3635
3635
|
}
|
|
3636
3636
|
}
|
|
3637
|
-
exports.parse =
|
|
3637
|
+
exports.parse = parse9;
|
|
3638
3638
|
}
|
|
3639
3639
|
});
|
|
3640
3640
|
|
|
@@ -5084,7 +5084,7 @@ var require_html = __commonJS({
|
|
|
5084
5084
|
}).join("");
|
|
5085
5085
|
}
|
|
5086
5086
|
set innerHTML(content) {
|
|
5087
|
-
const r =
|
|
5087
|
+
const r = parse9(content, this._parseOptions);
|
|
5088
5088
|
const nodes = r.childNodes.length ? r.childNodes : [new text_1.default(content, this)];
|
|
5089
5089
|
resetParent(nodes, this);
|
|
5090
5090
|
resetParent(this.childNodes, null);
|
|
@@ -5095,7 +5095,7 @@ var require_html = __commonJS({
|
|
|
5095
5095
|
content = [content];
|
|
5096
5096
|
} else if (typeof content == "string") {
|
|
5097
5097
|
options = Object.assign(Object.assign({}, this._parseOptions), options);
|
|
5098
|
-
const r =
|
|
5098
|
+
const r = parse9(content, options);
|
|
5099
5099
|
content = r.childNodes.length ? r.childNodes : [new text_1.default(r.innerHTML, this)];
|
|
5100
5100
|
}
|
|
5101
5101
|
resetParent(this.childNodes, null);
|
|
@@ -5109,7 +5109,7 @@ var require_html = __commonJS({
|
|
|
5109
5109
|
if (node2 instanceof node_1.default) {
|
|
5110
5110
|
return [node2];
|
|
5111
5111
|
} else if (typeof node2 == "string") {
|
|
5112
|
-
const r =
|
|
5112
|
+
const r = parse9(node2, this._parseOptions);
|
|
5113
5113
|
return r.childNodes.length ? r.childNodes : [new text_1.default(node2, this)];
|
|
5114
5114
|
}
|
|
5115
5115
|
return [];
|
|
@@ -5493,7 +5493,7 @@ var require_html = __commonJS({
|
|
|
5493
5493
|
if (arguments.length < 2) {
|
|
5494
5494
|
throw new Error("2 arguments required");
|
|
5495
5495
|
}
|
|
5496
|
-
const p =
|
|
5496
|
+
const p = parse9(html, this._parseOptions);
|
|
5497
5497
|
if (where === "afterend") {
|
|
5498
5498
|
this.after(...p.childNodes);
|
|
5499
5499
|
} else if (where === "afterbegin") {
|
|
@@ -5639,7 +5639,7 @@ var require_html = __commonJS({
|
|
|
5639
5639
|
}
|
|
5640
5640
|
/** Clone this Node */
|
|
5641
5641
|
clone() {
|
|
5642
|
-
return
|
|
5642
|
+
return parse9(this.toString(), this._parseOptions).firstChild;
|
|
5643
5643
|
}
|
|
5644
5644
|
};
|
|
5645
5645
|
exports.default = HTMLElement2;
|
|
@@ -5841,7 +5841,7 @@ var require_html = __commonJS({
|
|
|
5841
5841
|
return stack;
|
|
5842
5842
|
}
|
|
5843
5843
|
exports.base_parse = base_parse;
|
|
5844
|
-
function
|
|
5844
|
+
function parse9(data, options = {}) {
|
|
5845
5845
|
const stack = base_parse(data, options);
|
|
5846
5846
|
const [root] = stack;
|
|
5847
5847
|
while (stack.length > 1) {
|
|
@@ -5869,7 +5869,7 @@ var require_html = __commonJS({
|
|
|
5869
5869
|
}
|
|
5870
5870
|
return root;
|
|
5871
5871
|
}
|
|
5872
|
-
exports.parse =
|
|
5872
|
+
exports.parse = parse9;
|
|
5873
5873
|
function resolveInsertable(insertable) {
|
|
5874
5874
|
return insertable.map((val) => {
|
|
5875
5875
|
if (typeof val === "string") {
|
|
@@ -5937,18 +5937,18 @@ var require_dist = __commonJS({
|
|
|
5937
5937
|
var parse_1 = __importDefault(require_parse2());
|
|
5938
5938
|
var valid_1 = __importDefault(require_valid());
|
|
5939
5939
|
exports.valid = valid_1.default;
|
|
5940
|
-
function
|
|
5940
|
+
function parse9(data, options = {}) {
|
|
5941
5941
|
return (0, parse_1.default)(data, options);
|
|
5942
5942
|
}
|
|
5943
|
-
exports.default =
|
|
5944
|
-
exports.parse =
|
|
5945
|
-
|
|
5946
|
-
|
|
5947
|
-
|
|
5948
|
-
|
|
5949
|
-
|
|
5950
|
-
|
|
5951
|
-
|
|
5943
|
+
exports.default = parse9;
|
|
5944
|
+
exports.parse = parse9;
|
|
5945
|
+
parse9.parse = parse_1.default;
|
|
5946
|
+
parse9.HTMLElement = html_1.default;
|
|
5947
|
+
parse9.CommentNode = comment_1.default;
|
|
5948
|
+
parse9.valid = valid_1.default;
|
|
5949
|
+
parse9.Node = node_1.default;
|
|
5950
|
+
parse9.TextNode = text_1.default;
|
|
5951
|
+
parse9.NodeType = type_1.default;
|
|
5952
5952
|
}
|
|
5953
5953
|
});
|
|
5954
5954
|
|
|
@@ -6237,16 +6237,13 @@ function createOAuthRouter(options) {
|
|
|
6237
6237
|
}
|
|
6238
6238
|
const authorizeRequest = resolved.request;
|
|
6239
6239
|
const pairing = await readPairing();
|
|
6240
|
+
const pairingWindow = pairing ? { minutesLeft: Math.max(1, Math.ceil((pairing.expiresAt - now()) / 6e4)), attemptsLeft: PAIRING_CODE_MAX_ATTEMPTS - pairing.attempts } : void 0;
|
|
6240
6241
|
if (request.method === "GET") {
|
|
6241
|
-
return htmlResponse(approvalPage(authorizeRequest, params, {
|
|
6242
|
-
pairingOpen: Boolean(pairing),
|
|
6243
|
-
...pairing ? {} : { message: "No pairing window is open. Run `moodle mcp pair` on your computer, then reload this page." }
|
|
6244
|
-
}), 200, authorizeRequest.redirectUri);
|
|
6242
|
+
return htmlResponse(approvalPage(authorizeRequest, params, { window: pairingWindow }), 200, authorizeRequest.redirectUri);
|
|
6245
6243
|
}
|
|
6246
|
-
if (!pairing) {
|
|
6244
|
+
if (!pairing || !pairingWindow) {
|
|
6247
6245
|
return htmlResponse(approvalPage(authorizeRequest, params, {
|
|
6248
|
-
|
|
6249
|
-
message: "No pairing window is open. Run `moodle mcp pair` on your computer, then submit the code it prints."
|
|
6246
|
+
message: "That pairing window has closed. Run `moodle mcp pair` again and enter the new code."
|
|
6250
6247
|
}), 403, authorizeRequest.redirectUri);
|
|
6251
6248
|
}
|
|
6252
6249
|
const approvedClients = [...(await storage.list({ prefix: CLIENT_PREFIX })).values()].filter((client) => client.approvedAt !== void 0);
|
|
@@ -6256,8 +6253,8 @@ function createOAuthRouter(options) {
|
|
|
6256
6253
|
if (!await consumePairingAttempt(pairing, params.get("pairing_code") ?? "")) {
|
|
6257
6254
|
const remaining = PAIRING_CODE_MAX_ATTEMPTS - pairing.attempts - 1;
|
|
6258
6255
|
return htmlResponse(approvalPage(authorizeRequest, params, {
|
|
6259
|
-
|
|
6260
|
-
message: remaining > 0 ? `That
|
|
6256
|
+
window: remaining > 0 ? { ...pairingWindow, attemptsLeft: remaining } : void 0,
|
|
6257
|
+
message: remaining > 0 ? `That code is not correct. ${remaining} ${remaining === 1 ? "attempt" : "attempts"} left.` : "Too many incorrect codes, so the window closed. Run `moodle mcp pair` again for a new one."
|
|
6261
6258
|
}), 403, authorizeRequest.redirectUri);
|
|
6262
6259
|
}
|
|
6263
6260
|
await storage.put(`${CLIENT_PREFIX}${authorizeRequest.clientId}`, { ...authorizeRequest.client, approvedAt: now() });
|
|
@@ -6516,39 +6513,59 @@ function htmlResponse(body, status = 200, redirectUri) {
|
|
|
6516
6513
|
}
|
|
6517
6514
|
});
|
|
6518
6515
|
}
|
|
6519
|
-
var PAGE_STYLE =
|
|
6520
|
-
|
|
6521
|
-
|
|
6522
|
-
|
|
6523
|
-
|
|
6524
|
-
|
|
6525
|
-
|
|
6526
|
-
|
|
6527
|
-
|
|
6516
|
+
var PAGE_STYLE = `:root{color-scheme:light dark;--bg:#f6f7f9;--card:#fff;--line:#dfe2e8;--text:#16181d;--muted:#5b6472;--accent:#16181d;--accent-text:#fff;--danger:#a3111c;--ok:#0f6b3a;--mono:ui-monospace,SFMono-Regular,Menlo,monospace}
|
|
6517
|
+
@media(prefers-color-scheme:dark){:root{--bg:#121417;--card:#1b1e23;--line:#2c313a;--text:#e8eaee;--muted:#9aa3b0;--accent:#e8eaee;--accent-text:#121417;--danger:#ff8a8a;--ok:#6fd39a}}
|
|
6518
|
+
body{font:16px/1.5 system-ui,sans-serif;margin:0;padding:3rem 1.25rem;background:var(--bg);color:var(--text)}
|
|
6519
|
+
main{max-width:26rem;margin:0 auto;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:1.75rem}
|
|
6520
|
+
h1{font-size:1.15rem;margin:0 0 .75rem}p{margin:.5rem 0}dl{margin:0 0 1.25rem;font-size:.9rem}dt{color:var(--muted);margin-top:.5rem}
|
|
6521
|
+
dd{margin:0;word-break:break-all}label{display:block;font-size:.9rem;color:var(--muted);margin-bottom:.35rem}
|
|
6522
|
+
input{width:100%;box-sizing:border-box;font:1.35rem/1 var(--mono);letter-spacing:.2em;text-align:center;padding:.7rem;
|
|
6523
|
+
border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--text);text-transform:uppercase}
|
|
6524
|
+
input:focus{outline:2px solid var(--accent);outline-offset:1px}
|
|
6525
|
+
button{width:100%;margin-top:1rem;padding:.7rem;font-size:1rem;border:0;border-radius:8px;background:var(--accent);color:var(--accent-text);cursor:pointer}
|
|
6526
|
+
button.quiet{background:transparent;color:var(--text);border:1px solid var(--line)}
|
|
6527
|
+
code{font:.9em var(--mono);background:var(--bg);border:1px solid var(--line);border-radius:4px;padding:.05em .35em}
|
|
6528
|
+
ol{padding-left:1.25rem;margin:.5rem 0 1rem;font-size:.95rem}li{margin:.25rem 0}
|
|
6529
|
+
.note{font-size:.85rem;color:var(--muted);margin-top:1rem}.error{color:var(--danger);font-size:.9rem;margin:0 0 1rem}
|
|
6530
|
+
.open{color:var(--ok);font-size:.9rem;margin:0 0 .75rem}`;
|
|
6531
|
+
function hiddenFields(request, params) {
|
|
6532
|
+
return ["response_type", "client_id", "redirect_uri", "scope", "state", "code_challenge", "code_challenge_method", "resource"].map((name) => {
|
|
6528
6533
|
const value = name === "redirect_uri" ? request.redirectUri : params.get(name);
|
|
6529
6534
|
return value ? `<input type="hidden" name="${name}" value="${escapeHtml(value)}">` : "";
|
|
6530
6535
|
}).join("");
|
|
6531
|
-
|
|
6532
|
-
|
|
6533
|
-
|
|
6534
|
-
|
|
6536
|
+
}
|
|
6537
|
+
function approvalPage(request, params, options) {
|
|
6538
|
+
const hidden = hiddenFields(request, params);
|
|
6539
|
+
const body = options.window ? `<p class="open">A pairing window is open for ${options.window.minutesLeft} more ${options.window.minutesLeft === 1 ? "minute" : "minutes"}.</p>
|
|
6540
|
+
${options.message ? `<p class="error">${escapeHtml(options.message)}</p>` : ""}
|
|
6541
|
+
<form method="post" action="${AUTHORIZE_PATH}">${hidden}
|
|
6542
|
+
<label for="pairing_code">Enter the code that <code>moodle mcp pair</code> printed</label>
|
|
6543
|
+
<input id="pairing_code" name="pairing_code" placeholder="XXXX-XXXX" maxlength="9" required autocomplete="one-time-code" autocapitalize="characters" autocorrect="off" spellcheck="false" autofocus>
|
|
6544
|
+
<button type="submit">Approve access</button></form>
|
|
6545
|
+
<p class="note">${options.message ? "" : `${options.window.attemptsLeft} attempts allowed. `}The code works once and only while the window is open.</p>` : `${options.message ? `<p class="error">${escapeHtml(options.message)}</p>` : "<p>Nothing can be approved until you open a pairing window from your own computer.</p>"}
|
|
6546
|
+
<ol>
|
|
6547
|
+
<li>Open a terminal on the computer where you installed moodle-cli.</li>
|
|
6548
|
+
<li>Run <code>moodle mcp pair</code>. It prints an eight-character code.</li>
|
|
6549
|
+
<li>Come back here, reload, and enter the code.</li>
|
|
6550
|
+
</ol>
|
|
6551
|
+
<form method="get" action="${AUTHORIZE_PATH}">${hidden}<button type="submit" class="quiet">Reload this page</button></form>
|
|
6552
|
+
<p class="note">Anyone who reaches this page without your code is refused, which is what keeps this server private.</p>`;
|
|
6535
6553
|
return page("Approve MCP access", `
|
|
6536
6554
|
<h1>Approve access to your Moodle</h1>
|
|
6537
6555
|
<dl>
|
|
6538
|
-
<dt>
|
|
6539
|
-
<dt>
|
|
6540
|
-
<dt>
|
|
6556
|
+
<dt>Asking</dt><dd>${escapeHtml(request.client.clientName)}</dd>
|
|
6557
|
+
<dt>Returns to</dt><dd>${escapeHtml(new URL(request.redirectUri).origin)}</dd>
|
|
6558
|
+
<dt>Grants</dt><dd>Read-only access to your Moodle units, deadlines, grades, forums and files (<code>${OAUTH_SCOPE}</code>). Nothing is written to Moodle.</dd>
|
|
6541
6559
|
</dl>
|
|
6542
|
-
${
|
|
6543
|
-
${form}
|
|
6544
|
-
<p class="note">Run <code>moodle mcp pair</code> on your computer to get a code. It is valid for 10 minutes and one approval.</p>`);
|
|
6560
|
+
${body}`);
|
|
6545
6561
|
}
|
|
6546
6562
|
function errorPage(message) {
|
|
6547
|
-
return page("Request rejected", `<h1>Request rejected</h1><p class="error">${escapeHtml(message)}</p
|
|
6563
|
+
return page("Request rejected", `<h1>Request rejected</h1><p class="error">${escapeHtml(message)}</p>
|
|
6564
|
+
<p class="note">Close this tab and start the connection again from your MCP client. If it keeps failing, run <code>moodle mcp status</code> on your computer.</p>`);
|
|
6548
6565
|
}
|
|
6549
6566
|
function page(title, body) {
|
|
6550
6567
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
6551
|
-
<meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)}</title>
|
|
6568
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>${escapeHtml(title)}</title>
|
|
6552
6569
|
<style>${PAGE_STYLE}</style></head><body><main>${body}</main></body></html>`;
|
|
6553
6570
|
}
|
|
6554
6571
|
function escapeHtml(value) {
|
|
@@ -8529,18 +8546,18 @@ var validateAsync = async (schema2, value, _ctx) => {
|
|
|
8529
8546
|
return result.issues.length === 0;
|
|
8530
8547
|
};
|
|
8531
8548
|
var _encode = (_Err) => {
|
|
8532
|
-
const
|
|
8549
|
+
const parse9 = _parse(_Err);
|
|
8533
8550
|
const fn = (schema2, value, _ctx, _params) => {
|
|
8534
8551
|
const ctx = _ctx ? { ..._ctx, direction: "backward" } : { direction: "backward" };
|
|
8535
|
-
return
|
|
8552
|
+
return parse9(schema2, value, ctx, finalizeParams(fn, _params));
|
|
8536
8553
|
};
|
|
8537
8554
|
return fn;
|
|
8538
8555
|
};
|
|
8539
8556
|
var encode = /* @__PURE__ */ _encode($ZodRealError);
|
|
8540
8557
|
var _decode = (_Err) => {
|
|
8541
|
-
const
|
|
8558
|
+
const parse9 = _parse(_Err);
|
|
8542
8559
|
const fn = (schema2, value, _ctx, _params) => {
|
|
8543
|
-
return
|
|
8560
|
+
return parse9(schema2, value, _ctx, finalizeParams(fn, _params));
|
|
8544
8561
|
};
|
|
8545
8562
|
return fn;
|
|
8546
8563
|
};
|
|
@@ -10356,7 +10373,7 @@ var $ZodUnion = /* @__PURE__ */ $constructor("$ZodUnion", (inst, def) => {
|
|
|
10356
10373
|
defineLazyInternal(inst, "optout", (zod) => zod.def.options.some((o) => o._zod.optout === "optional") ? "optional" : void 0);
|
|
10357
10374
|
defineLazyInternal(inst, "values", (zod) => {
|
|
10358
10375
|
if (zod.def.options.every((o) => o._zod.values)) {
|
|
10359
|
-
return new Set(zod.def.options.flatMap((
|
|
10376
|
+
return new Set(zod.def.options.flatMap((option2) => Array.from(option2._zod.values)));
|
|
10360
10377
|
}
|
|
10361
10378
|
return void 0;
|
|
10362
10379
|
});
|
|
@@ -10374,8 +10391,8 @@ var $ZodUnion = /* @__PURE__ */ $constructor("$ZodUnion", (inst, def) => {
|
|
|
10374
10391
|
}
|
|
10375
10392
|
let async = false;
|
|
10376
10393
|
const results = [];
|
|
10377
|
-
for (const
|
|
10378
|
-
const result =
|
|
10394
|
+
for (const option2 of def.options) {
|
|
10395
|
+
const result = option2._zod.run({
|
|
10379
10396
|
value: payload.value,
|
|
10380
10397
|
issues: []
|
|
10381
10398
|
}, ctx);
|
|
@@ -10434,8 +10451,8 @@ var $ZodXor = /* @__PURE__ */ $constructor("$ZodXor", (inst, def) => {
|
|
|
10434
10451
|
}
|
|
10435
10452
|
let async = false;
|
|
10436
10453
|
const results = [];
|
|
10437
|
-
for (const
|
|
10438
|
-
const result =
|
|
10454
|
+
for (const option2 of def.options) {
|
|
10455
|
+
const result = option2._zod.run({
|
|
10439
10456
|
value: payload.value,
|
|
10440
10457
|
issues: []
|
|
10441
10458
|
}, ctx);
|
|
@@ -10460,24 +10477,24 @@ function getDiscriminatedOption(union2, value) {
|
|
|
10460
10477
|
map2 = discriminatorMap(internals.def);
|
|
10461
10478
|
internals.bag.optionsMap = map2;
|
|
10462
10479
|
}
|
|
10463
|
-
const
|
|
10464
|
-
if (
|
|
10480
|
+
const option2 = map2.get(value);
|
|
10481
|
+
if (option2 === null)
|
|
10465
10482
|
throw new Error(`Ambiguous discriminator value "${String(value)}"`);
|
|
10466
|
-
return
|
|
10483
|
+
return option2;
|
|
10467
10484
|
}
|
|
10468
10485
|
function discriminatorMap(def) {
|
|
10469
10486
|
const map2 = /* @__PURE__ */ new Map();
|
|
10470
|
-
for (const
|
|
10471
|
-
const values =
|
|
10487
|
+
for (const option2 of def.options) {
|
|
10488
|
+
const values = option2._zod.propValues?.[def.discriminator];
|
|
10472
10489
|
if (!values || values.size === 0)
|
|
10473
|
-
throw new Error(`Invalid discriminated union option at index "${def.options.indexOf(
|
|
10490
|
+
throw new Error(`Invalid discriminated union option at index "${def.options.indexOf(option2)}"`);
|
|
10474
10491
|
for (const value of values) {
|
|
10475
10492
|
if (map2.has(value)) {
|
|
10476
10493
|
if (value !== void 0)
|
|
10477
10494
|
throw new Error(`Duplicate discriminator value "${String(value)}"`);
|
|
10478
10495
|
map2.set(value, null);
|
|
10479
10496
|
} else {
|
|
10480
|
-
map2.set(value,
|
|
10497
|
+
map2.set(value, option2);
|
|
10481
10498
|
}
|
|
10482
10499
|
}
|
|
10483
10500
|
}
|
|
@@ -10490,10 +10507,10 @@ var $ZodDiscriminatedUnion = /* @__PURE__ */ $constructor("$ZodDiscriminatedUnio
|
|
|
10490
10507
|
defineLazyInternal(inst, "propValues", (zod) => {
|
|
10491
10508
|
const propValues = {};
|
|
10492
10509
|
let undefinedCount = 0;
|
|
10493
|
-
for (const
|
|
10494
|
-
const pv =
|
|
10510
|
+
for (const option2 of zod.def.options) {
|
|
10511
|
+
const pv = option2._zod.propValues;
|
|
10495
10512
|
if (!pv || Object.keys(pv).length === 0)
|
|
10496
|
-
throw new Error(`Invalid discriminated union option at index "${zod.def.options.indexOf(
|
|
10513
|
+
throw new Error(`Invalid discriminated union option at index "${zod.def.options.indexOf(option2)}"`);
|
|
10497
10514
|
if (pv[zod.def.discriminator]?.has(void 0))
|
|
10498
10515
|
undefinedCount++;
|
|
10499
10516
|
for (const [k, v] of Object.entries(pv)) {
|
|
@@ -10509,8 +10526,8 @@ var $ZodDiscriminatedUnion = /* @__PURE__ */ $constructor("$ZodDiscriminatedUnio
|
|
|
10509
10526
|
propValues[zod.def.discriminator]?.delete(void 0);
|
|
10510
10527
|
return propValues;
|
|
10511
10528
|
});
|
|
10512
|
-
def.options.forEach((
|
|
10513
|
-
const propShape = rawShape(
|
|
10529
|
+
def.options.forEach((option2, i) => {
|
|
10530
|
+
const propShape = rawShape(option2._zod.def);
|
|
10514
10531
|
if (propShape && !Object.prototype.hasOwnProperty.call(propShape, def.discriminator)) {
|
|
10515
10532
|
throw new Error(`Invalid discriminated union option at index "${i}"`);
|
|
10516
10533
|
}
|
|
@@ -20810,8 +20827,8 @@ function generateDiscriminatedUnionCheck(doc, ctx, def, accessor) {
|
|
|
20810
20827
|
doc.write(`let ${outputVar};`);
|
|
20811
20828
|
let firstBranch = true;
|
|
20812
20829
|
const claimed = /* @__PURE__ */ new Set();
|
|
20813
|
-
for (const
|
|
20814
|
-
const values =
|
|
20830
|
+
for (const option2 of def.options) {
|
|
20831
|
+
const values = option2._zod.propValues?.[def.discriminator];
|
|
20815
20832
|
if (!values || values.size === 0) {
|
|
20816
20833
|
throw new ZodCompileUnsupportedError("discriminated union option without static discriminator values");
|
|
20817
20834
|
}
|
|
@@ -20825,7 +20842,7 @@ function generateDiscriminatedUnionCheck(doc, ctx, def, accessor) {
|
|
|
20825
20842
|
const prefix = firstBranch ? "if" : "else if";
|
|
20826
20843
|
doc.write(`${prefix} (${conditions.join(" || ")}) {`);
|
|
20827
20844
|
doc.indented((d) => {
|
|
20828
|
-
const branchOutput = generateCheck(d, ctx,
|
|
20845
|
+
const branchOutput = generateCheck(d, ctx, option2, accessor);
|
|
20829
20846
|
d.write(`${outputVar} = ${branchOutput};`);
|
|
20830
20847
|
});
|
|
20831
20848
|
doc.write(`}`);
|
|
@@ -22407,14 +22424,14 @@ function compactTypeUnion(schema2) {
|
|
|
22407
22424
|
if (!Array.isArray(options) || options.length === 0 || schema2.type !== void 0)
|
|
22408
22425
|
return;
|
|
22409
22426
|
const types = [];
|
|
22410
|
-
for (const
|
|
22411
|
-
if (!
|
|
22427
|
+
for (const option2 of options) {
|
|
22428
|
+
if (!option2 || typeof option2 !== "object")
|
|
22412
22429
|
return;
|
|
22413
|
-
compactTypeUnion(
|
|
22414
|
-
const keys = Object.keys(
|
|
22430
|
+
compactTypeUnion(option2);
|
|
22431
|
+
const keys = Object.keys(option2);
|
|
22415
22432
|
if (keys.length !== 1 || keys[0] !== "type")
|
|
22416
22433
|
return;
|
|
22417
|
-
const type =
|
|
22434
|
+
const type = option2.type;
|
|
22418
22435
|
for (const member of Array.isArray(type) ? type : [type]) {
|
|
22419
22436
|
if (typeof member !== "string")
|
|
22420
22437
|
return;
|
|
@@ -22704,8 +22721,8 @@ function isTransforming(_schema, _ctx) {
|
|
|
22704
22721
|
return false;
|
|
22705
22722
|
}
|
|
22706
22723
|
if (def.type === "union") {
|
|
22707
|
-
for (const
|
|
22708
|
-
if (isTransforming(
|
|
22724
|
+
for (const option2 of def.options) {
|
|
22725
|
+
if (isTransforming(option2, ctx))
|
|
22709
22726
|
return true;
|
|
22710
22727
|
}
|
|
22711
22728
|
return false;
|
|
@@ -26452,7 +26469,7 @@ function stringValue(value) {
|
|
|
26452
26469
|
}
|
|
26453
26470
|
|
|
26454
26471
|
// src/version.ts
|
|
26455
|
-
var VERSION = "0.9.
|
|
26472
|
+
var VERSION = "0.9.3";
|
|
26456
26473
|
|
|
26457
26474
|
// src/worker/http.ts
|
|
26458
26475
|
var HEALTH_PATH = "/healthz";
|
|
@@ -27058,6 +27075,11 @@ var DASHBOARD_PATH = "/my/";
|
|
|
27058
27075
|
var COURSE_PATH = "/course/view.php";
|
|
27059
27076
|
var ASSIGN_VIEW_PATH = "/mod/assign/view.php";
|
|
27060
27077
|
var QUIZ_VIEW_PATH = "/mod/quiz/view.php";
|
|
27078
|
+
var QUIZ_REVIEW_PATH = "/mod/quiz/review.php";
|
|
27079
|
+
var QUIZ_START_PATH = "/mod/quiz/startattempt.php";
|
|
27080
|
+
var QUIZ_ATTEMPT_PATH = "/mod/quiz/attempt.php";
|
|
27081
|
+
var QUIZ_SUMMARY_PATH = "/mod/quiz/summary.php";
|
|
27082
|
+
var QUIZ_PROCESS_PATH = "/mod/quiz/processattempt.php";
|
|
27061
27083
|
var RESOURCE_VIEW_PATH = "/mod/resource/view.php";
|
|
27062
27084
|
var URL_VIEW_PATH = "/mod/url/view.php";
|
|
27063
27085
|
var PAGE_VIEW_PATH = "/mod/page/view.php";
|
|
@@ -27378,6 +27400,8 @@ function parseGradeOverviewRows(html, baseUrl) {
|
|
|
27378
27400
|
return rows;
|
|
27379
27401
|
}
|
|
27380
27402
|
function parseAssignmentHtml(html, assignmentId, baseUrl) {
|
|
27403
|
+
const root = (0, import_node_html_parser2.parse)(html);
|
|
27404
|
+
const feedback = root.querySelector(".feedback");
|
|
27381
27405
|
return {
|
|
27382
27406
|
id: assignmentId,
|
|
27383
27407
|
name: pageTitle(html),
|
|
@@ -27387,9 +27411,39 @@ function parseAssignmentHtml(html, assignmentId, baseUrl) {
|
|
|
27387
27411
|
grading_status: findTableValue(html, "Grading status"),
|
|
27388
27412
|
time_remaining: findTableValue(html, "Time remaining"),
|
|
27389
27413
|
grade: findTableValue(html, "Grade"),
|
|
27414
|
+
graded_on: feedback ? findTableValue(feedback.toString(), "Graded on") : "",
|
|
27415
|
+
graded_by: feedback ? findTableValue(feedback.toString(), "Graded by") : "",
|
|
27416
|
+
feedback_comments: feedback ? findTableValue(feedback.toString(), "Feedback comments") : "",
|
|
27417
|
+
criteria: feedback ? parseFeedbackCriteria(feedback) : [],
|
|
27418
|
+
file_entries: feedback ? parseFeedbackFiles(feedback, baseUrl) : [],
|
|
27390
27419
|
url: `${baseUrl.replace(/\/$/, "")}/mod/assign/view.php?id=${assignmentId}`
|
|
27391
27420
|
};
|
|
27392
27421
|
}
|
|
27422
|
+
function parseFeedbackCriteria(feedback) {
|
|
27423
|
+
const criteria = [];
|
|
27424
|
+
for (const row of feedback.querySelectorAll("tr.criterion")) {
|
|
27425
|
+
const level = row.querySelector("td.level.checked");
|
|
27426
|
+
const name = cleanNodeText(row.querySelector(".criterionshortname") ?? row.querySelector("td.description"));
|
|
27427
|
+
if (!name) continue;
|
|
27428
|
+
criteria.push({
|
|
27429
|
+
name,
|
|
27430
|
+
level: cleanNodeText(level?.querySelector(".definition")),
|
|
27431
|
+
score: cleanNodeText(row.querySelector("td.score") ?? level?.querySelector(".score")),
|
|
27432
|
+
remark: cleanTableCell(row.querySelector("td.remark"))
|
|
27433
|
+
});
|
|
27434
|
+
}
|
|
27435
|
+
return criteria;
|
|
27436
|
+
}
|
|
27437
|
+
function parseFeedbackFiles(feedback, baseUrl) {
|
|
27438
|
+
const entries = [];
|
|
27439
|
+
for (const link of feedback.querySelectorAll('a[href*="pluginfile.php"]')) {
|
|
27440
|
+
const url2 = resolveUrl(baseUrl, link.getAttribute("href") ?? "");
|
|
27441
|
+
const label = cleanNodeText(link);
|
|
27442
|
+
const name = /\.\w{1,5}$/u.test(label) ? label : decodeURIComponent(new URL(url2).pathname.split("/").at(-1) || "file");
|
|
27443
|
+
if (!entries.some((entry) => entry.url === url2)) entries.push(fileEntry(name, url2, baseUrl));
|
|
27444
|
+
}
|
|
27445
|
+
return entries;
|
|
27446
|
+
}
|
|
27393
27447
|
function parseQuizHtml(html, quizId, baseUrl) {
|
|
27394
27448
|
const root = (0, import_node_html_parser2.parse)(html);
|
|
27395
27449
|
return {
|
|
@@ -27398,12 +27452,85 @@ function parseQuizHtml(html, quizId, baseUrl) {
|
|
|
27398
27452
|
...activityContext(html),
|
|
27399
27453
|
opens_pretty: extractLabeledText(html, "Opens:"),
|
|
27400
27454
|
closes_pretty: extractLabeledText(html, "Closes:"),
|
|
27401
|
-
attempts_allowed:
|
|
27455
|
+
attempts_allowed: labeledParagraph(root, "Attempts allowed:"),
|
|
27456
|
+
time_limit: labeledParagraph(root, "Time limit:"),
|
|
27402
27457
|
availability: cleanText(root.textContent.match(/This quiz is currently[^\n]+/i)?.[0] ?? ""),
|
|
27403
27458
|
grade: findTableValue(html, "Grade"),
|
|
27459
|
+
attempts: parseQuizAttempts(root, baseUrl),
|
|
27404
27460
|
url: `${baseUrl.replace(/\/$/, "")}/mod/quiz/view.php?id=${quizId}`
|
|
27405
27461
|
};
|
|
27406
27462
|
}
|
|
27463
|
+
function labeledParagraph(root, label) {
|
|
27464
|
+
for (const p of root.querySelectorAll("p")) {
|
|
27465
|
+
const line = cleanNodeText(p);
|
|
27466
|
+
if (line.startsWith(label)) return cleanText(line.slice(label.length));
|
|
27467
|
+
}
|
|
27468
|
+
return "";
|
|
27469
|
+
}
|
|
27470
|
+
function parseQuizAttempts(root, baseUrl) {
|
|
27471
|
+
const attempts = [];
|
|
27472
|
+
for (const table of root.querySelectorAll("table.quizreviewsummary")) {
|
|
27473
|
+
const card = table.closest(".card") ?? table.parentNode;
|
|
27474
|
+
const link = card?.querySelector('a[href*="/mod/quiz/review.php"]');
|
|
27475
|
+
const reviewUrl = link ? resolveUrl(baseUrl, link.getAttribute("href") ?? "") : "";
|
|
27476
|
+
const id2 = numberQueryValue(reviewUrl, "attempt");
|
|
27477
|
+
if (!link || id2 === null) continue;
|
|
27478
|
+
const summary = tableValues(table);
|
|
27479
|
+
const number4 = Number(cleanNodeText(card?.querySelector(".card-title")).match(/\d+/)?.[0] ?? attempts.length + 1);
|
|
27480
|
+
attempts.push({ id: id2, number: number4, status: summary.Status ?? "", started: summary.Started ?? "", completed: summary.Completed ?? "", duration: summary.Duration ?? "", marks: summary.Marks ?? "", grade: summary.Grade ?? "", review_url: reviewUrl });
|
|
27481
|
+
}
|
|
27482
|
+
return attempts;
|
|
27483
|
+
}
|
|
27484
|
+
function parseQuizReviewHtml(html, attemptId, baseUrl) {
|
|
27485
|
+
const root = (0, import_node_html_parser2.parse)(html);
|
|
27486
|
+
const summary = tableValues(root.querySelector("table.quizreviewsummary"));
|
|
27487
|
+
const form = root.querySelector("form.questionflagsaveform");
|
|
27488
|
+
const url2 = `${baseUrl.replace(/\/$/, "")}/mod/quiz/review.php?attempt=${attemptId}`;
|
|
27489
|
+
return {
|
|
27490
|
+
id: attemptId,
|
|
27491
|
+
quiz_id: numberQueryValue(form?.getAttribute("action") ?? "", "cmid") ?? 0,
|
|
27492
|
+
course_id: parseCourseIdFromPageHtml(html) ?? 0,
|
|
27493
|
+
status: summary.Status ?? "",
|
|
27494
|
+
started: summary.Started ?? "",
|
|
27495
|
+
completed: summary.Completed ?? "",
|
|
27496
|
+
duration: summary.Duration ?? "",
|
|
27497
|
+
marks: summary.Marks ?? "",
|
|
27498
|
+
grade: summary.Grade ?? "",
|
|
27499
|
+
questions: root.querySelectorAll("div.que").map(parseQuizQuestion),
|
|
27500
|
+
url: url2
|
|
27501
|
+
};
|
|
27502
|
+
}
|
|
27503
|
+
function parseQuizQuestion(que) {
|
|
27504
|
+
const answer = que.querySelector(".answer");
|
|
27505
|
+
const picked = answer?.querySelectorAll("input:checked, input[checked]").map((input3) => {
|
|
27506
|
+
const label = input3.getAttribute("aria-labelledby");
|
|
27507
|
+
return cleanTableCell(label ? que.querySelector(`[id="${label}"]`) : input3.parentNode);
|
|
27508
|
+
}).filter(Boolean) ?? [];
|
|
27509
|
+
const typed = cleanText(que.querySelector('input[type="text"], input[type="number"]')?.getAttribute("value") ?? "");
|
|
27510
|
+
const response = picked.length ? picked.join("; ") : typed || blockText(answer?.querySelector(".qtype_essay_response") ?? answer);
|
|
27511
|
+
return {
|
|
27512
|
+
number: Number(cleanNodeText(que.querySelector(".qno")) || 0),
|
|
27513
|
+
type: que.classList.value[1] ?? "",
|
|
27514
|
+
state: cleanNodeText(que.querySelector(".info .state")),
|
|
27515
|
+
mark: cleanNodeText(que.querySelector(".info .grade")).replace(/^Mark\s+/u, ""),
|
|
27516
|
+
text: blockText(que.querySelector(".qtext")),
|
|
27517
|
+
response: response.replace(/\s*Word count: \d+$/u, ""),
|
|
27518
|
+
correct: blockText(que.querySelector(".rightanswer")).replace(/^The correct answers? (?:is|are):?\s*/iu, "").replace(/^'(.*)'\.?$/u, "$1"),
|
|
27519
|
+
feedback: blockText(que.querySelector(".outcome .feedback"))
|
|
27520
|
+
};
|
|
27521
|
+
}
|
|
27522
|
+
function blockText(node2) {
|
|
27523
|
+
if (!node2) return "";
|
|
27524
|
+
return cleanTableCell((0, import_node_html_parser2.parse)(node2.toString().replace(/<br\s*\/?>|<\/(?:p|div|li|h\d|tr)>/giu, "$& ")));
|
|
27525
|
+
}
|
|
27526
|
+
function tableValues(table) {
|
|
27527
|
+
const values = {};
|
|
27528
|
+
for (const row of table?.querySelectorAll("tr") ?? []) {
|
|
27529
|
+
const cells = row.querySelectorAll("th, td");
|
|
27530
|
+
if (cells.length >= 2) values[cleanNodeText(cells[0])] = cleanTableCell(cells[1]);
|
|
27531
|
+
}
|
|
27532
|
+
return values;
|
|
27533
|
+
}
|
|
27407
27534
|
function parseResourceHtml(html, resourceId, baseUrl) {
|
|
27408
27535
|
const root = (0, import_node_html_parser2.parse)(html);
|
|
27409
27536
|
const link = root.querySelector(".resourceworkaround a[href], .resourcecontent a[href], a.resourceworkaround[href]");
|
|
@@ -27583,12 +27710,12 @@ function parseForumGroupsHtml(html) {
|
|
|
27583
27710
|
}
|
|
27584
27711
|
const groups = [];
|
|
27585
27712
|
const seen = /* @__PURE__ */ new Set();
|
|
27586
|
-
for (const
|
|
27587
|
-
const groupId = safeInt(
|
|
27713
|
+
for (const option2 of select.querySelectorAll("option")) {
|
|
27714
|
+
const groupId = safeInt(option2.getAttribute("value"));
|
|
27588
27715
|
if (!groupId) {
|
|
27589
27716
|
continue;
|
|
27590
27717
|
}
|
|
27591
|
-
const groupName = cleanNodeText(
|
|
27718
|
+
const groupName = cleanNodeText(option2);
|
|
27592
27719
|
const key = `${groupId}:${groupName}`;
|
|
27593
27720
|
if (seen.has(key)) {
|
|
27594
27721
|
continue;
|
|
@@ -27608,9 +27735,9 @@ function selectedGroupName(root, groupId) {
|
|
|
27608
27735
|
return "";
|
|
27609
27736
|
}
|
|
27610
27737
|
for (const selector of ["select[name='groupinfo']", "select[name='group']"]) {
|
|
27611
|
-
const
|
|
27612
|
-
if (
|
|
27613
|
-
return cleanNodeText(
|
|
27738
|
+
const option2 = root.querySelector(selector)?.querySelector(`option[value='${groupId}']`) ?? null;
|
|
27739
|
+
if (option2) {
|
|
27740
|
+
return cleanNodeText(option2);
|
|
27614
27741
|
}
|
|
27615
27742
|
}
|
|
27616
27743
|
return "";
|
|
@@ -27726,7 +27853,7 @@ function cleanTableCell(node2) {
|
|
|
27726
27853
|
return "";
|
|
27727
27854
|
}
|
|
27728
27855
|
const clone2 = (0, import_node_html_parser2.parse)(node2.toString());
|
|
27729
|
-
for (const unwanted of clone2.querySelectorAll(".action-menu, .dropdown, script, style")) {
|
|
27856
|
+
for (const unwanted of clone2.querySelectorAll(".action-menu, .dropdown, .hidden, .accesshide, script, style")) {
|
|
27730
27857
|
unwanted.remove();
|
|
27731
27858
|
}
|
|
27732
27859
|
return cleanText(clone2.textContent.replace("( Empty )", "(Empty)"));
|
|
@@ -27944,7 +28071,7 @@ function formFields(form) {
|
|
|
27944
28071
|
}
|
|
27945
28072
|
if (tag === "select") {
|
|
27946
28073
|
const options = element.querySelectorAll("option");
|
|
27947
|
-
const chosen = options.find((
|
|
28074
|
+
const chosen = options.find((option2) => option2.hasAttribute("selected")) ?? options[0];
|
|
27948
28075
|
if (chosen) fields2.push([name, chosen.getAttribute("value") ?? cleanText(chosen.textContent)]);
|
|
27949
28076
|
continue;
|
|
27950
28077
|
}
|
|
@@ -28118,6 +28245,292 @@ function record2(value) {
|
|
|
28118
28245
|
return isRecord5(value) ? value : {};
|
|
28119
28246
|
}
|
|
28120
28247
|
|
|
28248
|
+
// src/moodle-quiz-core.ts
|
|
28249
|
+
var import_node_html_parser4 = __toESM(require_dist(), 1);
|
|
28250
|
+
async function startQuizAttempt(deps, quizId, options = {}) {
|
|
28251
|
+
if (!Number.isSafeInteger(quizId) || quizId <= 0) throw deps.usage("The quiz id must be a positive integer.");
|
|
28252
|
+
const viewUrl = `${deps.baseUrl}${QUIZ_VIEW_PATH}?id=${quizId}`;
|
|
28253
|
+
const viewHtml = await pageText2(deps, viewUrl);
|
|
28254
|
+
const root = (0, import_node_html_parser4.parse)(viewHtml);
|
|
28255
|
+
if (/safeexambrowser|Safe Exam Browser/iu.test(viewHtml)) throw deps.usage("This quiz requires the Safe Exam Browser, which the CLI cannot provide.", "Open it in the browser Moodle asks for.");
|
|
28256
|
+
const resume = formWithAction2(root, QUIZ_ATTEMPT_PATH) ?? root.querySelector(`a[href*="${QUIZ_ATTEMPT_PATH}?"]`);
|
|
28257
|
+
if (resume) {
|
|
28258
|
+
const target = resume.tagName.toLowerCase() === "form" ? `${resolveUrl(deps.baseUrl, resume.getAttribute("action") ?? "")}?${new URLSearchParams(formFields2(resume)).toString()}` : resolveUrl(deps.baseUrl, resume.getAttribute("href") ?? "");
|
|
28259
|
+
const attempt = numberParam(target, "attempt");
|
|
28260
|
+
if (attempt) return getAttemptPage(deps, attempt, quizId, 0);
|
|
28261
|
+
}
|
|
28262
|
+
const start = formWithAction2(root, QUIZ_START_PATH);
|
|
28263
|
+
if (!start) {
|
|
28264
|
+
const reason = cleanText(root.querySelector(".quizattempt, .quizinfo")?.textContent) || "the quiz page shows no attempt button";
|
|
28265
|
+
throw deps.usage(`Moodle offers no new attempt: ${reason}`, `See ${viewUrl}`);
|
|
28266
|
+
}
|
|
28267
|
+
let response = await deps.request(resolveUrl(deps.baseUrl, start.getAttribute("action") ?? ""), postInit(formFields2(start)));
|
|
28268
|
+
let html = await response.text();
|
|
28269
|
+
if (!onPath(response.url, QUIZ_ATTEMPT_PATH)) {
|
|
28270
|
+
const preflight = formWithAction2((0, import_node_html_parser4.parse)(html), QUIZ_START_PATH);
|
|
28271
|
+
if (!preflight) throw deps.fail(`Moodle did not start the attempt: ${noticesOf2(html) || "it returned an unexpected page"}`);
|
|
28272
|
+
const fields2 = [...formFields2(preflight).filter(([name]) => name !== "quizpassword"), ["submitbutton", "Start attempt"]];
|
|
28273
|
+
if (preflight.querySelector("input[name=quizpassword]")) {
|
|
28274
|
+
const password = options.password ? await options.password() : null;
|
|
28275
|
+
if (!password) throw deps.usage("This quiz needs its access password to start.", "Run moodle quiz start again at a terminal to be asked for it, or pass --password.");
|
|
28276
|
+
fields2.push(["quizpassword", password]);
|
|
28277
|
+
}
|
|
28278
|
+
response = await deps.request(resolveUrl(deps.baseUrl, preflight.getAttribute("action") ?? ""), postInit(fields2));
|
|
28279
|
+
html = await response.text();
|
|
28280
|
+
if (!onPath(response.url, QUIZ_ATTEMPT_PATH)) throw deps.fail(`Moodle did not start the attempt: ${noticesOf2(html) || "it returned the pre-flight form again"}`);
|
|
28281
|
+
}
|
|
28282
|
+
return withoutForm(parseAttemptPage(html, response.url, deps));
|
|
28283
|
+
}
|
|
28284
|
+
async function getAttemptPage(deps, attemptId, quizId, page2) {
|
|
28285
|
+
return withoutForm(await loadAttemptPage(deps, attemptId, quizId, page2));
|
|
28286
|
+
}
|
|
28287
|
+
async function loadAttemptPage(deps, attemptId, quizId, page2) {
|
|
28288
|
+
const url2 = attemptUrl(deps.baseUrl, attemptId, quizId, page2);
|
|
28289
|
+
const response = await deps.request(url2);
|
|
28290
|
+
const html = await response.text();
|
|
28291
|
+
if (onPath(response.url, QUIZ_REVIEW_PATH)) throw deps.usage(`Attempt ${attemptId} is already finished.`, `Its review is at ${response.url}`);
|
|
28292
|
+
if (!onPath(response.url, QUIZ_ATTEMPT_PATH)) throw deps.fail(`Moodle did not show attempt ${attemptId}: ${noticesOf2(html) || "it redirected elsewhere"}`);
|
|
28293
|
+
return parseAttemptPage(html, response.url, deps);
|
|
28294
|
+
}
|
|
28295
|
+
function withoutForm({ form: _form, ...page2 }) {
|
|
28296
|
+
return page2;
|
|
28297
|
+
}
|
|
28298
|
+
async function answerQuizQuestion(deps, request) {
|
|
28299
|
+
const first2 = await loadAttemptPage(deps, request.attemptId, request.quizId, 0);
|
|
28300
|
+
const entry = first2.navigation.find((item) => item.number === request.question.trim());
|
|
28301
|
+
if (!entry) throw deps.usage(`Attempt ${request.attemptId} has no question ${request.question}.`, `Questions: ${first2.navigation.map((item) => item.number).join(", ")}`);
|
|
28302
|
+
const page2 = entry.page === first2.page ? first2 : await loadAttemptPage(deps, request.attemptId, request.quizId, entry.page);
|
|
28303
|
+
const question2 = page2.questions.find((item) => item.slot === entry.slot);
|
|
28304
|
+
if (!question2) throw deps.fail(`Page ${entry.page + 1} does not contain question ${request.question}.`);
|
|
28305
|
+
const fields2 = encodeAnswer(deps, page2.form, question2, request.value);
|
|
28306
|
+
const replay = fields2.map(([name, value]) => name === "nextpage" ? [name, String(page2.page)] : [name, value]);
|
|
28307
|
+
const response = await deps.request(page2.form.action, postInit(replay));
|
|
28308
|
+
const html = await response.text();
|
|
28309
|
+
if (!onPath(response.url, QUIZ_ATTEMPT_PATH)) throw deps.fail(`Moodle did not save the answer: ${noticesOf2(html) || "it left the attempt page"}`);
|
|
28310
|
+
const after = parseAttemptPage(html, response.url, deps);
|
|
28311
|
+
const saved = after.questions.find((item) => item.slot === question2.slot);
|
|
28312
|
+
if (!saved || /not yet answered|not answered/iu.test(saved.state)) throw deps.fail(`Moodle accepted the post but still reports question ${request.question} as "${saved?.state || "missing"}".`);
|
|
28313
|
+
return withoutForm(after);
|
|
28314
|
+
}
|
|
28315
|
+
async function getAttemptSummary(deps, attemptId, quizId) {
|
|
28316
|
+
const { form: _form, ...summary } = await loadAttemptSummary(deps, attemptId, quizId);
|
|
28317
|
+
return summary;
|
|
28318
|
+
}
|
|
28319
|
+
async function loadAttemptSummary(deps, attemptId, quizId) {
|
|
28320
|
+
const url2 = `${deps.baseUrl}${QUIZ_SUMMARY_PATH}?attempt=${attemptId}&cmid=${quizId}`;
|
|
28321
|
+
const response = await deps.request(url2);
|
|
28322
|
+
const html = await response.text();
|
|
28323
|
+
if (onPath(response.url, QUIZ_REVIEW_PATH)) throw deps.usage(`Attempt ${attemptId} is already finished.`, `Its review is at ${response.url}`);
|
|
28324
|
+
const root = (0, import_node_html_parser4.parse)(html);
|
|
28325
|
+
const rows = root.querySelectorAll("table.quizsummaryofattempt tbody tr").flatMap((row) => {
|
|
28326
|
+
const cells = row.querySelectorAll("td");
|
|
28327
|
+
if (cells.length < 2) return [];
|
|
28328
|
+
const link = cells[0].querySelector("a")?.getAttribute("href") ?? "";
|
|
28329
|
+
return [{ number: cleanText(cells[0].textContent), state: cleanText(cells[1].textContent), page: numberParam(link, "page") ?? 0 }];
|
|
28330
|
+
});
|
|
28331
|
+
const finish = root.querySelector("form#frm-finishattempt") ?? formWithAction2(root, QUIZ_PROCESS_PATH);
|
|
28332
|
+
if (!finish || !rows.length) throw deps.fail(`Moodle did not show the summary of attempt ${attemptId}: ${noticesOf2(html) || "the page has no finish button"}`);
|
|
28333
|
+
const form = { action: resolveUrl(deps.baseUrl, finish.getAttribute("action") ?? ""), fields: formFields2(finish) };
|
|
28334
|
+
return { attempt: attemptId, quiz_id: quizId, name: pageHeading(root), rows, url: url2, form };
|
|
28335
|
+
}
|
|
28336
|
+
async function finishQuizAttempt(deps, attemptId, quizId) {
|
|
28337
|
+
const summary = await loadAttemptSummary(deps, attemptId, quizId);
|
|
28338
|
+
const response = await deps.request(summary.form.action, postInit(summary.form.fields));
|
|
28339
|
+
const html = await response.text();
|
|
28340
|
+
const receipt = { attempt: attemptId, quiz_id: quizId, name: summary.name, summary: summary.rows, url: response.url };
|
|
28341
|
+
if (onPath(response.url, QUIZ_REVIEW_PATH)) {
|
|
28342
|
+
receipt.review = parseQuizReviewHtml(html, attemptId, deps.baseUrl);
|
|
28343
|
+
return receipt;
|
|
28344
|
+
}
|
|
28345
|
+
const quiz = parseQuizHtml(onPath(response.url, QUIZ_VIEW_PATH) ? html : await pageText2(deps, `${deps.baseUrl}${QUIZ_VIEW_PATH}?id=${quizId}`), quizId, deps.baseUrl);
|
|
28346
|
+
const row = quiz.attempts.find((attempt) => attempt.id === attemptId);
|
|
28347
|
+
if (row && /in progress/iu.test(row.status)) throw deps.fail(`Moodle accepted the finish request but still lists attempt ${attemptId} as ${row.status}; check the quiz in a browser.`);
|
|
28348
|
+
receipt.url = quiz.url;
|
|
28349
|
+
if (row) {
|
|
28350
|
+
receipt.result = { status: row.status, marks: row.marks, grade: row.grade, completed: row.completed };
|
|
28351
|
+
return receipt;
|
|
28352
|
+
}
|
|
28353
|
+
const probe = await deps.request(attemptUrl(deps.baseUrl, attemptId, quizId, 0));
|
|
28354
|
+
if (onPath(probe.url, QUIZ_ATTEMPT_PATH) && (0, import_node_html_parser4.parse)(await probe.text()).querySelector("form#responseform")) throw deps.fail(`Moodle accepted the finish request but attempt ${attemptId} is still open; check the quiz in a browser.`);
|
|
28355
|
+
return receipt;
|
|
28356
|
+
}
|
|
28357
|
+
function parseAttemptPage(html, url2, deps) {
|
|
28358
|
+
const root = (0, import_node_html_parser4.parse)(html);
|
|
28359
|
+
const form = root.querySelector("form#responseform");
|
|
28360
|
+
if (!form) throw deps.fail(`Moodle did not render an attempt page: ${noticesOf2(html) || "no response form found"}`);
|
|
28361
|
+
const attempt = numberParam(url2, "attempt") ?? Number(form.querySelector("input[name=attempt]")?.getAttribute("value"));
|
|
28362
|
+
const quizId = numberParam(form.getAttribute("action") ?? "", "cmid") ?? numberParam(url2, "cmid") ?? 0;
|
|
28363
|
+
const page2 = Number(form.querySelector("input[name=thispage]")?.getAttribute("value") ?? numberParam(url2, "page") ?? 0);
|
|
28364
|
+
const navigation = root.querySelectorAll("a.qnbutton").map((button) => {
|
|
28365
|
+
const title = button.getAttribute("title") ?? "";
|
|
28366
|
+
const match = title.match(/^(?:Question|Information)?\s*(\S+)\s*-\s*(.+)$/u);
|
|
28367
|
+
return {
|
|
28368
|
+
slot: Number(button.getAttribute("id")?.replace(/^quiznavbutton/u, "") ?? 0),
|
|
28369
|
+
number: match?.[1] ?? cleanText(button.textContent),
|
|
28370
|
+
page: Number(button.getAttribute("data-quiz-page") ?? 0),
|
|
28371
|
+
state: match?.[2] ?? ""
|
|
28372
|
+
};
|
|
28373
|
+
});
|
|
28374
|
+
const questions = form.querySelectorAll("div.que").map(parseAttemptQuestion);
|
|
28375
|
+
return {
|
|
28376
|
+
attempt,
|
|
28377
|
+
quiz_id: quizId,
|
|
28378
|
+
name: pageHeading(root),
|
|
28379
|
+
page: page2,
|
|
28380
|
+
pages: Math.max(page2 + 1, ...navigation.map((entry) => entry.page + 1)),
|
|
28381
|
+
questions,
|
|
28382
|
+
navigation,
|
|
28383
|
+
url: attemptUrl(deps.baseUrl, attempt, quizId, page2),
|
|
28384
|
+
form: { action: resolveUrl(deps.baseUrl, form.getAttribute("action") ?? ""), fields: formFields2(form) }
|
|
28385
|
+
};
|
|
28386
|
+
}
|
|
28387
|
+
function parseAttemptQuestion(que) {
|
|
28388
|
+
const slot = Number(que.getAttribute("id")?.split("-").at(-1) ?? 0);
|
|
28389
|
+
const base = {
|
|
28390
|
+
slot,
|
|
28391
|
+
number: cleanText(que.querySelector(".info .qno, .info .no")?.textContent).replace(/^Question\s*/iu, "") || "i",
|
|
28392
|
+
type: que.classList.value[1] ?? "",
|
|
28393
|
+
kind: "unsupported",
|
|
28394
|
+
state: cleanText(que.querySelector(".info .state")?.textContent),
|
|
28395
|
+
text: blockText(que.querySelector(".qtext"))
|
|
28396
|
+
};
|
|
28397
|
+
if (que.classList.contains("description")) return { ...base, number: "i", kind: "info" };
|
|
28398
|
+
const inputs = que.querySelectorAll("input, textarea, select").filter((input3) => {
|
|
28399
|
+
const name = input3.getAttribute("name") ?? "";
|
|
28400
|
+
return name.startsWith("q") && !/_:(?:flagged|sequencecheck)$|_-seen$|_answerformat$/u.test(name) && !/^(?:hidden|submit)$/u.test(input3.getAttribute("type") ?? "");
|
|
28401
|
+
});
|
|
28402
|
+
const tag = (input3) => input3.tagName.toLowerCase();
|
|
28403
|
+
const type = (input3) => tag(input3) === "input" ? (input3.getAttribute("type") ?? "text").toLowerCase() : tag(input3);
|
|
28404
|
+
const isClearChoice = (input3) => type(input3) === "radio" && (input3.closest(".qtype_multichoice_clearchoice") !== null || input3.getAttribute("aria-hidden") === "true");
|
|
28405
|
+
const radios = inputs.filter((input3) => type(input3) === "radio" && !isClearChoice(input3));
|
|
28406
|
+
const boxes = inputs.filter((input3) => type(input3) === "checkbox");
|
|
28407
|
+
const texts = inputs.filter((input3) => ["textarea", "text", "number"].includes(type(input3)));
|
|
28408
|
+
const selects = inputs.filter((input3) => type(input3) === "select");
|
|
28409
|
+
const others = inputs.length - radios.length - boxes.length - texts.length - selects.length - inputs.filter(isClearChoice).length;
|
|
28410
|
+
const radioNames = new Set(radios.map((input3) => input3.getAttribute("name")));
|
|
28411
|
+
const only = (group) => group.length === inputs.length - inputs.filter(isClearChoice).length && others === 0;
|
|
28412
|
+
if (radios.length && radioNames.size === 1 && only(radios)) {
|
|
28413
|
+
return { ...base, kind: "choice", field: radios[0].getAttribute("name"), options: radios.map((input3, index) => option(que, input3, index)) };
|
|
28414
|
+
}
|
|
28415
|
+
if (boxes.length && only(boxes)) return { ...base, kind: "multi", options: boxes.map((input3, index) => option(que, input3, index)) };
|
|
28416
|
+
if (selects.length === 1 && only(selects)) {
|
|
28417
|
+
const select = selects[0];
|
|
28418
|
+
const name = select.getAttribute("name");
|
|
28419
|
+
const choices = select.querySelectorAll("option").filter((item) => (item.getAttribute("value") ?? "") !== "");
|
|
28420
|
+
return { ...base, kind: "choice", field: name, options: choices.map((item, index) => ({ key: String.fromCharCode(97 + index), field: name, value: item.getAttribute("value") ?? "", text: cleanText(item.textContent), chosen: item.hasAttribute("selected") })) };
|
|
28421
|
+
}
|
|
28422
|
+
if (texts.length === 1 && only(texts)) {
|
|
28423
|
+
const text = texts[0];
|
|
28424
|
+
const html = tag(text) === "textarea";
|
|
28425
|
+
return { ...base, kind: "text", field: text.getAttribute("name"), answer: html ? blockText((0, import_node_html_parser4.parse)(text.textContent)) : cleanText(text.getAttribute("value") ?? "") };
|
|
28426
|
+
}
|
|
28427
|
+
return base;
|
|
28428
|
+
}
|
|
28429
|
+
function option(que, input3, index) {
|
|
28430
|
+
const labelId = input3.getAttribute("aria-labelledby");
|
|
28431
|
+
const id2 = input3.getAttribute("id");
|
|
28432
|
+
const label = (labelId ? que.querySelector(`[id="${labelId}"]`) : null) ?? (id2 ? que.querySelector(`label[for="${id2}"]`) : null) ?? input3.parentNode;
|
|
28433
|
+
const text = blockText(label) || label?.querySelectorAll("img").map((img) => cleanText(img.getAttribute("alt"))).filter(Boolean).join(" ") || "(image; see the quiz in a browser)";
|
|
28434
|
+
return { key: String.fromCharCode(97 + index), field: input3.getAttribute("name") ?? "", value: input3.getAttribute("value") ?? "", text, chosen: input3.hasAttribute("checked") };
|
|
28435
|
+
}
|
|
28436
|
+
function encodeAnswer(deps, form, question2, value) {
|
|
28437
|
+
const raw = value.trim();
|
|
28438
|
+
if (question2.kind === "info") throw deps.usage(`Question ${question2.number} is an information block; it takes no answer.`);
|
|
28439
|
+
if (question2.kind === "unsupported" || !question2.field && question2.kind !== "multi") throw deps.usage(`Question ${question2.number} is a ${question2.type || "question"} type the CLI cannot answer.`, "Answer it in a browser; other questions can still be answered here.");
|
|
28440
|
+
if (question2.kind === "text") {
|
|
28441
|
+
if (!raw) throw deps.usage(`Question ${question2.number} needs a written answer.`);
|
|
28442
|
+
const format = form.fields.find(([name]) => name === `${question2.field}format`)?.[1];
|
|
28443
|
+
return [...form.fields.filter(([name]) => name !== question2.field), [question2.field, format === "1" ? paragraphs(value) : raw]];
|
|
28444
|
+
}
|
|
28445
|
+
const options = question2.options ?? [];
|
|
28446
|
+
const picks = raw.split(",").map((part) => part.trim()).filter(Boolean).map((part) => {
|
|
28447
|
+
const match = options.find((item) => item.key === part.toLowerCase()) ?? options.find((item) => cleanText(item.text).toLowerCase() === part.toLowerCase());
|
|
28448
|
+
if (!match) throw deps.usage(`Question ${question2.number} has no option '${part}'.`, `Choose from ${options.map((item) => item.key).join(", ")}.`);
|
|
28449
|
+
return match;
|
|
28450
|
+
});
|
|
28451
|
+
if (!picks.length) throw deps.usage(`Question ${question2.number} needs an option letter.`, `Choose from ${options.map((item) => item.key).join(", ")}.`);
|
|
28452
|
+
if (question2.kind === "choice") {
|
|
28453
|
+
if (picks.length > 1) throw deps.usage(`Question ${question2.number} takes one option, not ${picks.length}.`);
|
|
28454
|
+
return [...form.fields.filter(([name]) => name !== question2.field), [question2.field, picks[0].value]];
|
|
28455
|
+
}
|
|
28456
|
+
const chosen = new Set(picks.map((pick2) => pick2.key));
|
|
28457
|
+
const boxNames = new Set(options.map((item) => item.field));
|
|
28458
|
+
return [
|
|
28459
|
+
...form.fields.filter(([name]) => !boxNames.has(name)),
|
|
28460
|
+
...options.map((item) => [item.field, chosen.has(item.key) ? "1" : "0"])
|
|
28461
|
+
];
|
|
28462
|
+
}
|
|
28463
|
+
function noticesOf2(html) {
|
|
28464
|
+
const root = (0, import_node_html_parser4.parse)(html);
|
|
28465
|
+
const texts = [];
|
|
28466
|
+
for (const node2 of root.querySelectorAll(".alert, .errorbox, .error, #notice")) {
|
|
28467
|
+
for (const junk of node2.querySelectorAll("button, .close")) junk.remove();
|
|
28468
|
+
const text = cleanText(node2.textContent);
|
|
28469
|
+
if (text && !texts.includes(text)) texts.push(text);
|
|
28470
|
+
}
|
|
28471
|
+
return texts.join(" ");
|
|
28472
|
+
}
|
|
28473
|
+
function formWithAction2(root, path) {
|
|
28474
|
+
return root.querySelectorAll("form").find((form) => onPath(form.getAttribute("action") ?? "", path)) ?? null;
|
|
28475
|
+
}
|
|
28476
|
+
function formFields2(form) {
|
|
28477
|
+
const fields2 = [];
|
|
28478
|
+
for (const element of form.querySelectorAll("input, textarea, select")) {
|
|
28479
|
+
const name = element.getAttribute("name");
|
|
28480
|
+
if (!name) continue;
|
|
28481
|
+
const tag = element.tagName.toLowerCase();
|
|
28482
|
+
if (tag === "textarea") {
|
|
28483
|
+
fields2.push([name, element.textContent]);
|
|
28484
|
+
continue;
|
|
28485
|
+
}
|
|
28486
|
+
if (tag === "select") {
|
|
28487
|
+
const options = element.querySelectorAll("option");
|
|
28488
|
+
const chosen = options.find((item) => item.hasAttribute("selected")) ?? options[0];
|
|
28489
|
+
if (chosen) fields2.push([name, chosen.getAttribute("value") ?? cleanText(chosen.textContent)]);
|
|
28490
|
+
continue;
|
|
28491
|
+
}
|
|
28492
|
+
const type = (element.getAttribute("type") ?? "text").toLowerCase();
|
|
28493
|
+
if (["submit", "button", "image", "file", "reset"].includes(type)) continue;
|
|
28494
|
+
if ((type === "checkbox" || type === "radio") && !element.hasAttribute("checked")) continue;
|
|
28495
|
+
fields2.push([name, element.getAttribute("value") ?? (type === "checkbox" ? "on" : "")]);
|
|
28496
|
+
}
|
|
28497
|
+
return fields2;
|
|
28498
|
+
}
|
|
28499
|
+
function postInit(fields2) {
|
|
28500
|
+
return { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(fields2).toString() };
|
|
28501
|
+
}
|
|
28502
|
+
async function pageText2(deps, url2) {
|
|
28503
|
+
return (await deps.request(url2)).text();
|
|
28504
|
+
}
|
|
28505
|
+
function attemptUrl(baseUrl, attempt, quizId, page2) {
|
|
28506
|
+
return `${baseUrl}${QUIZ_ATTEMPT_PATH}?attempt=${attempt}&cmid=${quizId}${page2 ? `&page=${page2}` : ""}`;
|
|
28507
|
+
}
|
|
28508
|
+
function pageHeading(root) {
|
|
28509
|
+
const heading = cleanText(root.querySelector(".page-header-headings h1, #page-header h1")?.textContent);
|
|
28510
|
+
if (heading) return heading;
|
|
28511
|
+
const title = cleanText(root.querySelector("title")?.textContent).replace(/\s*\(page \d+ of \d+\)/iu, "").split(" | ")[0].trim();
|
|
28512
|
+
return title || cleanText(root.querySelector("h2")?.textContent);
|
|
28513
|
+
}
|
|
28514
|
+
function onPath(url2, path) {
|
|
28515
|
+
try {
|
|
28516
|
+
return new URL(url2, "https://moodle.invalid").pathname.endsWith(path);
|
|
28517
|
+
} catch {
|
|
28518
|
+
return false;
|
|
28519
|
+
}
|
|
28520
|
+
}
|
|
28521
|
+
function numberParam(url2, key) {
|
|
28522
|
+
try {
|
|
28523
|
+
const value = Number(new URL(url2, "https://moodle.invalid").searchParams.get(key));
|
|
28524
|
+
return Number.isSafeInteger(value) && value > 0 ? value : key === "page" && value === 0 ? 0 : null;
|
|
28525
|
+
} catch {
|
|
28526
|
+
return null;
|
|
28527
|
+
}
|
|
28528
|
+
}
|
|
28529
|
+
function paragraphs(text) {
|
|
28530
|
+
const escaped = text.trim().replace(/&/gu, "&").replace(/</gu, "<").replace(/>/gu, ">");
|
|
28531
|
+
return escaped.split(/\n\s*\n/u).map((block) => `<p>${block.trim().replace(/\n/gu, "<br>")}</p>`).join("");
|
|
28532
|
+
}
|
|
28533
|
+
|
|
28121
28534
|
// src/parsers.ts
|
|
28122
28535
|
function schema(parser) {
|
|
28123
28536
|
return { parse: parser };
|
|
@@ -29118,6 +29531,10 @@ var MoodleClientCore = class {
|
|
|
29118
29531
|
async getQuiz(id2) {
|
|
29119
29532
|
return parseQuizHtml(await this.get(QUIZ_VIEW_PATH, { id: id2 }), id2, this.baseUrl);
|
|
29120
29533
|
}
|
|
29534
|
+
async getQuizAttempt(attemptId) {
|
|
29535
|
+
await this.ensureSession();
|
|
29536
|
+
return parseQuizReviewHtml(await this.get(QUIZ_REVIEW_PATH, { attempt: attemptId, showall: 1 }), attemptId, this.baseUrl);
|
|
29537
|
+
}
|
|
29121
29538
|
async getResource(id2) {
|
|
29122
29539
|
const url2 = `${this.baseUrl}${RESOURCE_VIEW_PATH}?id=${id2}`;
|
|
29123
29540
|
const response = await this.requestAbsolute(url2);
|
|
@@ -29166,6 +29583,32 @@ var MoodleClientCore = class {
|
|
|
29166
29583
|
usage: (message, hint) => this.errors.usage ? this.errors.usage(message, hint) : new MoodleClientCoreError("usage", message, hint)
|
|
29167
29584
|
}, request);
|
|
29168
29585
|
}
|
|
29586
|
+
/** Starts a new attempt, or resumes the one already in progress, and returns its first page. */
|
|
29587
|
+
async startQuizAttempt(quizId, options = {}) {
|
|
29588
|
+
return startQuizAttempt(await this.quizDeps(), quizId, options);
|
|
29589
|
+
}
|
|
29590
|
+
async getQuizAttemptPage(attemptId, quizId, page2 = 0) {
|
|
29591
|
+
return getAttemptPage(await this.quizDeps(), attemptId, quizId, page2);
|
|
29592
|
+
}
|
|
29593
|
+
async getQuizAttemptSummary(attemptId, quizId) {
|
|
29594
|
+
return getAttemptSummary(await this.quizDeps(), attemptId, quizId);
|
|
29595
|
+
}
|
|
29596
|
+
async answerQuizQuestion(request) {
|
|
29597
|
+
return answerQuizQuestion(await this.quizDeps(), request);
|
|
29598
|
+
}
|
|
29599
|
+
/** Submits the attempt for grading. Moodle treats this as final. */
|
|
29600
|
+
async finishQuizAttempt(attemptId, quizId) {
|
|
29601
|
+
return finishQuizAttempt(await this.quizDeps(), attemptId, quizId);
|
|
29602
|
+
}
|
|
29603
|
+
async quizDeps() {
|
|
29604
|
+
await this.ensureSession();
|
|
29605
|
+
return {
|
|
29606
|
+
baseUrl: this.baseUrl,
|
|
29607
|
+
request: (url2, init, options) => this.requestAbsolute(url2, init, options),
|
|
29608
|
+
fail: (message, moodleErrorCode) => this.errors.api(message, moodleErrorCode),
|
|
29609
|
+
usage: (message, hint) => this.errors.usage ? this.errors.usage(message, hint) : new MoodleClientCoreError("usage", message, hint)
|
|
29610
|
+
};
|
|
29611
|
+
}
|
|
29169
29612
|
async getNewsForums(courseId) {
|
|
29170
29613
|
const units = courseId === void 0 ? await this.getCourses() : (await this.getCourses()).filter((c) => c.id === courseId);
|
|
29171
29614
|
const forums = [];
|
|
@@ -29527,7 +29970,7 @@ function safeUrl(value) {
|
|
|
29527
29970
|
}
|
|
29528
29971
|
|
|
29529
29972
|
// src/mcp/gateway.ts
|
|
29530
|
-
var
|
|
29973
|
+
var import_node_html_parser5 = __toESM(require_dist(), 1);
|
|
29531
29974
|
var MAX_MCP_FILE_BYTES = 16 * 1024 * 1024;
|
|
29532
29975
|
var MoodleGatewayError = class extends Error {
|
|
29533
29976
|
code;
|
|
@@ -29562,6 +30005,7 @@ function createMoodleGateway(client, hooks = {}) {
|
|
|
29562
30005
|
return limit2 === void 0 ? activities : activities.slice(0, limit2);
|
|
29563
30006
|
},
|
|
29564
30007
|
getActivity: ({ activityId }) => client.getActivity(activityId),
|
|
30008
|
+
...client.getQuizAttempt ? { getQuizAttempt: (attemptId) => client.getQuizAttempt(attemptId) } : {},
|
|
29565
30009
|
getGrades: ({ courseId }) => client.getCourseGrades(courseId),
|
|
29566
30010
|
async listForums({ courseId, limit: limit2 }) {
|
|
29567
30011
|
const forums = await client.getForums(courseId);
|
|
@@ -29602,7 +30046,9 @@ function createMoodleGateway(client, hooks = {}) {
|
|
|
29602
30046
|
name,
|
|
29603
30047
|
mimeType: contentType(response),
|
|
29604
30048
|
bytes: content.byteLength,
|
|
29605
|
-
|
|
30049
|
+
// The Moodle-origin URL, never the post-redirect CDN one: stripping query parameters from
|
|
30050
|
+
// a signed CDN link leaves an address that can only ever answer MissingKey.
|
|
30051
|
+
uri: publicFileUrl(target.url),
|
|
29606
30052
|
blob: encodeBase64(content)
|
|
29607
30053
|
};
|
|
29608
30054
|
}
|
|
@@ -29668,7 +30114,7 @@ async function fileFromActivity(client, activityId) {
|
|
|
29668
30114
|
return { url: entry.url, name: entry.name };
|
|
29669
30115
|
}
|
|
29670
30116
|
function resourceLinks(html, baseUrl) {
|
|
29671
|
-
const root = (0,
|
|
30117
|
+
const root = (0, import_node_html_parser5.parse)(html);
|
|
29672
30118
|
const entries = root.querySelectorAll(".resourceworkaround a[href], .resourcecontent a[href], a.resourceworkaround[href]").map((link) => ({
|
|
29673
30119
|
name: link.textContent.trim(),
|
|
29674
30120
|
url: new URL(link.getAttribute("href") ?? "", baseUrl).toString()
|
|
@@ -29722,7 +30168,7 @@ function isHtml(response) {
|
|
|
29722
30168
|
return type.includes("text/html") || type.includes("application/xhtml+xml");
|
|
29723
30169
|
}
|
|
29724
30170
|
function looksLikeLoginPage(html) {
|
|
29725
|
-
const root = (0,
|
|
30171
|
+
const root = (0, import_node_html_parser5.parse)(html);
|
|
29726
30172
|
return root.querySelector('form[action*="/login/"], input[name="password"], #page-login-index') !== null || /<title>\s*(?:log in|login)/iu.test(html);
|
|
29727
30173
|
}
|
|
29728
30174
|
function contentType(response) {
|
|
@@ -29786,7 +30232,10 @@ var fields = (keys) => Object.fromEntries(keys.map((key) => [key, s]));
|
|
|
29786
30232
|
var file2 = external_exports.object({ name: s, url: s, requires_authentication: external_exports.boolean().optional() });
|
|
29787
30233
|
var activityFields = { id, name: s, type: s, unit_id: n, section_id: n, hidden: external_exports.boolean().optional(), due: s, due_at: n, files: external_exports.array(file2).optional() };
|
|
29788
30234
|
var activityListSchema = external_exports.object({ ...activityFields, section: s, unit_code: s, description: s });
|
|
29789
|
-
var
|
|
30235
|
+
var criterion = external_exports.object({ name: s, level: s, score: s, remark: s });
|
|
30236
|
+
var attemptSummary = { id, status: s, started: s, completed: s, duration: s, marks: s, grade: s };
|
|
30237
|
+
var question = external_exports.object({ number: id, type: s, state: s, mark: s, text: s, response: s, correct: s, feedback: s });
|
|
30238
|
+
var activitySchema = external_exports.object({ ...activityFields, ...fields(["url", "target_url", "section", "unit_code", "description", "submission_status", "grading_status", "grade", "graded_on", "graded_by", "feedback_comments", "due_pretty", "opens_pretty", "closes_pretty", "attempts_allowed", "time_limit", "availability", "time_remaining", "content_text"]), criteria: external_exports.array(criterion).optional(), attempts: external_exports.array(external_exports.object({ ...attemptSummary, number: id })).optional(), files: external_exports.array(file2).optional() });
|
|
29790
30239
|
var sectionSchema = external_exports.object({ id, name: s, activity_count: id, hidden: external_exports.boolean().optional(), positional: external_exports.boolean().optional(), activities: external_exports.array(activityListSchema).optional() });
|
|
29791
30240
|
var current = external_exports.object({ id, name: s, estimated: external_exports.boolean().optional() });
|
|
29792
30241
|
var unitSchema = external_exports.object({ id, code: s, name: s, start: s, end: s, start_at: n, end_at: n, hidden: external_exports.boolean().optional(), current_section: current.optional() });
|
|
@@ -29800,22 +30249,27 @@ var receiptSchema = external_exports.object({ id, name: s, unit_id: n, url: s, a
|
|
|
29800
30249
|
var input2 = (shape) => external_exports.object(shape).strict();
|
|
29801
30250
|
var list = (key, value) => external_exports.object({ [key]: external_exports.array(value).optional(), total: id });
|
|
29802
30251
|
var intentContracts = {
|
|
29803
|
-
home: { when: "dashboard", command: "moodle", what: "Today
|
|
30252
|
+
home: { when: "dashboard", command: "moodle", what: "Today's date and timezone, items due soon, unread counts and the current section of each unit.", instead: "due for longer deadline lists", refs: "days defaults to 14", then: "unit or item", cost: "small dashboard", input: input2({ days: external_exports.number().int().min(1).max(365).default(14) }), output: external_exports.object({ home: external_exports.object({ today: external_exports.string(), timezone: external_exports.string(), timezone_source: s, name: s, siteurl: s, units: external_exports.array(unitSchema).optional(), due: external_exports.array(dueSchema).optional(), total: id, unread: counts.optional(), errors: external_exports.array(external_exports.string()).optional() }) }) },
|
|
29804
30253
|
due: { when: "deadlines", command: "moodle due [UNIT] --days 14", what: "Items due in a date window.", instead: "item for submission details", refs: "unit code, name, id or URL", then: "item with activity_id", cost: "up to 20 rows by default", input: input2({ unit: ref.optional(), days: external_exports.number().int().min(1).max(365).default(14), limit }), output: list("due", dueSchema) },
|
|
29805
30254
|
units: { when: "unit names", command: "moodle units", what: "Enrolled units with ids, codes and names.", instead: "unit for sections", refs: "none", then: "unit with a name or id", cost: "small list", input: input2({ limit: limit.default(200) }), output: list("units", unitSchema) },
|
|
29806
30255
|
unit: { when: "a unit or section", command: "moodle UNIT [SECTION]", what: "Section index and current section; section argument returns activities and files.", instead: "find for a named item", refs: "unit code, name, id or URL; section number or name", then: "item or file with activity id", cost: "index about 4 KB; section about 0.5 KB", input: input2({ unit: ref, section: ref.optional() }), output: external_exports.object({ unit: unitSchema, sections: external_exports.array(sectionSchema).optional(), total: id }) },
|
|
29807
30256
|
find: { when: "find slides or a task", command: 'moodle find "QUERY" [UNIT]', what: "Ranked sections, activities and discussion subjects.", instead: "search_forums for post text; due for deadlines", refs: "query and optional unit code, name, id or URL", then: "item or file with id", cost: "up to 20 rows by default", input: input2({ query: external_exports.string().trim().min(1), unit: ref.optional(), types: external_exports.array(external_exports.string()).optional(), limit }), output: list("results", activityListSchema) },
|
|
29808
|
-
item: { when: "submission or item detail", command: 'moodle UNIT "TASK"', what: "
|
|
29809
|
-
|
|
30257
|
+
item: { when: "submission or item detail", command: 'moodle UNIT "TASK"', what: "One activity in full: due date, status, grade, marker feedback with rubric or marking-guide rows and feedback files, attached files, quiz attempts, or a forum's latest threads.", instead: "file for binary content; attempt for quiz answers", refs: "id, same-site URL, or UNIT TASK phrase", then: "file with a file id, or attempt with an attempt id", cost: "one item", input: input2({ ref }), output: external_exports.object({ item: activitySchema, threads: external_exports.array(external_exports.object({ id, name: s })).optional(), total: n }) },
|
|
30258
|
+
attempt: { when: "a quiz attempt", command: "moodle attempt ID", what: "Each question with your response, and mark, correct answer and feedback when the site shows them.", instead: "item for the attempt list", refs: "attempt id or review URL", then: "item with quiz_id", cost: "one attempt", input: input2({ attempt: ref }), output: external_exports.object({ attempt: external_exports.object({ ...attemptSummary, quiz_id: n, unit_id: n, url: s, questions: external_exports.array(question).optional() }) }) },
|
|
30259
|
+
grades: { when: "my grades", command: "moodle grades [UNIT]", what: "Gradebook rows across units or in one unit, with grade, range, percentage and grader feedback.", instead: "item for submission status and rubric detail", refs: "optional unit code, name, id or URL", then: "item for an ungraded task", cost: "graded_only narrows output", input: input2({ unit: ref.optional(), graded_only: external_exports.boolean().default(false) }), output: list("grades", gradeSchema) },
|
|
29810
30260
|
news: { when: "announcements", command: "moodle news [UNIT]", what: "Latest announcement threads with first-post text.", instead: "search_forums for other discussions", refs: "optional unit code, name, id or URL", then: "thread with discussion id", cost: "up to 5 announcements by default", input: input2({ unit: ref.optional(), limit: limit.default(5) }), output: list("news", external_exports.object({ id, name: s, unit_id: id, unit_code: s, forum_id: id, post: postSchema.optional() })) },
|
|
29811
30261
|
thread: { when: "discussion posts", command: "moodle threads show ID", what: "A discussion and a page of compact posts, with attachment links.", instead: "news for announcements", refs: "discussion_id; offset and limit", then: "increase offset while posts_total exceeds returned", cost: "up to 20 posts by default", input: input2({ discussion_id: external_exports.number().int().positive(), limit, offset: external_exports.number().int().nonnegative().default(0) }), output: external_exports.object({ thread: external_exports.object({ id, name: s, unit_id: id, forum_id: id, url: s, posts: external_exports.array(postSchema).optional(), posts_total: id, offset: id }) }) },
|
|
29812
30262
|
search_forums: { when: "forum post text", command: 'moodle forums search "QUERY" --unit UNIT', what: "Matching posts with unit and forum name maps.", instead: "find for activity names", refs: "query, optional unit or courseId and forumId", then: "thread with discussion_id", cost: "bounded forum scan; total covers scanned scope", input: input2({ query: external_exports.string().trim().min(1), unit: ref.optional(), courseId: external_exports.number().int().positive().optional(), forumId: external_exports.number().int().positive().optional(), limit, includePostText: external_exports.boolean().default(true), titlesOnly: external_exports.boolean().default(false), unreadOnly: external_exports.boolean().default(false), sortBy: external_exports.enum(["relevance", "recent"]).default("relevance"), maxForums: limit.default(20), maxDiscussionsPerForum: limit.default(50) }), output: external_exports.object({ results: external_exports.array(searchSchema).optional(), total: id, forums: external_exports.record(external_exports.string(), external_exports.string()).optional(), units: external_exports.record(external_exports.string(), external_exports.string()).optional(), scope: external_exports.object({ max_forums: id, max_discussions_per_forum: id }) }) },
|
|
29813
30263
|
submit: { when: "upload assignment files", command: 'moodle submit "UNIT TASK" FILE... [--final]', what: "Upload local files into an assignment; returns the receipt Moodle shows afterwards.", instead: "item for status only", refs: "assignment id, same-site URL or UNIT TASK phrase; local file paths", then: "dry_run (default) only plans; show the plan to the person, then rerun with dry_run false; final submits for grading and cannot be undone", cost: "writes to Moodle", input: input2({ ref, files: external_exports.array(external_exports.string().trim().min(1)).max(20).default([]), final: external_exports.boolean().default(false), replace: external_exports.boolean().default(false), accept_statement: external_exports.boolean().default(false), dry_run: external_exports.boolean().default(true) }), output: external_exports.object({ submission: receiptSchema }) },
|
|
29814
|
-
file: { when: "download a file", command: 'moodle get "UNIT TASK" --to DIR', what: "One authenticated file as
|
|
30264
|
+
file: { when: "download a file", command: 'moodle get "UNIT TASK" --to DIR', what: "One authenticated file: an image as image content, anything else as embedded binary, at most 16 MiB.", instead: "item for file choices", refs: "resource id, same-site URL, or UNIT TASK phrase", then: "read the returned image or resource", cost: "binary content up to 16 MiB", input: input2({ ref }), output: external_exports.object({ file: external_exports.object({ name: external_exports.string(), mime_type: external_exports.string(), bytes: id, uri: external_exports.string() }) }) }
|
|
29815
30265
|
};
|
|
29816
30266
|
function intentDescription(name) {
|
|
29817
30267
|
const c = intentContracts[name];
|
|
29818
|
-
|
|
30268
|
+
const alternatives = c.instead.split("; ").map((rule) => {
|
|
30269
|
+
const [tool, ...purpose] = rule.split(" for ");
|
|
30270
|
+
return `for ${purpose.join(" for ")} call ${tool}`;
|
|
30271
|
+
}).join("; ");
|
|
30272
|
+
return `${c.what} Use it for ${c.when}; ${alternatives}. Input: ${c.refs}. Next: ${c.then}. Cost: ${c.cost}.`;
|
|
29819
30273
|
}
|
|
29820
30274
|
|
|
29821
30275
|
// src/results.ts
|
|
@@ -29975,6 +30429,15 @@ function createIntentService(gateway, now = () => Date.now()) {
|
|
|
29975
30429
|
result = { item: { ...itemRow(activity), ...due }, threads: threads?.slice(0, 20).map((t) => ({ id: t.id, name: t.subject })), total: threads?.length };
|
|
29976
30430
|
break;
|
|
29977
30431
|
}
|
|
30432
|
+
case "attempt": {
|
|
30433
|
+
if (!gateway.getQuizAttempt) throw new ReferenceError2("not_found", "This gateway cannot read quiz attempts.", []);
|
|
30434
|
+
const raw = String(input3.attempt);
|
|
30435
|
+
const id2 = raw.includes("://") ? Number(new URL(raw).searchParams.get("attempt")) : Number(raw);
|
|
30436
|
+
if (!Number.isInteger(id2) || id2 <= 0) throw new ReferenceError2("not_found", "Use a quiz attempt id or a review URL with ?attempt=.", []);
|
|
30437
|
+
const { course_id, questions, ...attempt } = await gateway.getQuizAttempt(id2);
|
|
30438
|
+
result = { attempt: { ...attempt, unit_id: course_id || void 0, questions } };
|
|
30439
|
+
break;
|
|
30440
|
+
}
|
|
29978
30441
|
case "home":
|
|
29979
30442
|
case "due": {
|
|
29980
30443
|
const data = name === "due" && gateway.getDue ? { user: await user(), courses: await courses(), todo: await gateway.getDue(Number(input3.days)), errors: [] } : await overview(Number(input3.days));
|
|
@@ -30141,7 +30604,10 @@ function createMoodleMcpServer(gateway, options = {}) {
|
|
|
30141
30604
|
protocolVersion,
|
|
30142
30605
|
capabilities: { tools: { listChanged: false } },
|
|
30143
30606
|
serverInfo,
|
|
30144
|
-
instructions:
|
|
30607
|
+
instructions: [
|
|
30608
|
+
gateway.submitAssignment ? "Access to the authenticated user's Moodle data. Only submit writes; it defaults to a dry run." : "Read-only access to the authenticated user's Moodle data.",
|
|
30609
|
+
...options.instructions ?? []
|
|
30610
|
+
].join(" ")
|
|
30145
30611
|
});
|
|
30146
30612
|
}
|
|
30147
30613
|
if (request.method === "ping") {
|
|
@@ -30262,6 +30728,9 @@ async function callTool(gateway, params) {
|
|
|
30262
30728
|
function toolContent(name, payload, structuredContent) {
|
|
30263
30729
|
const text = { type: "text", text: JSON.stringify(structuredContent) };
|
|
30264
30730
|
if (name !== "get_file" || !isMoodleFile(payload)) return [text];
|
|
30731
|
+
if (payload.mimeType.startsWith("image/")) {
|
|
30732
|
+
return [text, { type: "image", data: payload.blob, mimeType: payload.mimeType }];
|
|
30733
|
+
}
|
|
30265
30734
|
return [
|
|
30266
30735
|
text,
|
|
30267
30736
|
{
|
|
@@ -30307,6 +30776,40 @@ function isMoodleFile(value) {
|
|
|
30307
30776
|
return isRecord8(value) && typeof value.name === "string" && typeof value.mimeType === "string" && typeof value.bytes === "number" && typeof value.uri === "string" && typeof value.blob === "string";
|
|
30308
30777
|
}
|
|
30309
30778
|
|
|
30779
|
+
// src/update-core.ts
|
|
30780
|
+
var PACKAGE_NAME2 = "moodle-cli";
|
|
30781
|
+
var LATEST_VERSION_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME2}/dist-tags`;
|
|
30782
|
+
var UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
30783
|
+
var UPDATE_RETRY_MS = 60 * 60 * 1e3;
|
|
30784
|
+
function compareVersions(a, b) {
|
|
30785
|
+
const [aMain, aPre] = a.split("-", 2);
|
|
30786
|
+
const [bMain, bPre] = b.split("-", 2);
|
|
30787
|
+
const left = aMain.split(".").map(Number);
|
|
30788
|
+
const right = bMain.split(".").map(Number);
|
|
30789
|
+
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
30790
|
+
const diff = (left[index] ?? 0) - (right[index] ?? 0);
|
|
30791
|
+
if (diff !== 0) return Math.sign(diff);
|
|
30792
|
+
}
|
|
30793
|
+
if (Boolean(aPre) === Boolean(bPre)) return (aPre ?? "").localeCompare(bPre ?? "");
|
|
30794
|
+
return aPre ? -1 : 1;
|
|
30795
|
+
}
|
|
30796
|
+
function isNewerVersion(candidate, current2) {
|
|
30797
|
+
return Boolean(candidate) && /^\d+\.\d+\.\d+/u.test(candidate) && compareVersions(candidate, current2) > 0;
|
|
30798
|
+
}
|
|
30799
|
+
async function fetchLatestVersion(fetchImpl = fetch, timeoutMs = 5e3) {
|
|
30800
|
+
try {
|
|
30801
|
+
const response = await fetchImpl(LATEST_VERSION_URL, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(timeoutMs) });
|
|
30802
|
+
if (!response.ok) return null;
|
|
30803
|
+
const tags = await response.json();
|
|
30804
|
+
return typeof tags.latest === "string" ? tags.latest : null;
|
|
30805
|
+
} catch {
|
|
30806
|
+
return null;
|
|
30807
|
+
}
|
|
30808
|
+
}
|
|
30809
|
+
function updateHint(current2, latest) {
|
|
30810
|
+
return `moodle-cli ${latest} is available (running ${current2}). Run: moodle update`;
|
|
30811
|
+
}
|
|
30812
|
+
|
|
30310
30813
|
// src/worker/moodle-upstream.ts
|
|
30311
30814
|
var DASHBOARD_PATH2 = "/my/";
|
|
30312
30815
|
var AJAX_PATH = "/lib/ajax/service.php";
|
|
@@ -30417,6 +30920,7 @@ var DEFAULT_TOUCH_DELAY_MS = 30 * 60 * 1e3;
|
|
|
30417
30920
|
var MIN_TOUCH_DELAY_MS = 60 * 1e3;
|
|
30418
30921
|
var MAX_BACKOFF_MS = 30 * 60 * 1e3;
|
|
30419
30922
|
var SESSION_STALE_MS = 24 * 60 * 60 * 1e3;
|
|
30923
|
+
var LATEST_VERSION_KEY = "latest_version";
|
|
30420
30924
|
var SESSION_EXPIRING_MS = 15 * 60 * 1e3;
|
|
30421
30925
|
var SessionBroker = class {
|
|
30422
30926
|
constructor(state, env, dependencies) {
|
|
@@ -30469,7 +30973,10 @@ var SessionBroker = class {
|
|
|
30469
30973
|
userid: session.moodle_user_id,
|
|
30470
30974
|
fetchImpl: this.dependencies.fetchImpl
|
|
30471
30975
|
});
|
|
30472
|
-
const
|
|
30976
|
+
const initializing = isRecord10(envelope.request) && envelope.request.method === "initialize";
|
|
30977
|
+
const latest = initializing ? await this.latestVersion(true) : void 0;
|
|
30978
|
+
const instructions = latest && isNewerVersion(latest, VERSION) ? [`${updateHint(VERSION, latest)} to redeploy this server.`] : void 0;
|
|
30979
|
+
const server = createMoodleMcpServer(createMoodleGateway(client), { instructions });
|
|
30473
30980
|
const response = await server.handle(envelope.request, envelope.context);
|
|
30474
30981
|
return Response.json({ response }, { headers: { "cache-control": "private, no-store" } });
|
|
30475
30982
|
}
|
|
@@ -30480,10 +30987,22 @@ var SessionBroker = class {
|
|
|
30480
30987
|
await this.state.storage.setAlarm(this.now() + MAX_BACKOFF_MS);
|
|
30481
30988
|
}
|
|
30482
30989
|
}
|
|
30990
|
+
// The Worker can only report a newer release; deploying one needs the owner's
|
|
30991
|
+
// Cloudflare credentials, which stay on their machine.
|
|
30992
|
+
async latestVersion(refresh) {
|
|
30993
|
+
const cached2 = await this.state.storage.get(LATEST_VERSION_KEY);
|
|
30994
|
+
if (cached2 && this.now() - cached2.checked_at < UPDATE_CHECK_TTL_MS) return cached2.latest;
|
|
30995
|
+
if (!refresh) return cached2?.latest;
|
|
30996
|
+
const latest = await fetchLatestVersion(this.dependencies.fetchImpl, 3e3);
|
|
30997
|
+
if (!latest) return cached2?.latest;
|
|
30998
|
+
await this.state.storage.put(LATEST_VERSION_KEY, { latest, checked_at: this.now() });
|
|
30999
|
+
return latest;
|
|
31000
|
+
}
|
|
30483
31001
|
async ready() {
|
|
30484
31002
|
const session = await this.loadSession();
|
|
30485
31003
|
const health = readiness(session, this.now());
|
|
30486
|
-
|
|
31004
|
+
const latest = await this.latestVersion(false);
|
|
31005
|
+
return Response.json({ ...health, version: VERSION, ...latest ? { latest_version: latest } : {}, encryptionKeyId: (await this.keyring()).current.id, ...this.env.SESSION_CREDENTIAL_ID ? { credentialId: this.env.SESSION_CREDENTIAL_ID } : {} }, {
|
|
30487
31006
|
status: health.status === "fail" ? 503 : 200,
|
|
30488
31007
|
headers: { "content-type": "application/health+json; charset=utf-8" }
|
|
30489
31008
|
});
|