brightspace-mcp-server 3.13.17 → 3.13.19
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 +1 -1
- package/build/auth/auth-runner.js +56 -10
- package/build/tools/get-assignments.js +441 -72
- package/build/tools/get-my-grades.js +32 -8
- package/build/utils/deep-links.js +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@ School-specific quirks: [docs/sign-in.md](docs/sign-in.md#per-school-notes).
|
|
|
68
68
|
| Grades | "Am I passing all my classes?" · "Compare my grades across courses" · "What did my professor say about Mini-Assignment 1?" (own assignment feedback is read from every route Brightspace documents for students) · "Is there feedback on my midterm?" (quiz-scored grades link to the quiz, since quiz feedback, including feedback only viewable in LockDown Browser, isn't readable through the API) |
|
|
69
69
|
| Due dates | "What's due in the next 48 hours?" · "Build me a study schedule for the week" |
|
|
70
70
|
| Assignments and rubrics | "What does the lab 4 spec actually ask for?" · "Why did I lose points on the analysis criterion?" |
|
|
71
|
-
| Quizzes and exams | "Which quizzes close this week?" · "Is there a midterm in the gradebook that isn't on my assignments list?" |
|
|
71
|
+
| Quizzes and exams | "Which quizzes close this week?" · "Is there a midterm in the gradebook that isn't on my assignments list?" (quizzes are collected from the quiz list, the course content, and the gradebook, so a closed quiz or one the list leaves out still shows up; each course says whether its quiz sources all answered, and if Brightspace refused one it's marked incomplete rather than passed off as everything; "complete" means every source answered, not that every quiz is guaranteed to be listed. A gradebook column linked to a quiz the list omits now shows up as a quiz with its dates and attempts instead of a bare grade-only row when the quiz can be read. Gradebook-only rows now also appear on tenants that send only the documented `GradeType` on grade columns; hidden grade columns, hidden assignments and inactive quizzes never produce one. Content quizzes beyond the first 30 per course are listed from the course content alone and the course is marked incomplete. Quiz rows may also say whether they are open, closed, or not yet open, and mark a quiz whose details Brightspace would not return; gradebook lookups for unlisted quizzes are capped at 20 per course, or 5 when the course's quiz tool can't be identified) |
|
|
72
72
|
| Announcements | "Did any professor post something important today?" · "Read the file attached to today's announcement" |
|
|
73
73
|
| Course content | "Find the midterm review slides" · "Download every PDF from Module 5" · "Search this course for office hours" · or just read a file inline instead of saving it |
|
|
74
74
|
| Syllabus | "What's the late policy in ECE 264's syllabus?" (if the course keeps its syllabus in an external tool such as Simple Syllabus, you get the link to open instead) |
|
|
@@ -282,6 +282,17 @@ export class AuthRunner {
|
|
|
282
282
|
pendingChallenge = null;
|
|
283
283
|
/** Resolves on the next marker from the current child; null between children. */
|
|
284
284
|
challengeSignal = null;
|
|
285
|
+
/**
|
|
286
|
+
* Callers polling the current child (awaitBackgroundChild), woken whenever
|
|
287
|
+
* it reports a challenge, so a changed number reaches them at once.
|
|
288
|
+
*/
|
|
289
|
+
challengeWatchers = new Set();
|
|
290
|
+
/**
|
|
291
|
+
* The current child's last number-match challenge (relayKey) handed back
|
|
292
|
+
* to a caller here; null until one is. A retry joining the child waits
|
|
293
|
+
* only while the number is still this one (issue #229).
|
|
294
|
+
*/
|
|
295
|
+
challengeToldKey = null;
|
|
285
296
|
/** Callers inside run() right now. */
|
|
286
297
|
waiting = 0;
|
|
287
298
|
/** When the last caller left run(). */
|
|
@@ -365,9 +376,12 @@ export class AuthRunner {
|
|
|
365
376
|
catch (error) {
|
|
366
377
|
this.noteRelayReported(error);
|
|
367
378
|
const answer = dedupeChallenge(error, wave);
|
|
368
|
-
if (answer instanceof AuthProcessError && answer.kind === "mfaPending" && !answer.
|
|
369
|
-
|
|
370
|
-
|
|
379
|
+
if (answer instanceof AuthProcessError && answer.kind === "mfaPending" && !answer.relayed) {
|
|
380
|
+
this.challengeToldKey = relayKey(answer.kind, answer.numberMatch);
|
|
381
|
+
if (!answer.duplicate) {
|
|
382
|
+
const now = Date.now();
|
|
383
|
+
this.challengeTold = { anchor: now, last: now };
|
|
384
|
+
}
|
|
371
385
|
}
|
|
372
386
|
throw answer;
|
|
373
387
|
}
|
|
@@ -387,6 +401,7 @@ export class AuthRunner {
|
|
|
387
401
|
if (!this.childDone || pending?.kind !== "mfaPending" || !told || !isQueuedSibling(told, arrivedAt))
|
|
388
402
|
return null;
|
|
389
403
|
told.last = arrivedAt;
|
|
404
|
+
this.challengeToldKey = relayKey(pending.kind, pending.numberMatch);
|
|
390
405
|
// In full, not the short sibling line: if the answer before it was lost,
|
|
391
406
|
// this one still carries the digits (#201). It never reaches catch in
|
|
392
407
|
// run(), so it extends the chain without restarting it.
|
|
@@ -444,7 +459,8 @@ export class AuthRunner {
|
|
|
444
459
|
// shows them: Claude Desktop sends one and displays nothing, so holding
|
|
445
460
|
// the call open after announcing kept the number from the user for up
|
|
446
461
|
// to 45 seconds. The retry is what joins and waits for the approval.
|
|
447
|
-
// An automaticPending asks nothing of anyone, so keep waiting on it
|
|
462
|
+
// An automaticPending asks nothing of anyone, so keep waiting on it,
|
|
463
|
+
// until it turns into a number: this caller was told none yet.
|
|
448
464
|
if (error.kind === "mfaPending") {
|
|
449
465
|
try {
|
|
450
466
|
onChallenge?.(error.numberMatch);
|
|
@@ -457,7 +473,7 @@ export class AuthRunner {
|
|
|
457
473
|
const windowMs = pollWindowMs(Date.now() - startedAt);
|
|
458
474
|
if (windowMs <= 0)
|
|
459
475
|
throw error;
|
|
460
|
-
return this.awaitBackgroundChild(childDone, windowMs);
|
|
476
|
+
return this.awaitBackgroundChild(childDone, windowMs, null);
|
|
461
477
|
}
|
|
462
478
|
}
|
|
463
479
|
/**
|
|
@@ -540,8 +556,9 @@ export class AuthRunner {
|
|
|
540
556
|
? { phase: "automatic" }
|
|
541
557
|
: { phase: "approval", numberMatch: relayed.numberMatch ?? (latest.phase === "approval" ? latest.numberMatch : undefined) };
|
|
542
558
|
// A challenge appearing while this caller waited on a sign-in that had
|
|
543
|
-
// none yet
|
|
544
|
-
|
|
559
|
+
// none yet, or a changed number (Entra's expired and the owner asked
|
|
560
|
+
// for a new one), is what the user needs right now: answer at once.
|
|
561
|
+
if (next.phase === "approval" && (latest.phase !== "approval" || next.numberMatch !== latest.numberMatch)) {
|
|
545
562
|
throw new AuthProcessError(...mfaPendingFailure(next.numberMatch), next.numberMatch, false, true);
|
|
546
563
|
}
|
|
547
564
|
latest = next;
|
|
@@ -603,6 +620,8 @@ export class AuthRunner {
|
|
|
603
620
|
* read the number from the previous answer and is approving right now, so
|
|
604
621
|
* this is the call that should carry the result. A still-unapproved
|
|
605
622
|
* sign-in re-answers with the latest challenge and the caller tries again.
|
|
623
|
+
* A number no caller here was told yet (it changed since, or the call that
|
|
624
|
+
* would have carried it gave up first) is answered at once instead.
|
|
606
625
|
*/
|
|
607
626
|
async joinBackgroundChild(childDone, startedAt) {
|
|
608
627
|
if (!this.pendingChallenge && this.challengeSignal) {
|
|
@@ -610,21 +629,35 @@ export class AuthRunner {
|
|
|
610
629
|
}
|
|
611
630
|
if (!this.pendingChallenge)
|
|
612
631
|
return childDone;
|
|
613
|
-
return this.awaitBackgroundChild(childDone, Math.max(0, pollWindowMs(Date.now() - startedAt)));
|
|
632
|
+
return this.awaitBackgroundChild(childDone, Math.max(0, pollWindowMs(Date.now() - startedAt)), this.challengeToldKey);
|
|
614
633
|
}
|
|
615
634
|
/**
|
|
616
635
|
* Race a background child already past its MFA challenge against a wait
|
|
617
636
|
* of graceMs. The child's own outcome wins if it lands in time; otherwise
|
|
618
637
|
* the caller is re-answered with the latest challenge the child reported.
|
|
638
|
+
* A number-match challenge other than toldKey (the one this caller already
|
|
639
|
+
* knows; null for none) answers at once, whether it is already there or
|
|
640
|
+
* arrives during the wait (issue #229): the user is looking at a number
|
|
641
|
+
* that no longer works.
|
|
619
642
|
*/
|
|
620
|
-
awaitBackgroundChild(childDone, graceMs) {
|
|
643
|
+
awaitBackgroundChild(childDone, graceMs, toldKey) {
|
|
621
644
|
const challenge = this.pendingChallenge;
|
|
622
645
|
return new Promise((resolve, reject) => {
|
|
623
646
|
let settled = false;
|
|
647
|
+
const watchNumber = () => {
|
|
648
|
+
const pending = this.pendingChallenge;
|
|
649
|
+
if (settled || pending?.kind !== "mfaPending" || relayKey(pending.kind, pending.numberMatch) === toldKey)
|
|
650
|
+
return;
|
|
651
|
+
settled = true;
|
|
652
|
+
clearTimeout(graceTimer);
|
|
653
|
+
this.challengeWatchers.delete(watchNumber);
|
|
654
|
+
reject(new AuthProcessError(...mfaPendingFailure(pending.numberMatch), pending.numberMatch));
|
|
655
|
+
};
|
|
624
656
|
const graceTimer = setTimeout(() => {
|
|
625
657
|
if (settled)
|
|
626
658
|
return;
|
|
627
659
|
settled = true;
|
|
660
|
+
this.challengeWatchers.delete(watchNumber);
|
|
628
661
|
const pending = this.pendingChallenge ?? challenge;
|
|
629
662
|
if (pending?.kind === "automaticPending") {
|
|
630
663
|
reject(automaticPendingFailure());
|
|
@@ -634,17 +667,21 @@ export class AuthRunner {
|
|
|
634
667
|
reject(new AuthProcessError(...mfaPendingFailure(numberMatch), numberMatch));
|
|
635
668
|
}, graceMs);
|
|
636
669
|
graceTimer.unref?.();
|
|
670
|
+
this.challengeWatchers.add(watchNumber);
|
|
671
|
+
watchNumber();
|
|
637
672
|
childDone.then((value) => {
|
|
638
673
|
if (settled)
|
|
639
674
|
return;
|
|
640
675
|
settled = true;
|
|
641
676
|
clearTimeout(graceTimer);
|
|
677
|
+
this.challengeWatchers.delete(watchNumber);
|
|
642
678
|
resolve(value);
|
|
643
679
|
}, (error) => {
|
|
644
680
|
if (settled)
|
|
645
681
|
return;
|
|
646
682
|
settled = true;
|
|
647
683
|
clearTimeout(graceTimer);
|
|
684
|
+
this.challengeWatchers.delete(watchNumber);
|
|
648
685
|
reject(error);
|
|
649
686
|
});
|
|
650
687
|
});
|
|
@@ -746,12 +783,14 @@ export class AuthRunner {
|
|
|
746
783
|
this.challengeSignal = null;
|
|
747
784
|
this.wave = null;
|
|
748
785
|
this.challengeTold = null;
|
|
786
|
+
this.challengeToldKey = null;
|
|
749
787
|
this.relayProbe = null;
|
|
750
788
|
}
|
|
751
789
|
});
|
|
752
790
|
trackedCompletion.catch(() => { });
|
|
753
791
|
this.childDone = trackedCompletion;
|
|
754
792
|
this.pendingChallenge = null;
|
|
793
|
+
this.challengeToldKey = null;
|
|
755
794
|
this.relayProbe = null;
|
|
756
795
|
let resolveChallengeSignal;
|
|
757
796
|
this.challengeSignal = new Promise((res) => { resolveChallengeSignal = res; });
|
|
@@ -798,6 +837,10 @@ export class AuthRunner {
|
|
|
798
837
|
}
|
|
799
838
|
if (firstChallenge)
|
|
800
839
|
resolveChallengeSignal();
|
|
840
|
+
// A caller already polling is answered with a number it was not told
|
|
841
|
+
// (issue #229); the rest keep waiting.
|
|
842
|
+
for (const watch of [...this.challengeWatchers])
|
|
843
|
+
watch();
|
|
801
844
|
const answer = new AuthProcessError(...mfaPendingFailure(this.pendingChallenge?.numberMatch), this.pendingChallenge?.numberMatch, false, relayed);
|
|
802
845
|
if (relayed)
|
|
803
846
|
this.relayProbe = answer;
|
|
@@ -929,7 +972,10 @@ export class AuthRunner {
|
|
|
929
972
|
4: ["unsupported", "This identity provider cannot complete headless authentication. See the authentication logs."],
|
|
930
973
|
5: ["secureStorage", "The native credential store is unavailable or locked. Unlock it and retry."],
|
|
931
974
|
6: ["transport", "Brightspace authentication is temporarily unavailable because of a network or server failure. Your saved session was preserved. Try again later."],
|
|
932
|
-
|
|
975
|
+
// The MFA loop gave up (MfaApprovalError) and recorded the
|
|
976
|
+
// cooldown: the number it showed no longer works, so a caller
|
|
977
|
+
// still polling hears what the next call would, not "enter 42".
|
|
978
|
+
7: ["cooldown", `The MFA approval was not completed in time. Automatic MFA is paused; run ${AUTH_COMMAND} to retry immediately.`],
|
|
933
979
|
};
|
|
934
980
|
// Busy, but the owning sign-in's challenge came through: the caller
|
|
935
981
|
// was told what to approve, so answer with that, not "busy".
|
|
@@ -10,7 +10,7 @@ import { toolResponse, sanitizeError, isAuthUnavailable, authPendingNotice } fro
|
|
|
10
10
|
import { convertHtmlToMarkdown } from "../utils/html-converter.js";
|
|
11
11
|
import { log } from "../utils/logger.js";
|
|
12
12
|
import { applyCourseFilter } from "../utils/course-filter.js";
|
|
13
|
-
import { gradebookUrl, quizUrl } from "../utils/deep-links.js";
|
|
13
|
+
import { contentTopicUrl, gradebookUrl, quizUrl } from "../utils/deep-links.js";
|
|
14
14
|
import { assignmentLinkResolver } from "./assignment-links.js";
|
|
15
15
|
import { dueIn } from "../utils/due-in.js";
|
|
16
16
|
import { feedbackText, FEEDBACK_UNAVAILABLE_NOTE } from "../utils/feedback.js";
|
|
@@ -24,36 +24,127 @@ function richTextHtml(field) {
|
|
|
24
24
|
return nested ?? field.Html ?? null;
|
|
25
25
|
}
|
|
26
26
|
/**
|
|
27
|
-
* The grade
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* The grade types a student is actually scored on. D2L documents exactly four
|
|
28
|
+
* GradeObject shapes, told apart by a string GradeType; anything else a
|
|
29
|
+
* gradebook lists (category, calculated, formula, final grade) is the
|
|
30
|
+
* gradebook's own arithmetic, not work anybody owes. Those are not documented
|
|
31
|
+
* as GradeType values, so a column is excluded when its GradeType is some
|
|
32
|
+
* other string, and a calculated column also carries no AssociatedTool.
|
|
30
33
|
*/
|
|
34
|
+
const STUDENT_SCORED_TYPES = new Set(["numeric", "passfail", "selectbox", "text"]);
|
|
35
|
+
/** The numeric GRADEOBJ_T values for the same four types: 1 numeric, 2 passfail, 3 selectbox, 4 text. */
|
|
31
36
|
const STUDENT_SCORED = new Set([1, 2, 3, 4]);
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
function isStudentScored(column) {
|
|
38
|
+
if (!column || typeof column !== "object")
|
|
39
|
+
return false;
|
|
40
|
+
if (typeof column.GradeType === "string" && STUDENT_SCORED_TYPES.has(column.GradeType.toLowerCase()))
|
|
41
|
+
return true;
|
|
42
|
+
return STUDENT_SCORED.has(column.GradeObjectTypeId);
|
|
43
|
+
}
|
|
44
|
+
/** The columns a student is scored on, from whatever the grades route returned. */
|
|
45
|
+
function studentScoredColumns(gradebook) {
|
|
46
|
+
if (!Array.isArray(gradebook))
|
|
47
|
+
return [];
|
|
48
|
+
return gradebook.filter((c) => isStudentScored(c) && typeof c.Id === "number");
|
|
49
|
+
}
|
|
50
|
+
/** The quiz id a content topic points at, or null when it carries none. */
|
|
51
|
+
function contentQuizId(topic) {
|
|
52
|
+
const candidates = [
|
|
53
|
+
topic.ToolItemId,
|
|
54
|
+
Number(topic.ActivityId),
|
|
55
|
+
// quiz_summary.d2l?qi=N and quiz_start_frame_auto.d2l?qi=N: the same qi
|
|
56
|
+
// parameter quizUrl() builds, present when ToolItemId is not.
|
|
57
|
+
Number(/(?:[?&]|&)qi=(\d+)(?:[&#]|$)/i.exec(topic.Url ?? "")?.[1]),
|
|
58
|
+
];
|
|
59
|
+
for (const id of candidates) {
|
|
60
|
+
if (typeof id === "number" && Number.isSafeInteger(id) && id > 0)
|
|
61
|
+
return id;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
/** Titles compare by words, not by case or spacing, so a topic can be matched to a listed quiz. */
|
|
66
|
+
function normalizedTitle(title) {
|
|
67
|
+
return typeof title === "string" ? title.trim().replace(/\s+/g, " ").toLowerCase() : "";
|
|
68
|
+
}
|
|
69
|
+
/** Every quiz topic in a table of contents, depth first, hidden or broken ones included. */
|
|
70
|
+
function quizTopicsOf(raw) {
|
|
34
71
|
if (!raw || typeof raw !== "object")
|
|
35
72
|
return [];
|
|
36
|
-
const found =
|
|
37
|
-
const visit = (modules) => {
|
|
38
|
-
if (!Array.isArray(modules))
|
|
73
|
+
const found = [];
|
|
74
|
+
const visit = (modules, depth) => {
|
|
75
|
+
if (!Array.isArray(modules) || depth > 50)
|
|
39
76
|
return;
|
|
40
77
|
for (const module of modules) {
|
|
41
78
|
for (const topic of module.Topics ?? []) {
|
|
42
79
|
const isQuiz = topic.ActivityType === 4
|
|
43
80
|
|| topic.TypeIdentifier?.toLowerCase() === "quiz"
|
|
44
|
-
|| /(?:[?&]|&)type=quiz(?:[&#]|$)/i.test(topic.Url ?? "")
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (!Number.isSafeInteger(activityId) || activityId <= 0 || listedIds.has(activityId))
|
|
49
|
-
continue;
|
|
50
|
-
if (!found.has(activityId))
|
|
51
|
-
found.set(activityId, topic);
|
|
81
|
+
|| /(?:[?&]|&)type=quiz(?:[&#]|$)/i.test(topic.Url ?? "")
|
|
82
|
+
|| /\/lms\/quizzing\/user\/quiz_/i.test(topic.Url ?? "");
|
|
83
|
+
if (isQuiz)
|
|
84
|
+
found.push(topic);
|
|
52
85
|
}
|
|
53
|
-
visit(module.Modules);
|
|
86
|
+
visit(module.Modules, depth + 1);
|
|
54
87
|
}
|
|
55
88
|
};
|
|
56
|
-
visit(raw.Modules);
|
|
89
|
+
visit(raw.Modules, 0);
|
|
90
|
+
return found;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The quiz tool's id as content quiz topics report it (ToolId, documented on
|
|
94
|
+
* every ToC topic). Tells the gradebook probe which columns belong to quizzes.
|
|
95
|
+
*/
|
|
96
|
+
function contentQuizToolIds(raw) {
|
|
97
|
+
return new Set(quizTopicsOf(raw)
|
|
98
|
+
.map((topic) => topic.ToolId)
|
|
99
|
+
.filter((id) => typeof id === "number" && Number.isSafeInteger(id) && id > 0));
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The quiz id behind a topic that names none, when the grade column the topic
|
|
103
|
+
* writes to (GradeItemId, documented on ToC topics) is linked to a tool item.
|
|
104
|
+
* A column of a different tool than the topic's is not the topic's quiz.
|
|
105
|
+
*/
|
|
106
|
+
function quizIdFromGradeColumn(topic, columnsById) {
|
|
107
|
+
if (typeof topic.GradeItemId !== "number")
|
|
108
|
+
return null;
|
|
109
|
+
const tool = columnsById.get(topic.GradeItemId)?.AssociatedTool;
|
|
110
|
+
const itemId = tool?.ToolItemId;
|
|
111
|
+
if (typeof itemId !== "number" || !Number.isSafeInteger(itemId) || itemId <= 0)
|
|
112
|
+
return null;
|
|
113
|
+
if (typeof topic.ToolId === "number" && typeof tool?.ToolId === "number" && topic.ToolId !== tool.ToolId) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
return itemId;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Quiz topics visible in course content but absent from the quiz listing.
|
|
120
|
+
* Topics are returned once each. Those that name a quiz (directly, or through
|
|
121
|
+
* their grade column) are keyed by id; one that names none is kept only if no
|
|
122
|
+
* listed quiz carries its title, and one linked from several modules is kept
|
|
123
|
+
* once, recognized by its title and link.
|
|
124
|
+
*/
|
|
125
|
+
function missingContentQuizTopics(raw, listedIds, listedTitles, columnsById) {
|
|
126
|
+
const found = new Map();
|
|
127
|
+
for (const topic of quizTopicsOf(raw)) {
|
|
128
|
+
// A hidden or broken topic is not something the student can open. An
|
|
129
|
+
// exempt one (IsExempt: the user is exempt from the topic, per the
|
|
130
|
+
// content docs) is still listed, and flagged as exempt on its row.
|
|
131
|
+
if (topic.IsHidden || topic.IsBroken)
|
|
132
|
+
continue;
|
|
133
|
+
const quizId = contentQuizId(topic) ?? quizIdFromGradeColumn(topic, columnsById);
|
|
134
|
+
if (quizId !== null) {
|
|
135
|
+
if (listedIds.has(quizId))
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
else if (listedTitles.has(normalizedTitle(topic.Title))) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const url = (topic.Url ?? "").trim();
|
|
142
|
+
const key = quizId !== null
|
|
143
|
+
? `quiz:${quizId}`
|
|
144
|
+
: url !== "" ? `link:${normalizedTitle(topic.Title)}|${url}` : `topic:${topic.TopicId}`;
|
|
145
|
+
if (!found.has(key))
|
|
146
|
+
found.set(key, { topic, quizId });
|
|
147
|
+
}
|
|
57
148
|
return [...found.values()];
|
|
58
149
|
}
|
|
59
150
|
/**
|
|
@@ -62,40 +153,234 @@ function missingContentQuizTopics(raw, listedIds) {
|
|
|
62
153
|
* us the normal full shape. If that route is also unavailable, the content
|
|
63
154
|
* topic still provides enough information to avoid silently hiding the work.
|
|
64
155
|
*/
|
|
65
|
-
async function recoverContentQuizzes(apiClient, courseId, toc, listedIds) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
156
|
+
async function recoverContentQuizzes(apiClient, courseId, toc, listedIds, listedTitles, columnsById) {
|
|
157
|
+
const topics = missingContentQuizTopics(toc, listedIds, listedTitles, columnsById);
|
|
158
|
+
// Only the first topics are asked for their details; the rest are listed
|
|
159
|
+
// from the table of contents alone, so a huge course costs a bounded number
|
|
160
|
+
// of requests and no quiz is dropped.
|
|
161
|
+
const readable = Math.min(topics.length, MAX_CONTENT_QUIZ_RECOVERIES);
|
|
162
|
+
const found = await Promise.all(topics.map(async ({ topic, quizId }, index) => {
|
|
163
|
+
const exempt = topic.IsExempt === true ? { exempt: true } : {};
|
|
164
|
+
const mayRead = index < MAX_CONTENT_QUIZ_RECOVERIES;
|
|
165
|
+
if (mayRead && quizId !== null) {
|
|
166
|
+
try {
|
|
167
|
+
const quiz = await apiClient.get(apiClient.le(courseId, `/quizzes/${quizId}`), {
|
|
168
|
+
ttl: DEFAULT_CACHE_TTLS.assignments,
|
|
169
|
+
});
|
|
170
|
+
return { quiz, source: "courseContent", topicId: topic.TopicId, ...exempt };
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
if (isAuthUnavailable(error))
|
|
174
|
+
throw error;
|
|
175
|
+
log("DEBUG", `Failed to fetch content-linked quiz ${quizId}: using content metadata`, error);
|
|
176
|
+
}
|
|
77
177
|
}
|
|
78
178
|
let detail = topic;
|
|
179
|
+
if (mayRead) {
|
|
180
|
+
try {
|
|
181
|
+
detail = await apiClient.get(apiClient.le(courseId, `/content/topics/${topic.TopicId}`), { ttl: DEFAULT_CACHE_TTLS.courseContent });
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (isAuthUnavailable(error))
|
|
185
|
+
throw error;
|
|
186
|
+
log("DEBUG", `Failed to fetch content topic ${topic.TopicId}: using table-of-contents metadata`, error);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
source: "courseContent",
|
|
191
|
+
topicId: topic.TopicId,
|
|
192
|
+
...exempt,
|
|
193
|
+
quiz: {
|
|
194
|
+
QuizId: quizId,
|
|
195
|
+
Name: detail.Title || topic.Title,
|
|
196
|
+
Description: detail.Description ?? null,
|
|
197
|
+
StartDate: detail.StartDate ?? detail.StartDateTime ?? topic.StartDateTime ?? null,
|
|
198
|
+
EndDate: detail.EndDate ?? detail.EndDateTime ?? topic.EndDateTime ?? null,
|
|
199
|
+
DueDate: detail.DueDate ?? null,
|
|
200
|
+
IsActive: true,
|
|
201
|
+
AttemptsAllowed: null,
|
|
202
|
+
ContentMetadataOnly: true,
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}));
|
|
206
|
+
return { found, considered: topics.length, read: readable };
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Content quizzes per course whose own record (and, failing that, content
|
|
210
|
+
* topic) is read. Each may cost two requests, so a course costs at most 60.
|
|
211
|
+
* Past the cap a quiz is still listed, from the table of contents alone, and
|
|
212
|
+
* the course's quizCoverage says the cap applied.
|
|
213
|
+
*/
|
|
214
|
+
const MAX_CONTENT_QUIZ_RECOVERIES = 30;
|
|
215
|
+
/**
|
|
216
|
+
* Probes per course. A course whose quiz tool is known (a listed or content
|
|
217
|
+
* quiz sits on it) is asked about that tool's other columns, up to 20. With no
|
|
218
|
+
* known quiz tool every non-dropbox column is a candidate, so far fewer are
|
|
219
|
+
* asked: most of them are discussions, surveys or manual items.
|
|
220
|
+
*/
|
|
221
|
+
const MAX_GRADEBOOK_QUIZ_PROBES = 20;
|
|
222
|
+
const MAX_UNTARGETED_GRADEBOOK_QUIZ_PROBES = 5;
|
|
223
|
+
/**
|
|
224
|
+
* A quiz read that answered 404 or 403 will answer the same again soon, so it
|
|
225
|
+
* is not repeated for ten minutes. Kept per client (so per signed-in account)
|
|
226
|
+
* and only for those two answers: a 401 is a sign-in matter and a 5xx or a
|
|
227
|
+
* network failure may pass.
|
|
228
|
+
*/
|
|
229
|
+
const PROBE_REFUSAL_TTL_MS = 10 * 60 * 1000;
|
|
230
|
+
const refusedProbes = new WeakMap();
|
|
231
|
+
function probeRecentlyRefused(apiClient, path, now) {
|
|
232
|
+
const until = refusedProbes.get(apiClient)?.get(path);
|
|
233
|
+
if (until === undefined)
|
|
234
|
+
return false;
|
|
235
|
+
if (until > now)
|
|
236
|
+
return true;
|
|
237
|
+
refusedProbes.get(apiClient).delete(path);
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
function rememberRefusedProbe(apiClient, path, now) {
|
|
241
|
+
let paths = refusedProbes.get(apiClient);
|
|
242
|
+
if (!paths) {
|
|
243
|
+
paths = new Map();
|
|
244
|
+
refusedProbes.set(apiClient, paths);
|
|
245
|
+
}
|
|
246
|
+
paths.set(path, now + PROBE_REFUSAL_TTL_MS);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Quizzes behind gradebook columns that nothing else accounted for. A column
|
|
250
|
+
* linked to a tool item (AssociatedTool) the quiz list and content both omit
|
|
251
|
+
* may still be a quiz the student can read by id, so it is asked for once.
|
|
252
|
+
* The answer is accepted only when the quiz names that very column as its
|
|
253
|
+
* GradeItemId: a discussion or survey that happens to share the id answers
|
|
254
|
+
* 404 or names a different column, and stays a bare gradebook row.
|
|
255
|
+
*
|
|
256
|
+
* A column the instructor hid from students is not asked. The folder ids
|
|
257
|
+
* include hidden folders, whose columns are never asked either.
|
|
258
|
+
* Tools already seen on a dropbox folder are not asked; once a quiz tool is
|
|
259
|
+
* known (from a listed or content quiz), only that tool's other columns are.
|
|
260
|
+
* A column named like an id-less content quiz is that quiz, not a new one.
|
|
261
|
+
*/
|
|
262
|
+
async function recoverGradebookQuizzes(apiClient, courseId, columns, folderIds, quizIds, contentQuizTools, idlessQuizTitles) {
|
|
263
|
+
const toolIdsOf = (ids) => new Set(columns
|
|
264
|
+
.filter((c) => ids.has(c.AssociatedTool?.ToolItemId))
|
|
265
|
+
.map((c) => c.AssociatedTool.ToolId));
|
|
266
|
+
const quizTools = new Set([...toolIdsOf(quizIds), ...contentQuizTools]);
|
|
267
|
+
const folderTools = toolIdsOf(folderIds);
|
|
268
|
+
const now = Date.now();
|
|
269
|
+
const candidates = columns
|
|
270
|
+
.filter((c) => {
|
|
271
|
+
const itemId = c.AssociatedTool?.ToolItemId;
|
|
272
|
+
if (typeof itemId !== "number" || !Number.isSafeInteger(itemId) || itemId <= 0)
|
|
273
|
+
return false;
|
|
274
|
+
if (c.IsHidden === true)
|
|
275
|
+
return false;
|
|
276
|
+
if (quizIds.has(itemId) || folderIds.has(itemId))
|
|
277
|
+
return false;
|
|
278
|
+
if (idlessQuizTitles.has(normalizedTitle(c.Name)))
|
|
279
|
+
return false;
|
|
280
|
+
if (probeRecentlyRefused(apiClient, apiClient.le(courseId, `/quizzes/${itemId}`), now))
|
|
281
|
+
return false;
|
|
282
|
+
if (quizTools.size > 0)
|
|
283
|
+
return quizTools.has(c.AssociatedTool.ToolId);
|
|
284
|
+
return !folderTools.has(c.AssociatedTool.ToolId);
|
|
285
|
+
})
|
|
286
|
+
.slice(0, quizTools.size > 0 ? MAX_GRADEBOOK_QUIZ_PROBES : MAX_UNTARGETED_GRADEBOOK_QUIZ_PROBES);
|
|
287
|
+
const found = await Promise.all(candidates.map(async (column) => {
|
|
288
|
+
const itemId = column.AssociatedTool.ToolItemId;
|
|
289
|
+
const path = apiClient.le(courseId, `/quizzes/${itemId}`);
|
|
79
290
|
try {
|
|
80
|
-
|
|
291
|
+
const quiz = await apiClient.get(path, { ttl: DEFAULT_CACHE_TTLS.assignments });
|
|
292
|
+
if (quiz?.QuizId === itemId && quiz.GradeItemId === column.Id) {
|
|
293
|
+
return { quiz, source: "gradebook" };
|
|
294
|
+
}
|
|
81
295
|
}
|
|
82
296
|
catch (error) {
|
|
83
297
|
if (isAuthUnavailable(error))
|
|
84
298
|
throw error;
|
|
85
|
-
|
|
299
|
+
if (error?.status === 404 || error?.status === 403)
|
|
300
|
+
rememberRefusedProbe(apiClient, path, now);
|
|
301
|
+
log("DEBUG", `Gradebook column ${column.Id} is not a readable quiz (${itemId})`, error);
|
|
86
302
|
}
|
|
87
|
-
return
|
|
88
|
-
QuizId: quizId,
|
|
89
|
-
Name: detail.Title || topic.Title,
|
|
90
|
-
Description: detail.Description ?? null,
|
|
91
|
-
StartDate: detail.StartDate ?? detail.StartDateTime ?? topic.StartDateTime ?? null,
|
|
92
|
-
EndDate: detail.EndDate ?? detail.EndDateTime ?? topic.EndDateTime ?? null,
|
|
93
|
-
DueDate: detail.DueDate ?? null,
|
|
94
|
-
IsActive: true,
|
|
95
|
-
AttemptsAllowed: null,
|
|
96
|
-
ContentMetadataOnly: true,
|
|
97
|
-
};
|
|
303
|
+
return null;
|
|
98
304
|
}));
|
|
305
|
+
return found.filter((item) => item !== null);
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* The content table of contents with date-restricted items included. D2L's
|
|
309
|
+
* default view omits modules and topics outside their date window, which is
|
|
310
|
+
* exactly where a closed (or not yet opened) quiz sits; ignoreDateRestrictions
|
|
311
|
+
* is documented to be open to students, and supported from LE 1.67, which the
|
|
312
|
+
* client's latest version covers. D2L documents that unsupported parameters
|
|
313
|
+
* are ignored, so the plain view is only a fallback for a request the tenant
|
|
314
|
+
* rejects outright (400, 403, 404), reported as such so coverage is not
|
|
315
|
+
* overstated. Anything else (429, a 5xx, a dropped connection) says nothing
|
|
316
|
+
* about the parameter, so it is not retried here: the content counts as unread.
|
|
317
|
+
*/
|
|
318
|
+
async function fetchContentToc(apiClient, courseId) {
|
|
319
|
+
try {
|
|
320
|
+
const toc = await apiClient.get(apiClient.le(courseId, "/content/toc?ignoreDateRestrictions=true&ignoreModuleDateRestrictions=true"), { ttl: DEFAULT_CACHE_TTLS.courseContent });
|
|
321
|
+
return { toc, datesIncluded: true };
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
if (isAuthUnavailable(error))
|
|
325
|
+
throw error;
|
|
326
|
+
const status = error?.status;
|
|
327
|
+
if (status !== 400 && status !== 403 && status !== 404)
|
|
328
|
+
throw error;
|
|
329
|
+
log("DEBUG", `Content table of contents with date restrictions ignored was refused (${status}) for course ${courseId}: using the default view`, error);
|
|
330
|
+
}
|
|
331
|
+
const toc = await apiClient.get(apiClient.le(courseId, "/content/toc"), {
|
|
332
|
+
ttl: DEFAULT_CACHE_TTLS.courseContent,
|
|
333
|
+
});
|
|
334
|
+
return { toc, datesIncluded: false };
|
|
335
|
+
}
|
|
336
|
+
const DETAILS_UNAVAILABLE_NOTE = "Brightspace showed this quiz in the course content but would not return its details to you; " +
|
|
337
|
+
"it may be closed, not yet open, or limited to certain students. Open the link to check.";
|
|
338
|
+
/**
|
|
339
|
+
* Whether a quiz is open, from its own start and end dates alone: before the
|
|
340
|
+
* start it is not yet open, after the end it is closed. With neither date
|
|
341
|
+
* (or none that parses) nothing is said, and a due date never closes a quiz.
|
|
342
|
+
*/
|
|
343
|
+
function availabilityField(startDate, endDate, now = Date.now()) {
|
|
344
|
+
const start = startDate ? new Date(startDate).getTime() : NaN;
|
|
345
|
+
const end = endDate ? new Date(endDate).getTime() : NaN;
|
|
346
|
+
if (!Number.isFinite(start) && !Number.isFinite(end))
|
|
347
|
+
return {};
|
|
348
|
+
if (Number.isFinite(start) && now < start)
|
|
349
|
+
return { availability: "notYetOpen" };
|
|
350
|
+
if (Number.isFinite(end) && now > end)
|
|
351
|
+
return { availability: "closed" };
|
|
352
|
+
return { availability: "open" };
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Coverage of the three routes quizzes are found through. Any one that did
|
|
356
|
+
* not answer may hide quizzes the others cannot see, so the inventory is only
|
|
357
|
+
* complete when all three did.
|
|
358
|
+
*/
|
|
359
|
+
function quizCoverage(answered) {
|
|
360
|
+
const unread = [
|
|
361
|
+
answered.quizList ? null : "the course's quiz list",
|
|
362
|
+
answered.content === "full" ? null
|
|
363
|
+
: answered.content === "dateRestricted"
|
|
364
|
+
? "the course content with date-restricted items included (quizzes whose module or access window has not opened or has closed are hidden from the default view)"
|
|
365
|
+
: "the course content (checked for quizzes the quiz list omits)",
|
|
366
|
+
answered.gradebook ? null : "the gradebook (checked for graded work the quiz list omits)",
|
|
367
|
+
].filter((source) => source !== null);
|
|
368
|
+
const recovery = answered.contentRecovery;
|
|
369
|
+
const truncated = recovery !== undefined && recovery.read < recovery.considered;
|
|
370
|
+
if (unread.length === 0 && !truncated)
|
|
371
|
+
return { quizCoverage: "complete" };
|
|
372
|
+
return {
|
|
373
|
+
quizCoverage: "incomplete",
|
|
374
|
+
quizCoverageNote: (unread.length > 0
|
|
375
|
+
? `Brightspace did not return ${unread.join(", or ")}, so this course may have quizzes not listed here. `
|
|
376
|
+
: "") +
|
|
377
|
+
(truncated
|
|
378
|
+
? `Brightspace read the details of only the first ${recovery.read} of ${recovery.considered} quizzes found in the course content; ` +
|
|
379
|
+
"the rest are listed from the course content alone, without their details or attempts. "
|
|
380
|
+
: "") +
|
|
381
|
+
"Do not treat the listed quizzes as all of them, and do not report an unlisted quiz as missing or not taken - " +
|
|
382
|
+
"tell the user to check Brightspace directly.",
|
|
383
|
+
};
|
|
99
384
|
}
|
|
100
385
|
/**
|
|
101
386
|
* Fetch assignments (dropbox + quizzes) for a single course
|
|
@@ -103,6 +388,10 @@ async function recoverContentQuizzes(apiClient, courseId, toc, listedIds) {
|
|
|
103
388
|
* baseUrl is optional: without it the items carry a null url.
|
|
104
389
|
*/
|
|
105
390
|
export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
391
|
+
return (await fetchCourseInventory(apiClient, courseId, baseUrl)).assignments;
|
|
392
|
+
}
|
|
393
|
+
/** A course's assignments and quizzes, with whether its quiz list is complete. */
|
|
394
|
+
async function fetchCourseInventory(apiClient, courseId, baseUrl) {
|
|
106
395
|
const assignments = [];
|
|
107
396
|
const assignmentLink = assignmentLinkResolver(apiClient, baseUrl, courseId);
|
|
108
397
|
// Fetch dropbox folders, quizzes, and the gradebook in parallel. The
|
|
@@ -118,9 +407,7 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
118
407
|
apiClient.get(apiClient.le(courseId, "/grades/"), {
|
|
119
408
|
ttl: DEFAULT_CACHE_TTLS.assignments,
|
|
120
409
|
}),
|
|
121
|
-
|
|
122
|
-
ttl: DEFAULT_CACHE_TTLS.courseContent,
|
|
123
|
-
}),
|
|
410
|
+
fetchContentToc(apiClient, courseId),
|
|
124
411
|
]);
|
|
125
412
|
// Each route may fail on its own without costing the others, but a failed
|
|
126
413
|
// sign-in means none of them answered: report that, not an empty course.
|
|
@@ -128,15 +415,21 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
128
415
|
if (result.status === "rejected" && isAuthUnavailable(result.reason))
|
|
129
416
|
throw result.reason;
|
|
130
417
|
}
|
|
418
|
+
// Ids of folders and quizzes the instructor hid or inactivated and that are
|
|
419
|
+
// therefore left out on purpose; their grade columns are not "uncovered".
|
|
420
|
+
const skippedIds = new Set();
|
|
131
421
|
// Process Dropbox folders
|
|
132
422
|
if (dropboxResult.status === "fulfilled") {
|
|
133
423
|
// D2L dropbox endpoint may return paged { Objects: [...] } or flat array
|
|
134
424
|
const dropboxRaw = dropboxResult.value;
|
|
135
425
|
const folders = Array.isArray(dropboxRaw) ? dropboxRaw : dropboxRaw.Objects ?? [];
|
|
136
426
|
for (const folder of folders) {
|
|
137
|
-
// Skip hidden folders
|
|
138
|
-
|
|
427
|
+
// Skip hidden folders. Their ids are remembered: the instructor hid the
|
|
428
|
+
// work, so its grade column must not resurface as a gradebook row.
|
|
429
|
+
if (folder.IsHidden) {
|
|
430
|
+
skippedIds.add(folder.Id);
|
|
139
431
|
continue;
|
|
432
|
+
}
|
|
140
433
|
// Fetch submissions for this folder. Some tenants deny students access
|
|
141
434
|
// to mysubmissions outright (403), in which case an empty list would be
|
|
142
435
|
// indistinguishable from genuinely unsubmitted work — track whether the
|
|
@@ -299,32 +592,50 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
299
592
|
}
|
|
300
593
|
// Process quizzes. Content discovery remains useful when the list route is
|
|
301
594
|
// forbidden or unavailable, so a failed list starts from an empty set.
|
|
302
|
-
|
|
595
|
+
const quizzes = [];
|
|
303
596
|
if (quizResult.status === "fulfilled") {
|
|
304
|
-
quizzes
|
|
597
|
+
quizzes.push(...quizResult.value.map((quiz) => ({ quiz, source: "quizList" })));
|
|
305
598
|
}
|
|
306
599
|
else {
|
|
307
600
|
log("DEBUG", `Failed to fetch quizzes for course ${courseId}`, quizResult.reason);
|
|
308
601
|
}
|
|
602
|
+
const knownQuizIds = () => new Set(quizzes.map((found) => found.quiz.QuizId).filter((id) => typeof id === "number"));
|
|
603
|
+
const knownQuizTitles = () => new Set(quizzes.map((found) => normalizedTitle(found.quiz.Name)));
|
|
604
|
+
const gradebookColumns = gradebookResult.status === "fulfilled"
|
|
605
|
+
? studentScoredColumns(gradebookResult.value)
|
|
606
|
+
: [];
|
|
607
|
+
let contentRecovery;
|
|
309
608
|
if (contentResult.status === "fulfilled") {
|
|
310
|
-
const
|
|
311
|
-
quizzes.push(...
|
|
609
|
+
const recovered = await recoverContentQuizzes(apiClient, courseId, contentResult.value.toc, knownQuizIds(), knownQuizTitles(), new Map(gradebookColumns.map((column) => [column.Id, column])));
|
|
610
|
+
quizzes.push(...recovered.found);
|
|
611
|
+
contentRecovery = { considered: recovered.considered, read: recovered.read };
|
|
312
612
|
}
|
|
313
613
|
else {
|
|
314
614
|
log("DEBUG", `Failed to fetch course content for quiz discovery in course ${courseId}`, contentResult.reason);
|
|
315
615
|
}
|
|
616
|
+
if (gradebookResult.status === "fulfilled") {
|
|
617
|
+
const folderIds = new Set([
|
|
618
|
+
...assignments.map((item) => item.id).filter((id) => typeof id === "number"),
|
|
619
|
+
...skippedIds,
|
|
620
|
+
]);
|
|
621
|
+
quizzes.push(...await recoverGradebookQuizzes(apiClient, courseId, gradebookColumns, folderIds, knownQuizIds(), contentResult.status === "fulfilled" ? contentQuizToolIds(contentResult.value.toc) : new Set(), new Set(quizzes.filter((found) => found.quiz.QuizId === null).map((found) => normalizedTitle(found.quiz.Name)))));
|
|
622
|
+
}
|
|
316
623
|
// Students on this tenant get 403 from /quizzes/{id}/attempts/. Once the
|
|
317
624
|
// first quiz of a course proves that, the remaining quizzes are not asked:
|
|
318
625
|
// the answer would be the same 403, at one wasted request each.
|
|
319
626
|
let attemptsForbidden = false;
|
|
320
|
-
for (const quiz of quizzes) {
|
|
321
|
-
// Skip inactive
|
|
322
|
-
|
|
627
|
+
for (const { quiz, source, topicId, exempt } of quizzes) {
|
|
628
|
+
// Skip quizzes the instructor marked inactive. A row that does not say
|
|
629
|
+
// is not inactive: only an explicit false hides a quiz.
|
|
630
|
+
if (quiz.IsActive === false) {
|
|
631
|
+
if (typeof quiz.QuizId === "number")
|
|
632
|
+
skippedIds.add(quiz.QuizId);
|
|
323
633
|
continue;
|
|
634
|
+
}
|
|
324
635
|
// Fetch quiz attempts. null means "not measured", which is different
|
|
325
636
|
// from an empty list, and the output says which one it was.
|
|
326
637
|
let attempts = null;
|
|
327
|
-
if (!attemptsForbidden && !quiz.ContentMetadataOnly) {
|
|
638
|
+
if (!attemptsForbidden && !quiz.ContentMetadataOnly && quiz.QuizId !== null) {
|
|
328
639
|
try {
|
|
329
640
|
const attemptsRaw = await apiClient.get(apiClient.le(courseId, `/quizzes/${quiz.QuizId}/attempts/`), { ttl: DEFAULT_CACHE_TTLS.assignments });
|
|
330
641
|
// D2L attempts endpoint may return paged { Objects: [...] } or flat array
|
|
@@ -334,8 +645,14 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
334
645
|
if (isAuthUnavailable(error))
|
|
335
646
|
throw error;
|
|
336
647
|
if (error?.status === 404) {
|
|
337
|
-
//
|
|
338
|
-
attempts
|
|
648
|
+
// For a quiz the course's quiz list returned, 404 means no
|
|
649
|
+
// attempts yet: a measurement of zero. A quiz that list did not
|
|
650
|
+
// return is one the quiz API is already hiding from this student,
|
|
651
|
+
// so the same 404 may be that, and says nothing about attempts.
|
|
652
|
+
if (source === "quizList")
|
|
653
|
+
attempts = [];
|
|
654
|
+
else
|
|
655
|
+
log("DEBUG", `Attempts for quiz ${quiz.QuizId} answered 404 and it is not a listed quiz: leaving them unknown`);
|
|
339
656
|
}
|
|
340
657
|
else if (error?.status === 403) {
|
|
341
658
|
attemptsForbidden = true;
|
|
@@ -370,8 +687,17 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
370
687
|
const quizAssignment = {
|
|
371
688
|
type: "quiz",
|
|
372
689
|
id: quiz.QuizId,
|
|
690
|
+
...(quiz.QuizId === null && topicId !== undefined ? { topicId } : {}),
|
|
373
691
|
name: quiz.Name,
|
|
374
|
-
|
|
692
|
+
// A content quiz with no quiz id links to its content topic instead.
|
|
693
|
+
url: !baseUrl
|
|
694
|
+
? null
|
|
695
|
+
: quiz.QuizId !== null
|
|
696
|
+
? quizUrl(baseUrl, courseId, quiz.QuizId)
|
|
697
|
+
: topicId !== undefined ? contentTopicUrl(baseUrl, courseId, topicId) : null,
|
|
698
|
+
// Which route found this quiz: the course's quiz list, a quiz topic
|
|
699
|
+
// in the course content, or a gradebook column linked to it.
|
|
700
|
+
source,
|
|
375
701
|
instructions: descriptionHtml
|
|
376
702
|
? convertHtmlToMarkdown(descriptionHtml).markdown
|
|
377
703
|
: "",
|
|
@@ -404,17 +730,40 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
404
730
|
: null,
|
|
405
731
|
gracePeriodMinutes: quiz.SubmissionGracePeriod ?? null,
|
|
406
732
|
hasPassword: Boolean(quiz.Password),
|
|
733
|
+
// Additive fields, each emitted only when it has something to say.
|
|
734
|
+
// availability is computed from the quiz's own start and end dates.
|
|
735
|
+
...availabilityField(quiz.StartDate, quiz.EndDate),
|
|
736
|
+
// A row built from the table of contents alone: the quiz's own record
|
|
737
|
+
// was refused, or the topic names no quiz id to ask for.
|
|
738
|
+
...(quiz.ContentMetadataOnly ? {
|
|
739
|
+
detailsStatus: "unavailable",
|
|
740
|
+
detailsStatusNote: DETAILS_UNAVAILABLE_NOTE,
|
|
741
|
+
} : {}),
|
|
742
|
+
// The topic's IsExempt: the user is exempt from the topic. Kept
|
|
743
|
+
// listed, since exempt does not mean it cannot be opened.
|
|
744
|
+
...(exempt ? { exempt: true } : {}),
|
|
407
745
|
};
|
|
408
746
|
assignments.push(quizAssignment);
|
|
409
747
|
}
|
|
410
748
|
// Process the gradebook last, once the fetched items are known.
|
|
411
749
|
if (gradebookResult.status === "fulfilled") {
|
|
412
|
-
assignments.push(...gradebookHeadsUp(gradebookResult.value, assignments, courseId, baseUrl));
|
|
750
|
+
assignments.push(...gradebookHeadsUp(gradebookResult.value, assignments, courseId, baseUrl, skippedIds));
|
|
413
751
|
}
|
|
414
752
|
else {
|
|
415
753
|
log("DEBUG", `Failed to fetch the gradebook for course ${courseId}`, gradebookResult.reason);
|
|
416
754
|
}
|
|
417
|
-
return
|
|
755
|
+
return {
|
|
756
|
+
assignments,
|
|
757
|
+
...quizCoverage({
|
|
758
|
+
// A 404 on the list means the course has no quiz tool: answered, and empty.
|
|
759
|
+
quizList: quizResult.status === "fulfilled" || quizResult.reason?.status === 404,
|
|
760
|
+
content: contentResult.status !== "fulfilled"
|
|
761
|
+
? "failed"
|
|
762
|
+
: contentResult.value.datesIncluded ? "full" : "dateRestricted",
|
|
763
|
+
gradebook: gradebookResult.status === "fulfilled",
|
|
764
|
+
contentRecovery,
|
|
765
|
+
}),
|
|
766
|
+
};
|
|
418
767
|
}
|
|
419
768
|
/**
|
|
420
769
|
* Gradebook columns describing work that no other route already offered.
|
|
@@ -429,18 +778,32 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
429
778
|
* A column with no id or no name is skipped rather than fatal: one malformed
|
|
430
779
|
* row should cost its own line, not the course's other heads-up rows.
|
|
431
780
|
*/
|
|
432
|
-
function gradebookHeadsUp(raw, fetched, courseId, baseUrl) {
|
|
781
|
+
function gradebookHeadsUp(raw, fetched, courseId, baseUrl, skippedIds = new Set()) {
|
|
433
782
|
if (!Array.isArray(raw))
|
|
434
783
|
return [];
|
|
435
|
-
|
|
784
|
+
// Fetched items, plus the hidden folders and inactive quizzes that were left
|
|
785
|
+
// out on purpose: a column of one of those is not work the tool missed.
|
|
786
|
+
const covered = new Set([
|
|
787
|
+
...fetched.map((item) => item.id).filter((id) => typeof id === "number"),
|
|
788
|
+
...skippedIds,
|
|
789
|
+
]);
|
|
790
|
+
// A content quiz that names no id cannot be matched to its column by id, so
|
|
791
|
+
// a column carrying the same title is taken to be that quiz.
|
|
792
|
+
const idlessQuizTitles = new Set(fetched
|
|
793
|
+
.filter((item) => item.type === "quiz" && item.id === null)
|
|
794
|
+
.map((item) => normalizedTitle(item.name)));
|
|
436
795
|
const rows = [];
|
|
437
796
|
for (const column of raw) {
|
|
438
|
-
if (!
|
|
797
|
+
if (!isStudentScored(column))
|
|
798
|
+
continue;
|
|
799
|
+
if (column.IsHidden === true)
|
|
439
800
|
continue;
|
|
440
801
|
if (covered.has(column.AssociatedTool?.ToolItemId))
|
|
441
802
|
continue;
|
|
442
803
|
if (typeof column.Id !== "number" || typeof column.Name !== "string")
|
|
443
804
|
continue;
|
|
805
|
+
if (idlessQuizTitles.has(normalizedTitle(column.Name)))
|
|
806
|
+
continue;
|
|
444
807
|
rows.push({
|
|
445
808
|
type: "gradeOnly",
|
|
446
809
|
id: column.Id,
|
|
@@ -462,7 +825,13 @@ export function registerGetAssignments(server, apiClient, config) {
|
|
|
462
825
|
title: "Get Assignments",
|
|
463
826
|
annotations: { readOnlyHint: true },
|
|
464
827
|
description: "Fetch assignments and quizzes for a specific course or all enrolled courses. Shows dropbox submissions and quizzes with due dates, status, and rubric info. Use this when the user asks about assignments, homework, what to submit, quizzes, or assignment details and rubrics. " +
|
|
465
|
-
"Read submissionStatus/attemptStatus, not just submission/attemptsUsed, to decide whether something was turned in: some tenants deny students access to submission or attempt data, in which case the status is \"unknown\" even though submission is null or attemptsUsed is 0. Never report an \"unknown\" item as missing, unsubmitted, or not attempted - say it could not be verified and point the user to Brightspace."
|
|
828
|
+
"Read submissionStatus/attemptStatus, not just submission/attemptsUsed, to decide whether something was turned in: some tenants deny students access to submission or attempt data, in which case the status is \"unknown\" even though submission is null or attemptsUsed is 0. Never report an \"unknown\" item as missing, unsubmitted, or not attempted - say it could not be verified and point the user to Brightspace. " +
|
|
829
|
+
"Quizzes come from the quiz list, the course content, and the gradebook; each quiz row says which in source. A quiz with id null was found only as a content topic. " +
|
|
830
|
+
"A quiz row with detailsStatus \"unavailable\" was shown in the course content but Brightspace would not return its details (it may be closed, not yet open, or limited to certain students), so its dates and attempts may be missing: point the user to its link. " +
|
|
831
|
+
"availability (notYetOpen, open, closed) is worked out from the quiz's own start and end dates and is left out when it has none. exempt: true means Brightspace marks the user exempt from that quiz's topic; the quiz is still listed. " +
|
|
832
|
+
"Each course also carries quizCoverage. \"complete\" means every quiz source answered, not that every quiz is guaranteed to be listed; \"incomplete\" means a quiz source could not be read, so the course may have quizzes not listed. " +
|
|
833
|
+
"Never conclude that a quiz was missed or not taken because it is absent, and never present the listed quizzes as all of them. " +
|
|
834
|
+
"A gradeOnly row named like a quiz is a quiz whose details could not be read.",
|
|
466
835
|
inputSchema: GetAssignmentsSchema,
|
|
467
836
|
}, async (args) => {
|
|
468
837
|
try {
|
|
@@ -472,9 +841,9 @@ export function registerGetAssignments(server, apiClient, config) {
|
|
|
472
841
|
// Single course case
|
|
473
842
|
if (courseId) {
|
|
474
843
|
try {
|
|
475
|
-
const
|
|
476
|
-
log("INFO", `get_assignments: Retrieved ${assignments.length} assignments for course ${courseId}`);
|
|
477
|
-
return toolResponse({ courseId,
|
|
844
|
+
const inventory = await fetchCourseInventory(apiClient, courseId, config.baseUrl);
|
|
845
|
+
log("INFO", `get_assignments: Retrieved ${inventory.assignments.length} assignments for course ${courseId}`);
|
|
846
|
+
return toolResponse({ courseId, ...inventory });
|
|
478
847
|
}
|
|
479
848
|
catch (error) {
|
|
480
849
|
// A pending sign-in is not an empty course: the routes never
|
|
@@ -520,11 +889,11 @@ export function registerGetAssignments(server, apiClient, config) {
|
|
|
520
889
|
// Fetch assignments for each course (handle 403s gracefully)
|
|
521
890
|
const assignmentPromises = filteredEnrollments.map(async (item) => {
|
|
522
891
|
try {
|
|
523
|
-
const
|
|
892
|
+
const inventory = await fetchCourseInventory(apiClient, item.OrgUnit.Id, config.baseUrl);
|
|
524
893
|
return {
|
|
525
894
|
courseId: item.OrgUnit.Id,
|
|
526
895
|
courseName: item.OrgUnit.Name,
|
|
527
|
-
|
|
896
|
+
...inventory,
|
|
528
897
|
};
|
|
529
898
|
}
|
|
530
899
|
catch (error) {
|
|
@@ -557,7 +926,7 @@ export function registerGetAssignments(server, apiClient, config) {
|
|
|
557
926
|
const pending = settled.filter((c) => c.authPending);
|
|
558
927
|
const courses = settled
|
|
559
928
|
.filter((c) => !c.authPending)
|
|
560
|
-
.map(({
|
|
929
|
+
.map(({ authPending: _authPending, authError: _authError, ...course }) => course);
|
|
561
930
|
log("INFO", `get_assignments: Retrieved assignments for ${courses.length} courses ` +
|
|
562
931
|
`(out of ${enrollmentItems.length} enrolled${pending.length > 0 ? `, ${pending.length} pending sign-in` : ""})`);
|
|
563
932
|
// Pending courses keep `assignments: []` so callers that read
|
|
@@ -191,7 +191,16 @@ export function registerGetMyGrades(server, apiClient, config) {
|
|
|
191
191
|
authError: error,
|
|
192
192
|
};
|
|
193
193
|
}
|
|
194
|
-
|
|
194
|
+
// Anything else (a 404, a 5xx, a dropped connection) still must
|
|
195
|
+
// not make the course vanish: the caller would read the result
|
|
196
|
+
// as every course's grades, with one silently missing (#245).
|
|
197
|
+
log("DEBUG", `get_my_grades: grades unavailable for course ${item.OrgUnit.Id} (${item.OrgUnit.Name})`, error);
|
|
198
|
+
return {
|
|
199
|
+
failed: true,
|
|
200
|
+
courseId: item.OrgUnit.Id,
|
|
201
|
+
courseName: item.OrgUnit.Name,
|
|
202
|
+
gradeUrl: gradebookUrl(config.baseUrl, item.OrgUnit.Id),
|
|
203
|
+
};
|
|
195
204
|
}
|
|
196
205
|
});
|
|
197
206
|
const results = await Promise.allSettled(gradePromises);
|
|
@@ -202,12 +211,16 @@ export function registerGetMyGrades(server, apiClient, config) {
|
|
|
202
211
|
const restrictedCourses = settled
|
|
203
212
|
.filter((c) => c.restricted)
|
|
204
213
|
.map(({ restricted: _restricted, ...rest }) => rest);
|
|
214
|
+
const failedCourses = settled
|
|
215
|
+
.filter((c) => c.failed)
|
|
216
|
+
.map(({ failed: _failed, ...rest }) => rest);
|
|
205
217
|
const courses = settled
|
|
206
|
-
.filter((c) => !c.authPending && !c.restricted)
|
|
218
|
+
.filter((c) => !c.authPending && !c.restricted && !c.failed)
|
|
207
219
|
.map(({ courseId, courseName, grades }) => ({ courseId, courseName, grades }));
|
|
208
220
|
log("INFO", `get_my_grades: Retrieved grades for ${courses.length} courses, ` +
|
|
209
221
|
`${restrictedCourses.length} restricted (out of ${enrollmentItems.length} enrolled` +
|
|
210
|
-
`${pending.length > 0 ? `, ${pending.length} pending sign-in` : ""}
|
|
222
|
+
`${pending.length > 0 ? `, ${pending.length} pending sign-in` : ""}` +
|
|
223
|
+
`${failedCourses.length > 0 ? `, ${failedCourses.length} failed` : ""})`);
|
|
211
224
|
// Pending courses keep `grades: []` so callers that read
|
|
212
225
|
// `courses[i].grades` still get an array; authPending marks it as
|
|
213
226
|
// unchecked rather than empty. `restrictedCourses` is always present
|
|
@@ -224,15 +237,26 @@ export function registerGetMyGrades(server, apiClient, config) {
|
|
|
224
237
|
],
|
|
225
238
|
restrictedCourses,
|
|
226
239
|
};
|
|
240
|
+
const notices = [];
|
|
227
241
|
if (pending.length > 0) {
|
|
228
242
|
response.authPending = true;
|
|
229
243
|
response.unavailableCourseIds = pending.map((c) => c.courseId);
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
244
|
+
notices.push("Sign-in to Brightspace is still in progress, so grades for " +
|
|
245
|
+
`${pending.length} course(s) (${pending.map((c) => c.courseId).join(", ")}) could not be ` +
|
|
246
|
+
`fetched yet. ${authPendingNotice(pending[0].authError)} Call get_my_grades again once ` +
|
|
247
|
+
"sign-in finishes.");
|
|
248
|
+
}
|
|
249
|
+
// Only present when something failed, so the usual response shape
|
|
250
|
+
// is unchanged; `courses` never includes these.
|
|
251
|
+
if (failedCourses.length > 0) {
|
|
252
|
+
response.failedCourses = failedCourses;
|
|
253
|
+
notices.push(`Grades for ${failedCourses.length} course(s) ` +
|
|
254
|
+
`(${failedCourses.map((c) => c.courseId).join(", ")}) could not be read from Brightspace, ` +
|
|
255
|
+
"so they are listed in failedCourses instead of courses. Do not report these courses as " +
|
|
256
|
+
"having no grades: open each gradeUrl, or call get_my_grades again for that courseId.");
|
|
235
257
|
}
|
|
258
|
+
if (notices.length > 0)
|
|
259
|
+
response.notice = notices.join(" ");
|
|
236
260
|
return toolResponse(response);
|
|
237
261
|
}
|
|
238
262
|
catch (error) {
|
|
@@ -29,6 +29,10 @@ export function assignmentUrl(baseUrl, courseId, folderId, groupId = 0) {
|
|
|
29
29
|
export function quizUrl(baseUrl, courseId, quizId) {
|
|
30
30
|
return `${trimBaseUrl(baseUrl)}/d2l/lms/quizzing/user/quiz_summary.d2l?qi=${quizId}&ou=${courseId}`;
|
|
31
31
|
}
|
|
32
|
+
/** Link to a topic in the course content viewer. */
|
|
33
|
+
export function contentTopicUrl(baseUrl, courseId, topicId) {
|
|
34
|
+
return `${trimBaseUrl(baseUrl)}/d2l/le/content/${courseId}/viewContent/${topicId}/View`;
|
|
35
|
+
}
|
|
32
36
|
/**
|
|
33
37
|
* Link to a course's own grades page. There is no per-column page a student
|
|
34
38
|
* may open, so every gradebook row in a course shares this one link.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.13.
|
|
3
|
+
"version": "3.13.19",
|
|
4
4
|
"mcpName": "io.github.RohanMuppa/brightspace",
|
|
5
5
|
"description": "MCP server for Brightspace (D2L). Check grades, due dates, assignments, announcements, syllabus, rosters and more via Claude, ChatGPT, Cursor, Windsurf, or any MCP client.",
|
|
6
6
|
"type": "module",
|