@ivanbard/dd2l 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.mjs ADDED
@@ -0,0 +1,137 @@
1
+ export const SCHOOL = 'https://onq.queensu.ca';
2
+
3
+ export class AccessError extends Error {
4
+ constructor(code, message, { status, retryAfter = 0 } = {}) {
5
+ super(message);
6
+ this.code = code;
7
+ this.status = status;
8
+ this.retryAfter = retryAfter;
9
+ }
10
+ }
11
+
12
+ // Never attach response bodies, headers, or credential-bearing URLs to errors.
13
+ export async function request(url, options = {}, fetchImpl = fetch) {
14
+ let response;
15
+ try {
16
+ response = await fetchImpl(url, {
17
+ ...options, redirect: 'manual', signal: AbortSignal.timeout(30_000),
18
+ });
19
+ } catch {
20
+ throw new AccessError('retry_wait', 'Network request failed or timed out.');
21
+ }
22
+ return checkedResponse(response, url);
23
+ }
24
+
25
+ export function checkedResponse(response, url) {
26
+ if (response.status === 401) throw new AccessError('needs_login', 'School sign-in is required.', { status: 401 });
27
+ if (response.status >= 300 && response.status < 400) {
28
+ const target = new URL(response.headers.get('location') || '/', url);
29
+ const login = /\/d2l\/(?:login|lms\/auth)/i.test(target.pathname) || target.hostname === 'login.microsoftonline.com';
30
+ throw new AccessError(login ? 'needs_login' : 'redirect', login ? 'School sign-in is required.' : 'Unexpected redirect was not followed.', { status: response.status });
31
+ }
32
+ if (response.status === 403) throw new AccessError('denied', 'Brightspace denied access to this resource.', { status: 403 });
33
+ if (response.status === 404) throw new AccessError('unavailable', 'Resource or API route is unavailable; absence is not established.', { status: 404 });
34
+ if (response.status === 429 || response.status >= 500) {
35
+ const raw = response.headers.get('retry-after');
36
+ const seconds = raw && /^\d+$/.test(raw) ? Number(raw) : Math.max(0, (Date.parse(raw) - Date.now()) / 1000);
37
+ throw new AccessError('retry_wait', `Brightspace returned HTTP ${response.status}.`, {
38
+ status: response.status, retryAfter: Number.isFinite(seconds) ? seconds * 1000 : 0,
39
+ });
40
+ }
41
+ if (!response.ok) throw new AccessError('failed', `Brightspace returned HTTP ${response.status}.`, { status: response.status });
42
+ return response;
43
+ }
44
+
45
+ export async function readJson(response) {
46
+ const text = await response.text();
47
+ if (!/\bapplication\/(?:[\w.+-]*\+)?json\b/i.test(response.headers.get('content-type') || '')) {
48
+ if (text.includes('/d2l/login?sessionExpired=1')) throw new AccessError('needs_login', 'School session has expired.');
49
+ throw new AccessError('invalid_response', 'Expected JSON; received a different response format.');
50
+ }
51
+ try { return JSON.parse(text); }
52
+ catch { throw new AccessError('invalid_response', 'Brightspace returned invalid JSON.'); }
53
+ }
54
+
55
+ export function apiUrl(origin, path) {
56
+ const url = new URL(path, origin);
57
+ if (url.origin !== origin || url.username || url.password || url.hash || !url.pathname.startsWith('/d2l/api/')) {
58
+ throw new AccessError('invalid_link', 'Refused a link outside this school’s API.');
59
+ }
60
+ return url;
61
+ }
62
+
63
+ export class BrightspaceClient {
64
+ constructor(session, { origin = SCHOOL, fetchImpl = fetch } = {}) {
65
+ this.session = session;
66
+ this.origin = new URL(origin).origin;
67
+ if (!this.origin.startsWith('https://')) throw new Error('School origin must use HTTPS.');
68
+ this.fetch = fetchImpl;
69
+ }
70
+
71
+ async versions() {
72
+ const data = await readJson(await request(`${this.origin}/d2l/api/versions/`, {}, this.fetch));
73
+ const versions = {};
74
+ for (const product of ['lp', 'le']) {
75
+ const value = data?.find?.(item => item.ProductCode === product)?.LatestVersion;
76
+ if (!/^\d+\.\d+$/.test(value || '')) throw new AccessError('invalid_response', 'Missing supported API versions.');
77
+ versions[product] = value;
78
+ }
79
+ return versions;
80
+ }
81
+
82
+ async response(path) {
83
+ const url = apiUrl(this.origin, path);
84
+ let token = await this.session.token();
85
+ for (let attempt = 0; attempt < 2; attempt++) {
86
+ try {
87
+ return await request(url, { headers: { authorization: `Bearer ${token}`, accept: 'application/json' } }, this.fetch);
88
+ } catch (error) {
89
+ if (error.code !== 'needs_login' || attempt) throw error;
90
+ token = await this.session.token(token);
91
+ }
92
+ }
93
+ }
94
+
95
+ async get(path) { return readJson(await this.response(path)); }
96
+
97
+ async collection(path) {
98
+ const data = [];
99
+ const seen = new Set();
100
+ let next = apiUrl(this.origin, path);
101
+ let pages = 0;
102
+ try {
103
+ while (next) {
104
+ if (pages === 200 || seen.has(next.href)) throw new AccessError('partial', 'Pagination stopped at a loop or page limit.');
105
+ seen.add(next.href);
106
+ const result = await this.get(next.href);
107
+ pages++;
108
+ if (Array.isArray(result)) {
109
+ data.push(...result);
110
+ next = null;
111
+ } else if (Array.isArray(result?.Items) && result.PagingInfo && typeof result.PagingInfo.HasMoreItems === 'boolean') {
112
+ data.push(...result.Items);
113
+ if (!result.PagingInfo.HasMoreItems) next = null;
114
+ else {
115
+ const bookmark = result.PagingInfo.Bookmark;
116
+ if (typeof bookmark !== 'string' || !bookmark) throw new AccessError('partial', 'More pages were advertised without a bookmark.');
117
+ next = apiUrl(this.origin, path);
118
+ next.searchParams.set('bookmark', bookmark);
119
+ }
120
+ } else if (Array.isArray(result?.Objects) && Object.hasOwn(result, 'Next')) {
121
+ data.push(...result.Objects);
122
+ if (!result.Next) next = null;
123
+ else if (typeof result.Next !== 'string') throw new AccessError('invalid_response', 'Invalid next-page marker.');
124
+ else if (/^(?:https?:|\/)/i.test(result.Next)) next = apiUrl(this.origin, result.Next);
125
+ else {
126
+ next = apiUrl(this.origin, path);
127
+ next.searchParams.set('bookmark', result.Next);
128
+ }
129
+ } else throw new AccessError('invalid_response', 'Unrecognized collection response; completeness cannot be established.');
130
+ }
131
+ return { data, pages, complete: true };
132
+ } catch (error) {
133
+ if (!(error instanceof AccessError) || error.code === 'needs_login' || error.status === 429 || !pages) throw error;
134
+ return { data, pages, complete: false, error: error.code, message: error.message, retryAfter: error.retryAfter };
135
+ }
136
+ }
137
+ }
@@ -0,0 +1,345 @@
1
+ import { AccessError } from './client.mjs';
2
+ import { readDocument } from './documents.mjs';
3
+ import { classifyOverview, collectSyllabus } from './syllabus.mjs';
4
+ import { courseNodes, reconcileQuizzes, batch } from './discovery.mjs';
5
+ import { readLinkedDocument, actionLink } from './links.mjs';
6
+ import { descriptionContent } from './documents.mjs';
7
+ import { youtubeId, youtubeTranscript } from './media.mjs';
8
+
9
+ function id(value) {
10
+ const result = String(value);
11
+ if (!/^\d+$/.test(result)) throw new Error('Expected a numeric Brightspace ID.');
12
+ return result;
13
+ }
14
+
15
+ export function fields(value) {
16
+ const keys = new Set();
17
+ const visit = (item, depth) => {
18
+ if (!item || typeof item !== 'object' || depth > 8) return;
19
+ if (Array.isArray(item)) { for (const child of item) visit(child, depth + 1); }
20
+ else for (const [key, child] of Object.entries(item)) { keys.add(key); visit(child, depth + 1); }
21
+ };
22
+ visit(value, 0);
23
+ return [...keys].sort();
24
+ }
25
+
26
+ async function readResource(client, category, path, { list = false, courseId, file = false } = {}) {
27
+ const observation = { key: path.split('?')[0], category, course: courseId, source: path,
28
+ retrievedAt: new Date().toISOString(), outcome: 'available' };
29
+ try {
30
+ const result = file ? { data: await readDocument(client, path), complete: true } : list ? await client.collection(path) : { data: await client.get(path), complete: true };
31
+ if (file && ['failed', 'partial', 'unsupported'].includes(result.data.textStatus)) result.complete = false;
32
+ Object.assign(observation, result);
33
+ if (!result.complete) observation.outcome = 'partial';
34
+ } catch (error) {
35
+ if (!(error instanceof AccessError) || error.code === 'needs_login') throw error;
36
+ Object.assign(observation, { outcome: error.code === 'partial' ? 'partial' : error.code === 'denied' ? 'denied' : 'failed',
37
+ error: error.code, message: error.message, status: error.status, retryAfter: error.retryAfter, complete: false });
38
+ }
39
+ return observation;
40
+ }
41
+
42
+ function selectCourse(enrollments, course) {
43
+ const candidates = enrollments.data.filter(item => item?.OrgUnit?.Id && item.Access?.CanAccess === true)
44
+ .sort((a, b) => (Date.parse(b.Access.LastAccessed) || 0) - (Date.parse(a.Access.LastAccessed) || 0));
45
+ const selected = course ? candidates.find(item => String(item.OrgUnit.Id) === id(course)) :
46
+ candidates.find(item => item.Access?.IsActive === true) ?? candidates[0];
47
+ if (course && !selected) throw new Error('Requested course was not confirmed accessible in your enrollments.');
48
+ return selected ? id(selected.OrgUnit.Id) : null;
49
+ }
50
+
51
+ function assignmentSamples(folders, grades) {
52
+ const scored = new Set((grades ?? []).filter(grade =>
53
+ grade.PointsNumerator != null || grade.WeightedNumerator != null || grade.ReleasedDate != null)
54
+ .map(grade => String(grade.GradeObjectIdentifier)));
55
+ const withRubric = folders.find(folder => folder.Assessment?.Rubrics?.length);
56
+ const withGrade = folders.find(folder => scored.has(String(folder.GradeItemId)));
57
+ return [...new Set([withGrade ?? withRubric ?? folders[0], withRubric].filter(Boolean))];
58
+ }
59
+
60
+ function feedbackEntity(folder, identity, submissions, groups) {
61
+ if (folder.DropboxType !== 1 && folder.GroupTypeId == null) return { type: 'user', entityId: id(identity.Identifier) };
62
+ // A group-category ID identifies the assignment's grouping, not a submitter.
63
+ const submitted = [...new Set((submissions ?? []).filter(item => item.Entity?.EntityType === 'Group')
64
+ .map(item => String(item.Entity.EntityId)))];
65
+ if (submitted.length === 1) return { type: 'group', entityId: id(submitted[0]) };
66
+ if (submitted.length > 1 || folder.GroupTypeId == null) return null;
67
+ const memberships = (groups ?? []).filter(group => String(group.GroupCategoryId) === String(folder.GroupTypeId));
68
+ return memberships.length === 1 ? { type: 'group', entityId: id(memberships[0].GroupId) } : null;
69
+ }
70
+
71
+ export class Connector {
72
+ constructor(client, store) { this.client = client; this.store = store; }
73
+
74
+ async courses() {
75
+ const versions = await this.client.versions();
76
+ const identity = await this.client.get(`/d2l/api/lp/${versions.lp}/users/whoami`);
77
+ const session = await this.client.session.load();
78
+ if (!identity?.Identifier || String(identity.Identifier) !== String(session.identity.Identifier)) {
79
+ throw new AccessError('needs_login', 'Account identity changed. Sign in again before reading course data.');
80
+ }
81
+ const account = this.store.getMeta('account');
82
+ if (account && account !== `${this.client.origin}:${identity.Identifier}`) throw new AccessError('needs_login', 'Local inventory belongs to another account. Sign out before switching accounts.');
83
+ const enrollments = await this.client.collection(`/d2l/api/lp/${versions.lp}/enrollments/myenrollments/?orgUnitTypeId=3`);
84
+ return { identity, versions, enrollments };
85
+ }
86
+
87
+ async syllabus({ course } = {}) {
88
+ const { identity, versions, enrollments } = await this.courses();
89
+ const courseId = selectCourse(enrollments, course);
90
+ if (!courseId) throw new Error('No accessible course was found.');
91
+ const le = `/d2l/api/le/${versions.le}/${courseId}`;
92
+ const content = await readResource(this.client, 'content', `${le}/content/toc`, { courseId });
93
+ const overview = classifyOverview(await readResource(this.client, 'overview', `${le}/overview`, { courseId }));
94
+ const result = await collectSyllabus(this.client, { le, courseId, content, overview });
95
+ const summary = { finishedAt: new Date().toISOString(), scope: 'syllabus sources for one course', versions,
96
+ courseCount: enrollments.data.length, ...result.summary };
97
+ this.store.commit(identity, this.client.origin, [content, overview, ...result.observations], summary, enrollments);
98
+ return summary;
99
+ }
100
+
101
+ async auditAll(options) {
102
+ const { enrollments } = await this.courses();
103
+ if (!enrollments.complete) throw new AccessError('retry_wait', 'Complete enrollment coverage is required for an all-course pass.');
104
+ const courses = enrollments.data.filter(item => item.Access?.CanAccess === true)
105
+ .sort((a, b) => Number(a.OrgUnit.Id) - Number(b.OrgUnit.Id));
106
+ const selected = batch(courses, this.store.getMeta('all_course_cursor'), 5);
107
+ const previous = this.store.getMeta('selected_course');
108
+ const results = [];
109
+ try {
110
+ for (const [index, course] of selected.items.entries()) {
111
+ results.push({ alias: `course-${selected.start + index + 1}`, ...await this.audit({ ...options, course: String(course.OrgUnit.Id) }) });
112
+ }
113
+ const summary = { finishedAt: new Date().toISOString(), scope: 'resumable all-accessible-course pass; up to five courses per run',
114
+ accessibleCourseCount: courses.length, coursesProcessed: results.length, nextCourseOffset: selected.next,
115
+ complete: !selected.limited && results.every(item => item.complete),
116
+ retryAfter: Math.max(0, ...results.map(item => item.retryAfter || 0)), courses: results };
117
+ this.store.meta('all_course_cursor', selected.next);
118
+ this.store.meta('last_run', summary);
119
+ return summary;
120
+ } finally { this.store.meta('selected_course', previous); }
121
+ }
122
+
123
+ async audit({ course, from, to, all } = {}) {
124
+ if (all) {
125
+ if (course) throw new Error('Choose either --all or --course.');
126
+ return this.auditAll({ from, to });
127
+ }
128
+ from ??= new Date(Date.now() - 30 * 86400_000).toISOString();
129
+ to ??= new Date(Date.now() + 120 * 86400_000).toISOString();
130
+ if (!Number.isFinite(Date.parse(from)) || !Number.isFinite(Date.parse(to)) || Date.parse(from) >= Date.parse(to)) {
131
+ throw new Error('Calendar window must contain valid ISO dates with from earlier than to.');
132
+ }
133
+ const { identity, versions, enrollments } = await this.courses();
134
+ const observations = [];
135
+ const checkpoints = [];
136
+ const traversal = {};
137
+ const window = (name, items, size = 25, owner = '') => {
138
+ const key = `cursor:${courseId}:${name}:${owner}`;
139
+ const selected = batch(items, this.store.getMeta(key), size);
140
+ const phase = traversal[name] ??= { total: 0, processed: 0, limited: false };
141
+ phase.total += selected.total;
142
+ phase.processed += selected.items.length;
143
+ phase.limited ||= selected.limited;
144
+ checkpoints.push([key, selected.next]);
145
+ return selected.items;
146
+ };
147
+ const capture = async (category, path, { list = false, courseId, file = false, version } = {}) => {
148
+ if (file && version) {
149
+ const cached = this.store.resource?.(path.split('?')[0]);
150
+ if (cached?.outcome === 'available' && cached.observation.version === version &&
151
+ Date.now() - Date.parse(cached.last_success) < 6 * 3600_000 && cached.data?.textStatus === 'available') {
152
+ const observation = { ...cached.observation, data: cached.data, cached: true, category, course: courseId, retrievedAt: cached.last_success };
153
+ observations.push(observation);
154
+ return observation;
155
+ }
156
+ }
157
+ const observation = await readResource(this.client, category, path, { list, courseId, file });
158
+ if (observation.status === 429) throw new AccessError('retry_wait', 'Brightspace rate limit reached; the pass stopped before further reads.', { status: 429, retryAfter: observation.retryAfter });
159
+ if (version) observation.version = version;
160
+ observations.push(observation);
161
+ return observation;
162
+ };
163
+ observations.push({ key: 'enrollments', category: 'enrollments', ...enrollments,
164
+ outcome: enrollments.complete ? 'available' : 'partial', retrievedAt: new Date().toISOString() });
165
+ const courseId = selectCourse(enrollments, course);
166
+ let syllabus;
167
+ let quizDiscovery;
168
+ if (courseId) {
169
+ const le = `/d2l/api/le/${versions.le}/${courseId}`;
170
+ const lp = `/d2l/api/lp/${versions.lp}/${courseId}`;
171
+ // Sequential reads keep this first discovery pass bounded and gentle on the tenant.
172
+ const read = (category, path, options) => capture(category, path, { courseId, ...options });
173
+ const content = await read('content', `${le}/content/toc`);
174
+ const overview = classifyOverview(await read('overview', `${le}/overview`));
175
+ const result = await collectSyllabus(this.client, { le, courseId, content, overview });
176
+ syllabus = result.summary;
177
+ observations.push(...result.observations);
178
+ const assignments = await read('assignments', `${le}/dropbox/folders/`, { list: true });
179
+ const quizList = await read('quizzes', `${le}/quizzes/`, { list: true });
180
+ const grades = await read('grades', `${le}/grades/values/myGradeValues/`, { list: true });
181
+ await read('announcements', `${le}/news/`, { list: true });
182
+ const forums = await read('discussion_forums', `${le}/discussions/forums/`, { list: true });
183
+ const groups = await read('groups', `${lp}/groups/`, { list: true });
184
+ await read('sections', `${lp}/sections/mysections/`, { list: true });
185
+ const calendar = new URL(`${le}/calendar/events/myEvents/`, this.client.origin);
186
+ calendar.searchParams.set('startDateTime', new Date(from).toISOString());
187
+ calendar.searchParams.set('endDateTime', new Date(to).toISOString());
188
+ const [major, minor] = versions.le.split('.').map(Number);
189
+ const events = await read('calendar', `${calendar.pathname}${calendar.search}`, { list: true });
190
+ const recurring = (events.data || []).filter(event => event.IsRecurring === true);
191
+ for (const event of window('recurring_events', recurring)) {
192
+ await read('calendar_occurrences', `${le}/calendar/event/${id(event.CalendarEventId)}/occurrences`);
193
+ }
194
+ const reconciled = reconcileQuizzes(quizList.data, content.data, grades.data, this.client.origin);
195
+ const toolNames = new Map();
196
+ for (const candidate of window('quiz_grade_associations', reconciled.candidates.filter(item => item.source === 'gradebook'))) {
197
+ const definition = await read('quiz_grade_association', `${le}/grades/${id(candidate.gradeItemId)}`);
198
+ const association = definition.data?.AssociatedTool;
199
+ if (!association || !/^\d+$/.test(String(association.ToolId)) || !/^\d+$/.test(String(association.ToolItemId))) continue;
200
+ const toolId = String(association.ToolId);
201
+ if (!toolNames.has(toolId)) {
202
+ const tool = await read('quiz_tool', `/d2l/api/lp/${versions.lp}/tools/orgUnits/${courseId}/${toolId}?namesOnly=true`);
203
+ toolNames.set(toolId, tool.data?.DisplayName || '');
204
+ }
205
+ if (!/quiz/i.test(toolNames.get(toolId))) continue;
206
+ const quizId = String(association.ToolItemId);
207
+ if (!reconciled.quizzes.some(quiz => quiz.quizId === quizId)) reconciled.quizzes.push({ quizId, name: candidate.name,
208
+ gradeItemId: candidate.gradeItemId, sources: ['grade_association'], restricted: false });
209
+ reconciled.candidates = reconciled.candidates.filter(item => item !== candidate);
210
+ }
211
+ const quizSourcesComplete = quizList.complete === true && content.complete === true && grades.complete === true;
212
+ quizDiscovery = { confirmedReferences: reconciled.quizzes.length, unresolvedCandidates: reconciled.candidates.length, sourcesComplete: quizSourcesComplete };
213
+ observations.push({ key: `${le}/quiz_inventory`, category: 'quiz_inventory', course: courseId, source: `${le}/content/toc`,
214
+ retrievedAt: new Date().toISOString(), outcome: quizSourcesComplete && !reconciled.candidates.length ? 'available' : 'partial', complete: quizSourcesComplete && !reconciled.candidates.length,
215
+ data: { ...reconciled, listCoverage: quizList.outcome, candidatesAreNotConfirmedQuizzes: true } });
216
+ for (const quiz of window('quiz_details', reconciled.quizzes.filter(item => !item.restricted))) {
217
+ const path = `${le}/quizzes/${id(quiz.quizId)}`;
218
+ await read('quiz_details', path);
219
+ await read('own_quiz_attempts', `${path}/attempts/?userId=${id(identity.Identifier)}`, { list: true });
220
+ }
221
+ const prioritized = [...new Set([...assignmentSamples(assignments.data ?? [], grades.data), ...(assignments.data || [])])];
222
+ for (const folder of window('assignments', prioritized)) {
223
+ const assignmentFiles = [];
224
+ const folderPath = `${le}/dropbox/folders/${id(folder.Id)}`;
225
+ const detail = await read('assignment_detail', folderPath);
226
+ const submissions = await read('own_submissions', `${folderPath}/submissions/mysubmissions/`, { list: true });
227
+ const entity = feedbackEntity(folder, identity, submissions.data, groups.data);
228
+ if (entity) {
229
+ const feedbackPath = `${folderPath}/feedback/${entity.type}/${entity.entityId}`;
230
+ const feedback = await read('own_feedback', feedbackPath);
231
+ for (const file of feedback.data?.Attachments || []) if (file.FileId != null) assignmentFiles.push({ category: 'feedback_file', path: `${feedbackPath}/attachments/${id(file.FileId)}` });
232
+ }
233
+ else observations.push({ key: `${folderPath}/feedback/`, course: courseId, category: 'own_feedback',
234
+ source: folderPath, retrievedAt: new Date().toISOString(), outcome: 'unknown', complete: false,
235
+ error: 'unresolved_group', message: 'Own group could not be identified unambiguously; no user or group feedback request was guessed.' });
236
+ const embedded = detail.data?.Assessment?.Rubrics?.length ? detail.data.Assessment.Rubrics : folder.Assessment?.Rubrics;
237
+ if (embedded?.length) observations.push({ key: `${folderPath}/assessment/rubrics`, course: courseId, category: 'rubrics',
238
+ source: detail.data?.Assessment?.Rubrics?.length ? folderPath : `${le}/dropbox/folders/`,
239
+ field: 'Assessment.Rubrics', retrievedAt: new Date().toISOString(), outcome: 'available', complete: true, data: embedded });
240
+ else if (major > 1 || minor >= 93) {
241
+ const rubrics = await read('rubrics', `${le}/rubrics/?objectType=Dropbox&objectId=${id(folder.Id)}`, { list: true });
242
+ // Distinct assignment associations share a route but must not share a cache key.
243
+ rubrics.key = rubrics.source;
244
+ }
245
+ for (const attachment of detail.data?.Attachments ?? folder.Attachments ?? []) {
246
+ if (attachment.FileId != null) assignmentFiles.push({ category: 'assignment_file', path: `${folderPath}/attachments/${id(attachment.FileId)}` });
247
+ }
248
+ for (const own of submissions.data || []) for (const submission of own.Submissions || []) for (const file of submission.Files || []) {
249
+ if (submission.Id != null && file.FileId != null) assignmentFiles.push({ category: 'own_submission_file', path: `${folderPath}/submissions/${id(submission.Id)}/files/${id(file.FileId)}` });
250
+ }
251
+ for (const file of window('assignment_files', assignmentFiles, 2, id(folder.Id))) await read(file.category, file.path, { file: true });
252
+ }
253
+ for (const forum of window('discussion_forums', forums.data || [], 5)) {
254
+ if (forum.IsHidden) continue;
255
+ const forumPath = `${le}/discussions/forums/${id(forum.ForumId)}`;
256
+ const topics = await read('discussion_topics', `${forumPath}/topics/`, { list: true });
257
+ for (const topic of window('discussion_posts', (topics.data || []).filter(item => !item.IsHidden), 5, id(forum.ForumId))) {
258
+ const topicPath = `${forumPath}/topics/${id(topic.TopicId)}`;
259
+ const posts = await read('discussion_posts', `${topicPath}/posts/`, { list: true });
260
+ const discussionFiles = [];
261
+ for (const post of posts.data || []) for (const attachment of post.Attachments || []) {
262
+ if (post.PostId != null && attachment.FileId != null) discussionFiles.push(`${topicPath}/posts/${id(post.PostId)}/attachments/${id(attachment.FileId)}`);
263
+ }
264
+ for (const path of window('discussion_files', discussionFiles, 2, id(topic.TopicId))) await read('discussion_file', path, { file: true });
265
+ }
266
+ }
267
+ const allNodes = courseNodes(content.data);
268
+ const files = allNodes.filter(topic => topic.TopicId != null && topic.ActivityType === 1);
269
+ for (const topic of files.filter(item => item.restricted)) observations.push({ key: `${le}/content/topics/${id(topic.TopicId)}/file`,
270
+ category: 'content_file', course: courseId, outcome: 'denied', complete: false, error: 'restricted',
271
+ source: `${le}/content/toc`, retrievedAt: new Date().toISOString(), message: 'Topic or ancestor is outside its release conditions.' });
272
+ for (const topic of window('content_files', files.filter(item => !item.restricted), 20)) {
273
+ const path = `${le}/content/topics/${id(topic.TopicId)}/file`;
274
+ const existing = observations.find(item => item.category === 'syllabus_source' && item.source === path && item.outcome === 'available');
275
+ if (existing) observations.push({ ...existing, key: path, category: 'content_file' });
276
+ else await read('content_file', path, { file: true, version: topic.LastModifiedDate });
277
+ }
278
+ const targets = new Set();
279
+ for (const topic of allNodes.filter(item => !item.restricted)) {
280
+ if (topic.Url && topic.ActivityType !== 1) targets.add(topic.Url);
281
+ for (const link of descriptionContent(topic.Description).links) targets.add(link.url);
282
+ }
283
+ for (const target of window('linked_content', [...targets], 20)) {
284
+ if (actionLink(target, this.client.origin)) continue;
285
+ let url;
286
+ try { url = new URL(target, this.client.origin); } catch { continue; }
287
+ const record = { key: `linked:${courseId}:${url.href}`, category: youtubeId(url.href) ? 'video_transcript' : 'linked_content',
288
+ course: courseId, source: url.href, retrievedAt: new Date().toISOString(), outcome: 'available', complete: true };
289
+ try {
290
+ record.data = youtubeId(url.href) ? await youtubeTranscript(this.client, url.href) : await readLinkedDocument(this.client, url.href, { le, courseId, tree: content.data });
291
+ if (record.data.textStatus && record.data.textStatus !== 'available' && record.data.textStatus !== 'empty') { record.complete = false; record.outcome = 'partial'; }
292
+ } catch (error) {
293
+ if (!(error instanceof AccessError) || error.code === 'needs_login') throw error;
294
+ if (error.status === 429) throw error;
295
+ 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 });
296
+ }
297
+ observations.push(record);
298
+ }
299
+ }
300
+ const summary = {
301
+ finishedAt: new Date().toISOString(), scope: 'one selected accessible course; resumable bounded traversal',
302
+ versions, courseCount: enrollments.data.length, enrollmentCoverageComplete: enrollments.complete,
303
+ selectedCourse: courseId ? 'course-1' : null, calendarWindow: { from, to }, syllabus, quizDiscovery,
304
+ traversal, complete: Boolean(courseId) && observations.every(item => item.complete) && Object.values(traversal).every(item => !item.limited),
305
+ retryAfter: Math.max(0, ...observations.filter(item => item.error === 'retry_wait').map(item => item.retryAfter || 60_000)),
306
+ resources: observations.map(item => ({ category: item.category, outcome: item.outcome,
307
+ complete: item.complete, pages: item.pages, count: Array.isArray(item.data) ? item.data.length : undefined,
308
+ fieldNames: fields(item.data), error: item.error, message: item.message })),
309
+ notChecked: ['private external-provider authorization', 'every object compared against the onQ browser', 'natural session-expiry soak test'],
310
+ };
311
+ this.store.commit(identity, this.client.origin, observations, summary, enrollments);
312
+ for (const [key, value] of checkpoints) this.store.meta(key, value);
313
+ this.selectedCourse = courseId;
314
+ if (courseId) this.store.meta('selected_course', courseId);
315
+ return summary;
316
+ }
317
+ }
318
+
319
+ export async function sync(connector, options, { signal, onUpdate = console.log, wait = ms => new Promise(resolve => {
320
+ const timer = setTimeout(done, ms);
321
+ function done() { clearTimeout(timer); signal?.removeEventListener('abort', done); resolve(); }
322
+ signal?.addEventListener('abort', done, { once: true });
323
+ if (signal?.aborted) done();
324
+ }) } = {}) {
325
+ let failures = 0;
326
+ options = { ...options, course: options?.all ? undefined : options?.course ?? connector.store.getMeta('selected_course') ?? undefined };
327
+ while (!signal?.aborted) {
328
+ let delay = 15 * 60_000;
329
+ connector.store.meta('state', { state: 'syncing', updatedAt: new Date().toISOString() });
330
+ try {
331
+ const summary = await connector.audit(options);
332
+ if (!options.all) options.course ??= connector.selectedCourse ?? undefined;
333
+ failures = 0;
334
+ delay = Math.max(delay, summary.retryAfter);
335
+ await onUpdate({ state: summary.retryAfter ? 'retry_wait' : 'ready', ...summary });
336
+ } catch (error) {
337
+ const state = error.code === 'needs_login' ? 'needs_login' : 'retry_wait';
338
+ connector.store.meta('state', { state, updatedAt: new Date().toISOString() });
339
+ if (state === 'needs_login' || !(error instanceof AccessError)) throw error;
340
+ delay = Math.max(error.retryAfter || 0, Math.min(15 * 60_000, 60_000 * 2 ** Math.min(failures++, 4)));
341
+ await onUpdate({ state, message: error.message });
342
+ }
343
+ if (!signal?.aborted) await wait(delay);
344
+ }
345
+ }
@@ -0,0 +1,45 @@
1
+ export function courseNodes(tree, now = Date.now()) {
2
+ const result = [];
3
+ function visit(node, unavailable = false) {
4
+ if (!node || typeof node !== 'object') return;
5
+ const restricted = unavailable || node.IsHidden === true || node.IsLocked === true ||
6
+ Date.parse(node.StartDateTime) > now || Date.parse(node.EndDateTime) < now;
7
+ result.push({ ...node, restricted });
8
+ for (const child of [...(node.Modules || []), ...(node.Topics || [])]) visit(child, restricted);
9
+ }
10
+ visit(tree);
11
+ return result;
12
+ }
13
+
14
+ export function reconcileQuizzes(list, tree, grades, origin) {
15
+ const known = new Map();
16
+ const candidates = [];
17
+ for (const quiz of list || []) if (/^\d+$/.test(String(quiz.QuizId))) known.set(String(quiz.QuizId), { quizId: String(quiz.QuizId), name: quiz.Name, sources: ['quiz_list'], gradeItemId: quiz.GradeItemId, restricted: false });
18
+ for (const topic of courseNodes(tree).filter(node => node.TopicId != null)) {
19
+ let url;
20
+ try { url = new URL(topic.Url, origin); } catch {}
21
+ const quizTopic = topic.ActivityType === 4 || /quiz/i.test(topic.TypeIdentifier || '') ||
22
+ (url?.origin === origin && url.pathname.includes('/quizzing/'));
23
+ if (!quizTopic) continue;
24
+ const identifier = topic.ToolItemId ?? (url?.origin === origin ? url.searchParams.get('qi') : null);
25
+ if (/^\d+$/.test(String(identifier))) {
26
+ const key = String(identifier);
27
+ const existing = known.get(key);
28
+ if (existing) { existing.sources.push('content'); existing.restricted &&= topic.restricted; }
29
+ else known.set(key, { quizId: key, name: topic.Title, sources: ['content'], topicId: topic.TopicId, restricted: topic.restricted, gradeItemId: topic.GradeItemId });
30
+ } else candidates.push({ name: topic.Title, topicId: topic.TopicId, source: 'content', restricted: topic.restricted, detailsStatus: 'unresolved' });
31
+ }
32
+ for (const grade of grades || []) {
33
+ const quiz = [...known.values()].find(item => item.gradeItemId != null && String(item.gradeItemId) === String(grade.GradeObjectIdentifier));
34
+ if (quiz) quiz.sources.push('gradebook');
35
+ else if (/\bquiz/i.test(grade.GradeObjectName || '')) candidates.push({ name: grade.GradeObjectName, gradeItemId: grade.GradeObjectIdentifier, source: 'gradebook', detailsStatus: 'unresolved' });
36
+ }
37
+ return { quizzes: [...known.values()], candidates };
38
+ }
39
+
40
+ export function batch(items, offset = 0, size = 25) {
41
+ const start = Number.isSafeInteger(offset) && offset >= 0 && offset < items.length ? offset : 0;
42
+ const selected = items.slice(start, start + size);
43
+ return { items: selected, total: items.length, start, next: start + selected.length >= items.length ? 0 : start + selected.length,
44
+ limited: items.length > selected.length };
45
+ }