@ivanbard/dd2l 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +47 -8
- package/bin/dd2l.mjs +7 -7
- package/docs/README.md +108 -11
- package/package.json +3 -2
- package/src/app-runner.mjs +58 -35
- package/src/assignment-workspace.mjs +128 -0
- package/src/canvas.mjs +17 -0
- package/src/cli-banner.mjs +58 -0
- package/src/cli.mjs +41 -19
- package/src/client.mjs +150 -137
- package/src/connector.mjs +587 -345
- package/src/discovery.mjs +45 -45
- package/src/documents.mjs +154 -154
- package/src/links.mjs +99 -99
- package/src/llm.mjs +43 -6
- package/src/media.mjs +54 -54
- package/src/ocr.mjs +24 -24
- package/src/office.mjs +61 -61
- package/src/pdf-worker.mjs +26 -26
- package/src/research.mjs +70 -0
- package/src/session.mjs +5 -5
- package/src/store.mjs +108 -106
- package/src/syllabus.mjs +191 -191
- package/src/task-workspace.mjs +138 -0
- package/src/ui-data.mjs +36 -14
- package/src/ui-demo.mjs +17 -1
- package/src/ui-server.mjs +51 -15
- package/src/windows-ocr.ps1 +32 -32
- package/ui/answer-renderer.js +88 -88
- package/ui/answer-view.js +47 -47
- package/ui/app.js +96 -13
- package/ui/assets/widget-announcements.svg +4 -4
- package/ui/assets/widget-shortcuts.svg +5 -5
- package/ui/assignment-workspace.js +106 -0
- package/ui/canvas-contract.js +41 -0
- package/ui/canvas-runtime.js +158 -0
- package/ui/canvas.js +96 -0
- package/ui/index.html +15 -4
- package/ui/loading.js +33 -33
- package/ui/style.css +15 -0
- package/ui/widget-layout.js +39 -39
- package/ui/widgets.js +252 -252
package/src/connector.mjs
CHANGED
|
@@ -1,345 +1,587 @@
|
|
|
1
|
-
import { AccessError } from './client.mjs';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
if (
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
.
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (submitted.length
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
if (!
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
async
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
if (
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
if (
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
if (
|
|
294
|
-
if (
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
1
|
+
import { AccessError, BrightspaceClient } from './client.mjs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { readDocument } from './documents.mjs';
|
|
4
|
+
import { classifyOverview, collectSyllabus } from './syllabus.mjs';
|
|
5
|
+
import { courseNodes, reconcileQuizzes, batch } from './discovery.mjs';
|
|
6
|
+
import { readLinkedDocument, actionLink, linkUrl } from './links.mjs';
|
|
7
|
+
import { descriptionContent } from './documents.mjs';
|
|
8
|
+
import { youtubeId, youtubeTranscript } from './media.mjs';
|
|
9
|
+
|
|
10
|
+
function id(value) {
|
|
11
|
+
const result = String(value);
|
|
12
|
+
if (!/^\d+$/.test(result)) throw new Error('Expected a numeric Brightspace ID.');
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function fields(value) {
|
|
17
|
+
const keys = new Set();
|
|
18
|
+
const visit = (item, depth) => {
|
|
19
|
+
if (!item || typeof item !== 'object' || depth > 8) return;
|
|
20
|
+
if (Array.isArray(item)) { for (const child of item) visit(child, depth + 1); }
|
|
21
|
+
else for (const [key, child] of Object.entries(item)) { keys.add(key); visit(child, depth + 1); }
|
|
22
|
+
};
|
|
23
|
+
visit(value, 0);
|
|
24
|
+
return [...keys].sort();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function readResource(client, category, path, { list = false, courseId, file = false } = {}) {
|
|
28
|
+
const observation = { key: path.split('?')[0], category, course: courseId, source: path,
|
|
29
|
+
retrievedAt: new Date().toISOString(), outcome: 'available' };
|
|
30
|
+
try {
|
|
31
|
+
const result = file ? { data: await readDocument(client, path), complete: true } : list ? await client.collection(path) : { data: await client.get(path), complete: true };
|
|
32
|
+
if (file && ['failed', 'partial', 'unsupported'].includes(result.data.textStatus)) result.complete = false;
|
|
33
|
+
Object.assign(observation, result);
|
|
34
|
+
if (!result.complete) observation.outcome = 'partial';
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (!(error instanceof AccessError) || error.code === 'needs_login') throw error;
|
|
37
|
+
Object.assign(observation, { outcome: error.code === 'partial' ? 'partial' : error.code === 'denied' ? 'denied' : 'failed',
|
|
38
|
+
error: error.code, message: error.message, status: error.status, retryAfter: error.retryAfter, complete: false });
|
|
39
|
+
}
|
|
40
|
+
return observation;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function selectCourse(enrollments, course) {
|
|
44
|
+
const candidates = enrollments.data.filter(item => item?.OrgUnit?.Id && item.Access?.CanAccess === true)
|
|
45
|
+
.sort((a, b) => (Date.parse(b.Access.LastAccessed) || 0) - (Date.parse(a.Access.LastAccessed) || 0));
|
|
46
|
+
const selected = course ? candidates.find(item => String(item.OrgUnit.Id) === id(course)) :
|
|
47
|
+
candidates.find(item => item.Access?.IsActive === true) ?? candidates[0];
|
|
48
|
+
if (course && !selected) throw new Error('Requested course was not confirmed accessible in your enrollments.');
|
|
49
|
+
return selected ? id(selected.OrgUnit.Id) : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function assignmentSamples(folders, grades) {
|
|
53
|
+
const scored = new Set((grades ?? []).filter(grade =>
|
|
54
|
+
grade.PointsNumerator != null || grade.WeightedNumerator != null || grade.ReleasedDate != null)
|
|
55
|
+
.map(grade => String(grade.GradeObjectIdentifier)));
|
|
56
|
+
const withRubric = folders.find(folder => folder.Assessment?.Rubrics?.length);
|
|
57
|
+
const withGrade = folders.find(folder => scored.has(String(folder.GradeItemId)));
|
|
58
|
+
return [...new Set([withGrade ?? withRubric ?? folders[0], withRubric].filter(Boolean))];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function feedbackEntity(folder, identity, submissions, groups) {
|
|
62
|
+
if (folder.DropboxType !== 1 && folder.GroupTypeId == null) return { type: 'user', entityId: id(identity.Identifier) };
|
|
63
|
+
// A group-category ID identifies the assignment's grouping, not a submitter.
|
|
64
|
+
const submitted = [...new Set((submissions ?? []).filter(item => item.Entity?.EntityType === 'Group')
|
|
65
|
+
.map(item => String(item.Entity.EntityId)))];
|
|
66
|
+
if (submitted.length === 1) return { type: 'group', entityId: id(submitted[0]) };
|
|
67
|
+
if (submitted.length > 1 || folder.GroupTypeId == null) return null;
|
|
68
|
+
const memberships = (groups ?? []).filter(group => String(group.GroupCategoryId) === String(folder.GroupTypeId));
|
|
69
|
+
return memberships.length === 1 ? { type: 'group', entityId: id(memberships[0].GroupId) } : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class Connector {
|
|
73
|
+
constructor(client, store) { this.client = client; this.store = store; this.queue = Promise.resolve(); }
|
|
74
|
+
|
|
75
|
+
exclusive(operation, signal) {
|
|
76
|
+
const run = this.queue.then(() => { signal?.throwIfAborted(); return operation(); });
|
|
77
|
+
this.queue = run.catch(() => {});
|
|
78
|
+
if (!signal) return run;
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const abort = () => reject(signal.reason);
|
|
81
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
82
|
+
if (signal.aborted) abort();
|
|
83
|
+
run.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async courses(client = this.client) {
|
|
88
|
+
const versions = await client.versions();
|
|
89
|
+
const identity = await client.get(`/d2l/api/lp/${versions.lp}/users/whoami`);
|
|
90
|
+
const session = await client.session.load();
|
|
91
|
+
if (!identity?.Identifier || String(identity.Identifier) !== String(session.identity.Identifier)) {
|
|
92
|
+
throw new AccessError('needs_login', 'Account identity changed. Sign in again before reading course data.');
|
|
93
|
+
}
|
|
94
|
+
const account = this.store.getMeta('account');
|
|
95
|
+
if (account && account !== `${this.client.origin}:${identity.Identifier}`) throw new AccessError('needs_login', 'Local inventory belongs to another account. Sign out before switching accounts.');
|
|
96
|
+
const enrollments = await client.collection(`/d2l/api/lp/${versions.lp}/enrollments/myenrollments/?orgUnitTypeId=3`);
|
|
97
|
+
return { identity, versions, enrollments };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
syllabus(options) { return this.exclusive(() => this.readSyllabus(options)); }
|
|
101
|
+
|
|
102
|
+
async readSyllabus({ course } = {}) {
|
|
103
|
+
const { identity, versions, enrollments } = await this.courses();
|
|
104
|
+
const courseId = selectCourse(enrollments, course);
|
|
105
|
+
if (!courseId) throw new Error('No accessible course was found.');
|
|
106
|
+
const le = `/d2l/api/le/${versions.le}/${courseId}`;
|
|
107
|
+
const content = await readResource(this.client, 'content', `${le}/content/toc`, { courseId });
|
|
108
|
+
const overview = classifyOverview(await readResource(this.client, 'overview', `${le}/overview`, { courseId }));
|
|
109
|
+
const result = await collectSyllabus(this.client, { le, courseId, content, overview });
|
|
110
|
+
const summary = { finishedAt: new Date().toISOString(), scope: 'syllabus sources for one course', versions,
|
|
111
|
+
courseCount: enrollments.data.length, ...result.summary };
|
|
112
|
+
this.store.commit(identity, this.client.origin, [content, overview, ...result.observations], summary, enrollments);
|
|
113
|
+
return summary;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
browse(action, { scope = '', accountScope = '', resources = new Map(), signal } = {}) {
|
|
117
|
+
return this.exclusive(async () => {
|
|
118
|
+
signal?.throwIfAborted();
|
|
119
|
+
const sections = ['courses', 'content', 'assignments', 'quizzes', 'announcements', 'grades', 'calendar', 'overview', 'discussions'];
|
|
120
|
+
const browsing = action?.action === 'browse';
|
|
121
|
+
const target = action?.action === 'read' && resources.get(action.resource);
|
|
122
|
+
if ((!browsing && !target) || (browsing && (!sections.includes(action.section) ||
|
|
123
|
+
typeof action.query !== 'string' || action.query.length > 200 ||
|
|
124
|
+
typeof action.course !== 'string' || action.course.length > 20 ||
|
|
125
|
+
(action.section !== 'courses' && !/^\d+$/.test(action.course))))) {
|
|
126
|
+
throw new AccessError('invalid_link', 'Choose a course area or a resource returned by discovery.');
|
|
127
|
+
}
|
|
128
|
+
const course = browsing ? action.course : target.course;
|
|
129
|
+
if (scope && course && course !== scope) throw new AccessError('denied', 'The selected course is the boundary for this request.');
|
|
130
|
+
const client = new BrightspaceClient(this.client.session, { origin: this.client.origin, fetchImpl: this.client.fetch, signal });
|
|
131
|
+
const { identity, versions, enrollments } = await this.courses(client);
|
|
132
|
+
const account = createHash('sha256').update(`${client.origin}:${identity.Identifier}`).digest('hex');
|
|
133
|
+
if (accountScope && account !== accountScope) throw new AccessError('needs_login', 'School account changed. Start a new workspace request.');
|
|
134
|
+
signal?.throwIfAborted();
|
|
135
|
+
const observations = [{ key: 'enrollments', category: 'enrollments', ...enrollments,
|
|
136
|
+
outcome: enrollments.complete ? 'available' : 'partial', retrievedAt: new Date().toISOString() }];
|
|
137
|
+
const candidates = [];
|
|
138
|
+
const le = `/d2l/api/le/${versions.le}/${course}`;
|
|
139
|
+
const read = async (category, path, options = {}) => {
|
|
140
|
+
signal?.throwIfAborted();
|
|
141
|
+
const observation = await readResource(client, category, path, { courseId: course, ...options });
|
|
142
|
+
signal?.throwIfAborted();
|
|
143
|
+
if (category === 'rubrics') observation.key = path;
|
|
144
|
+
observations.push(observation);
|
|
145
|
+
if (observation.status === 429) throw new AccessError('retry_wait', 'Brightspace rate limit reached. Live discovery stopped.', { status: 429, retryAfter: observation.retryAfter });
|
|
146
|
+
return observation;
|
|
147
|
+
};
|
|
148
|
+
const visible = value => value && !value.IsHidden && !value.IsLocked && !value.IsDeleted && value.IsPublished !== false &&
|
|
149
|
+
!(Date.parse(value.StartDateTime) > Date.now()) && !(Date.parse(value.EndDateTime) < Date.now());
|
|
150
|
+
const requireData = row => {
|
|
151
|
+
if (row.outcome !== 'available') throw new AccessError(row.outcome === 'denied' ? 'denied' : 'unavailable', 'This resource could not be read live; cached evidence may be incomplete.');
|
|
152
|
+
return row.data;
|
|
153
|
+
};
|
|
154
|
+
const deny = (category, key) => {
|
|
155
|
+
observations.push({ key, category, course, outcome: 'denied', complete: false, retrievedAt: new Date().toISOString() });
|
|
156
|
+
throw new AccessError('restricted', 'Resource or its parent is hidden, removed, or outside release conditions.');
|
|
157
|
+
};
|
|
158
|
+
const offer = (kind, value, title, description = '') => candidates.push({ kind, course, ...value,
|
|
159
|
+
title: String(title || kind).slice(0, 200), description: String(description).slice(0, 400) });
|
|
160
|
+
const links = (content, owner) => {
|
|
161
|
+
for (const link of content.links || []) {
|
|
162
|
+
if (actionLink(link.url, client.origin)) continue;
|
|
163
|
+
try {
|
|
164
|
+
const url = new URL(link.url, new URL(owner.base || '/', client.origin));
|
|
165
|
+
const linkedCourse = url.origin === client.origin && url.pathname.match(/^\/d2l\/(?:le\/content|api\/le\/\d+\.\d+)\/(\d+)\//)?.[1];
|
|
166
|
+
const enforced = url.origin === client.origin && url.pathname.match(/^\/content\/enforced\/(\d+)[-/]/)?.[1];
|
|
167
|
+
if ((linkedCourse && linkedCourse !== course) || (enforced && enforced !== course)) continue;
|
|
168
|
+
if (!linkedCourse) linkUrl(client, url.href);
|
|
169
|
+
offer('link', { url: url.href, owner }, link.label || url.pathname);
|
|
170
|
+
} catch { /* Unsupported providers and action links are not browsing capabilities. */ }
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
const tree = async () => {
|
|
174
|
+
const previous = courseNodes(this.store.resource?.(`${le}/content/toc`)?.data);
|
|
175
|
+
const toc = requireData(await read('content', `${le}/content/toc`));
|
|
176
|
+
const nodes = courseNodes(toc), current = new Map(nodes.filter(item => item.TopicId != null).map(item => [String(item.TopicId), item]));
|
|
177
|
+
for (const node of [...nodes, ...previous.filter(item => item.TopicId != null && !current.has(String(item.TopicId)))]) {
|
|
178
|
+
if (node.TopicId == null || (!node.restricted && current.has(String(node.TopicId)))) continue;
|
|
179
|
+
observations.push({ key: `${le}/content/topics/${node.TopicId}/file`, course, category: 'content_file',
|
|
180
|
+
outcome: 'denied', complete: false, retrievedAt: new Date().toISOString() });
|
|
181
|
+
for (const link of [{ url: node.Url }, ...descriptionContent(node.Description).links].filter(item => item.url)) {
|
|
182
|
+
try { observations.push({ key: `linked:${course}:${new URL(link.url, client.origin).href}`, course,
|
|
183
|
+
category: 'linked_content', outcome: 'denied', complete: false, retrievedAt: new Date().toISOString() }); } catch {}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return toc;
|
|
187
|
+
};
|
|
188
|
+
const assignment = async assignmentId => {
|
|
189
|
+
const folders = requireData(await read('assignments', `${le}/dropbox/folders/`, { list: true }));
|
|
190
|
+
const folder = folders.find(item => String(item.Id) === assignmentId && visible(item));
|
|
191
|
+
if (!folder) deny('assignment_detail', `${le}/dropbox/folders/${assignmentId}`);
|
|
192
|
+
return folder;
|
|
193
|
+
};
|
|
194
|
+
const topic = async topicId => {
|
|
195
|
+
const toc = await tree();
|
|
196
|
+
const node = courseNodes(toc).find(item => String(item.TopicId) === topicId);
|
|
197
|
+
if (!node || node.restricted) deny('content_file', `${le}/content/topics/${topicId}/file`);
|
|
198
|
+
return { toc, node };
|
|
199
|
+
};
|
|
200
|
+
const forum = async forumId => {
|
|
201
|
+
const forums = requireData(await read('discussion_forums', `${le}/discussions/forums/`, { list: true }));
|
|
202
|
+
if (!forums.some(item => String(item.ForumId) === forumId && visible(item))) deny('discussion_topics', `${le}/discussions/forums/${forumId}/topics/`);
|
|
203
|
+
return requireData(await read('discussion_topics', `${le}/discussions/forums/${forumId}/topics/`, { list: true }));
|
|
204
|
+
};
|
|
205
|
+
try {
|
|
206
|
+
if (course && !enrollments.data.some(item => String(item.OrgUnit?.Id) === course && item.Access?.CanAccess === true)) {
|
|
207
|
+
throw new AccessError('denied', 'Course access is not confirmed by current enrollments.');
|
|
208
|
+
}
|
|
209
|
+
if (browsing) {
|
|
210
|
+
if (action.section === 'courses') return { outcome: 'available', accountScope: account, courses: enrollments.data.filter(item => item.Access?.CanAccess === true && (!scope || String(item.OrgUnit.Id) === scope))
|
|
211
|
+
.slice(0, 100).map(item => ({ id: String(item.OrgUnit.Id), name: item.OrgUnit.Name })), resources: [] };
|
|
212
|
+
if (action.section === 'content') {
|
|
213
|
+
for (const node of courseNodes(await tree()).filter(item => !item.restricted && item.TopicId != null)) {
|
|
214
|
+
if (node.ActivityType === 1) offer('topic', { topicId: id(node.TopicId) }, node.Title, descriptionContent(node.Description).text);
|
|
215
|
+
else links({ links: [{ url: node.Url, label: node.Title }, ...descriptionContent(node.Description).links].filter(link => link.url) }, { topicId: id(node.TopicId) });
|
|
216
|
+
}
|
|
217
|
+
} else if (action.section === 'assignments') {
|
|
218
|
+
for (const folder of requireData(await read('assignments', `${le}/dropbox/folders/`, { list: true })).filter(visible)) {
|
|
219
|
+
offer('assignment', { assignmentId: id(folder.Id) }, folder.Name, descriptionContent(folder.Instructions).text);
|
|
220
|
+
}
|
|
221
|
+
} else if (action.section === 'quizzes') {
|
|
222
|
+
const list = await read('quizzes', `${le}/quizzes/`, { list: true });
|
|
223
|
+
for (const quiz of reconcileQuizzes((list.data || []).filter(visible), await tree(), [], client.origin).quizzes.filter(item => !item.restricted)) {
|
|
224
|
+
offer('quiz', { quizId: id(quiz.quizId) }, quiz.name);
|
|
225
|
+
}
|
|
226
|
+
} else if (action.section === 'discussions') {
|
|
227
|
+
for (const item of requireData(await read('discussion_forums', `${le}/discussions/forums/`, { list: true })).filter(visible)) offer('forum', { forumId: id(item.ForumId) }, item.Name);
|
|
228
|
+
} else {
|
|
229
|
+
const routes = { announcements: ['announcements', 'news/', true], grades: ['grades', 'grades/values/myGradeValues/', true],
|
|
230
|
+
calendar: ['calendar', `calendar/events/myEvents/?startDateTime=${new Date(Date.now() - 30 * 86400_000).toISOString()}&endDateTime=${new Date(Date.now() + 120 * 86400_000).toISOString()}`, true], overview: ['overview', 'overview', false] };
|
|
231
|
+
const [category, path, list] = routes[action.section];
|
|
232
|
+
const row = await read(category, `${le}/${path}`, { list });
|
|
233
|
+
if (action.section === 'overview') Object.assign(row, classifyOverview(row));
|
|
234
|
+
if (action.section === 'calendar') for (const event of row.data || []) {
|
|
235
|
+
if (event.IsRecurring && event.CalendarEventId != null && visible(event)) offer('calendar', { eventId: id(event.CalendarEventId) }, event.Title);
|
|
236
|
+
}
|
|
237
|
+
if (action.section === 'overview' && row.data) {
|
|
238
|
+
links(descriptionContent(row.data.Description), { overview: true });
|
|
239
|
+
if (row.data.Attachment) offer('overview_file', {}, row.data.Attachment.FileName || 'Overview attachment');
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} else if (target.kind === 'assignment' || target.kind === 'assignment_file') {
|
|
243
|
+
const folder = await assignment(target.assignmentId);
|
|
244
|
+
const path = `${le}/dropbox/folders/${target.assignmentId}`;
|
|
245
|
+
const detail = requireData(await read('assignment_detail', path));
|
|
246
|
+
if (!visible(detail)) deny('assignment_detail', path);
|
|
247
|
+
if (target.kind === 'assignment_file') {
|
|
248
|
+
const attachment = (detail.Attachments || folder.Attachments || []).find(item => String(item.FileId) === target.fileId && visible(item));
|
|
249
|
+
if (!attachment) deny('assignment_file', `${path}/attachments/${target.fileId}`);
|
|
250
|
+
const file = await read('assignment_file', `${path}/attachments/${target.fileId}`, { file: true });
|
|
251
|
+
if (file.data) { file.data.Title = target.title; links(file.data, { assignmentId: target.assignmentId, base: client.origin }); }
|
|
252
|
+
} else {
|
|
253
|
+
const submissions = await read('own_submissions', `${path}/submissions/mysubmissions/`, { list: true });
|
|
254
|
+
const rubrics = detail.Assessment?.Rubrics || folder.Assessment?.Rubrics;
|
|
255
|
+
if (rubrics?.length) observations.push({ key: `${path}/assessment/rubrics`, category: 'rubrics', course, data: rubrics,
|
|
256
|
+
outcome: 'available', complete: true, source: path, retrievedAt: new Date().toISOString() });
|
|
257
|
+
else {
|
|
258
|
+
const [major, minor] = versions.le.split('.').map(Number);
|
|
259
|
+
if (major > 1 || minor >= 93) await read('rubrics', `${le}/rubrics/?objectType=Dropbox&objectId=${target.assignmentId}`, { list: true });
|
|
260
|
+
}
|
|
261
|
+
const groups = folder.DropboxType === 1 || folder.GroupTypeId != null ? (await read('groups', `/d2l/api/lp/${versions.lp}/${course}/groups/`, { list: true })).data : [];
|
|
262
|
+
const entity = feedbackEntity(folder, identity, submissions.data, groups);
|
|
263
|
+
if (entity) await read('own_feedback', `${path}/feedback/${entity.type}/${entity.entityId}`);
|
|
264
|
+
for (const file of detail.Attachments || folder.Attachments || []) if (file.FileId != null && visible(file)) offer('assignment_file', { assignmentId: target.assignmentId, fileId: id(file.FileId) }, file.FileName || file.Name);
|
|
265
|
+
links(descriptionContent(detail.Instructions || folder.Instructions), { assignmentId: target.assignmentId });
|
|
266
|
+
}
|
|
267
|
+
} else if (target.kind === 'topic') {
|
|
268
|
+
const { node } = await topic(target.topicId);
|
|
269
|
+
const file = await read('content_file', `${le}/content/topics/${target.topicId}/file`, { file: true });
|
|
270
|
+
if (file.data) { file.data.Title = node.Title; links(file.data, { topicId: target.topicId, base: node.Url || file.data.resolvedUrl || client.origin }); }
|
|
271
|
+
} else if (target.kind === 'quiz') {
|
|
272
|
+
const list = await read('quizzes', `${le}/quizzes/`, { list: true });
|
|
273
|
+
const known = reconcileQuizzes((list.data || []).filter(visible), await tree(), [], client.origin).quizzes.find(item => item.quizId === target.quizId && !item.restricted);
|
|
274
|
+
if (!known || (list.data || []).some(item => String(item.QuizId) === target.quizId && !visible(item))) deny('quiz_details', `${le}/quizzes/${target.quizId}`);
|
|
275
|
+
const detail = await read('quiz_details', `${le}/quizzes/${target.quizId}`);
|
|
276
|
+
if (detail.data && !visible(detail.data)) deny('quiz_details', detail.key);
|
|
277
|
+
else await read('own_quiz_attempts', `${le}/quizzes/${target.quizId}/attempts/?userId=${id(identity.Identifier)}`, { list: true });
|
|
278
|
+
} else if (target.kind === 'forum') {
|
|
279
|
+
for (const item of (await forum(target.forumId)).filter(visible)) offer('discussion', { forumId: target.forumId, topicId: id(item.TopicId) }, item.Name);
|
|
280
|
+
} else if (target.kind === 'discussion') {
|
|
281
|
+
if (!(await forum(target.forumId)).some(item => String(item.TopicId) === target.topicId && visible(item))) deny('discussion_posts', `${le}/discussions/topics/${target.topicId}/posts/`);
|
|
282
|
+
await read('discussion_posts', `${le}/discussions/topics/${target.topicId}/posts/`, { list: true });
|
|
283
|
+
} else if (target.kind === 'overview_file') {
|
|
284
|
+
if (!requireData(await read('overview', `${le}/overview`)).Attachment) deny('content_file', `${le}/overview/attachment`);
|
|
285
|
+
const file = await read('content_file', `${le}/overview/attachment`, { file: true });
|
|
286
|
+
if (file.data) { file.data.Title = target.title; links(file.data, { overview: true, base: client.origin }); }
|
|
287
|
+
} else if (target.kind === 'calendar') {
|
|
288
|
+
const events = requireData(await read('calendar', `${le}/calendar/events/myEvents/?startDateTime=${new Date(Date.now() - 30 * 86400_000).toISOString()}&endDateTime=${new Date(Date.now() + 120 * 86400_000).toISOString()}`, { list: true }));
|
|
289
|
+
if (!events.some(event => String(event.CalendarEventId) === target.eventId && visible(event))) deny('calendar_occurrences', `${le}/calendar/event/${target.eventId}/occurrences`);
|
|
290
|
+
await read('calendar_occurrences', `${le}/calendar/event/${target.eventId}/occurrences`);
|
|
291
|
+
} else if (target.kind === 'link') {
|
|
292
|
+
let toc;
|
|
293
|
+
if (target.owner.topicId) ({ toc } = await topic(target.owner.topicId));
|
|
294
|
+
else if (target.owner.assignmentId) await assignment(target.owner.assignmentId);
|
|
295
|
+
else requireData(await read('overview', `${le}/overview`));
|
|
296
|
+
const linkedTopic = new URL(target.url).pathname.match(/\/viewContent\/(\d+)\//)?.[1] ||
|
|
297
|
+
new URL(target.url).pathname.match(/\/content\/topics\/(\d+)\/file$/)?.[1];
|
|
298
|
+
if (linkedTopic && new URL(target.url).origin === client.origin) ({ toc } = await topic(linkedTopic));
|
|
299
|
+
signal?.throwIfAborted();
|
|
300
|
+
const row = { key: `linked:${course}:${target.url}`, source: target.url, course, category: 'linked_content',
|
|
301
|
+
retrievedAt: new Date().toISOString(), outcome: 'available', complete: true };
|
|
302
|
+
try {
|
|
303
|
+
row.data = await readLinkedDocument(client, target.url, { le, courseId: course, tree: toc });
|
|
304
|
+
if (row.data.textStatus && !['available', 'empty'].includes(row.data.textStatus)) { row.outcome = 'partial'; row.complete = false; }
|
|
305
|
+
row.data.Title = target.title;
|
|
306
|
+
links(row.data, { ...target.owner, base: target.url });
|
|
307
|
+
} catch (error) {
|
|
308
|
+
if (error.code === 'needs_login' || error.status === 429 || signal?.aborted) throw error;
|
|
309
|
+
Object.assign(row, { outcome: ['denied', 'restricted'].includes(error.code) ? 'denied' : 'failed', complete: false, error: error.code });
|
|
310
|
+
}
|
|
311
|
+
observations.push(row);
|
|
312
|
+
}
|
|
313
|
+
const terms = (browsing ? action.query : '').toLowerCase().match(/[\p{L}\p{N}]+/gu) || [];
|
|
314
|
+
const ranked = candidates.map(item => ({ item, score: terms.reduce((score, term) => score + (`${item.title} ${item.description}`.toLowerCase().includes(term) ? 1 : 0), 0) }))
|
|
315
|
+
.filter(item => !terms.length || item.score).sort((a, b) => b.score - a.score);
|
|
316
|
+
const result = ranked.slice(0, 30).map(({ item }) => {
|
|
317
|
+
const reference = createHash('sha256').update(JSON.stringify(item)).digest('base64url').slice(0, 24);
|
|
318
|
+
resources.set(reference, item);
|
|
319
|
+
return { resource: reference, course: item.course, kind: item.kind, title: item.title, description: item.description };
|
|
320
|
+
});
|
|
321
|
+
return { outcome: observations.some(row => !['available'].includes(row.outcome)) ? 'partial' : 'available', accountScope: account, resources: result,
|
|
322
|
+
total: ranked.length, resourceKeys: observations.map(row => row.key), outcomes: observations.slice(1).map(({ category, outcome }) => ({ category, outcome })) };
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if (target?.kind === 'link' && ['restricted', 'denied'].includes(error.code)) observations.push({
|
|
325
|
+
key: `linked:${course}:${target.url}`, course, category: 'linked_content', outcome: 'denied', complete: false, retrievedAt: new Date().toISOString() });
|
|
326
|
+
throw error;
|
|
327
|
+
} finally {
|
|
328
|
+
signal?.throwIfAborted();
|
|
329
|
+
this.store.commit(identity, client.origin, observations, { ...this.store.getMeta('last_run'),
|
|
330
|
+
onDemand: { checkedAt: new Date().toISOString(), resources: observations.length - 1 } }, enrollments);
|
|
331
|
+
}
|
|
332
|
+
}, signal);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async auditAll(options, client = this.client) {
|
|
336
|
+
const { enrollments } = await this.courses(client);
|
|
337
|
+
if (!enrollments.complete) throw new AccessError('retry_wait', 'Complete enrollment coverage is required for an all-course pass.');
|
|
338
|
+
const courses = enrollments.data.filter(item => item.Access?.CanAccess === true)
|
|
339
|
+
.sort((a, b) => Number(a.OrgUnit.Id) - Number(b.OrgUnit.Id));
|
|
340
|
+
const selected = batch(courses, this.store.getMeta('all_course_cursor'), 5);
|
|
341
|
+
const previous = this.store.getMeta('selected_course');
|
|
342
|
+
const results = [];
|
|
343
|
+
try {
|
|
344
|
+
for (const [index, course] of selected.items.entries()) {
|
|
345
|
+
client.signal?.throwIfAborted();
|
|
346
|
+
results.push({ alias: `course-${selected.start + index + 1}`, ...await this.audit({ ...options, course: String(course.OrgUnit.Id) }, { signal: client.signal }) });
|
|
347
|
+
}
|
|
348
|
+
const summary = { finishedAt: new Date().toISOString(), scope: 'resumable all-accessible-course pass; up to five courses per run',
|
|
349
|
+
accessibleCourseCount: courses.length, coursesProcessed: results.length, nextCourseOffset: selected.next,
|
|
350
|
+
complete: !selected.limited && results.every(item => item.complete),
|
|
351
|
+
retryAfter: Math.max(0, ...results.map(item => item.retryAfter || 0)), courses: results };
|
|
352
|
+
this.store.meta('all_course_cursor', selected.next);
|
|
353
|
+
this.store.meta('last_run', summary);
|
|
354
|
+
return summary;
|
|
355
|
+
} finally { this.store.meta('selected_course', previous); }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
audit(options = {}, { signal } = {}) {
|
|
359
|
+
if (options.all && options.course) return Promise.reject(new Error('Choose either --all or --course.'));
|
|
360
|
+
const client = signal ? new BrightspaceClient(this.client.session, { origin: this.client.origin, fetchImpl: this.client.fetch, signal }) : this.client;
|
|
361
|
+
return options.all ? this.auditAll({ from: options.from, to: options.to }, client) : this.exclusive(() => this.auditCourse(options, client));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async auditCourse({ course, from, to, all } = {}, client = this.client) {
|
|
365
|
+
from ??= new Date(Date.now() - 30 * 86400_000).toISOString();
|
|
366
|
+
to ??= new Date(Date.now() + 120 * 86400_000).toISOString();
|
|
367
|
+
if (!Number.isFinite(Date.parse(from)) || !Number.isFinite(Date.parse(to)) || Date.parse(from) >= Date.parse(to)) {
|
|
368
|
+
throw new Error('Calendar window must contain valid ISO dates with from earlier than to.');
|
|
369
|
+
}
|
|
370
|
+
const { identity, versions, enrollments } = await this.courses(client);
|
|
371
|
+
const observations = [];
|
|
372
|
+
const checkpoints = [];
|
|
373
|
+
const traversal = {};
|
|
374
|
+
const window = (name, items, size = 25, owner = '') => {
|
|
375
|
+
const key = `cursor:${courseId}:${name}:${owner}`;
|
|
376
|
+
const selected = batch(items, this.store.getMeta(key), size);
|
|
377
|
+
const phase = traversal[name] ??= { total: 0, processed: 0, limited: false };
|
|
378
|
+
phase.total += selected.total;
|
|
379
|
+
phase.processed += selected.items.length;
|
|
380
|
+
phase.limited ||= selected.limited;
|
|
381
|
+
checkpoints.push([key, selected.next]);
|
|
382
|
+
return selected.items;
|
|
383
|
+
};
|
|
384
|
+
const capture = async (category, path, { list = false, courseId, file = false, version } = {}) => {
|
|
385
|
+
if (file && version) {
|
|
386
|
+
const cached = this.store.resource?.(path.split('?')[0]);
|
|
387
|
+
if (cached?.outcome === 'available' && cached.observation.version === version &&
|
|
388
|
+
Date.now() - Date.parse(cached.last_success) < 6 * 3600_000 && cached.data?.textStatus === 'available') {
|
|
389
|
+
const observation = { ...cached.observation, data: cached.data, cached: true, category, course: courseId, retrievedAt: cached.last_success };
|
|
390
|
+
observations.push(observation);
|
|
391
|
+
return observation;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
client.signal?.throwIfAborted();
|
|
395
|
+
const observation = await readResource(client, category, path, { list, courseId, file });
|
|
396
|
+
if (observation.status === 429) throw new AccessError('retry_wait', 'Brightspace rate limit reached; the pass stopped before further reads.', { status: 429, retryAfter: observation.retryAfter });
|
|
397
|
+
if (version) observation.version = version;
|
|
398
|
+
observations.push(observation);
|
|
399
|
+
return observation;
|
|
400
|
+
};
|
|
401
|
+
observations.push({ key: 'enrollments', category: 'enrollments', ...enrollments,
|
|
402
|
+
outcome: enrollments.complete ? 'available' : 'partial', retrievedAt: new Date().toISOString() });
|
|
403
|
+
const courseId = selectCourse(enrollments, course);
|
|
404
|
+
let syllabus;
|
|
405
|
+
let quizDiscovery;
|
|
406
|
+
if (courseId) {
|
|
407
|
+
const le = `/d2l/api/le/${versions.le}/${courseId}`;
|
|
408
|
+
const lp = `/d2l/api/lp/${versions.lp}/${courseId}`;
|
|
409
|
+
// Sequential reads keep this first discovery pass bounded and gentle on the tenant.
|
|
410
|
+
const read = (category, path, options) => capture(category, path, { courseId, ...options });
|
|
411
|
+
const content = await read('content', `${le}/content/toc`);
|
|
412
|
+
const overview = classifyOverview(await read('overview', `${le}/overview`));
|
|
413
|
+
const result = await collectSyllabus(client, { le, courseId, content, overview });
|
|
414
|
+
syllabus = result.summary;
|
|
415
|
+
observations.push(...result.observations);
|
|
416
|
+
const assignments = await read('assignments', `${le}/dropbox/folders/`, { list: true });
|
|
417
|
+
const quizList = await read('quizzes', `${le}/quizzes/`, { list: true });
|
|
418
|
+
const grades = await read('grades', `${le}/grades/values/myGradeValues/`, { list: true });
|
|
419
|
+
await read('announcements', `${le}/news/`, { list: true });
|
|
420
|
+
const forums = await read('discussion_forums', `${le}/discussions/forums/`, { list: true });
|
|
421
|
+
const groups = await read('groups', `${lp}/groups/`, { list: true });
|
|
422
|
+
await read('sections', `${lp}/sections/mysections/`, { list: true });
|
|
423
|
+
const calendar = new URL(`${le}/calendar/events/myEvents/`, this.client.origin);
|
|
424
|
+
calendar.searchParams.set('startDateTime', new Date(from).toISOString());
|
|
425
|
+
calendar.searchParams.set('endDateTime', new Date(to).toISOString());
|
|
426
|
+
const [major, minor] = versions.le.split('.').map(Number);
|
|
427
|
+
const events = await read('calendar', `${calendar.pathname}${calendar.search}`, { list: true });
|
|
428
|
+
const recurring = (events.data || []).filter(event => event.IsRecurring === true);
|
|
429
|
+
for (const event of window('recurring_events', recurring)) {
|
|
430
|
+
await read('calendar_occurrences', `${le}/calendar/event/${id(event.CalendarEventId)}/occurrences`);
|
|
431
|
+
}
|
|
432
|
+
const reconciled = reconcileQuizzes(quizList.data, content.data, grades.data, this.client.origin);
|
|
433
|
+
const toolNames = new Map();
|
|
434
|
+
for (const candidate of window('quiz_grade_associations', reconciled.candidates.filter(item => item.source === 'gradebook'))) {
|
|
435
|
+
const definition = await read('quiz_grade_association', `${le}/grades/${id(candidate.gradeItemId)}`);
|
|
436
|
+
const association = definition.data?.AssociatedTool;
|
|
437
|
+
if (!association || !/^\d+$/.test(String(association.ToolId)) || !/^\d+$/.test(String(association.ToolItemId))) continue;
|
|
438
|
+
const toolId = String(association.ToolId);
|
|
439
|
+
if (!toolNames.has(toolId)) {
|
|
440
|
+
const tool = await read('quiz_tool', `/d2l/api/lp/${versions.lp}/tools/orgUnits/${courseId}/${toolId}?namesOnly=true`);
|
|
441
|
+
toolNames.set(toolId, tool.data?.DisplayName || '');
|
|
442
|
+
}
|
|
443
|
+
if (!/quiz/i.test(toolNames.get(toolId))) continue;
|
|
444
|
+
const quizId = String(association.ToolItemId);
|
|
445
|
+
if (!reconciled.quizzes.some(quiz => quiz.quizId === quizId)) reconciled.quizzes.push({ quizId, name: candidate.name,
|
|
446
|
+
gradeItemId: candidate.gradeItemId, sources: ['grade_association'], restricted: false });
|
|
447
|
+
reconciled.candidates = reconciled.candidates.filter(item => item !== candidate);
|
|
448
|
+
}
|
|
449
|
+
const quizSourcesComplete = quizList.complete === true && content.complete === true && grades.complete === true;
|
|
450
|
+
quizDiscovery = { confirmedReferences: reconciled.quizzes.length, unresolvedCandidates: reconciled.candidates.length, sourcesComplete: quizSourcesComplete };
|
|
451
|
+
observations.push({ key: `${le}/quiz_inventory`, category: 'quiz_inventory', course: courseId, source: `${le}/content/toc`,
|
|
452
|
+
retrievedAt: new Date().toISOString(), outcome: quizSourcesComplete && !reconciled.candidates.length ? 'available' : 'partial', complete: quizSourcesComplete && !reconciled.candidates.length,
|
|
453
|
+
data: { ...reconciled, listCoverage: quizList.outcome, candidatesAreNotConfirmedQuizzes: true } });
|
|
454
|
+
for (const quiz of window('quiz_details', reconciled.quizzes.filter(item => !item.restricted))) {
|
|
455
|
+
const path = `${le}/quizzes/${id(quiz.quizId)}`;
|
|
456
|
+
await read('quiz_details', path);
|
|
457
|
+
await read('own_quiz_attempts', `${path}/attempts/?userId=${id(identity.Identifier)}`, { list: true });
|
|
458
|
+
}
|
|
459
|
+
const prioritized = [...new Set([...assignmentSamples(assignments.data ?? [], grades.data), ...(assignments.data || [])])];
|
|
460
|
+
for (const folder of window('assignments', prioritized)) {
|
|
461
|
+
const assignmentFiles = [];
|
|
462
|
+
const folderPath = `${le}/dropbox/folders/${id(folder.Id)}`;
|
|
463
|
+
const detail = await read('assignment_detail', folderPath);
|
|
464
|
+
const submissions = await read('own_submissions', `${folderPath}/submissions/mysubmissions/`, { list: true });
|
|
465
|
+
const entity = feedbackEntity(folder, identity, submissions.data, groups.data);
|
|
466
|
+
if (entity) {
|
|
467
|
+
const feedbackPath = `${folderPath}/feedback/${entity.type}/${entity.entityId}`;
|
|
468
|
+
const feedback = await read('own_feedback', feedbackPath);
|
|
469
|
+
for (const file of feedback.data?.Attachments || []) if (file.FileId != null) assignmentFiles.push({ category: 'feedback_file', path: `${feedbackPath}/attachments/${id(file.FileId)}` });
|
|
470
|
+
}
|
|
471
|
+
else observations.push({ key: `${folderPath}/feedback/`, course: courseId, category: 'own_feedback',
|
|
472
|
+
source: folderPath, retrievedAt: new Date().toISOString(), outcome: 'unknown', complete: false,
|
|
473
|
+
error: 'unresolved_group', message: 'Own group could not be identified unambiguously; no user or group feedback request was guessed.' });
|
|
474
|
+
const embedded = detail.data?.Assessment?.Rubrics?.length ? detail.data.Assessment.Rubrics : folder.Assessment?.Rubrics;
|
|
475
|
+
if (embedded?.length) observations.push({ key: `${folderPath}/assessment/rubrics`, course: courseId, category: 'rubrics',
|
|
476
|
+
source: detail.data?.Assessment?.Rubrics?.length ? folderPath : `${le}/dropbox/folders/`,
|
|
477
|
+
field: 'Assessment.Rubrics', retrievedAt: new Date().toISOString(), outcome: 'available', complete: true, data: embedded });
|
|
478
|
+
else if (major > 1 || minor >= 93) {
|
|
479
|
+
const rubrics = await read('rubrics', `${le}/rubrics/?objectType=Dropbox&objectId=${id(folder.Id)}`, { list: true });
|
|
480
|
+
// Distinct assignment associations share a route but must not share a cache key.
|
|
481
|
+
rubrics.key = rubrics.source;
|
|
482
|
+
}
|
|
483
|
+
for (const attachment of detail.data?.Attachments ?? folder.Attachments ?? []) {
|
|
484
|
+
if (attachment.FileId != null) assignmentFiles.push({ category: 'assignment_file', path: `${folderPath}/attachments/${id(attachment.FileId)}` });
|
|
485
|
+
}
|
|
486
|
+
for (const own of submissions.data || []) for (const submission of own.Submissions || []) for (const file of submission.Files || []) {
|
|
487
|
+
if (submission.Id != null && file.FileId != null) assignmentFiles.push({ category: 'own_submission_file', path: `${folderPath}/submissions/${id(submission.Id)}/files/${id(file.FileId)}` });
|
|
488
|
+
}
|
|
489
|
+
for (const file of window('assignment_files', assignmentFiles, 2, id(folder.Id))) await read(file.category, file.path, { file: true });
|
|
490
|
+
}
|
|
491
|
+
for (const forum of window('discussion_forums', forums.data || [], 5)) {
|
|
492
|
+
if (forum.IsHidden) continue;
|
|
493
|
+
const forumPath = `${le}/discussions/forums/${id(forum.ForumId)}`;
|
|
494
|
+
const topics = await read('discussion_topics', `${forumPath}/topics/`, { list: true });
|
|
495
|
+
for (const topic of window('discussion_posts', (topics.data || []).filter(item => !item.IsHidden), 5, id(forum.ForumId))) {
|
|
496
|
+
const topicPath = `${forumPath}/topics/${id(topic.TopicId)}`;
|
|
497
|
+
const posts = await read('discussion_posts', `${topicPath}/posts/`, { list: true });
|
|
498
|
+
const discussionFiles = [];
|
|
499
|
+
for (const post of posts.data || []) for (const attachment of post.Attachments || []) {
|
|
500
|
+
if (post.PostId != null && attachment.FileId != null) discussionFiles.push(`${topicPath}/posts/${id(post.PostId)}/attachments/${id(attachment.FileId)}`);
|
|
501
|
+
}
|
|
502
|
+
for (const path of window('discussion_files', discussionFiles, 2, id(topic.TopicId))) await read('discussion_file', path, { file: true });
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
const allNodes = courseNodes(content.data);
|
|
506
|
+
const files = allNodes.filter(topic => topic.TopicId != null && topic.ActivityType === 1);
|
|
507
|
+
for (const topic of files.filter(item => item.restricted)) observations.push({ key: `${le}/content/topics/${id(topic.TopicId)}/file`,
|
|
508
|
+
category: 'content_file', course: courseId, outcome: 'denied', complete: false, error: 'restricted',
|
|
509
|
+
source: `${le}/content/toc`, retrievedAt: new Date().toISOString(), message: 'Topic or ancestor is outside its release conditions.' });
|
|
510
|
+
for (const topic of window('content_files', files.filter(item => !item.restricted), 20)) {
|
|
511
|
+
const path = `${le}/content/topics/${id(topic.TopicId)}/file`;
|
|
512
|
+
const existing = observations.find(item => item.category === 'syllabus_source' && item.source === path && item.outcome === 'available');
|
|
513
|
+
if (existing) observations.push({ ...existing, key: path, category: 'content_file' });
|
|
514
|
+
else await read('content_file', path, { file: true, version: topic.LastModifiedDate });
|
|
515
|
+
}
|
|
516
|
+
const targets = new Set();
|
|
517
|
+
for (const topic of allNodes.filter(item => !item.restricted)) {
|
|
518
|
+
if (topic.Url && topic.ActivityType !== 1) targets.add(topic.Url);
|
|
519
|
+
for (const link of descriptionContent(topic.Description).links) targets.add(link.url);
|
|
520
|
+
}
|
|
521
|
+
for (const target of window('linked_content', [...targets], 20)) {
|
|
522
|
+
if (actionLink(target, this.client.origin)) continue;
|
|
523
|
+
let url;
|
|
524
|
+
try { url = new URL(target, this.client.origin); } catch { continue; }
|
|
525
|
+
const record = { key: `linked:${courseId}:${url.href}`, category: youtubeId(url.href) ? 'video_transcript' : 'linked_content',
|
|
526
|
+
course: courseId, source: url.href, retrievedAt: new Date().toISOString(), outcome: 'available', complete: true };
|
|
527
|
+
try {
|
|
528
|
+
client.signal?.throwIfAborted();
|
|
529
|
+
record.data = youtubeId(url.href) ? await youtubeTranscript(client, url.href) : await readLinkedDocument(client, url.href, { le, courseId, tree: content.data });
|
|
530
|
+
if (record.data.textStatus && record.data.textStatus !== 'available' && record.data.textStatus !== 'empty') { record.complete = false; record.outcome = 'partial'; }
|
|
531
|
+
} catch (error) {
|
|
532
|
+
if (!(error instanceof AccessError) || error.code === 'needs_login') throw error;
|
|
533
|
+
if (error.status === 429) throw error;
|
|
534
|
+
Object.assign(record, { outcome: error.code === 'denied' || error.code === 'restricted' ? 'denied' : 'failed', complete: false, error: error.code, message: error.message, status: error.status, retryAfter: error.retryAfter });
|
|
535
|
+
}
|
|
536
|
+
observations.push(record);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
const summary = {
|
|
540
|
+
finishedAt: new Date().toISOString(), scope: 'one selected accessible course; resumable bounded traversal',
|
|
541
|
+
versions, courseCount: enrollments.data.length, enrollmentCoverageComplete: enrollments.complete,
|
|
542
|
+
selectedCourse: courseId ? 'course-1' : null, calendarWindow: { from, to }, syllabus, quizDiscovery,
|
|
543
|
+
traversal, complete: Boolean(courseId) && observations.every(item => item.complete) && Object.values(traversal).every(item => !item.limited),
|
|
544
|
+
retryAfter: Math.max(0, ...observations.filter(item => item.error === 'retry_wait').map(item => item.retryAfter || 60_000)),
|
|
545
|
+
resources: observations.map(item => ({ category: item.category, outcome: item.outcome,
|
|
546
|
+
complete: item.complete, pages: item.pages, count: Array.isArray(item.data) ? item.data.length : undefined,
|
|
547
|
+
fieldNames: fields(item.data), error: item.error, message: item.message })),
|
|
548
|
+
notChecked: ['private external-provider authorization', 'every object compared against the onQ browser', 'natural session-expiry soak test'],
|
|
549
|
+
};
|
|
550
|
+
client.signal?.throwIfAborted();
|
|
551
|
+
this.store.commit(identity, this.client.origin, observations, summary, enrollments);
|
|
552
|
+
for (const [key, value] of checkpoints) this.store.meta(key, value);
|
|
553
|
+
this.selectedCourse = courseId;
|
|
554
|
+
if (courseId) this.store.meta('selected_course', courseId);
|
|
555
|
+
return summary;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
export async function sync(connector, options, { signal, onUpdate = console.log, wait = ms => new Promise(resolve => {
|
|
560
|
+
const timer = setTimeout(done, ms);
|
|
561
|
+
function done() { clearTimeout(timer); signal?.removeEventListener('abort', done); resolve(); }
|
|
562
|
+
signal?.addEventListener('abort', done, { once: true });
|
|
563
|
+
if (signal?.aborted) done();
|
|
564
|
+
}) } = {}) {
|
|
565
|
+
let failures = 0;
|
|
566
|
+
options = { ...options, course: options?.all ? undefined : options?.course ?? connector.store.getMeta('selected_course') ?? undefined };
|
|
567
|
+
while (!signal?.aborted) {
|
|
568
|
+
let delay = 15 * 60_000;
|
|
569
|
+
connector.store.meta('state', { state: 'syncing', updatedAt: new Date().toISOString() });
|
|
570
|
+
try {
|
|
571
|
+
const summary = await connector.audit(options, { signal });
|
|
572
|
+
if (signal?.aborted) break;
|
|
573
|
+
if (!options.all) options.course ??= connector.selectedCourse ?? undefined;
|
|
574
|
+
failures = 0;
|
|
575
|
+
delay = Math.max(delay, summary.retryAfter);
|
|
576
|
+
await onUpdate({ state: summary.retryAfter ? 'retry_wait' : 'ready', ...summary });
|
|
577
|
+
} catch (error) {
|
|
578
|
+
if (signal?.aborted) break;
|
|
579
|
+
const state = error.code === 'needs_login' ? 'needs_login' : 'retry_wait';
|
|
580
|
+
connector.store.meta('state', { state, updatedAt: new Date().toISOString() });
|
|
581
|
+
if (state === 'needs_login' || !(error instanceof AccessError)) throw error;
|
|
582
|
+
delay = Math.max(error.retryAfter || 0, Math.min(15 * 60_000, 60_000 * 2 ** Math.min(failures++, 4)));
|
|
583
|
+
await onUpdate({ state, message: error.message });
|
|
584
|
+
}
|
|
585
|
+
if (!signal?.aborted) await wait(delay);
|
|
586
|
+
}
|
|
587
|
+
}
|