moodle-cli 0.9.2 → 0.9.4

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.
@@ -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 parse8(formula) {
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 = parse8;
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 = parse8(content, this._parseOptions);
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 = parse8(content, options);
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 = parse8(node2, this._parseOptions);
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 = parse8(html, this._parseOptions);
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 parse8(this.toString(), this._parseOptions).firstChild;
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 parse8(data, options = {}) {
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 = parse8;
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 parse8(data, options = {}) {
5940
+ function parse9(data, options = {}) {
5941
5941
  return (0, parse_1.default)(data, options);
5942
5942
  }
5943
- exports.default = parse8;
5944
- exports.parse = parse8;
5945
- parse8.parse = parse_1.default;
5946
- parse8.HTMLElement = html_1.default;
5947
- parse8.CommentNode = comment_1.default;
5948
- parse8.valid = valid_1.default;
5949
- parse8.Node = node_1.default;
5950
- parse8.TextNode = text_1.default;
5951
- parse8.NodeType = type_1.default;
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
- pairingOpen: false,
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
- pairingOpen: remaining > 0,
6260
- message: remaining > 0 ? `That pairing code is not correct. ${remaining} ${remaining === 1 ? "attempt remains" : "attempts remain"}.` : "Too many incorrect attempts. Run `moodle mcp pair` again to open a new pairing window."
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 = `body{font:16px/1.5 system-ui,sans-serif;margin:0;padding:3rem 1.25rem;background:#f6f7f9;color:#16181d}
6520
- main{max-width:26rem;margin:0 auto;background:#fff;border:1px solid #dfe2e8;border-radius:12px;padding:1.75rem}
6521
- h1{font-size:1.15rem;margin:0 0 .75rem}dl{margin:0 0 1.25rem;font-size:.9rem}dt{color:#5b6472;margin-top:.5rem}
6522
- dd{margin:0;word-break:break-all}input{width:100%;box-sizing:border-box;font:1.25rem/1 ui-monospace,monospace;
6523
- letter-spacing:.18em;text-align:center;padding:.7rem;border:1px solid #c3c8d1;border-radius:8px;text-transform:uppercase}
6524
- button{width:100%;margin-top:1rem;padding:.7rem;font-size:1rem;border:0;border-radius:8px;background:#16181d;color:#fff}
6525
- .note{font-size:.85rem;color:#5b6472;margin-top:1rem}.error{color:#a3111c;font-size:.9rem;margin:0 0 1rem}`;
6526
- function approvalPage(request, params, options) {
6527
- const hidden = ["response_type", "client_id", "redirect_uri", "scope", "state", "code_challenge", "code_challenge_method", "resource"].map((name) => {
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
- const form = options.pairingOpen ? `<form method="post" action="${AUTHORIZE_PATH}">${hidden}
6532
- <label for="pairing_code">Pairing code</label>
6533
- <input id="pairing_code" name="pairing_code" autocomplete="off" autocapitalize="characters" spellcheck="false" autofocus>
6534
- <button type="submit">Approve access</button></form>` : "";
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>Client</dt><dd>${escapeHtml(request.client.clientName)}</dd>
6539
- <dt>Redirect</dt><dd>${escapeHtml(new URL(request.redirectUri).origin)}</dd>
6540
- <dt>Access</dt><dd>Read-only Moodle data (${OAUTH_SCOPE})</dd>
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
- ${options.message ? `<p class="error">${escapeHtml(options.message)}</p>` : ""}
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 parse8 = _parse(_Err);
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 parse8(schema2, value, ctx, finalizeParams(fn, _params));
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 parse8 = _parse(_Err);
8558
+ const parse9 = _parse(_Err);
8542
8559
  const fn = (schema2, value, _ctx, _params) => {
8543
- return parse8(schema2, value, _ctx, finalizeParams(fn, _params));
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((option) => Array.from(option._zod.values)));
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 option of def.options) {
10378
- const result = option._zod.run({
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 option of def.options) {
10438
- const result = option._zod.run({
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 option = map2.get(value);
10464
- if (option === null)
10480
+ const option2 = map2.get(value);
10481
+ if (option2 === null)
10465
10482
  throw new Error(`Ambiguous discriminator value "${String(value)}"`);
10466
- return option;
10483
+ return option2;
10467
10484
  }
10468
10485
  function discriminatorMap(def) {
10469
10486
  const map2 = /* @__PURE__ */ new Map();
10470
- for (const option of def.options) {
10471
- const values = option._zod.propValues?.[def.discriminator];
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(option)}"`);
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, option);
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 option of zod.def.options) {
10494
- const pv = option._zod.propValues;
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(option)}"`);
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((option, i) => {
10513
- const propShape = rawShape(option._zod.def);
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 option of def.options) {
20814
- const values = option._zod.propValues?.[def.discriminator];
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, option, accessor);
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 option of options) {
22411
- if (!option || typeof option !== "object")
22427
+ for (const option2 of options) {
22428
+ if (!option2 || typeof option2 !== "object")
22412
22429
  return;
22413
- compactTypeUnion(option);
22414
- const keys = Object.keys(option);
22430
+ compactTypeUnion(option2);
22431
+ const keys = Object.keys(option2);
22415
22432
  if (keys.length !== 1 || keys[0] !== "type")
22416
22433
  return;
22417
- const type = option.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 option of def.options) {
22708
- if (isTransforming(option, ctx))
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.2";
26472
+ var VERSION = "0.9.4";
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: cleanText(root.textContent.match(/Attempts allowed:\s*([^\n]+)/i)?.[1] ?? ""),
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 option of select.querySelectorAll("option")) {
27587
- const groupId = safeInt(option.getAttribute("value"));
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(option);
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 option = root.querySelector(selector)?.querySelector(`option[value='${groupId}']`) ?? null;
27612
- if (option) {
27613
- return cleanNodeText(option);
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)"));
@@ -27864,8 +27991,6 @@ function parseSubmissionForm(html, deps) {
27864
27991
  const repositories = (Array.isArray(picker.repositories) ? picker.repositories : Object.values(record2(picker.repositories))).map(record2);
27865
27992
  const upload = repositories.find((repo) => repo.type === "upload");
27866
27993
  if (!upload || upload.id === void 0) throw deps.fail("The site does not allow direct file uploads for this assignment.");
27867
- const accepted = options.accepted_types;
27868
- const acceptedTypes = Array.isArray(accepted) ? accepted.map(String).filter(Boolean) : accepted === void 0 || accepted === "*" ? "*" : [String(accepted)];
27869
27994
  return {
27870
27995
  action: resolveUrl(deps.baseUrl, form.getAttribute("action") || `${deps.baseUrl}${ASSIGN_VIEW_PATH}`),
27871
27996
  fields: fields2,
@@ -27879,7 +28004,7 @@ function parseSubmissionForm(html, deps) {
27879
28004
  maxBytes: integer2(options.maxbytes),
27880
28005
  areaMaxBytes: integer2(options.areamaxbytes),
27881
28006
  maxFiles: integer2(options.maxfiles),
27882
- acceptedTypes: acceptedTypes.length === 1 && acceptedTypes[0] === "*" ? "*" : acceptedTypes,
28007
+ acceptedTypes: acceptedTypesOf(options.accepted_types),
27883
28008
  ...statementOf(form)
27884
28009
  };
27885
28010
  }
@@ -27944,7 +28069,7 @@ function formFields(form) {
27944
28069
  }
27945
28070
  if (tag === "select") {
27946
28071
  const options = element.querySelectorAll("option");
27947
- const chosen = options.find((option) => option.hasAttribute("selected")) ?? options[0];
28072
+ const chosen = options.find((option2) => option2.hasAttribute("selected")) ?? options[0];
27948
28073
  if (chosen) fields2.push([name, chosen.getAttribute("value") ?? cleanText(chosen.textContent)]);
27949
28074
  continue;
27950
28075
  }
@@ -27977,6 +28102,11 @@ function filemanagerOptions(html, itemid) {
27977
28102
  }
27978
28103
  return null;
27979
28104
  }
28105
+ function acceptedTypesOf(value) {
28106
+ const list2 = Array.isArray(value) ? value : isRecord5(value) ? Object.values(value) : value === void 0 ? [] : [value];
28107
+ const types = list2.map((type) => String(type).trim()).filter(Boolean);
28108
+ return types.length === 0 || types.includes("*") ? "*" : types;
28109
+ }
27980
28110
  function balancedObject(text, start) {
27981
28111
  if (text[start] !== "{") return null;
27982
28112
  let depth = 0;
@@ -28118,6 +28248,292 @@ function record2(value) {
28118
28248
  return isRecord5(value) ? value : {};
28119
28249
  }
28120
28250
 
28251
+ // src/moodle-quiz-core.ts
28252
+ var import_node_html_parser4 = __toESM(require_dist(), 1);
28253
+ async function startQuizAttempt(deps, quizId, options = {}) {
28254
+ if (!Number.isSafeInteger(quizId) || quizId <= 0) throw deps.usage("The quiz id must be a positive integer.");
28255
+ const viewUrl = `${deps.baseUrl}${QUIZ_VIEW_PATH}?id=${quizId}`;
28256
+ const viewHtml = await pageText2(deps, viewUrl);
28257
+ const root = (0, import_node_html_parser4.parse)(viewHtml);
28258
+ 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.");
28259
+ const resume = formWithAction2(root, QUIZ_ATTEMPT_PATH) ?? root.querySelector(`a[href*="${QUIZ_ATTEMPT_PATH}?"]`);
28260
+ if (resume) {
28261
+ const target = resume.tagName.toLowerCase() === "form" ? `${resolveUrl(deps.baseUrl, resume.getAttribute("action") ?? "")}?${new URLSearchParams(formFields2(resume)).toString()}` : resolveUrl(deps.baseUrl, resume.getAttribute("href") ?? "");
28262
+ const attempt = numberParam(target, "attempt");
28263
+ if (attempt) return getAttemptPage(deps, attempt, quizId, 0);
28264
+ }
28265
+ const start = formWithAction2(root, QUIZ_START_PATH);
28266
+ if (!start) {
28267
+ const reason = cleanText(root.querySelector(".quizattempt, .quizinfo")?.textContent) || "the quiz page shows no attempt button";
28268
+ throw deps.usage(`Moodle offers no new attempt: ${reason}`, `See ${viewUrl}`);
28269
+ }
28270
+ let response = await deps.request(resolveUrl(deps.baseUrl, start.getAttribute("action") ?? ""), postInit(formFields2(start)));
28271
+ let html = await response.text();
28272
+ if (!onPath(response.url, QUIZ_ATTEMPT_PATH)) {
28273
+ const preflight = formWithAction2((0, import_node_html_parser4.parse)(html), QUIZ_START_PATH);
28274
+ if (!preflight) throw deps.fail(`Moodle did not start the attempt: ${noticesOf2(html) || "it returned an unexpected page"}`);
28275
+ const fields2 = [...formFields2(preflight).filter(([name]) => name !== "quizpassword"), ["submitbutton", "Start attempt"]];
28276
+ if (preflight.querySelector("input[name=quizpassword]")) {
28277
+ const password = options.password ? await options.password() : null;
28278
+ 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.");
28279
+ fields2.push(["quizpassword", password]);
28280
+ }
28281
+ response = await deps.request(resolveUrl(deps.baseUrl, preflight.getAttribute("action") ?? ""), postInit(fields2));
28282
+ html = await response.text();
28283
+ 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"}`);
28284
+ }
28285
+ return withoutForm(parseAttemptPage(html, response.url, deps));
28286
+ }
28287
+ async function getAttemptPage(deps, attemptId, quizId, page2) {
28288
+ return withoutForm(await loadAttemptPage(deps, attemptId, quizId, page2));
28289
+ }
28290
+ async function loadAttemptPage(deps, attemptId, quizId, page2) {
28291
+ const url2 = attemptUrl(deps.baseUrl, attemptId, quizId, page2);
28292
+ const response = await deps.request(url2);
28293
+ const html = await response.text();
28294
+ if (onPath(response.url, QUIZ_REVIEW_PATH)) throw deps.usage(`Attempt ${attemptId} is already finished.`, `Its review is at ${response.url}`);
28295
+ if (!onPath(response.url, QUIZ_ATTEMPT_PATH)) throw deps.fail(`Moodle did not show attempt ${attemptId}: ${noticesOf2(html) || "it redirected elsewhere"}`);
28296
+ return parseAttemptPage(html, response.url, deps);
28297
+ }
28298
+ function withoutForm({ form: _form, ...page2 }) {
28299
+ return page2;
28300
+ }
28301
+ async function answerQuizQuestion(deps, request) {
28302
+ const first2 = await loadAttemptPage(deps, request.attemptId, request.quizId, 0);
28303
+ const entry = first2.navigation.find((item) => item.number === request.question.trim());
28304
+ if (!entry) throw deps.usage(`Attempt ${request.attemptId} has no question ${request.question}.`, `Questions: ${first2.navigation.map((item) => item.number).join(", ")}`);
28305
+ const page2 = entry.page === first2.page ? first2 : await loadAttemptPage(deps, request.attemptId, request.quizId, entry.page);
28306
+ const question2 = page2.questions.find((item) => item.slot === entry.slot);
28307
+ if (!question2) throw deps.fail(`Page ${entry.page + 1} does not contain question ${request.question}.`);
28308
+ const fields2 = encodeAnswer(deps, page2.form, question2, request.value);
28309
+ const replay = fields2.map(([name, value]) => name === "nextpage" ? [name, String(page2.page)] : [name, value]);
28310
+ const response = await deps.request(page2.form.action, postInit(replay));
28311
+ const html = await response.text();
28312
+ if (!onPath(response.url, QUIZ_ATTEMPT_PATH)) throw deps.fail(`Moodle did not save the answer: ${noticesOf2(html) || "it left the attempt page"}`);
28313
+ const after = parseAttemptPage(html, response.url, deps);
28314
+ const saved = after.questions.find((item) => item.slot === question2.slot);
28315
+ 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"}".`);
28316
+ return withoutForm(after);
28317
+ }
28318
+ async function getAttemptSummary(deps, attemptId, quizId) {
28319
+ const { form: _form, ...summary } = await loadAttemptSummary(deps, attemptId, quizId);
28320
+ return summary;
28321
+ }
28322
+ async function loadAttemptSummary(deps, attemptId, quizId) {
28323
+ const url2 = `${deps.baseUrl}${QUIZ_SUMMARY_PATH}?attempt=${attemptId}&cmid=${quizId}`;
28324
+ const response = await deps.request(url2);
28325
+ const html = await response.text();
28326
+ if (onPath(response.url, QUIZ_REVIEW_PATH)) throw deps.usage(`Attempt ${attemptId} is already finished.`, `Its review is at ${response.url}`);
28327
+ const root = (0, import_node_html_parser4.parse)(html);
28328
+ const rows = root.querySelectorAll("table.quizsummaryofattempt tbody tr").flatMap((row) => {
28329
+ const cells = row.querySelectorAll("td");
28330
+ if (cells.length < 2) return [];
28331
+ const link = cells[0].querySelector("a")?.getAttribute("href") ?? "";
28332
+ return [{ number: cleanText(cells[0].textContent), state: cleanText(cells[1].textContent), page: numberParam(link, "page") ?? 0 }];
28333
+ });
28334
+ const finish = root.querySelector("form#frm-finishattempt") ?? formWithAction2(root, QUIZ_PROCESS_PATH);
28335
+ if (!finish || !rows.length) throw deps.fail(`Moodle did not show the summary of attempt ${attemptId}: ${noticesOf2(html) || "the page has no finish button"}`);
28336
+ const form = { action: resolveUrl(deps.baseUrl, finish.getAttribute("action") ?? ""), fields: formFields2(finish) };
28337
+ return { attempt: attemptId, quiz_id: quizId, name: pageHeading(root), rows, url: url2, form };
28338
+ }
28339
+ async function finishQuizAttempt(deps, attemptId, quizId) {
28340
+ const summary = await loadAttemptSummary(deps, attemptId, quizId);
28341
+ const response = await deps.request(summary.form.action, postInit(summary.form.fields));
28342
+ const html = await response.text();
28343
+ const receipt = { attempt: attemptId, quiz_id: quizId, name: summary.name, summary: summary.rows, url: response.url };
28344
+ if (onPath(response.url, QUIZ_REVIEW_PATH)) {
28345
+ receipt.review = parseQuizReviewHtml(html, attemptId, deps.baseUrl);
28346
+ return receipt;
28347
+ }
28348
+ const quiz = parseQuizHtml(onPath(response.url, QUIZ_VIEW_PATH) ? html : await pageText2(deps, `${deps.baseUrl}${QUIZ_VIEW_PATH}?id=${quizId}`), quizId, deps.baseUrl);
28349
+ const row = quiz.attempts.find((attempt) => attempt.id === attemptId);
28350
+ 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.`);
28351
+ receipt.url = quiz.url;
28352
+ if (row) {
28353
+ receipt.result = { status: row.status, marks: row.marks, grade: row.grade, completed: row.completed };
28354
+ return receipt;
28355
+ }
28356
+ const probe = await deps.request(attemptUrl(deps.baseUrl, attemptId, quizId, 0));
28357
+ 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.`);
28358
+ return receipt;
28359
+ }
28360
+ function parseAttemptPage(html, url2, deps) {
28361
+ const root = (0, import_node_html_parser4.parse)(html);
28362
+ const form = root.querySelector("form#responseform");
28363
+ if (!form) throw deps.fail(`Moodle did not render an attempt page: ${noticesOf2(html) || "no response form found"}`);
28364
+ const attempt = numberParam(url2, "attempt") ?? Number(form.querySelector("input[name=attempt]")?.getAttribute("value"));
28365
+ const quizId = numberParam(form.getAttribute("action") ?? "", "cmid") ?? numberParam(url2, "cmid") ?? 0;
28366
+ const page2 = Number(form.querySelector("input[name=thispage]")?.getAttribute("value") ?? numberParam(url2, "page") ?? 0);
28367
+ const navigation = root.querySelectorAll("a.qnbutton").map((button) => {
28368
+ const title = button.getAttribute("title") ?? "";
28369
+ const match = title.match(/^(?:Question|Information)?\s*(\S+)\s*-\s*(.+)$/u);
28370
+ return {
28371
+ slot: Number(button.getAttribute("id")?.replace(/^quiznavbutton/u, "") ?? 0),
28372
+ number: match?.[1] ?? cleanText(button.textContent),
28373
+ page: Number(button.getAttribute("data-quiz-page") ?? 0),
28374
+ state: match?.[2] ?? ""
28375
+ };
28376
+ });
28377
+ const questions = form.querySelectorAll("div.que").map(parseAttemptQuestion);
28378
+ return {
28379
+ attempt,
28380
+ quiz_id: quizId,
28381
+ name: pageHeading(root),
28382
+ page: page2,
28383
+ pages: Math.max(page2 + 1, ...navigation.map((entry) => entry.page + 1)),
28384
+ questions,
28385
+ navigation,
28386
+ url: attemptUrl(deps.baseUrl, attempt, quizId, page2),
28387
+ form: { action: resolveUrl(deps.baseUrl, form.getAttribute("action") ?? ""), fields: formFields2(form) }
28388
+ };
28389
+ }
28390
+ function parseAttemptQuestion(que) {
28391
+ const slot = Number(que.getAttribute("id")?.split("-").at(-1) ?? 0);
28392
+ const base = {
28393
+ slot,
28394
+ number: cleanText(que.querySelector(".info .qno, .info .no")?.textContent).replace(/^Question\s*/iu, "") || "i",
28395
+ type: que.classList.value[1] ?? "",
28396
+ kind: "unsupported",
28397
+ state: cleanText(que.querySelector(".info .state")?.textContent),
28398
+ text: blockText(que.querySelector(".qtext"))
28399
+ };
28400
+ if (que.classList.contains("description")) return { ...base, number: "i", kind: "info" };
28401
+ const inputs = que.querySelectorAll("input, textarea, select").filter((input3) => {
28402
+ const name = input3.getAttribute("name") ?? "";
28403
+ return name.startsWith("q") && !/_:(?:flagged|sequencecheck)$|_-seen$|_answerformat$/u.test(name) && !/^(?:hidden|submit)$/u.test(input3.getAttribute("type") ?? "");
28404
+ });
28405
+ const tag = (input3) => input3.tagName.toLowerCase();
28406
+ const type = (input3) => tag(input3) === "input" ? (input3.getAttribute("type") ?? "text").toLowerCase() : tag(input3);
28407
+ const isClearChoice = (input3) => type(input3) === "radio" && (input3.closest(".qtype_multichoice_clearchoice") !== null || input3.getAttribute("aria-hidden") === "true");
28408
+ const radios = inputs.filter((input3) => type(input3) === "radio" && !isClearChoice(input3));
28409
+ const boxes = inputs.filter((input3) => type(input3) === "checkbox");
28410
+ const texts = inputs.filter((input3) => ["textarea", "text", "number"].includes(type(input3)));
28411
+ const selects = inputs.filter((input3) => type(input3) === "select");
28412
+ const others = inputs.length - radios.length - boxes.length - texts.length - selects.length - inputs.filter(isClearChoice).length;
28413
+ const radioNames = new Set(radios.map((input3) => input3.getAttribute("name")));
28414
+ const only = (group) => group.length === inputs.length - inputs.filter(isClearChoice).length && others === 0;
28415
+ if (radios.length && radioNames.size === 1 && only(radios)) {
28416
+ return { ...base, kind: "choice", field: radios[0].getAttribute("name"), options: radios.map((input3, index) => option(que, input3, index)) };
28417
+ }
28418
+ if (boxes.length && only(boxes)) return { ...base, kind: "multi", options: boxes.map((input3, index) => option(que, input3, index)) };
28419
+ if (selects.length === 1 && only(selects)) {
28420
+ const select = selects[0];
28421
+ const name = select.getAttribute("name");
28422
+ const choices = select.querySelectorAll("option").filter((item) => (item.getAttribute("value") ?? "") !== "");
28423
+ 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") })) };
28424
+ }
28425
+ if (texts.length === 1 && only(texts)) {
28426
+ const text = texts[0];
28427
+ const html = tag(text) === "textarea";
28428
+ return { ...base, kind: "text", field: text.getAttribute("name"), answer: html ? blockText((0, import_node_html_parser4.parse)(text.textContent)) : cleanText(text.getAttribute("value") ?? "") };
28429
+ }
28430
+ return base;
28431
+ }
28432
+ function option(que, input3, index) {
28433
+ const labelId = input3.getAttribute("aria-labelledby");
28434
+ const id2 = input3.getAttribute("id");
28435
+ const label = (labelId ? que.querySelector(`[id="${labelId}"]`) : null) ?? (id2 ? que.querySelector(`label[for="${id2}"]`) : null) ?? input3.parentNode;
28436
+ const text = blockText(label) || label?.querySelectorAll("img").map((img) => cleanText(img.getAttribute("alt"))).filter(Boolean).join(" ") || "(image; see the quiz in a browser)";
28437
+ return { key: String.fromCharCode(97 + index), field: input3.getAttribute("name") ?? "", value: input3.getAttribute("value") ?? "", text, chosen: input3.hasAttribute("checked") };
28438
+ }
28439
+ function encodeAnswer(deps, form, question2, value) {
28440
+ const raw = value.trim();
28441
+ if (question2.kind === "info") throw deps.usage(`Question ${question2.number} is an information block; it takes no answer.`);
28442
+ 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.");
28443
+ if (question2.kind === "text") {
28444
+ if (!raw) throw deps.usage(`Question ${question2.number} needs a written answer.`);
28445
+ const format = form.fields.find(([name]) => name === `${question2.field}format`)?.[1];
28446
+ return [...form.fields.filter(([name]) => name !== question2.field), [question2.field, format === "1" ? paragraphs(value) : raw]];
28447
+ }
28448
+ const options = question2.options ?? [];
28449
+ const picks = raw.split(",").map((part) => part.trim()).filter(Boolean).map((part) => {
28450
+ const match = options.find((item) => item.key === part.toLowerCase()) ?? options.find((item) => cleanText(item.text).toLowerCase() === part.toLowerCase());
28451
+ if (!match) throw deps.usage(`Question ${question2.number} has no option '${part}'.`, `Choose from ${options.map((item) => item.key).join(", ")}.`);
28452
+ return match;
28453
+ });
28454
+ if (!picks.length) throw deps.usage(`Question ${question2.number} needs an option letter.`, `Choose from ${options.map((item) => item.key).join(", ")}.`);
28455
+ if (question2.kind === "choice") {
28456
+ if (picks.length > 1) throw deps.usage(`Question ${question2.number} takes one option, not ${picks.length}.`);
28457
+ return [...form.fields.filter(([name]) => name !== question2.field), [question2.field, picks[0].value]];
28458
+ }
28459
+ const chosen = new Set(picks.map((pick2) => pick2.key));
28460
+ const boxNames = new Set(options.map((item) => item.field));
28461
+ return [
28462
+ ...form.fields.filter(([name]) => !boxNames.has(name)),
28463
+ ...options.map((item) => [item.field, chosen.has(item.key) ? "1" : "0"])
28464
+ ];
28465
+ }
28466
+ function noticesOf2(html) {
28467
+ const root = (0, import_node_html_parser4.parse)(html);
28468
+ const texts = [];
28469
+ for (const node2 of root.querySelectorAll(".alert, .errorbox, .error, #notice")) {
28470
+ for (const junk of node2.querySelectorAll("button, .close")) junk.remove();
28471
+ const text = cleanText(node2.textContent);
28472
+ if (text && !texts.includes(text)) texts.push(text);
28473
+ }
28474
+ return texts.join(" ");
28475
+ }
28476
+ function formWithAction2(root, path) {
28477
+ return root.querySelectorAll("form").find((form) => onPath(form.getAttribute("action") ?? "", path)) ?? null;
28478
+ }
28479
+ function formFields2(form) {
28480
+ const fields2 = [];
28481
+ for (const element of form.querySelectorAll("input, textarea, select")) {
28482
+ const name = element.getAttribute("name");
28483
+ if (!name) continue;
28484
+ const tag = element.tagName.toLowerCase();
28485
+ if (tag === "textarea") {
28486
+ fields2.push([name, element.textContent]);
28487
+ continue;
28488
+ }
28489
+ if (tag === "select") {
28490
+ const options = element.querySelectorAll("option");
28491
+ const chosen = options.find((item) => item.hasAttribute("selected")) ?? options[0];
28492
+ if (chosen) fields2.push([name, chosen.getAttribute("value") ?? cleanText(chosen.textContent)]);
28493
+ continue;
28494
+ }
28495
+ const type = (element.getAttribute("type") ?? "text").toLowerCase();
28496
+ if (["submit", "button", "image", "file", "reset"].includes(type)) continue;
28497
+ if ((type === "checkbox" || type === "radio") && !element.hasAttribute("checked")) continue;
28498
+ fields2.push([name, element.getAttribute("value") ?? (type === "checkbox" ? "on" : "")]);
28499
+ }
28500
+ return fields2;
28501
+ }
28502
+ function postInit(fields2) {
28503
+ return { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(fields2).toString() };
28504
+ }
28505
+ async function pageText2(deps, url2) {
28506
+ return (await deps.request(url2)).text();
28507
+ }
28508
+ function attemptUrl(baseUrl, attempt, quizId, page2) {
28509
+ return `${baseUrl}${QUIZ_ATTEMPT_PATH}?attempt=${attempt}&cmid=${quizId}${page2 ? `&page=${page2}` : ""}`;
28510
+ }
28511
+ function pageHeading(root) {
28512
+ const heading = cleanText(root.querySelector(".page-header-headings h1, #page-header h1")?.textContent);
28513
+ if (heading) return heading;
28514
+ const title = cleanText(root.querySelector("title")?.textContent).replace(/\s*\(page \d+ of \d+\)/iu, "").split(" | ")[0].trim();
28515
+ return title || cleanText(root.querySelector("h2")?.textContent);
28516
+ }
28517
+ function onPath(url2, path) {
28518
+ try {
28519
+ return new URL(url2, "https://moodle.invalid").pathname.endsWith(path);
28520
+ } catch {
28521
+ return false;
28522
+ }
28523
+ }
28524
+ function numberParam(url2, key) {
28525
+ try {
28526
+ const value = Number(new URL(url2, "https://moodle.invalid").searchParams.get(key));
28527
+ return Number.isSafeInteger(value) && value > 0 ? value : key === "page" && value === 0 ? 0 : null;
28528
+ } catch {
28529
+ return null;
28530
+ }
28531
+ }
28532
+ function paragraphs(text) {
28533
+ const escaped = text.trim().replace(/&/gu, "&amp;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;");
28534
+ return escaped.split(/\n\s*\n/u).map((block) => `<p>${block.trim().replace(/\n/gu, "<br>")}</p>`).join("");
28535
+ }
28536
+
28121
28537
  // src/parsers.ts
28122
28538
  function schema(parser) {
28123
28539
  return { parse: parser };
@@ -29118,6 +29534,10 @@ var MoodleClientCore = class {
29118
29534
  async getQuiz(id2) {
29119
29535
  return parseQuizHtml(await this.get(QUIZ_VIEW_PATH, { id: id2 }), id2, this.baseUrl);
29120
29536
  }
29537
+ async getQuizAttempt(attemptId) {
29538
+ await this.ensureSession();
29539
+ return parseQuizReviewHtml(await this.get(QUIZ_REVIEW_PATH, { attempt: attemptId, showall: 1 }), attemptId, this.baseUrl);
29540
+ }
29121
29541
  async getResource(id2) {
29122
29542
  const url2 = `${this.baseUrl}${RESOURCE_VIEW_PATH}?id=${id2}`;
29123
29543
  const response = await this.requestAbsolute(url2);
@@ -29166,6 +29586,32 @@ var MoodleClientCore = class {
29166
29586
  usage: (message, hint) => this.errors.usage ? this.errors.usage(message, hint) : new MoodleClientCoreError("usage", message, hint)
29167
29587
  }, request);
29168
29588
  }
29589
+ /** Starts a new attempt, or resumes the one already in progress, and returns its first page. */
29590
+ async startQuizAttempt(quizId, options = {}) {
29591
+ return startQuizAttempt(await this.quizDeps(), quizId, options);
29592
+ }
29593
+ async getQuizAttemptPage(attemptId, quizId, page2 = 0) {
29594
+ return getAttemptPage(await this.quizDeps(), attemptId, quizId, page2);
29595
+ }
29596
+ async getQuizAttemptSummary(attemptId, quizId) {
29597
+ return getAttemptSummary(await this.quizDeps(), attemptId, quizId);
29598
+ }
29599
+ async answerQuizQuestion(request) {
29600
+ return answerQuizQuestion(await this.quizDeps(), request);
29601
+ }
29602
+ /** Submits the attempt for grading. Moodle treats this as final. */
29603
+ async finishQuizAttempt(attemptId, quizId) {
29604
+ return finishQuizAttempt(await this.quizDeps(), attemptId, quizId);
29605
+ }
29606
+ async quizDeps() {
29607
+ await this.ensureSession();
29608
+ return {
29609
+ baseUrl: this.baseUrl,
29610
+ request: (url2, init, options) => this.requestAbsolute(url2, init, options),
29611
+ fail: (message, moodleErrorCode) => this.errors.api(message, moodleErrorCode),
29612
+ usage: (message, hint) => this.errors.usage ? this.errors.usage(message, hint) : new MoodleClientCoreError("usage", message, hint)
29613
+ };
29614
+ }
29169
29615
  async getNewsForums(courseId) {
29170
29616
  const units = courseId === void 0 ? await this.getCourses() : (await this.getCourses()).filter((c) => c.id === courseId);
29171
29617
  const forums = [];
@@ -29527,7 +29973,7 @@ function safeUrl(value) {
29527
29973
  }
29528
29974
 
29529
29975
  // src/mcp/gateway.ts
29530
- var import_node_html_parser4 = __toESM(require_dist(), 1);
29976
+ var import_node_html_parser5 = __toESM(require_dist(), 1);
29531
29977
  var MAX_MCP_FILE_BYTES = 16 * 1024 * 1024;
29532
29978
  var MoodleGatewayError = class extends Error {
29533
29979
  code;
@@ -29562,6 +30008,7 @@ function createMoodleGateway(client, hooks = {}) {
29562
30008
  return limit2 === void 0 ? activities : activities.slice(0, limit2);
29563
30009
  },
29564
30010
  getActivity: ({ activityId }) => client.getActivity(activityId),
30011
+ ...client.getQuizAttempt ? { getQuizAttempt: (attemptId) => client.getQuizAttempt(attemptId) } : {},
29565
30012
  getGrades: ({ courseId }) => client.getCourseGrades(courseId),
29566
30013
  async listForums({ courseId, limit: limit2 }) {
29567
30014
  const forums = await client.getForums(courseId);
@@ -29602,7 +30049,9 @@ function createMoodleGateway(client, hooks = {}) {
29602
30049
  name,
29603
30050
  mimeType: contentType(response),
29604
30051
  bytes: content.byteLength,
29605
- uri: publicFileUrl(response.url || target.url),
30052
+ // The Moodle-origin URL, never the post-redirect CDN one: stripping query parameters from
30053
+ // a signed CDN link leaves an address that can only ever answer MissingKey.
30054
+ uri: publicFileUrl(target.url),
29606
30055
  blob: encodeBase64(content)
29607
30056
  };
29608
30057
  }
@@ -29668,7 +30117,7 @@ async function fileFromActivity(client, activityId) {
29668
30117
  return { url: entry.url, name: entry.name };
29669
30118
  }
29670
30119
  function resourceLinks(html, baseUrl) {
29671
- const root = (0, import_node_html_parser4.parse)(html);
30120
+ const root = (0, import_node_html_parser5.parse)(html);
29672
30121
  const entries = root.querySelectorAll(".resourceworkaround a[href], .resourcecontent a[href], a.resourceworkaround[href]").map((link) => ({
29673
30122
  name: link.textContent.trim(),
29674
30123
  url: new URL(link.getAttribute("href") ?? "", baseUrl).toString()
@@ -29722,7 +30171,7 @@ function isHtml(response) {
29722
30171
  return type.includes("text/html") || type.includes("application/xhtml+xml");
29723
30172
  }
29724
30173
  function looksLikeLoginPage(html) {
29725
- const root = (0, import_node_html_parser4.parse)(html);
30174
+ const root = (0, import_node_html_parser5.parse)(html);
29726
30175
  return root.querySelector('form[action*="/login/"], input[name="password"], #page-login-index') !== null || /<title>\s*(?:log in|login)/iu.test(html);
29727
30176
  }
29728
30177
  function contentType(response) {
@@ -29786,7 +30235,10 @@ var fields = (keys) => Object.fromEntries(keys.map((key) => [key, s]));
29786
30235
  var file2 = external_exports.object({ name: s, url: s, requires_authentication: external_exports.boolean().optional() });
29787
30236
  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
30237
  var activityListSchema = external_exports.object({ ...activityFields, section: s, unit_code: s, description: s });
29789
- var activitySchema = external_exports.object({ ...activityFields, ...fields(["url", "target_url", "section", "unit_code", "description", "submission_status", "grading_status", "grade", "due_pretty", "opens_pretty", "closes_pretty", "attempts_allowed", "availability", "time_remaining", "content_text"]), files: external_exports.array(file2).optional() });
30238
+ var criterion = external_exports.object({ name: s, level: s, score: s, remark: s });
30239
+ var attemptSummary = { id, status: s, started: s, completed: s, duration: s, marks: s, grade: s };
30240
+ var question = external_exports.object({ number: id, type: s, state: s, mark: s, text: s, response: s, correct: s, feedback: s });
30241
+ 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
30242
  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
30243
  var current = external_exports.object({ id, name: s, estimated: external_exports.boolean().optional() });
29792
30244
  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 +30252,27 @@ var receiptSchema = external_exports.object({ id, name: s, unit_id: n, url: s, a
29800
30252
  var input2 = (shape) => external_exports.object(shape).strict();
29801
30253
  var list = (key, value) => external_exports.object({ [key]: external_exports.array(value).optional(), total: id });
29802
30254
  var intentContracts = {
29803
- home: { when: "dashboard", command: "moodle", what: "Today, timezone, due items and unread counts.", 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() }) }) },
30255
+ 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
30256
  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
30257
  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
30258
  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
30259
  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: "Activity due dates, status, grades, files, or latest forum threads.", instead: "file for binary content", refs: "id, same-site URL, or UNIT TASK phrase", then: "file with id or files URL", 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 }) },
29809
- grades: { when: "my grades", command: "moodle grades [UNIT]", what: "Grades across units or in one unit.", instead: "item for submission status", 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) },
30260
+ 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 }) },
30261
+ 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() }) }) },
30262
+ 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
30263
  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
30264
  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
30265
  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
30266
  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 embedded content, at most 16 MiB.", instead: "item for file choices", refs: "resource id, same-site URL, or UNIT TASK phrase", then: "read the returned 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() }) }) }
30267
+ 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
30268
  };
29816
30269
  function intentDescription(name) {
29817
30270
  const c = intentContracts[name];
29818
- return `${c.what} Use when: ${c.when}. Not for: ${c.instead}. Refs: ${c.refs}. Then: ${c.then}. Cost: ${c.cost}.`;
30271
+ const alternatives = c.instead.split("; ").map((rule) => {
30272
+ const [tool, ...purpose] = rule.split(" for ");
30273
+ return `for ${purpose.join(" for ")} call ${tool}`;
30274
+ }).join("; ");
30275
+ return `${c.what} Use it for ${c.when}; ${alternatives}. Input: ${c.refs}. Next: ${c.then}. Cost: ${c.cost}.`;
29819
30276
  }
29820
30277
 
29821
30278
  // src/results.ts
@@ -29975,6 +30432,15 @@ function createIntentService(gateway, now = () => Date.now()) {
29975
30432
  result = { item: { ...itemRow(activity), ...due }, threads: threads?.slice(0, 20).map((t) => ({ id: t.id, name: t.subject })), total: threads?.length };
29976
30433
  break;
29977
30434
  }
30435
+ case "attempt": {
30436
+ if (!gateway.getQuizAttempt) throw new ReferenceError2("not_found", "This gateway cannot read quiz attempts.", []);
30437
+ const raw = String(input3.attempt);
30438
+ const id2 = raw.includes("://") ? Number(new URL(raw).searchParams.get("attempt")) : Number(raw);
30439
+ if (!Number.isInteger(id2) || id2 <= 0) throw new ReferenceError2("not_found", "Use a quiz attempt id or a review URL with ?attempt=.", []);
30440
+ const { course_id, questions, ...attempt } = await gateway.getQuizAttempt(id2);
30441
+ result = { attempt: { ...attempt, unit_id: course_id || void 0, questions } };
30442
+ break;
30443
+ }
29978
30444
  case "home":
29979
30445
  case "due": {
29980
30446
  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 +30607,10 @@ function createMoodleMcpServer(gateway, options = {}) {
30141
30607
  protocolVersion,
30142
30608
  capabilities: { tools: { listChanged: false } },
30143
30609
  serverInfo,
30144
- instructions: 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."
30610
+ instructions: [
30611
+ 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.",
30612
+ ...options.instructions ?? []
30613
+ ].join(" ")
30145
30614
  });
30146
30615
  }
30147
30616
  if (request.method === "ping") {
@@ -30262,6 +30731,9 @@ async function callTool(gateway, params) {
30262
30731
  function toolContent(name, payload, structuredContent) {
30263
30732
  const text = { type: "text", text: JSON.stringify(structuredContent) };
30264
30733
  if (name !== "get_file" || !isMoodleFile(payload)) return [text];
30734
+ if (payload.mimeType.startsWith("image/")) {
30735
+ return [text, { type: "image", data: payload.blob, mimeType: payload.mimeType }];
30736
+ }
30265
30737
  return [
30266
30738
  text,
30267
30739
  {
@@ -30307,6 +30779,40 @@ function isMoodleFile(value) {
30307
30779
  return isRecord8(value) && typeof value.name === "string" && typeof value.mimeType === "string" && typeof value.bytes === "number" && typeof value.uri === "string" && typeof value.blob === "string";
30308
30780
  }
30309
30781
 
30782
+ // src/update-core.ts
30783
+ var PACKAGE_NAME2 = "moodle-cli";
30784
+ var LATEST_VERSION_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME2}/dist-tags`;
30785
+ var UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1e3;
30786
+ var UPDATE_RETRY_MS = 60 * 60 * 1e3;
30787
+ function compareVersions(a, b) {
30788
+ const [aMain, aPre] = a.split("-", 2);
30789
+ const [bMain, bPre] = b.split("-", 2);
30790
+ const left = aMain.split(".").map(Number);
30791
+ const right = bMain.split(".").map(Number);
30792
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
30793
+ const diff = (left[index] ?? 0) - (right[index] ?? 0);
30794
+ if (diff !== 0) return Math.sign(diff);
30795
+ }
30796
+ if (Boolean(aPre) === Boolean(bPre)) return (aPre ?? "").localeCompare(bPre ?? "");
30797
+ return aPre ? -1 : 1;
30798
+ }
30799
+ function isNewerVersion(candidate, current2) {
30800
+ return Boolean(candidate) && /^\d+\.\d+\.\d+/u.test(candidate) && compareVersions(candidate, current2) > 0;
30801
+ }
30802
+ async function fetchLatestVersion(fetchImpl = fetch, timeoutMs = 5e3) {
30803
+ try {
30804
+ const response = await fetchImpl(LATEST_VERSION_URL, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(timeoutMs) });
30805
+ if (!response.ok) return null;
30806
+ const tags = await response.json();
30807
+ return typeof tags.latest === "string" ? tags.latest : null;
30808
+ } catch {
30809
+ return null;
30810
+ }
30811
+ }
30812
+ function updateHint(current2, latest) {
30813
+ return `moodle-cli ${latest} is available (running ${current2}). Run: moodle update`;
30814
+ }
30815
+
30310
30816
  // src/worker/moodle-upstream.ts
30311
30817
  var DASHBOARD_PATH2 = "/my/";
30312
30818
  var AJAX_PATH = "/lib/ajax/service.php";
@@ -30417,6 +30923,7 @@ var DEFAULT_TOUCH_DELAY_MS = 30 * 60 * 1e3;
30417
30923
  var MIN_TOUCH_DELAY_MS = 60 * 1e3;
30418
30924
  var MAX_BACKOFF_MS = 30 * 60 * 1e3;
30419
30925
  var SESSION_STALE_MS = 24 * 60 * 60 * 1e3;
30926
+ var LATEST_VERSION_KEY = "latest_version";
30420
30927
  var SESSION_EXPIRING_MS = 15 * 60 * 1e3;
30421
30928
  var SessionBroker = class {
30422
30929
  constructor(state, env, dependencies) {
@@ -30469,7 +30976,10 @@ var SessionBroker = class {
30469
30976
  userid: session.moodle_user_id,
30470
30977
  fetchImpl: this.dependencies.fetchImpl
30471
30978
  });
30472
- const server = createMoodleMcpServer(createMoodleGateway(client));
30979
+ const initializing = isRecord10(envelope.request) && envelope.request.method === "initialize";
30980
+ const latest = initializing ? await this.latestVersion(true) : void 0;
30981
+ const instructions = latest && isNewerVersion(latest, VERSION) ? [`${updateHint(VERSION, latest)} to redeploy this server.`] : void 0;
30982
+ const server = createMoodleMcpServer(createMoodleGateway(client), { instructions });
30473
30983
  const response = await server.handle(envelope.request, envelope.context);
30474
30984
  return Response.json({ response }, { headers: { "cache-control": "private, no-store" } });
30475
30985
  }
@@ -30480,10 +30990,22 @@ var SessionBroker = class {
30480
30990
  await this.state.storage.setAlarm(this.now() + MAX_BACKOFF_MS);
30481
30991
  }
30482
30992
  }
30993
+ // The Worker can only report a newer release; deploying one needs the owner's
30994
+ // Cloudflare credentials, which stay on their machine.
30995
+ async latestVersion(refresh) {
30996
+ const cached2 = await this.state.storage.get(LATEST_VERSION_KEY);
30997
+ if (cached2 && this.now() - cached2.checked_at < UPDATE_CHECK_TTL_MS) return cached2.latest;
30998
+ if (!refresh) return cached2?.latest;
30999
+ const latest = await fetchLatestVersion(this.dependencies.fetchImpl, 3e3);
31000
+ if (!latest) return cached2?.latest;
31001
+ await this.state.storage.put(LATEST_VERSION_KEY, { latest, checked_at: this.now() });
31002
+ return latest;
31003
+ }
30483
31004
  async ready() {
30484
31005
  const session = await this.loadSession();
30485
31006
  const health = readiness(session, this.now());
30486
- return Response.json({ ...health, encryptionKeyId: (await this.keyring()).current.id, ...this.env.SESSION_CREDENTIAL_ID ? { credentialId: this.env.SESSION_CREDENTIAL_ID } : {} }, {
31007
+ const latest = await this.latestVersion(false);
31008
+ 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
31009
  status: health.status === "fail" ? 503 : 200,
30488
31010
  headers: { "content-type": "application/health+json; charset=utf-8" }
30489
31011
  });