@ivanbard/dd2l 0.0.0-stage → 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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +122 -3
  3. package/THIRD_PARTY_NOTICES.md +50 -0
  4. package/bin/dd2l.mjs +7 -0
  5. package/docs/README.md +227 -0
  6. package/package.json +56 -6
  7. package/src/app-runner.mjs +58 -0
  8. package/src/assignment-workspace.mjs +128 -0
  9. package/src/canvas.mjs +17 -0
  10. package/src/cli-banner.mjs +58 -0
  11. package/src/cli.mjs +142 -0
  12. package/src/client.mjs +150 -0
  13. package/src/connector.mjs +587 -0
  14. package/src/discovery.mjs +45 -0
  15. package/src/documents.mjs +154 -0
  16. package/src/links.mjs +99 -0
  17. package/src/llm.mjs +243 -0
  18. package/src/media.mjs +54 -0
  19. package/src/ocr.mjs +24 -0
  20. package/src/office.mjs +61 -0
  21. package/src/pdf-worker.mjs +26 -0
  22. package/src/research.mjs +70 -0
  23. package/src/session.mjs +142 -0
  24. package/src/store.mjs +108 -0
  25. package/src/syllabus.mjs +191 -0
  26. package/src/task-workspace.mjs +138 -0
  27. package/src/ui-data.mjs +123 -0
  28. package/src/ui-demo.mjs +36 -0
  29. package/src/ui-server.mjs +161 -0
  30. package/src/windows-ocr.ps1 +32 -0
  31. package/ui/answer-renderer.js +88 -0
  32. package/ui/answer-view.js +47 -0
  33. package/ui/app.js +385 -0
  34. package/ui/assets/LUCIDE-LICENSE.txt +15 -0
  35. package/ui/assets/avatar.svg +3 -0
  36. package/ui/assets/chevron.svg +5 -0
  37. package/ui/assets/courses.svg +14 -0
  38. package/ui/assets/logo.svg +13 -0
  39. package/ui/assets/new-chat.svg +14 -0
  40. package/ui/assets/search.svg +14 -0
  41. package/ui/assets/send.svg +8 -0
  42. package/ui/assets/settings.svg +14 -0
  43. package/ui/assets/widget-announcements.svg +4 -0
  44. package/ui/assets/widget-shortcuts.svg +5 -0
  45. package/ui/assignment-workspace.js +106 -0
  46. package/ui/canvas-contract.js +41 -0
  47. package/ui/canvas-runtime.js +158 -0
  48. package/ui/canvas.js +96 -0
  49. package/ui/index.html +84 -0
  50. package/ui/loading.js +33 -0
  51. package/ui/style.css +241 -0
  52. package/ui/widget-layout.js +39 -0
  53. package/ui/widgets.js +252 -0
