@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.
- package/LICENSE +21 -0
- package/README.md +122 -3
- package/THIRD_PARTY_NOTICES.md +50 -0
- package/bin/dd2l.mjs +7 -0
- package/docs/README.md +227 -0
- package/package.json +56 -6
- package/src/app-runner.mjs +58 -0
- package/src/assignment-workspace.mjs +128 -0
- package/src/canvas.mjs +17 -0
- package/src/cli-banner.mjs +58 -0
- package/src/cli.mjs +142 -0
- package/src/client.mjs +150 -0
- package/src/connector.mjs +587 -0
- package/src/discovery.mjs +45 -0
- package/src/documents.mjs +154 -0
- package/src/links.mjs +99 -0
- package/src/llm.mjs +243 -0
- package/src/media.mjs +54 -0
- package/src/ocr.mjs +24 -0
- package/src/office.mjs +61 -0
- package/src/pdf-worker.mjs +26 -0
- package/src/research.mjs +70 -0
- package/src/session.mjs +142 -0
- package/src/store.mjs +108 -0
- package/src/syllabus.mjs +191 -0
- package/src/task-workspace.mjs +138 -0
- package/src/ui-data.mjs +123 -0
- package/src/ui-demo.mjs +36 -0
- package/src/ui-server.mjs +161 -0
- package/src/windows-ocr.ps1 +32 -0
- package/ui/answer-renderer.js +88 -0
- package/ui/answer-view.js +47 -0
- package/ui/app.js +385 -0
- package/ui/assets/LUCIDE-LICENSE.txt +15 -0
- package/ui/assets/avatar.svg +3 -0
- package/ui/assets/chevron.svg +5 -0
- package/ui/assets/courses.svg +14 -0
- package/ui/assets/logo.svg +13 -0
- package/ui/assets/new-chat.svg +14 -0
- package/ui/assets/search.svg +14 -0
- package/ui/assets/send.svg +8 -0
- package/ui/assets/settings.svg +14 -0
- package/ui/assets/widget-announcements.svg +4 -0
- package/ui/assets/widget-shortcuts.svg +5 -0
- package/ui/assignment-workspace.js +106 -0
- package/ui/canvas-contract.js +41 -0
- package/ui/canvas-runtime.js +158 -0
- package/ui/canvas.js +96 -0
- package/ui/index.html +84 -0
- package/ui/loading.js +33 -0
- package/ui/style.css +241 -0
- package/ui/widget-layout.js +39 -0
- package/ui/widgets.js +252 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { htmlContent } from './documents.mjs';
|
|
2
|
+
|
|
3
|
+
const numeric = value => /^\d+$/.test(String(value ?? ''));
|
|
4
|
+
const visible = value => value && !value.IsHidden && !value.IsLocked && !value.IsDeleted && value.IsPublished !== false;
|
|
5
|
+
const readable = row => row?.data != null && !['denied', 'not_configured'].includes(row.outcome);
|
|
6
|
+
const normalize = value => String(value).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
|
|
7
|
+
const date = value => Number.isFinite(Date.parse(value)) ? value : null;
|
|
8
|
+
const fields = new Set(['Name', 'Title', 'Description', 'Instructions', 'Text', 'Html', 'text',
|
|
9
|
+
'Criteria', 'CriteriaGroups', 'Levels', 'Rubrics', 'Feedback', 'FeedbackText', 'Points', 'MaxPoints', 'Score', 'OutOf']);
|
|
10
|
+
|
|
11
|
+
// Project only coursework fields: never expose raw responses, retained bytes, or credentials.
|
|
12
|
+
function courseworkText(value, depth = 0) {
|
|
13
|
+
if (depth > 10 || value == null) return '';
|
|
14
|
+
if (typeof value === 'string' || typeof value === 'number') return String(value);
|
|
15
|
+
if (Array.isArray(value)) return value.map(item => courseworkText(item, depth + 1)).filter(Boolean).join('\n');
|
|
16
|
+
if (!visible(value)) return '';
|
|
17
|
+
if (value.Html) return htmlContent(value.Html).text;
|
|
18
|
+
return Object.entries(value).filter(([key]) => fields.has(key))
|
|
19
|
+
.map(([key, item]) => `${['Points', 'MaxPoints', 'Score', 'OutOf'].includes(key) ? `${key}: ` : ''}${courseworkText(item, depth + 1)}`)
|
|
20
|
+
.filter(Boolean).join('\n');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function state(row) {
|
|
24
|
+
if (!row) return 'missing';
|
|
25
|
+
if (row.outcome === 'denied') return 'inaccessible';
|
|
26
|
+
if (row.outcome === 'not_configured') return 'not_configured';
|
|
27
|
+
return readable(row) ? row.outcome === 'available' ? 'available' : 'stale' : 'missing';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function projectAssignments(rows, courses, origin) {
|
|
31
|
+
const assignments = [];
|
|
32
|
+
for (const course of courses) {
|
|
33
|
+
const scoped = rows.filter(row => String(row.course) === course.id);
|
|
34
|
+
const listing = scoped.find(row => row.category === 'assignments');
|
|
35
|
+
if (!readable(listing) || !Array.isArray(listing.data)) continue;
|
|
36
|
+
for (const folder of listing.data.filter(item => numeric(item.Id) && visible(item))) {
|
|
37
|
+
const id = String(folder.Id);
|
|
38
|
+
const related = scoped.filter(row => {
|
|
39
|
+
const path = row.key || '';
|
|
40
|
+
return path.match(/\/dropbox\/folders\/(\d+)(?:\/|$)/)?.[1] === id ||
|
|
41
|
+
(row.category === 'rubrics' && new URL(path, origin).searchParams.get('objectId') === id);
|
|
42
|
+
});
|
|
43
|
+
const detail = related.find(row => row.category === 'assignment_detail');
|
|
44
|
+
if (readable(detail) && !visible(detail.data)) continue;
|
|
45
|
+
const data = { ...folder, ...(readable(detail) ? detail.data : {}) };
|
|
46
|
+
const url = `${origin}/d2l/lms/dropbox/user/folder_submit_files.d2l?db=${id}&ou=${course.id}`;
|
|
47
|
+
const record = (kind, title, row, text = readable(row) ? courseworkText(row.data) : '') => ({
|
|
48
|
+
id: `${course.id}:${id}:${kind}:${row?.key || title}`, resourceKey: row?.key || null, course: course.id, courseName: course.name,
|
|
49
|
+
category: kind, title, text: text.length > 100_000 ? `${text.slice(0, 100_000)}\n[Excerpt truncated. Open Brightspace for the full source.]` : text, status: state(row),
|
|
50
|
+
stale: state(row) === 'stale', lastSuccess: row?.last_success || null, checkedAt: row?.checked_at || null, url,
|
|
51
|
+
});
|
|
52
|
+
const instructions = courseworkText(data.Instructions) || courseworkText(data.Description);
|
|
53
|
+
const records = [record('instructions', 'Instructions and deliverables', readable(detail) ? detail : listing, instructions)];
|
|
54
|
+
const rubric = related.find(row => row.category === 'rubrics');
|
|
55
|
+
const embedded = data.Assessment?.Rubrics;
|
|
56
|
+
records.push(rubric ? record('rubric', 'Rubric', rubric) : embedded?.length
|
|
57
|
+
? record('rubric', 'Rubric', readable(detail) ? detail : listing, courseworkText(embedded))
|
|
58
|
+
: record('rubric', 'Rubric', null));
|
|
59
|
+
const submissions = related.find(row => row.category === 'own_submissions');
|
|
60
|
+
// A complete empty response establishes no submissions; a missing read does not.
|
|
61
|
+
let submissionStatus = 'Submission status not retrieved';
|
|
62
|
+
if (readable(submissions) && Array.isArray(submissions.data)) {
|
|
63
|
+
const count = submissions.data
|
|
64
|
+
.reduce((total, entity) => total + (entity.Submissions?.length || 0), 0);
|
|
65
|
+
submissionStatus = count ? `${count} cached submission${count === 1 ? '' : 's'}` : 'No submissions in the cached response';
|
|
66
|
+
if (state(submissions) === 'stale') submissionStatus += ' · stale';
|
|
67
|
+
} else if (state(submissions) === 'inaccessible') submissionStatus = 'Submission status inaccessible';
|
|
68
|
+
const feedback = related.find(row => row.category === 'own_feedback');
|
|
69
|
+
records.push(record('feedback', 'Your feedback', feedback));
|
|
70
|
+
for (const attachment of data.Attachments || []) {
|
|
71
|
+
if (!numeric(attachment.FileId) || !visible(attachment)) continue;
|
|
72
|
+
const file = related.find(row => row.category === 'assignment_file' && row.key.endsWith(`/attachments/${attachment.FileId}`));
|
|
73
|
+
records.push(record('attachment', attachment.FileName || attachment.Name || `Attachment ${attachment.FileId}`, file));
|
|
74
|
+
}
|
|
75
|
+
assignments.push({ id, course: course.id, courseName: course.name, title: data.Name || `Assignment ${id}`,
|
|
76
|
+
dueDate: date(data.DueDate), startDate: date(data.Availability?.StartDate || data.StartDate),
|
|
77
|
+
endDate: date(data.Availability?.EndDate || data.EndDate), submissionStatus, url,
|
|
78
|
+
stale: state(listing) === 'stale' || state(detail) === 'stale', lastSuccess: (readable(detail) ? detail : listing).last_success || null,
|
|
79
|
+
records });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return assignments;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function assignmentWorkspace(inventory, query, course = '', assignmentId = '') {
|
|
86
|
+
const normalized = normalize(query);
|
|
87
|
+
const number = normalized.match(/\b(?:assignment|assign|a|hw|homework)\s*0*(\d+)\b/)?.[1];
|
|
88
|
+
const courseMatches = inventory.courses.filter(item => {
|
|
89
|
+
const codes = [...normalize(item.name).matchAll(/\b([a-z]{2,})\s*(\d{3,4})\b/g)];
|
|
90
|
+
return codes.some(code => new RegExp(`\\b${code[1]}\\s*${code[2]}\\b`).test(normalized));
|
|
91
|
+
});
|
|
92
|
+
// An explicitly named course takes precedence over context from the previous request.
|
|
93
|
+
const namedCourses = courseMatches.map(item => item.id);
|
|
94
|
+
const unknownCourse = !namedCourses.length && /\b(?!assignment\b|homework\b)[a-z]{2,}\s*\d{3,4}\b/.test(normalized);
|
|
95
|
+
let candidates = (inventory.assignments || []).filter(item => namedCourses.length ? namedCourses.includes(item.course) : !course || item.course === course);
|
|
96
|
+
if (unknownCourse && number) candidates = [];
|
|
97
|
+
if (number) candidates = candidates.filter(item => normalize(item.title).match(/\b(?:assignment|assign|a|hw|homework)\s*0*(\d+)\b/)?.[1] === number);
|
|
98
|
+
else {
|
|
99
|
+
const titles = candidates.filter(item => normalized.includes(normalize(item.title)));
|
|
100
|
+
if (titles.length) candidates = titles;
|
|
101
|
+
else if (assignmentId && !namedCourses.length && !/\b(?:assignment|homework|quiz|test|exam)\b/.test(normalized)) candidates = candidates.filter(item => item.id === assignmentId);
|
|
102
|
+
else if (!/\b(?:assignment|homework)\b/.test(normalized)) return null;
|
|
103
|
+
}
|
|
104
|
+
if (candidates.length > 1 && assignmentId && course) {
|
|
105
|
+
const selected = candidates.filter(item => item.id === assignmentId && item.course === course);
|
|
106
|
+
if (selected.length) candidates = selected;
|
|
107
|
+
}
|
|
108
|
+
const choices = candidates.map(({ id, course, courseName, title }) => ({ id, course, courseName, title }));
|
|
109
|
+
if (candidates.length !== 1) return { status: candidates.length ? 'choose' : 'missing', choices };
|
|
110
|
+
const target = candidates[0];
|
|
111
|
+
const title = normalize(target.title);
|
|
112
|
+
const shortTitle = title.match(/\b(?:assignment|homework|hw)\s*\d+\b/)?.[0] || title;
|
|
113
|
+
const instructions = normalize(target.records.find(record => record.category === 'instructions')?.text || '');
|
|
114
|
+
const supporting = inventory.sources.filter(source => source.course === target.course &&
|
|
115
|
+
['content', 'content_file', 'linked_content', 'syllabus_source', 'announcements'].includes(source.category))
|
|
116
|
+
.map(source => {
|
|
117
|
+
const name = normalize(source.title), body = normalize(source.text);
|
|
118
|
+
const mentions = value => (` ${value} `).includes(` ${shortTitle} `);
|
|
119
|
+
const reason = mentions(name) || mentions(body) ? 'Mentions this assignment'
|
|
120
|
+
: name.length > 8 && instructions.includes(name) ? 'Referenced in the assignment instructions'
|
|
121
|
+
: source.category === 'syllabus_source' ? 'Course policy reference' : '';
|
|
122
|
+
return { ...source, status: source.stale ? 'stale' : 'available', reason };
|
|
123
|
+
}).filter(source => source.reason).sort((a, b) => (a.reason === 'Course policy reference') - (b.reason === 'Course policy reference')).slice(0, 6);
|
|
124
|
+
const submitting = /\b(submit|submission|ready|hand in)\b/.test(normalized);
|
|
125
|
+
const order = submitting || /\b(rubric|criterion|criteria)\b/.test(normalized) ? ['rubric', 'instructions', 'attachment', 'feedback'] : ['instructions', 'attachment', 'rubric', 'feedback'];
|
|
126
|
+
const records = [...target.records].sort((a, b) => order.indexOf(a.category) - order.indexOf(b.category));
|
|
127
|
+
return { status: 'resolved', ...target, records, supporting, intent: submitting ? 'submit' : 'work' };
|
|
128
|
+
}
|
package/src/canvas.mjs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { canvasRuntime } from '../ui/canvas-runtime.js';
|
|
3
|
+
import { validCanvasState } from '../ui/canvas-contract.js';
|
|
4
|
+
|
|
5
|
+
export function canvasDocument() {
|
|
6
|
+
const nonce = randomBytes(24).toString('base64');
|
|
7
|
+
return {
|
|
8
|
+
policy: `sandbox allow-scripts; default-src 'none'; script-src 'nonce-${nonce}' 'unsafe-eval'; worker-src blob:; style-src 'unsafe-inline'; img-src data:; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'`,
|
|
9
|
+
html: `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>
|
|
10
|
+
:root { color-scheme: dark; font-family: system-ui,sans-serif; color:#e8e9e6; background:#222; }
|
|
11
|
+
* { box-sizing:border-box; } body { margin:0; overflow-wrap:anywhere; } #canvas { padding:20px; }
|
|
12
|
+
button,input,select,textarea { font:inherit; } button { cursor:pointer; } :focus-visible { outline:2px solid #9bd1df; outline-offset:3px; }
|
|
13
|
+
pre { white-space:pre-wrap; } svg { max-width:100%; } input,textarea,select { max-width:100%; }
|
|
14
|
+
@media(prefers-reduced-motion:reduce) { *,*::before,*::after { animation:none!important; transition:none!important; } }
|
|
15
|
+
</style><style id="generated-style"></style></head><body><main id="canvas"></main><script nonce="${nonce}">(${canvasRuntime.toString()})(${validCanvasState.toString()});</script></body></html>`,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { styleText } from 'node:util';
|
|
2
|
+
|
|
3
|
+
const bayer = [
|
|
4
|
+
[0, 32, 8, 40, 2, 34, 10, 42],
|
|
5
|
+
[48, 16, 56, 24, 50, 18, 58, 26],
|
|
6
|
+
[12, 44, 4, 36, 14, 46, 6, 38],
|
|
7
|
+
[60, 28, 52, 20, 62, 30, 54, 22],
|
|
8
|
+
[3, 35, 11, 43, 1, 33, 9, 41],
|
|
9
|
+
[51, 19, 59, 27, 49, 17, 57, 25],
|
|
10
|
+
[15, 47, 7, 39, 13, 45, 5, 37],
|
|
11
|
+
[63, 31, 55, 23, 61, 29, 53, 21],
|
|
12
|
+
];
|
|
13
|
+
const dots = [[0, 1, 2, 6], [3, 4, 5, 7]];
|
|
14
|
+
const glyphs = {
|
|
15
|
+
d: ['00001', '00001', '01111', '10001', '10001', '10001', '01111'],
|
|
16
|
+
2: ['01110', '10001', '00001', '00010', '00100', '01000', '11111'],
|
|
17
|
+
l: ['11000', '01000', '01000', '01000', '01000', '01000', '00110'],
|
|
18
|
+
};
|
|
19
|
+
const wordmark = Array.from({ length: 8 }, (_, y) =>
|
|
20
|
+
[...'dd2l'].map(letter => glyphs[letter][y] ?? '00000').join('0') + '0');
|
|
21
|
+
|
|
22
|
+
function raster(width, row, pixel) {
|
|
23
|
+
return Array.from({ length: width }, (_, cell) => {
|
|
24
|
+
let mask = 0;
|
|
25
|
+
for (let column = 0; column < 2; column++) {
|
|
26
|
+
for (let dot = 0; dot < 4; dot++) {
|
|
27
|
+
if (pixel(cell * 2 + column, row * 4 + dot)) mask |= 1 << dots[column][dot];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Keep even the dense areas on the dot grid to avoid a block-to-Braille seam.
|
|
31
|
+
return String.fromCodePoint(0x2800 + mask);
|
|
32
|
+
}).join('');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function bar(width, row, mirrored = false) {
|
|
36
|
+
return raster(width, row, (x, y) => {
|
|
37
|
+
if (mirrored) x = width * 2 - 1 - x;
|
|
38
|
+
const t = Math.min(1, (x + 0.5) / (width * 2 * 0.85));
|
|
39
|
+
const density = t * t * (3 - 2 * t);
|
|
40
|
+
return density > (bayer[y][x % 8] + 0.5) / 64;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function renderBanner(columns = 80) {
|
|
45
|
+
// Leave the last column free so the terminal doesn't wrap the banner.
|
|
46
|
+
const width = Math.max(1, columns - 1);
|
|
47
|
+
if (width < 31) {
|
|
48
|
+
const title = 'dd2l'.slice(0, width);
|
|
49
|
+
return ' '.repeat(Math.floor((width - title.length) / 2)) + styleText('bold', title);
|
|
50
|
+
}
|
|
51
|
+
const labelWidth = wordmark[0].length / 2;
|
|
52
|
+
const barWidth = Math.min(20, Math.floor((width - labelWidth - 4) / 2));
|
|
53
|
+
const padding = ' '.repeat(Math.floor((width - barWidth * 2 - labelWidth - 4) / 2));
|
|
54
|
+
return [0, 1].map(row => {
|
|
55
|
+
const label = raster(labelWidth, row, (x, y) => wordmark[y][x] === '1');
|
|
56
|
+
return `${padding}${styleText('blueBright', bar(barWidth, row))} ${label} ${styleText('blueBright', bar(barWidth, row, true))}`;
|
|
57
|
+
}).join('\n');
|
|
58
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { mkdir, rm } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { select, text, isCancel, cancel, log, outro } from '@clack/prompts';
|
|
5
|
+
import { BrightspaceClient, SCHOOL, schoolOrigin } from './client.mjs';
|
|
6
|
+
import { Session, login, atomicWrite } from './session.mjs';
|
|
7
|
+
import { Store, lock } from './store.mjs';
|
|
8
|
+
import { Connector, sync } from './connector.mjs';
|
|
9
|
+
import { runApp } from './app-runner.mjs';
|
|
10
|
+
import { renderBanner } from './cli-banner.mjs';
|
|
11
|
+
|
|
12
|
+
const help = `dd2l — direct, read-only Brightspace connector (Windows, Node 24+)
|
|
13
|
+
|
|
14
|
+
dd2l setup Guided school sign-in, then open the app and sync
|
|
15
|
+
dd2l start Open the app and sync with your saved school session
|
|
16
|
+
dd2l login Open Chrome for school sign-in and MFA
|
|
17
|
+
dd2l courses List your course IDs and names
|
|
18
|
+
dd2l audit [--course ID] Audit one course (defaults to an accessible active course)
|
|
19
|
+
dd2l audit --all Advance the all-course pass (five courses per run)
|
|
20
|
+
dd2l syllabus [--course ID] Retrieve syllabus/course-information sources and text
|
|
21
|
+
dd2l sync [--course ID] Repeat that audit every 15 minutes until Ctrl+C
|
|
22
|
+
dd2l sync --all Rotate through all accessible courses in bounded passes
|
|
23
|
+
dd2l status Show coverage and last successful reads
|
|
24
|
+
dd2l logout Delete dd2l's session and local inventory
|
|
25
|
+
|
|
26
|
+
Without a global install: npx @ivanbard/dd2l <command>.
|
|
27
|
+
From source: npm start -- <command>, or npm run setup.
|
|
28
|
+
|
|
29
|
+
audit/sync: --from ISO --to ISO Calendar window (default: past 30 / next 120 days)
|
|
30
|
+
syllabus: --course ID Choose the course without changing the sync selection
|
|
31
|
+
|
|
32
|
+
Data: %LOCALAPPDATA%\\dd2l (session protected for this Windows user; inventory is private local data).
|
|
33
|
+
ChatGPT is optional in the UI; connector commands make no external model calls.`;
|
|
34
|
+
|
|
35
|
+
async function main() {
|
|
36
|
+
const { positionals, values } = parseArgs({ allowPositionals: true, options: {
|
|
37
|
+
course: { type: 'string' }, from: { type: 'string' }, to: { type: 'string' }, all: { type: 'boolean' }, help: { type: 'boolean', short: 'h' },
|
|
38
|
+
} });
|
|
39
|
+
const command = positionals[0];
|
|
40
|
+
if (values.help || !command) { console.log(help); return; }
|
|
41
|
+
if (positionals.length > 1 || !['setup', 'start', 'login', 'courses', 'audit', 'syllabus', 'sync', 'status', 'logout'].includes(command)) throw new Error('Unknown command. Run dd2l --help.');
|
|
42
|
+
if (!['audit', 'sync', 'syllabus'].includes(command) && Object.keys(values).length) throw new Error('Date/course options apply only to audit, syllabus and sync.');
|
|
43
|
+
if (command === 'syllabus' && (values.from || values.to)) throw new Error('Calendar date options apply only to audit and sync.');
|
|
44
|
+
if (values.all && (!['audit', 'sync'].includes(command) || values.course)) throw new Error('--all applies to audit/sync and cannot be combined with --course.');
|
|
45
|
+
if (process.platform !== 'win32' || !process.env.LOCALAPPDATA) throw new Error('This first connector requires Windows with LOCALAPPDATA.');
|
|
46
|
+
const directory = join(process.env.LOCALAPPDATA, 'dd2l');
|
|
47
|
+
let selectedOrigin;
|
|
48
|
+
if (['setup', 'start'].includes(command)) console.log(`\n${renderBanner(process.stdout.columns)}\n`);
|
|
49
|
+
if (command === 'setup') {
|
|
50
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error('Run dd2l setup in an interactive terminal.');
|
|
51
|
+
log.info('Read-only access. Coursework is stored unencrypted on this PC.');
|
|
52
|
+
const school = await select({ message: 'Select your school', options: [
|
|
53
|
+
{ value: SCHOOL, label: 'Queen\'s University', hint: 'onQ · connect and collect coursework' },
|
|
54
|
+
{ value: 'not-listed', label: 'My school isn\'t listed', hint: 'enter a Brightspace URL' },
|
|
55
|
+
] });
|
|
56
|
+
if (isCancel(school)) { cancel('Setup cancelled. No school connection was made.'); return; }
|
|
57
|
+
if (school === 'not-listed') {
|
|
58
|
+
const url = await text({ message: 'Your school\'s Brightspace URL', placeholder: 'your-school.brightspace.com',
|
|
59
|
+
validate: value => { try { schoolOrigin(value); } catch (error) { return error.message; } },
|
|
60
|
+
});
|
|
61
|
+
if (isCancel(url)) { cancel('Setup cancelled. No school connection was made.'); return; }
|
|
62
|
+
selectedOrigin = schoolOrigin(url);
|
|
63
|
+
log.info('Custom school · compatibility has not been verified.');
|
|
64
|
+
log.info('Help add your school: open an issue with its name and URL.\nhttps://github.com/ivanbard/dd2l/issues/new');
|
|
65
|
+
} else selectedOrigin = school;
|
|
66
|
+
}
|
|
67
|
+
await mkdir(directory, { recursive: true });
|
|
68
|
+
// Status is read-only and can inspect a running worker without taking its lock.
|
|
69
|
+
const release = command === 'status' ? async () => {} : await lock(directory);
|
|
70
|
+
let store;
|
|
71
|
+
try {
|
|
72
|
+
store = new Store(directory);
|
|
73
|
+
const origin = ['status', 'logout'].includes(command) ? SCHOOL : schoolOrigin(selectedOrigin ?? store.getMeta('school_origin') ?? SCHOOL);
|
|
74
|
+
const session = new Session(directory, { origin });
|
|
75
|
+
const client = new BrightspaceClient(session, { origin });
|
|
76
|
+
const connector = new Connector(client, store);
|
|
77
|
+
if (command === 'login' || command === 'setup') {
|
|
78
|
+
if (command === 'setup') {
|
|
79
|
+
log.step(`Sign in to ${origin === SCHOOL ? 'Queen\'s onQ' : new URL(origin).hostname}`);
|
|
80
|
+
log.info('Complete sign-in and MFA in Chrome. Your password is never saved.\nSession credentials are encrypted for your Windows account.');
|
|
81
|
+
}
|
|
82
|
+
await login(session, client, { expectedAccount: store.getMeta('account'),
|
|
83
|
+
...(command === 'setup' ? { onProgress: () => {} } : {}) });
|
|
84
|
+
store.meta('school_origin', origin);
|
|
85
|
+
if (command === 'setup') log.success('School connected · identity verified');
|
|
86
|
+
store.meta('state', { state: 'ready', updatedAt: new Date().toISOString() });
|
|
87
|
+
}
|
|
88
|
+
if (command === 'start' || command === 'setup') {
|
|
89
|
+
if (command === 'setup') log.info(`Coursework and grades will be cached in:\n${directory}`);
|
|
90
|
+
await runApp(connector, { onUpdate: async summary => {
|
|
91
|
+
await atomicWrite(join(directory, 'summary.json'), JSON.stringify(summary, null, 2));
|
|
92
|
+
} });
|
|
93
|
+
outro('dd2l stopped. Your cached coursework is saved.');
|
|
94
|
+
} else if (command === 'courses') {
|
|
95
|
+
const { enrollments } = await connector.courses();
|
|
96
|
+
console.log(JSON.stringify({ complete: enrollments.complete, courses: enrollments.data.map(item => ({
|
|
97
|
+
id: item.OrgUnit?.Id, name: item.OrgUnit?.Name, active: item.Access?.IsActive, canAccess: item.Access?.CanAccess,
|
|
98
|
+
})) }, null, 2));
|
|
99
|
+
} else if (command === 'syllabus') {
|
|
100
|
+
const summary = await connector.syllabus(values);
|
|
101
|
+
await atomicWrite(join(directory, 'syllabus-summary.json'), JSON.stringify(summary, null, 2));
|
|
102
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
103
|
+
} else if (command === 'audit' || command === 'sync') {
|
|
104
|
+
const report = async summary => {
|
|
105
|
+
await atomicWrite(join(directory, 'summary.json'), JSON.stringify(summary, null, 2));
|
|
106
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
107
|
+
};
|
|
108
|
+
if (command === 'audit') await report(await connector.audit(values));
|
|
109
|
+
else {
|
|
110
|
+
const controller = new AbortController();
|
|
111
|
+
const stop = () => controller.abort();
|
|
112
|
+
process.on('SIGINT', stop);
|
|
113
|
+
process.on('SIGTERM', stop);
|
|
114
|
+
try { await sync(connector, values, { signal: controller.signal, onUpdate: report }); }
|
|
115
|
+
finally { process.off('SIGINT', stop); process.off('SIGTERM', stop); }
|
|
116
|
+
}
|
|
117
|
+
} else if (command === 'status') console.log(JSON.stringify(store.status(), null, 2));
|
|
118
|
+
else if (command === 'logout') {
|
|
119
|
+
await rm(session.path, { force: true });
|
|
120
|
+
await rm(join(directory, 'summary.json'), { force: true });
|
|
121
|
+
await rm(join(directory, 'syllabus-summary.json'), { force: true });
|
|
122
|
+
store.clear();
|
|
123
|
+
store.close();
|
|
124
|
+
store = null;
|
|
125
|
+
for (const name of ['inventory.sqlite', 'inventory.sqlite-wal', 'inventory.sqlite-shm']) {
|
|
126
|
+
await rm(join(directory, name), { force: true });
|
|
127
|
+
}
|
|
128
|
+
console.log('dd2l session and inventory removed. School sign-in on other apps is unchanged.');
|
|
129
|
+
}
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (store && error.code === 'needs_login') store.meta('state', { state: 'needs_login', updatedAt: new Date().toISOString() });
|
|
132
|
+
if (error.code === 'needs_login' && ['start', 'setup'].includes(command)) error.message += ' Run dd2l setup to sign in and restart.';
|
|
133
|
+
throw error;
|
|
134
|
+
} finally { store?.close(); await release(); }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
main().catch(error => {
|
|
138
|
+
const message = `${error.code || 'error'}: ${error.message}`;
|
|
139
|
+
if (['setup', 'start'].includes(process.argv[2]) && process.stdout.isTTY) log.error(message);
|
|
140
|
+
else console.error(message);
|
|
141
|
+
process.exitCode = 1;
|
|
142
|
+
});
|
package/src/client.mjs
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
export const SCHOOL = 'https://onq.queensu.ca';
|
|
2
|
+
|
|
3
|
+
export function schoolOrigin(value) {
|
|
4
|
+
const message = 'Enter a valid Brightspace address (HTTPS only, no embedded username or password).';
|
|
5
|
+
if (typeof value !== 'string' || /[\u0000-\u0020\u007f\\]/.test(value.trim())) throw new Error(message);
|
|
6
|
+
const address = value.trim().replace(/^\/\//, '');
|
|
7
|
+
let url;
|
|
8
|
+
try { url = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(address) ? address : `https://${address}`); } catch { throw new Error(message); }
|
|
9
|
+
if (url.protocol !== 'https:' || url.username || url.password) throw new Error(message);
|
|
10
|
+
return url.origin;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class AccessError extends Error {
|
|
14
|
+
constructor(code, message, { status, retryAfter = 0 } = {}) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.status = status;
|
|
18
|
+
this.retryAfter = retryAfter;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Never attach response bodies, headers, or credential-bearing URLs to errors.
|
|
23
|
+
export async function request(url, options = {}, fetchImpl = fetch) {
|
|
24
|
+
options.signal?.throwIfAborted();
|
|
25
|
+
let response;
|
|
26
|
+
try {
|
|
27
|
+
response = await fetchImpl(url, {
|
|
28
|
+
...options, redirect: 'manual', signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
|
|
29
|
+
});
|
|
30
|
+
} catch {
|
|
31
|
+
options.signal?.throwIfAborted();
|
|
32
|
+
throw new AccessError('retry_wait', 'Network request failed or timed out.');
|
|
33
|
+
}
|
|
34
|
+
return checkedResponse(response, url);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function checkedResponse(response, url) {
|
|
38
|
+
if (response.status === 401) throw new AccessError('needs_login', 'School sign-in is required.', { status: 401 });
|
|
39
|
+
if (response.status >= 300 && response.status < 400) {
|
|
40
|
+
const target = new URL(response.headers.get('location') || '/', url);
|
|
41
|
+
const login = /\/d2l\/(?:login|lms\/auth)/i.test(target.pathname) || target.hostname === 'login.microsoftonline.com';
|
|
42
|
+
throw new AccessError(login ? 'needs_login' : 'redirect', login ? 'School sign-in is required.' : 'Unexpected redirect was not followed.', { status: response.status });
|
|
43
|
+
}
|
|
44
|
+
if (response.status === 403) throw new AccessError('denied', 'Brightspace denied access to this resource.', { status: 403 });
|
|
45
|
+
if (response.status === 404) throw new AccessError('unavailable', 'Resource or API route is unavailable; absence is not established.', { status: 404 });
|
|
46
|
+
if (response.status === 429 || response.status >= 500) {
|
|
47
|
+
const raw = response.headers.get('retry-after');
|
|
48
|
+
const seconds = raw && /^\d+$/.test(raw) ? Number(raw) : Math.max(0, (Date.parse(raw) - Date.now()) / 1000);
|
|
49
|
+
throw new AccessError('retry_wait', `Brightspace returned HTTP ${response.status}.`, {
|
|
50
|
+
status: response.status, retryAfter: Number.isFinite(seconds) ? seconds * 1000 : 0,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
if (!response.ok) throw new AccessError('failed', `Brightspace returned HTTP ${response.status}.`, { status: response.status });
|
|
54
|
+
return response;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function readJson(response) {
|
|
58
|
+
const text = await response.text();
|
|
59
|
+
if (!/\bapplication\/(?:[\w.+-]*\+)?json\b/i.test(response.headers.get('content-type') || '')) {
|
|
60
|
+
if (text.includes('/d2l/login?sessionExpired=1')) throw new AccessError('needs_login', 'School session has expired.');
|
|
61
|
+
throw new AccessError('invalid_response', 'Expected JSON; received a different response format.');
|
|
62
|
+
}
|
|
63
|
+
try { return JSON.parse(text); }
|
|
64
|
+
catch { throw new AccessError('invalid_response', 'Brightspace returned invalid JSON.'); }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function apiUrl(origin, path) {
|
|
68
|
+
const url = new URL(path, origin);
|
|
69
|
+
if (url.origin !== origin || url.username || url.password || url.hash || !url.pathname.startsWith('/d2l/api/')) {
|
|
70
|
+
throw new AccessError('invalid_link', 'Refused a link outside this school’s API.');
|
|
71
|
+
}
|
|
72
|
+
return url;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class BrightspaceClient {
|
|
76
|
+
constructor(session, { origin = SCHOOL, fetchImpl = fetch, signal } = {}) {
|
|
77
|
+
this.session = session;
|
|
78
|
+
this.origin = schoolOrigin(origin);
|
|
79
|
+
this.fetch = fetchImpl;
|
|
80
|
+
this.signal = signal;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async versions() {
|
|
84
|
+
const data = await readJson(await request(`${this.origin}/d2l/api/versions/`, { signal: this.signal }, this.fetch));
|
|
85
|
+
const versions = {};
|
|
86
|
+
for (const product of ['lp', 'le']) {
|
|
87
|
+
const value = data?.find?.(item => item.ProductCode === product)?.LatestVersion;
|
|
88
|
+
if (!/^\d+\.\d+$/.test(value || '')) throw new AccessError('invalid_response', 'Missing supported API versions.');
|
|
89
|
+
versions[product] = value;
|
|
90
|
+
}
|
|
91
|
+
return versions;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async response(path) {
|
|
95
|
+
this.signal?.throwIfAborted();
|
|
96
|
+
const url = apiUrl(this.origin, path);
|
|
97
|
+
let token = await this.session.token();
|
|
98
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
99
|
+
try {
|
|
100
|
+
return await request(url, { headers: { authorization: `Bearer ${token}`, accept: 'application/json' }, signal: this.signal }, this.fetch);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (error.code !== 'needs_login' || attempt) throw error;
|
|
103
|
+
token = await this.session.token(token);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async get(path) { return readJson(await this.response(path)); }
|
|
109
|
+
|
|
110
|
+
async collection(path) {
|
|
111
|
+
const data = [];
|
|
112
|
+
const seen = new Set();
|
|
113
|
+
let next = apiUrl(this.origin, path);
|
|
114
|
+
let pages = 0;
|
|
115
|
+
try {
|
|
116
|
+
while (next) {
|
|
117
|
+
if (pages === 200 || seen.has(next.href)) throw new AccessError('partial', 'Pagination stopped at a loop or page limit.');
|
|
118
|
+
seen.add(next.href);
|
|
119
|
+
const result = await this.get(next.href);
|
|
120
|
+
pages++;
|
|
121
|
+
if (Array.isArray(result)) {
|
|
122
|
+
data.push(...result);
|
|
123
|
+
next = null;
|
|
124
|
+
} else if (Array.isArray(result?.Items) && result.PagingInfo && typeof result.PagingInfo.HasMoreItems === 'boolean') {
|
|
125
|
+
data.push(...result.Items);
|
|
126
|
+
if (!result.PagingInfo.HasMoreItems) next = null;
|
|
127
|
+
else {
|
|
128
|
+
const bookmark = result.PagingInfo.Bookmark;
|
|
129
|
+
if (typeof bookmark !== 'string' || !bookmark) throw new AccessError('partial', 'More pages were advertised without a bookmark.');
|
|
130
|
+
next = apiUrl(this.origin, path);
|
|
131
|
+
next.searchParams.set('bookmark', bookmark);
|
|
132
|
+
}
|
|
133
|
+
} else if (Array.isArray(result?.Objects) && Object.hasOwn(result, 'Next')) {
|
|
134
|
+
data.push(...result.Objects);
|
|
135
|
+
if (!result.Next) next = null;
|
|
136
|
+
else if (typeof result.Next !== 'string') throw new AccessError('invalid_response', 'Invalid next-page marker.');
|
|
137
|
+
else if (/^(?:https?:|\/)/i.test(result.Next)) next = apiUrl(this.origin, result.Next);
|
|
138
|
+
else {
|
|
139
|
+
next = apiUrl(this.origin, path);
|
|
140
|
+
next.searchParams.set('bookmark', result.Next);
|
|
141
|
+
}
|
|
142
|
+
} else throw new AccessError('invalid_response', 'Unrecognized collection response; completeness cannot be established.');
|
|
143
|
+
}
|
|
144
|
+
return { data, pages, complete: true };
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (!(error instanceof AccessError) || error.code === 'needs_login' || error.status === 429 || !pages) throw error;
|
|
147
|
+
return { data, pages, complete: false, error: error.code, message: error.message, retryAfter: error.retryAfter };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|