@ivanbard/dd2l 0.1.0 → 0.1.2
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 +100 -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/store.mjs
CHANGED
|
@@ -1,106 +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.
|
|
43
|
-
this.db
|
|
44
|
-
|
|
45
|
-
CREATE TABLE IF NOT EXISTS
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
successful ? observation.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
this.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
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
|
+
}
|
package/src/syllabus.mjs
CHANGED
|
@@ -1,191 +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
|
-
}
|
|
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
|
+
}
|