@@ -0,0 +1,26 @@
1
+ import { parentPort, workerData } from 'node:worker_threads';
2
+ import { getDocumentProxy, extractText, renderPageAsImage } from 'unpdf';
3
+
4
+ let document;
5
+ try {
6
+ document = await getDocumentProxy(new Uint8Array(workerData));
7
+ if (document.numPages > 250) throw new Error('page limit');
8
+ const { text: pageTexts, totalPages } = await extractText(document, { mergePages: false });
9
+ const images = [];
10
+ const links = [];
11
+ let ocrLimited = false;
12
+ for (let index = 0; index < pageTexts.length; index++) {
13
+ const page = await document.getPage(index + 1);
14
+ for (const annotation of await page.getAnnotations()) if (annotation.url && links.length < 200) links.push({ url: annotation.url, label: '', page: index + 1 });
15
+ if (pageTexts[index].trim()) continue;
16
+ if (!(await page.getOperatorList()).fnArray.length) continue;
17
+ const viewport = page.getViewport({ scale: 1.5 });
18
+ if (images.length === 10 || viewport.width * viewport.height > 16_000_000) { ocrLimited = true; continue; }
19
+ images.push({ index, bytes: await renderPageAsImage(document, index + 1, { scale: 1.5, canvasImport: () => import('@napi-rs/canvas') }) });
20
+ }
21
+ const text = pageTexts.join('\n\n');
22
+ parentPort.postMessage({ text: text.slice(0, 1_000_000), pageTexts: pageTexts.map(value => value.slice(0, 1_000_000)),
23
+ pages: totalPages, truncated: text.length > 1_000_000 || ocrLimited, ocrLimited, images, links });
24
+ } catch {
25
+ parentPort.postMessage({ error: 'PDF text extraction failed or exceeded the 250-page limit; bytes remain available.' });
26
+ } finally { await document?.loadingTask?.destroy(); }
@@ -0,0 +1,70 @@
1
+ import { dirname, basename } from 'node:path';
2
+ import { Connector } from './connector.mjs';
3
+ import { BrightspaceClient, SCHOOL } from './client.mjs';
4
+ import { Session } from './session.mjs';
5
+ import { Store, lock } from './store.mjs';
6
+ import { matches } from '../ui/answer-view.js';
7
+
8
+ export const researchActions = { type: 'array', minItems: 0, maxItems: 2, items: { anyOf: [
9
+ { type: 'object', additionalProperties: false, required: ['action', 'course', 'section', 'query'], properties: {
10
+ action: { type: 'string', enum: ['browse'] }, course: { type: 'string', maxLength: 20 },
11
+ section: { type: 'string', enum: ['courses', 'content', 'assignments', 'quizzes', 'announcements', 'grades', 'calendar', 'overview', 'discussions'] },
12
+ query: { type: 'string', maxLength: 200 },
13
+ } },
14
+ { type: 'object', additionalProperties: false, required: ['action', 'resource'], properties: {
15
+ action: { type: 'string', enum: ['read'] }, resource: { type: 'string', maxLength: 24 },
16
+ } },
17
+ ] } };
18
+
19
+ async function localBrowse(path, action, options) {
20
+ if (basename(path) !== 'inventory.sqlite') throw new Error('Live reading requires the standard inventory cache.');
21
+ const directory = dirname(path);
22
+ let release;
23
+ try { release = await lock(directory); }
24
+ catch { throw Object.assign(new Error('Another dd2l command owns the inventory. Use the UI opened by dd2l start to share its connector.'), { code: 'busy' }); }
25
+ let store;
26
+ try {
27
+ store = new Store(directory);
28
+ const origin = store.getMeta('school_origin') || SCHOOL;
29
+ const connector = new Connector(new BrightspaceClient(new Session(directory, { origin }), { origin }), store);
30
+ return await connector.browse(action, options);
31
+ } finally { store?.close(); await release(); }
32
+ }
33
+
34
+ // A request owns its discovered handles and budget; the model never supplies a network URL.
35
+ export function createResearch({ connector, inventoryPath, scope = '', accountScope = '', signal }) {
36
+ const resources = new Map(), history = [], keys = new Set();
37
+ let available = true, used = 0;
38
+ return {
39
+ history, keys,
40
+ get available() { return available && used < 8; },
41
+ get remaining() { return Math.max(0, 8 - used); },
42
+ async execute(action) {
43
+ signal?.throwIfAborted();
44
+ if (!available || used >= 8) throw new Error('Live read budget is exhausted.');
45
+ if (!matches([action], researchActions)) throw new Error('Invalid Brightspace read request.');
46
+ used++;
47
+ let result;
48
+ try {
49
+ const options = { scope, accountScope, resources, signal };
50
+ result = connector ? await connector.browse(action, options) : await localBrowse(inventoryPath, action, options);
51
+ for (const key of result.resourceKeys || []) keys.add(key);
52
+ accountScope ||= result.accountScope || '';
53
+ const { resourceKeys, accountScope: account, ...safe } = result;
54
+ result = safe;
55
+ } catch (error) {
56
+ signal?.throwIfAborted();
57
+ const stopped = !['denied', 'restricted', 'invalid_link', 'unavailable', 'partial', 'external_auth_required', 'unsupported_link'].includes(error.code);
58
+ if (stopped) available = false;
59
+ result = { outcome: error.code || 'busy', message: error.code === 'needs_login'
60
+ ? 'School sign-in is required. Run setup again; cached sources may still be used.'
61
+ : error.code === 'retry_wait' ? 'Brightspace is temporarily unavailable or rate limited. Further live reads stopped.'
62
+ : error.code === 'busy' ? error.message
63
+ : !stopped ? error.message : 'Live reading is unavailable. Cached evidence may still be used.',
64
+ retryAfter: error.retryAfter || 0, resources: [] };
65
+ }
66
+ history.push({ action, result });
67
+ return result;
68
+ },
69
+ };
70
+ }
@@ -0,0 +1,142 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readFile, writeFile, rename, mkdir, rm } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { AccessError, BrightspaceClient, SCHOOL, schoolOrigin, request, readJson } from './client.mjs';
6
+
7
+ // Secrets go through stdin, never command arguments or a plaintext temp file.
8
+ export function protect(bytes, decrypt = false) {
9
+ if (process.platform !== 'win32') throw new Error('Protected session storage currently requires Windows.');
10
+ const method = decrypt ? 'Unprotect' : 'Protect';
11
+ const script = `Add-Type -AssemblyName System.Security; try { $b=[Convert]::FromBase64String([Console]::In.ReadToEnd()); $r=[Security.Cryptography.ProtectedData]::${method}($b,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser); [Console]::Out.Write([Convert]::ToBase64String($r)) } catch { exit 1 }`;
12
+ return new Promise((resolve, reject) => {
13
+ const child = execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script],
14
+ { windowsHide: true, timeout: 30_000, maxBuffer: 8 * 1024 * 1024 }, (error, stdout) => {
15
+ if (error) reject(new Error('Windows could not protect or unlock this session. Sign in again if it belongs to another Windows account.'));
16
+ else resolve(Buffer.from(stdout.trim(), 'base64'));
17
+ });
18
+ child.stdin.on('error', () => {});
19
+ child.stdin.end(Buffer.from(bytes).toString('base64'));
20
+ });
21
+ }
22
+
23
+ export async function atomicWrite(path, value) {
24
+ const temp = `${path}.${randomUUID()}.tmp`;
25
+ try {
26
+ await writeFile(temp, value, { mode: 0o600, flag: 'wx' });
27
+ await rename(temp, path);
28
+ } finally { await rm(temp, { force: true }); }
29
+ }
30
+
31
+ export class Session {
32
+ constructor(directory, { origin = SCHOOL, fetchImpl = fetch, crypt = protect } = {}) {
33
+ this.directory = directory;
34
+ this.path = join(directory, 'session.dpapi');
35
+ this.origin = schoolOrigin(origin);
36
+ this.fetch = fetchImpl;
37
+ this.crypt = crypt;
38
+ }
39
+
40
+ async load() {
41
+ if (this.value) return this.value;
42
+ let encrypted;
43
+ try { encrypted = await readFile(this.path); }
44
+ catch (error) {
45
+ if (error.code === 'ENOENT') throw new AccessError('needs_login', 'Run npx @ivanbard/dd2l setup (npm run setup from source) to sign in.');
46
+ throw error;
47
+ }
48
+ let value;
49
+ try { value = JSON.parse((await this.crypt(encrypted, true)).toString()); }
50
+ catch { throw new AccessError('needs_login', 'Saved session could not be unlocked. Run npx @ivanbard/dd2l setup (npm run setup from source).'); }
51
+ if (value.origin !== this.origin || !Array.isArray(value.cookies) || !value.csrf || !value.identity?.Identifier) {
52
+ throw new AccessError('needs_login', 'Saved session does not match this school or is incomplete.');
53
+ }
54
+ this.value = value;
55
+ return value;
56
+ }
57
+
58
+ async save(value) {
59
+ await mkdir(this.directory, { recursive: true });
60
+ await atomicWrite(this.path, await this.crypt(Buffer.from(JSON.stringify(value))));
61
+ this.value = value;
62
+ }
63
+
64
+ async token(rejected) {
65
+ const value = await this.load();
66
+ if (value.token && value.expiresAt > Date.now() + 60_000 && value.token !== rejected) return value.token;
67
+ if (!this.renewing) this.renewing = this.renew(value).finally(() => { this.renewing = null; });
68
+ return this.renewing;
69
+ }
70
+
71
+ async renew(value) {
72
+ const updated = await mintSession(this.origin, value, this.fetch);
73
+ await this.save(updated);
74
+ return updated.token;
75
+ }
76
+ }
77
+
78
+ async function mintSession(origin, value, fetchImpl) {
79
+ const url = new URL(origin);
80
+ const cookies = value.cookies.filter(cookie => {
81
+ const domain = String(cookie.domain).replace(/^\./, '');
82
+ return (url.hostname === domain || url.hostname.endsWith(`.${domain}`)) &&
83
+ (!cookie.expires || cookie.expires === -1 || cookie.expires > Date.now() / 1000) &&
84
+ (cookie.path === '/' || '/d2l/lp/auth/oauth2/token'.startsWith(cookie.path));
85
+ });
86
+ if (!cookies.length) throw new AccessError('needs_login', 'School session has expired. Run npx @ivanbard/dd2l setup (npm run setup from source).');
87
+ const payload = await readJson(await request(`${origin}/d2l/lp/auth/oauth2/token`, {
88
+ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded',
89
+ cookie: cookies.map(c => `${c.name}=${c.value}`).join('; '), 'x-csrf-token': value.csrf },
90
+ body: 'scope=*:*:*',
91
+ }, fetchImpl));
92
+ if (typeof payload.access_token !== 'string' || !payload.access_token) throw new AccessError('invalid_response', 'Token exchange did not return an API token.');
93
+ let exp;
94
+ try { exp = JSON.parse(Buffer.from(payload.access_token.split('.')[1], 'base64url')).exp * 1000; } catch {}
95
+ const ttl = Number(payload.expires_in);
96
+ const expiresAt = Number.isFinite(exp) && exp > Date.now() ? exp : Date.now() + (ttl > 0 && Number.isFinite(ttl) ? ttl : 300) * 1000;
97
+ return { ...value, token: payload.access_token, expiresAt };
98
+ }
99
+
100
+ export async function login(session, client, { onProgress = console.error, expectedAccount } = {}) {
101
+ const { chromium } = await import('playwright-core');
102
+ const versions = await client.versions();
103
+ onProgress('Opening Chrome. Complete your school sign-in and MFA in the browser.');
104
+ const browser = await chromium.launch({ channel: 'chrome', headless: false, timeout: 30_000 });
105
+ try {
106
+ const context = await browser.newContext();
107
+ const page = await context.newPage();
108
+ await page.goto(`${session.origin}/d2l/home`, { waitUntil: 'domcontentloaded', timeout: 60_000 });
109
+ const deadline = Date.now() + 5 * 60_000;
110
+ while (Date.now() < deadline) {
111
+ if (page.isClosed()) throw new AccessError('needs_login', 'Sign-in browser was closed before verification.');
112
+ if (new URL(page.url()).origin === session.origin && /\/d2l\/(?:home|le\b|lms\b)/.test(new URL(page.url()).pathname)) {
113
+ let csrf;
114
+ try {
115
+ csrf = await page.evaluate(() =>
116
+ window.D2L?.LP?.Web?.Authentication?.Xsrf?.GetXsrfToken?.() ||
117
+ localStorage.getItem('XSRF.Token') || document.querySelector('meta[name="d2l-xsrf-token"]')?.content);
118
+ } catch { /* SSO navigation can replace the page during this read. */ }
119
+ if (typeof csrf === 'string' && csrf) {
120
+ const candidate = { origin: session.origin, csrf, cookies: await context.cookies(session.origin) };
121
+ try {
122
+ const verified = await mintSession(session.origin, candidate, session.fetch);
123
+ const candidateClient = new BrightspaceClient({ token: async () => verified.token }, { origin: session.origin, fetchImpl: session.fetch });
124
+ const identity = await candidateClient.get(`/d2l/api/lp/${versions.lp}/users/whoami`);
125
+ if (!identity?.Identifier) throw new AccessError('invalid_response', 'Could not verify the signed-in identity.');
126
+ if (expectedAccount && expectedAccount !== `${session.origin}:${identity.Identifier}`) {
127
+ throw new AccessError('account_mismatch', 'Inventory belongs to another school account. Run logout before switching accounts.');
128
+ }
129
+ // Verify before persisting; a failed login must not overwrite a working session.
130
+ await session.save({ ...verified, identity });
131
+ onProgress('School identity verified; session saved with Windows protection.');
132
+ return;
133
+ } catch (error) {
134
+ if (error.code !== 'needs_login') throw error;
135
+ }
136
+ }
137
+ }
138
+ await new Promise(resolve => setTimeout(resolve, 1500));
139
+ }
140
+ throw new AccessError('needs_login', 'Sign-in did not complete within five minutes.');
141
+ } finally { await browser.close(); }
142
+ }
package/src/store.mjs ADDED
@@ -0,0 +1,108 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { mkdir, open, readFile, rm } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+
6
+ export function withoutCredentials(value) {
7
+ return JSON.parse(JSON.stringify(value, (key, item) => {
8
+ if (/^(?:access_token|refresh_token|authorization|cookie|csrfToken|xsrfToken|password|passcode)$/i.test(key)) return '[redacted]';
9
+ if (typeof item === 'string') return item.replace(/([?&](?:amp;)?(?:d2lSessionVal|access_token|refresh_token|csrf|xsrf|token)=)[^&\s"'<>]*/gi, '$1[redacted]');
10
+ return item;
11
+ }));
12
+ }
13
+
14
+ export async function lock(directory) {
15
+ await mkdir(directory, { recursive: true });
16
+ const path = join(directory, 'worker.lock');
17
+ const id = randomUUID();
18
+ for (let attempt = 0; attempt < 2; attempt++) {
19
+ try {
20
+ const handle = await open(path, 'wx', 0o600);
21
+ try { await handle.writeFile(JSON.stringify({ pid: process.pid, id })); } finally { await handle.close(); }
22
+ return async () => {
23
+ const current = JSON.parse(await readFile(path, 'utf8'));
24
+ if (current.id === id) await rm(path);
25
+ };
26
+ } catch (error) {
27
+ if (error.code !== 'EEXIST') throw error;
28
+ let owner;
29
+ try { owner = JSON.parse(await readFile(path, 'utf8')); } catch {}
30
+ if (!owner?.pid || attempt) throw new Error('Another dd2l command owns this data directory.');
31
+ try { process.kill(owner.pid, 0); }
32
+ catch (error) {
33
+ if (error.code === 'ESRCH') { await rm(path, { force: true }); continue; }
34
+ }
35
+ throw new Error('Another dd2l command is running. Stop it before starting this command.');
36
+ }
37
+ }
38
+ }
39
+
40
+ export class Store {
41
+ constructor(directory) {
42
+ this.path = join(directory, 'inventory.sqlite');
43
+ this.db = new DatabaseSync(this.path);
44
+ this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;
45
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
46
+ CREATE TABLE IF NOT EXISTS resources (
47
+ key TEXT PRIMARY KEY, course TEXT, category TEXT NOT NULL,
48
+ data TEXT, last_success TEXT, checked_at TEXT NOT NULL, outcome TEXT NOT NULL,
49
+ observation TEXT NOT NULL, last_source TEXT
50
+ );`);
51
+ // Preserve inventories created before source-window tracking was added.
52
+ if (!this.db.prepare('PRAGMA table_info(resources)').all().some(column => column.name === 'last_source')) {
53
+ this.db.exec('ALTER TABLE resources ADD COLUMN last_source TEXT');
54
+ }
55
+ }
56
+
57
+ getMeta(key) {
58
+ const row = this.db.prepare('SELECT value FROM meta WHERE key=?').get(key);
59
+ return row ? JSON.parse(row.value) : null;
60
+ }
61
+ meta(key, value) { this.db.prepare('INSERT OR REPLACE INTO meta VALUES (?, ?)').run(key, JSON.stringify(value)); }
62
+ resource(key) {
63
+ const row = this.db.prepare('SELECT * FROM resources WHERE key=?').get(key);
64
+ return row ? { ...row, data: row.data ? JSON.parse(row.data) : null, observation: JSON.parse(row.observation) } : null;
65
+ }
66
+
67
+ commit(identity, origin, observations, summary, enrollments) {
68
+ const account = `${origin}:${identity.Identifier}`;
69
+ const previous = this.getMeta('account');
70
+ if (previous && previous !== account) throw new Error('This data directory belongs to another school account. Sign out before switching accounts.');
71
+ const stamp = new Date().toISOString();
72
+ this.db.exec('BEGIN IMMEDIATE');
73
+ try {
74
+ this.meta('account', account);
75
+ this.meta('school_origin', origin);
76
+ for (const raw of observations) {
77
+ const observation = withoutCredentials(raw);
78
+ const successful = observation.outcome === 'available' && observation.complete !== false;
79
+ const cleared = ['denied', 'not_configured'].includes(observation.outcome);
80
+ const prior = this.db.prepare('SELECT data,last_success,last_source FROM resources WHERE key=?').get(observation.key);
81
+ this.db.prepare('INSERT OR REPLACE INTO resources (key,course,category,data,last_success,checked_at,outcome,observation,last_source) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)').run(
82
+ observation.key, observation.course ?? null, observation.category,
83
+ successful ? JSON.stringify(observation.data) : cleared ? null : prior?.data ?? null,
84
+ successful ? observation.cached ? prior?.last_success ?? observation.retrievedAt : stamp : cleared ? null : prior?.last_success ?? null,
85
+ stamp, observation.outcome, JSON.stringify(successful ? { ...observation, data: undefined } : observation),
86
+ successful ? observation.source ?? null : cleared ? null : prior?.last_source ?? null);
87
+ }
88
+ // Only a complete, unfiltered enrollment read can establish loss of course access.
89
+ if (enrollments?.complete) {
90
+ const allowed = new Set(enrollments.data.filter(x => x.Access?.CanAccess !== false).map(x => String(x.OrgUnit.Id)));
91
+ for (const row of this.db.prepare('SELECT DISTINCT course FROM resources WHERE course IS NOT NULL').all()) {
92
+ if (!allowed.has(row.course)) this.db.prepare("UPDATE resources SET data=NULL,last_success=NULL,last_source=NULL,outcome='denied',observation=? WHERE course=?")
93
+ .run(JSON.stringify({ outcome: 'denied', message: 'Course access is no longer confirmed by complete enrollments.' }), row.course);
94
+ }
95
+ }
96
+ this.meta('last_run', summary);
97
+ this.meta('state', { state: summary.retryAfter ? 'retry_wait' : 'ready', updatedAt: stamp });
98
+ this.db.exec('COMMIT');
99
+ } catch (error) { this.db.exec('ROLLBACK'); throw error; }
100
+ }
101
+
102
+ status() {
103
+ return { state: this.getMeta('state'), lastRun: this.getMeta('last_run'),
104
+ resources: this.db.prepare('SELECT category,outcome,checked_at,last_success FROM resources ORDER BY category').all() };
105
+ }
106
+ clear() { this.db.exec('BEGIN; DELETE FROM resources; DELETE FROM meta; COMMIT;'); }
107
+ close() { this.db.close(); }
108
+ }
@@ -0,0 +1,191 @@
1
+ import { AccessError, apiUrl } from './client.mjs';
2
+ import { descriptionContent, readDocument } from './documents.mjs';
3
+ import { readLinkedDocument, actionLink } from './links.mjs';
4
+
5
+ const syllabusTitle = /syllab|course\s+(?:outline|overview|information|polic|guide)|\boutline\b|plan de cours/i;
6
+ const explicitTitle = /syllab|course\s+outline|plan de cours/i;
7
+
8
+ export function findSyllabusSources(tree, now = Date.now()) {
9
+ const sources = [];
10
+ function visit(node, parents = [], inSyllabusModule = false, ancestorUnavailable = false) {
11
+ if (!node || typeof node !== 'object') return;
12
+ const title = String(node.Title || '');
13
+ const description = descriptionContent(node.Description);
14
+ const directMatch = syllabusTitle.test(title);
15
+ const matched = directMatch || inSyllabusModule || explicitTitle.test(description.text);
16
+ const unavailable = ancestorUnavailable || node.IsHidden === true || node.IsLocked === true ||
17
+ Date.parse(node.StartDateTime) > now || Date.parse(node.EndDateTime) < now;
18
+ const common = { title, parents, topicId: node.TopicId, moduleId: node.ModuleId, activityType: node.ActivityType, lastModifiedDate: node.LastModifiedDate,
19
+ url: node.Url, unavailable, role: explicitTitle.test(title) ? 'explicit_syllabus' : 'course_information',
20
+ matchedBy: directMatch ? 'title' : inSyllabusModule ? 'parent_module' : 'description' };
21
+ if (matched && description.text) sources.push({ ...common, kind: node.TopicId != null ? 'topic_description' : 'module_description', description: description.text, links: description.links });
22
+ if (matched && node.TopicId != null) sources.push({ ...common, kind: node.ActivityType === 1 ? 'content_file' : 'linked_topic', description: '' });
23
+ for (const child of node.Modules || []) visit(child, [...parents, title].filter(Boolean), inSyllabusModule || directMatch, unavailable);
24
+ for (const child of node.Topics || []) visit(child, [...parents, title].filter(Boolean), inSyllabusModule || directMatch, unavailable);
25
+ }
26
+ visit(tree);
27
+ return sources;
28
+ }
29
+
30
+ export function classifyOverview(observation) {
31
+ if (observation.status === 404) {
32
+ observation.outcome = 'not_configured';
33
+ observation.complete = true;
34
+ observation.message = 'Course content overview is not configured; other syllabus locations still need checking.';
35
+ delete observation.error;
36
+ }
37
+ return observation;
38
+ }
39
+
40
+ export async function collectSyllabus(client, { le, courseId, content, overview }) {
41
+ const descriptors = content.data ? findSyllabusSources(content.data) : [];
42
+ const observations = [];
43
+ const catalog = [];
44
+ let documentReads = 0;
45
+ let limited = false;
46
+ const resolved = new Map();
47
+ const source = async (descriptor, action) => {
48
+ const identity = descriptor.kind === 'overview' ? 'overview' : descriptor.kind === 'overview_attachment' ? 'overview_attachment' :
49
+ `${descriptor.kind}:${descriptor.kind === 'linked_document' ? descriptor.url : descriptor.topicId ?? descriptor.moduleId ?? JSON.stringify([...(descriptor.parents || []), descriptor.title])}`;
50
+ const key = `syllabus:${courseId}:${identity}`;
51
+ const record = { key, category: 'syllabus_source', course: courseId, source: descriptor.source || `${le}/content/toc`,
52
+ retrievedAt: new Date().toISOString(), outcome: 'available', complete: true };
53
+ let entry = { ...descriptor, key, retrievedAt: record.retrievedAt };
54
+ delete entry.description;
55
+ try {
56
+ if (descriptor.unavailable) throw new AccessError('restricted', 'Source is hidden, locked, or outside its release window.');
57
+ const data = await action();
58
+ const role = descriptor.kind === 'overview_attachment' && explicitTitle.test(data.text?.slice(0, 2000) || '') ? 'explicit_syllabus' : descriptor.role;
59
+ record.data = { ...descriptor, ...data, role };
60
+ entry = { ...entry, outcome: 'available', textStatus: data.textStatus, textCharacters: data.text?.trim().length || 0, format: data.format,
61
+ pages: data.pages, links: data.links || [], linksLimited: data.linksLimited, source: record.source, role };
62
+ if (data.textStatus === 'failed' || data.textStatus === 'partial') {
63
+ record.complete = false;
64
+ record.outcome = 'partial';
65
+ entry.outcome = 'partial';
66
+ }
67
+ } catch (error) {
68
+ if (!(error instanceof AccessError) || error.code === 'needs_login') throw error;
69
+ if (error.status === 429) throw error;
70
+ record.outcome = ['denied', 'restricted'].includes(error.code) ? 'denied' : error.code === 'partial' ? 'partial' : 'failed';
71
+ Object.assign(record, { complete: false, error: error.code, message: error.message, status: error.status, retryAfter: error.retryAfter });
72
+ entry = { ...entry, outcome: record.outcome, error: error.code, message: error.message, textCharacters: 0 };
73
+ }
74
+ observations.push(record);
75
+ catalog.push(entry);
76
+ if (entry.textStatus === 'available') {
77
+ for (const candidate of [descriptor.url, record.source]) if (candidate) {
78
+ try { resolved.set(new URL(candidate, client.origin).href.split('#')[0], entry.key); } catch {}
79
+ }
80
+ }
81
+ };
82
+
83
+ if (overview.outcome === 'not_configured') {
84
+ for (const kind of ['overview', 'overview_attachment']) {
85
+ observations.push({ key: `syllabus:${courseId}:${kind}`, category: 'syllabus_source', course: courseId,
86
+ source: kind === 'overview' ? `${le}/overview` : `${le}/overview/attachment`, outcome: 'not_configured', complete: true,
87
+ retrievedAt: new Date().toISOString(), status: 404 });
88
+ }
89
+ } else if (overview.outcome === 'available') {
90
+ await source({ kind: 'overview', title: 'Course overview', role: 'course_information', source: `${le}/overview` }, async () => {
91
+ const value = descriptionContent(overview.data?.Description);
92
+ return { ...value, format: 'inline', textStatus: value.text ? 'available' : 'empty' };
93
+ });
94
+ if (overview.data?.HasAttachment === true) {
95
+ documentReads++;
96
+ await source({ kind: 'overview_attachment', title: 'Course overview attachment', role: 'course_information', source: `${le}/overview/attachment` }, () => readDocument(client, `${le}/overview/attachment`));
97
+ }
98
+ }
99
+
100
+ for (const descriptor of descriptors.slice(0, 100)) {
101
+ if (descriptor.unavailable) {
102
+ await source(descriptor, async () => ({}));
103
+ continue;
104
+ }
105
+ if (descriptor.kind.endsWith('_description')) {
106
+ await source(descriptor, async () => ({ text: descriptor.description, links: descriptor.links || [], format: 'inline', textStatus: descriptor.description ? 'available' : 'empty' }));
107
+ continue;
108
+ }
109
+ if (!/^\d+$/.test(String(descriptor.topicId))) {
110
+ catalog.push({ ...descriptor, outcome: 'unresolved', textCharacters: 0 });
111
+ continue;
112
+ }
113
+ const topicPath = `${le}/content/topics/${descriptor.topicId}`;
114
+ if (descriptor.kind === 'content_file') {
115
+ if (documentReads >= 20) { limited = true; catalog.push({ ...descriptor, outcome: 'not_read_limit', textCharacters: 0 }); continue; }
116
+ documentReads++;
117
+ await source({ ...descriptor, source: `${topicPath}/file` }, () => readDocument(client, `${topicPath}/file`));
118
+ } else {
119
+ await source({ ...descriptor, source: topicPath }, async () => {
120
+ const metadata = await client.get(topicPath);
121
+ const value = descriptionContent(metadata.Description);
122
+ const target = metadata.Url || descriptor.url;
123
+ // Link targets are handled below with separate school/public trust boundaries.
124
+ let linkStatus = 'unresolved';
125
+ if (target) {
126
+ try { apiUrl(client.origin, target); linkStatus = 'api_link_not_followed'; }
127
+ catch {
128
+ try { linkStatus = new URL(target, client.origin).origin === client.origin ? 'school_link_not_followed' : 'external'; }
129
+ catch { linkStatus = 'invalid'; }
130
+ }
131
+ }
132
+ return { ...value, format: 'link', metadata, links: target ? [...value.links, { url: target, label: descriptor.title, status: linkStatus }] : value.links,
133
+ textStatus: value.text ? 'available' : target && actionLink(target, client.origin) ? 'not_applicable' : 'unresolved' };
134
+ });
135
+ }
136
+ }
137
+ // Breadth-first, deduplicated document links; never activate surveys or enrollment links.
138
+ const seen = new Set();
139
+ for (let index = 0; index < catalog.length && index < 200; index++) {
140
+ const entry = catalog[index];
141
+ for (const link of entry.links || []) {
142
+ if (actionLink(link.url, client.origin)) { link.status = 'not_content'; continue; }
143
+ let target;
144
+ try { target = new URL(link.url, entry.url ? new URL(entry.url, client.origin) : client.origin); target.hash = ''; }
145
+ catch { link.status = 'invalid'; continue; }
146
+ // Read source documents, not an unbounded crawl of their citations and site navigation.
147
+ if (target.origin !== client.origin && ((entry.linkDepth || 0) > 0 ||
148
+ (entry.format === 'pdf' && !/\.(?:pdf|docx|pptx|xlsx)$/i.test(target.pathname) && !syllabusTitle.test(link.label || '')))) {
149
+ link.status = 'reference';
150
+ continue;
151
+ }
152
+ if (resolved.has(target.href)) { link.status = 'read'; link.sourceKey = resolved.get(target.href); continue; }
153
+ if (seen.has(target.href)) { link.status = 'already_attempted'; continue; }
154
+ seen.add(target.href);
155
+ if ((entry.linkDepth || 0) >= 2 || documentReads >= 20) { limited = true; link.status = 'not_read_limit'; continue; }
156
+ documentReads++;
157
+ await source({ kind: 'linked_document', title: link.label || 'Linked course document', url: target.href,
158
+ parents: [...(entry.parents || []), entry.title].filter(Boolean), role: 'course_information',
159
+ source: target.href, linkDepth: (entry.linkDepth || 0) + 1 }, () => readLinkedDocument(client, target.href, { le, courseId, tree: content.data }));
160
+ const linked = catalog.at(-1);
161
+ link.status = linked.textStatus === 'available' ? 'read' : linked.error || linked.textStatus || linked.outcome;
162
+ link.sourceKey = linked.key;
163
+ if (link.status === 'read') resolved.set(target.href, linked.key);
164
+ }
165
+ if (entry.kind === 'linked_topic' && entry.textStatus === 'unresolved' && entry.links?.length &&
166
+ entry.links.every(link => ['read', 'not_content', 'reference'].includes(link.status))) {
167
+ entry.textStatus = 'linked';
168
+ const observation = observations.find(item => item.key === entry.key);
169
+ if (observation?.data) observation.data.textStatus = 'linked';
170
+ }
171
+ }
172
+ limited ||= descriptors.length > 100 || catalog.some(item => item.linksLimited);
173
+ const readable = catalog.filter(item => item.textStatus === 'available' && item.textCharacters > 0);
174
+ const explicit = readable.filter(item => item.role === 'explicit_syllabus');
175
+ const discoveryComplete = content.complete === true && overview.complete === true && !limited;
176
+ const unreadLinkCount = catalog.reduce((sum, item) => sum + (item.links?.filter(link => !['read', 'not_content', 'reference'].includes(link.status)).length || 0), 0);
177
+ const textCoverageComplete = discoveryComplete && !unreadLinkCount && catalog.every(item => item.outcome === 'available' && ['available', 'empty', 'not_applicable', 'linked'].includes(item.textStatus));
178
+ const summary = {
179
+ result: explicit.length ? 'explicit_sources' : readable.length ? 'course_information' : 'unresolved',
180
+ overview: overview.outcome === 'available' ? descriptionContent(overview.data?.Description).text ? 'readable' : 'empty' : overview.outcome,
181
+ candidateCount: descriptors.length, readableSourceCount: readable.length, explicitSourceCount: explicit.length,
182
+ textCharacters: readable.reduce((sum, item) => sum + item.textCharacters, 0),
183
+ discoveryComplete, textCoverageComplete, limited, unreadLinkCount,
184
+ unresolvedSourceCount: catalog.filter(item => item.outcome !== 'available' || !['available', 'empty', 'not_applicable', 'linked'].includes(item.textStatus)).length,
185
+ formats: [...new Set(readable.map(item => item.format))].sort(),
186
+ retryAfter: Math.max(0, ...observations.filter(item => item.error === 'retry_wait').map(item => item.retryAfter || 60_000)),
187
+ };
188
+ observations.push({ key: `${le}/syllabus`, category: 'syllabus', course: courseId, source: `${le}/content/toc`,
189
+ retrievedAt: new Date().toISOString(), outcome: 'available', complete: discoveryComplete, data: { ...summary, sources: catalog } });
190
+ return { observations, summary };
191
+ }