@ivanbard/dd2l 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +47 -8
- package/bin/dd2l.mjs +7 -7
- package/docs/README.md +108 -11
- package/package.json +3 -2
- package/src/app-runner.mjs +58 -35
- package/src/assignment-workspace.mjs +128 -0
- package/src/canvas.mjs +17 -0
- package/src/cli-banner.mjs +58 -0
- package/src/cli.mjs +41 -19
- package/src/client.mjs +150 -137
- package/src/connector.mjs +587 -345
- package/src/discovery.mjs +45 -45
- package/src/documents.mjs +154 -154
- package/src/links.mjs +99 -99
- package/src/llm.mjs +43 -6
- package/src/media.mjs +54 -54
- package/src/ocr.mjs +24 -24
- package/src/office.mjs +61 -61
- package/src/pdf-worker.mjs +26 -26
- package/src/research.mjs +70 -0
- package/src/session.mjs +5 -5
- package/src/store.mjs +108 -106
- package/src/syllabus.mjs +191 -191
- package/src/task-workspace.mjs +138 -0
- package/src/ui-data.mjs +36 -14
- package/src/ui-demo.mjs +17 -1
- package/src/ui-server.mjs +51 -15
- package/src/windows-ocr.ps1 +32 -32
- package/ui/answer-renderer.js +88 -88
- package/ui/answer-view.js +47 -47
- package/ui/app.js +96 -13
- package/ui/assets/widget-announcements.svg +4 -4
- package/ui/assets/widget-shortcuts.svg +5 -5
- package/ui/assignment-workspace.js +106 -0
- package/ui/canvas-contract.js +41 -0
- package/ui/canvas-runtime.js +158 -0
- package/ui/canvas.js +96 -0
- package/ui/index.html +15 -4
- package/ui/loading.js +33 -33
- package/ui/style.css +15 -0
- package/ui/widget-layout.js +39 -39
- package/ui/widgets.js +252 -252
package/src/discovery.mjs
CHANGED
|
@@ -1,45 +1,45 @@
|
|
|
1
|
-
export function courseNodes(tree, now = Date.now()) {
|
|
2
|
-
const result = [];
|
|
3
|
-
function visit(node, unavailable = false) {
|
|
4
|
-
if (!node || typeof node !== 'object') return;
|
|
5
|
-
const restricted = unavailable || node.IsHidden === true || node.IsLocked === true ||
|
|
6
|
-
Date.parse(node.StartDateTime) > now || Date.parse(node.EndDateTime) < now;
|
|
7
|
-
result.push({ ...node, restricted });
|
|
8
|
-
for (const child of [...(node.Modules || []), ...(node.Topics || [])]) visit(child, restricted);
|
|
9
|
-
}
|
|
10
|
-
visit(tree);
|
|
11
|
-
return result;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function reconcileQuizzes(list, tree, grades, origin) {
|
|
15
|
-
const known = new Map();
|
|
16
|
-
const candidates = [];
|
|
17
|
-
for (const quiz of list || []) if (/^\d+$/.test(String(quiz.QuizId))) known.set(String(quiz.QuizId), { quizId: String(quiz.QuizId), name: quiz.Name, sources: ['quiz_list'], gradeItemId: quiz.GradeItemId, restricted: false });
|
|
18
|
-
for (const topic of courseNodes(tree).filter(node => node.TopicId != null)) {
|
|
19
|
-
let url;
|
|
20
|
-
try { url = new URL(topic.Url, origin); } catch {}
|
|
21
|
-
const quizTopic = topic.ActivityType === 4 || /quiz/i.test(topic.TypeIdentifier || '') ||
|
|
22
|
-
(url?.origin === origin && url.pathname.includes('/quizzing/'));
|
|
23
|
-
if (!quizTopic) continue;
|
|
24
|
-
const identifier = topic.ToolItemId ?? (url?.origin === origin ? url.searchParams.get('qi') : null);
|
|
25
|
-
if (/^\d+$/.test(String(identifier))) {
|
|
26
|
-
const key = String(identifier);
|
|
27
|
-
const existing = known.get(key);
|
|
28
|
-
if (existing) { existing.sources.push('content'); existing.restricted &&= topic.restricted; }
|
|
29
|
-
else known.set(key, { quizId: key, name: topic.Title, sources: ['content'], topicId: topic.TopicId, restricted: topic.restricted, gradeItemId: topic.GradeItemId });
|
|
30
|
-
} else candidates.push({ name: topic.Title, topicId: topic.TopicId, source: 'content', restricted: topic.restricted, detailsStatus: 'unresolved' });
|
|
31
|
-
}
|
|
32
|
-
for (const grade of grades || []) {
|
|
33
|
-
const quiz = [...known.values()].find(item => item.gradeItemId != null && String(item.gradeItemId) === String(grade.GradeObjectIdentifier));
|
|
34
|
-
if (quiz) quiz.sources.push('gradebook');
|
|
35
|
-
else if (/\bquiz/i.test(grade.GradeObjectName || '')) candidates.push({ name: grade.GradeObjectName, gradeItemId: grade.GradeObjectIdentifier, source: 'gradebook', detailsStatus: 'unresolved' });
|
|
36
|
-
}
|
|
37
|
-
return { quizzes: [...known.values()], candidates };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function batch(items, offset = 0, size = 25) {
|
|
41
|
-
const start = Number.isSafeInteger(offset) && offset >= 0 && offset < items.length ? offset : 0;
|
|
42
|
-
const selected = items.slice(start, start + size);
|
|
43
|
-
return { items: selected, total: items.length, start, next: start + selected.length >= items.length ? 0 : start + selected.length,
|
|
44
|
-
limited: items.length > selected.length };
|
|
45
|
-
}
|
|
1
|
+
export function courseNodes(tree, now = Date.now()) {
|
|
2
|
+
const result = [];
|
|
3
|
+
function visit(node, unavailable = false) {
|
|
4
|
+
if (!node || typeof node !== 'object') return;
|
|
5
|
+
const restricted = unavailable || node.IsHidden === true || node.IsLocked === true ||
|
|
6
|
+
Date.parse(node.StartDateTime) > now || Date.parse(node.EndDateTime) < now;
|
|
7
|
+
result.push({ ...node, restricted });
|
|
8
|
+
for (const child of [...(node.Modules || []), ...(node.Topics || [])]) visit(child, restricted);
|
|
9
|
+
}
|
|
10
|
+
visit(tree);
|
|
11
|
+
return result;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function reconcileQuizzes(list, tree, grades, origin) {
|
|
15
|
+
const known = new Map();
|
|
16
|
+
const candidates = [];
|
|
17
|
+
for (const quiz of list || []) if (/^\d+$/.test(String(quiz.QuizId))) known.set(String(quiz.QuizId), { quizId: String(quiz.QuizId), name: quiz.Name, sources: ['quiz_list'], gradeItemId: quiz.GradeItemId, restricted: false });
|
|
18
|
+
for (const topic of courseNodes(tree).filter(node => node.TopicId != null)) {
|
|
19
|
+
let url;
|
|
20
|
+
try { url = new URL(topic.Url, origin); } catch {}
|
|
21
|
+
const quizTopic = topic.ActivityType === 4 || /quiz/i.test(topic.TypeIdentifier || '') ||
|
|
22
|
+
(url?.origin === origin && url.pathname.includes('/quizzing/'));
|
|
23
|
+
if (!quizTopic) continue;
|
|
24
|
+
const identifier = topic.ToolItemId ?? (url?.origin === origin ? url.searchParams.get('qi') : null);
|
|
25
|
+
if (/^\d+$/.test(String(identifier))) {
|
|
26
|
+
const key = String(identifier);
|
|
27
|
+
const existing = known.get(key);
|
|
28
|
+
if (existing) { existing.sources.push('content'); existing.restricted &&= topic.restricted; }
|
|
29
|
+
else known.set(key, { quizId: key, name: topic.Title, sources: ['content'], topicId: topic.TopicId, restricted: topic.restricted, gradeItemId: topic.GradeItemId });
|
|
30
|
+
} else candidates.push({ name: topic.Title, topicId: topic.TopicId, source: 'content', restricted: topic.restricted, detailsStatus: 'unresolved' });
|
|
31
|
+
}
|
|
32
|
+
for (const grade of grades || []) {
|
|
33
|
+
const quiz = [...known.values()].find(item => item.gradeItemId != null && String(item.gradeItemId) === String(grade.GradeObjectIdentifier));
|
|
34
|
+
if (quiz) quiz.sources.push('gradebook');
|
|
35
|
+
else if (/\bquiz/i.test(grade.GradeObjectName || '')) candidates.push({ name: grade.GradeObjectName, gradeItemId: grade.GradeObjectIdentifier, source: 'gradebook', detailsStatus: 'unresolved' });
|
|
36
|
+
}
|
|
37
|
+
return { quizzes: [...known.values()], candidates };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function batch(items, offset = 0, size = 25) {
|
|
41
|
+
const start = Number.isSafeInteger(offset) && offset >= 0 && offset < items.length ? offset : 0;
|
|
42
|
+
const selected = items.slice(start, start + size);
|
|
43
|
+
return { items: selected, total: items.length, start, next: start + selected.length >= items.length ? 0 : start + selected.length,
|
|
44
|
+
limited: items.length > selected.length };
|
|
45
|
+
}
|
package/src/documents.mjs
CHANGED
|
@@ -1,154 +1,154 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import { Worker } from 'node:worker_threads';
|
|
3
|
-
import { Parser } from 'htmlparser2';
|
|
4
|
-
import { AccessError } from './client.mjs';
|
|
5
|
-
import { officeText } from './office.mjs';
|
|
6
|
-
import { recognizeImage } from './ocr.mjs';
|
|
7
|
-
|
|
8
|
-
export function htmlContent(html) {
|
|
9
|
-
html = String(html || '');
|
|
10
|
-
let mainStart = -1, mainEnd, depth = 0, elements = 0;
|
|
11
|
-
const scan = new Parser({ onopentag(name, attrs) {
|
|
12
|
-
if (++elements > 100_000) throw new AccessError('partial', 'HTML exceeds the extraction element limit.');
|
|
13
|
-
if (mainStart < 0 && (name === 'main' || attrs.role === 'main')) { mainStart = scan.startIndex; depth = 1; }
|
|
14
|
-
else if (depth) depth++;
|
|
15
|
-
}, onclosetag() { if (depth && --depth === 0) mainEnd = scan.endIndex + 1; } });
|
|
16
|
-
scan.end(html);
|
|
17
|
-
if (mainStart >= 0) html = html.slice(mainStart, mainEnd);
|
|
18
|
-
const parts = [];
|
|
19
|
-
const links = [];
|
|
20
|
-
const block = new Set(['p', 'div', 'br', 'li', 'tr', 'td', 'th', 'h1', 'h2', 'h3', 'h4', 'section']);
|
|
21
|
-
const ignored = new Set(['head', 'script', 'style', 'template', 'noscript', 'nav', 'footer']);
|
|
22
|
-
let hidden = 0;
|
|
23
|
-
let anchor;
|
|
24
|
-
const parser = new Parser({
|
|
25
|
-
onopentag(name, attrs) {
|
|
26
|
-
if (ignored.has(name)) hidden++;
|
|
27
|
-
if (hidden) return;
|
|
28
|
-
if (block.has(name)) parts.push('\n');
|
|
29
|
-
if (name === 'a' && attrs.href) {
|
|
30
|
-
anchor = { url: attrs.href, label: '' };
|
|
31
|
-
links.push(anchor);
|
|
32
|
-
}
|
|
33
|
-
if (['iframe', 'embed', 'object'].includes(name) && (attrs.src || attrs.data)) {
|
|
34
|
-
links.push({ url: attrs.src || attrs.data, label: attrs.title || '', embedded: true });
|
|
35
|
-
}
|
|
36
|
-
},
|
|
37
|
-
ontext(value) { if (!hidden) { parts.push(value); if (anchor) anchor.label += value; } },
|
|
38
|
-
onclosetag(name) {
|
|
39
|
-
if (ignored.has(name)) hidden = Math.max(0, hidden - 1);
|
|
40
|
-
if (!hidden && block.has(name)) parts.push('\n');
|
|
41
|
-
if (name === 'a') anchor = undefined;
|
|
42
|
-
},
|
|
43
|
-
}, { decodeEntities: true });
|
|
44
|
-
parser.end(String(html || ''));
|
|
45
|
-
return { text: parts.join('').replace(/[\t \u00a0]+/g, ' ').replace(/ *\n */g, '\n').replace(/\n{3,}/g, '\n\n').trim(), links };
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function descriptionContent(description) {
|
|
49
|
-
if (description?.Html) return htmlContent(description.Html);
|
|
50
|
-
return { text: String(description?.Text || '').trim(), links: [] };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
async function pdfText(bytes) {
|
|
54
|
-
return new Promise((resolve, reject) => {
|
|
55
|
-
const worker = new Worker(new URL('./pdf-worker.mjs', import.meta.url), {
|
|
56
|
-
workerData: bytes, stdout: true, stderr: true, resourceLimits: { maxOldGenerationSizeMb: 256 },
|
|
57
|
-
});
|
|
58
|
-
worker.stdout.resume();
|
|
59
|
-
worker.stderr.resume();
|
|
60
|
-
let settled = false;
|
|
61
|
-
const timer = setTimeout(() => finish(new AccessError('extraction_failed', 'PDF extraction exceeded its 30-second limit.')), 30_000);
|
|
62
|
-
const finish = (error, value) => {
|
|
63
|
-
if (settled) return;
|
|
64
|
-
settled = true;
|
|
65
|
-
clearTimeout(timer);
|
|
66
|
-
void worker.terminate();
|
|
67
|
-
if (error) reject(error); else resolve(value);
|
|
68
|
-
};
|
|
69
|
-
worker.once('message', value => value.error ? finish(new AccessError('extraction_failed', value.error)) : finish(null, value));
|
|
70
|
-
worker.once('error', () => finish(new AccessError('extraction_failed', 'PDF text extraction failed.')));
|
|
71
|
-
worker.once('exit', code => { if (code !== 0) finish(new AccessError('extraction_failed', 'PDF extraction worker stopped.')); });
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export async function readDocument(client, path) {
|
|
76
|
-
const response = await client.response(path);
|
|
77
|
-
const contentType = response.headers.get('content-type') || 'application/octet-stream';
|
|
78
|
-
if (!response.body) throw new AccessError('invalid_response', 'Document response has no body.');
|
|
79
|
-
const reader = response.body.getReader();
|
|
80
|
-
const chunks = [];
|
|
81
|
-
let size = 0;
|
|
82
|
-
try {
|
|
83
|
-
while (true) {
|
|
84
|
-
const { done, value } = await reader.read();
|
|
85
|
-
if (done) break;
|
|
86
|
-
size += value.length;
|
|
87
|
-
if (size > 20 * 1024 * 1024) throw new AccessError('partial', 'Document exceeds the 20 MiB read limit.');
|
|
88
|
-
chunks.push(value);
|
|
89
|
-
}
|
|
90
|
-
} catch (error) {
|
|
91
|
-
if (error instanceof AccessError) throw error;
|
|
92
|
-
throw new AccessError('retry_wait', 'Document body read failed or timed out.');
|
|
93
|
-
} finally { await reader.cancel().catch(() => {}); }
|
|
94
|
-
const bytes = Buffer.concat(chunks);
|
|
95
|
-
const isPdf = bytes.subarray(0, 5).toString() === '%PDF-' || /application\/pdf/i.test(contentType);
|
|
96
|
-
const isHtml = /(?:text\/html|application\/xhtml\+xml)/i.test(contentType);
|
|
97
|
-
const isOffice = /openxmlformats|msword|ms-excel|ms-powerpoint/i.test(contentType) || bytes.subarray(0, 2).toString() === 'PK';
|
|
98
|
-
if (isHtml && bytes.toString().includes('/d2l/login?sessionExpired=1')) throw new AccessError('needs_login', 'School session has expired.');
|
|
99
|
-
const result = { contentType, size, sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
100
|
-
base64: size <= 2 * 1024 * 1024 ? bytes.toString('base64') : undefined, bytesRetained: size <= 2 * 1024 * 1024,
|
|
101
|
-
text: null, links: [], resolvedUrl: response.url || undefined,
|
|
102
|
-
format: isPdf ? 'pdf' : isHtml ? 'html' : 'other', textStatus: 'unsupported' };
|
|
103
|
-
if (isPdf) {
|
|
104
|
-
try {
|
|
105
|
-
const extracted = await pdfText(bytes);
|
|
106
|
-
const { images, pageTexts, ...metadata } = extracted;
|
|
107
|
-
let ocrFailed = false;
|
|
108
|
-
let ocrPages = 0;
|
|
109
|
-
for (const image of images) {
|
|
110
|
-
try { pageTexts[image.index] = await recognizeImage(image.bytes); ocrPages++; }
|
|
111
|
-
catch (error) { ocrFailed = true; result.textError = error.message; break; }
|
|
112
|
-
}
|
|
113
|
-
if (images.length) {
|
|
114
|
-
metadata.text = pageTexts.join('\n\n').slice(0, 1_000_000);
|
|
115
|
-
metadata.ocrPages = ocrPages;
|
|
116
|
-
metadata.ocrPendingPages = images.length - ocrPages;
|
|
117
|
-
metadata.needsReview = true;
|
|
118
|
-
metadata.extractionMethod = 'embedded_text_and_windows_ocr';
|
|
119
|
-
}
|
|
120
|
-
if (ocrFailed) metadata.truncated = true;
|
|
121
|
-
Object.assign(result, metadata);
|
|
122
|
-
result.textStatus = metadata.truncated ? 'partial' : result.text.trim() ? 'available' : 'empty';
|
|
123
|
-
} catch (error) {
|
|
124
|
-
result.textStatus = 'failed';
|
|
125
|
-
result.textError = error.message;
|
|
126
|
-
}
|
|
127
|
-
} else if (isOffice) {
|
|
128
|
-
try {
|
|
129
|
-
Object.assign(result, officeText(bytes));
|
|
130
|
-
result.textStatus = result.truncated ? 'partial' : result.text ? 'available' : 'empty';
|
|
131
|
-
} catch (error) {
|
|
132
|
-
result.textStatus = /No supported/.test(error.message) ? 'unsupported' : 'failed';
|
|
133
|
-
result.format = result.textStatus === 'unsupported' ? 'archive' : 'other';
|
|
134
|
-
result.textError = result.textStatus === 'unsupported' ? 'Archive has no supported Office document parts; archive contents were not extracted.' :
|
|
135
|
-
'Office extraction failed, exceeded safety limits, or uses a legacy binary format; export to DOCX/PPTX/XLSX or PDF.';
|
|
136
|
-
}
|
|
137
|
-
} else if (/^image\/(?:png|jpeg|bmp|tiff)/i.test(contentType)) {
|
|
138
|
-
try {
|
|
139
|
-
result.text = await recognizeImage(bytes);
|
|
140
|
-
Object.assign(result, { format: 'image', extractionMethod: 'windows_ocr', needsReview: true, textStatus: result.text ? 'available' : 'empty' });
|
|
141
|
-
} catch (error) { result.textStatus = 'failed'; result.textError = error.message; }
|
|
142
|
-
} else if (isHtml) {
|
|
143
|
-
Object.assign(result, htmlContent(bytes.toString('utf8')));
|
|
144
|
-
result.textStatus = result.text ? 'available' : 'empty';
|
|
145
|
-
} else if (/^(?:text\/|application\/json)/i.test(contentType)) {
|
|
146
|
-
const text = bytes.toString('utf8').trim();
|
|
147
|
-
result.text = text.slice(0, 1_000_000);
|
|
148
|
-
result.format = 'text';
|
|
149
|
-
result.textStatus = text.length > 1_000_000 ? 'partial' : result.text ? 'available' : 'empty';
|
|
150
|
-
}
|
|
151
|
-
if (result.text?.length > 1_000_000) { result.text = result.text.slice(0, 1_000_000); result.textStatus = 'partial'; result.truncated = true; }
|
|
152
|
-
if (result.links.length > 200) { result.links = result.links.slice(0, 200); result.linksLimited = true; }
|
|
153
|
-
return result;
|
|
154
|
-
}
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { Worker } from 'node:worker_threads';
|
|
3
|
+
import { Parser } from 'htmlparser2';
|
|
4
|
+
import { AccessError } from './client.mjs';
|
|
5
|
+
import { officeText } from './office.mjs';
|
|
6
|
+
import { recognizeImage } from './ocr.mjs';
|
|
7
|
+
|
|
8
|
+
export function htmlContent(html) {
|
|
9
|
+
html = String(html || '');
|
|
10
|
+
let mainStart = -1, mainEnd, depth = 0, elements = 0;
|
|
11
|
+
const scan = new Parser({ onopentag(name, attrs) {
|
|
12
|
+
if (++elements > 100_000) throw new AccessError('partial', 'HTML exceeds the extraction element limit.');
|
|
13
|
+
if (mainStart < 0 && (name === 'main' || attrs.role === 'main')) { mainStart = scan.startIndex; depth = 1; }
|
|
14
|
+
else if (depth) depth++;
|
|
15
|
+
}, onclosetag() { if (depth && --depth === 0) mainEnd = scan.endIndex + 1; } });
|
|
16
|
+
scan.end(html);
|
|
17
|
+
if (mainStart >= 0) html = html.slice(mainStart, mainEnd);
|
|
18
|
+
const parts = [];
|
|
19
|
+
const links = [];
|
|
20
|
+
const block = new Set(['p', 'div', 'br', 'li', 'tr', 'td', 'th', 'h1', 'h2', 'h3', 'h4', 'section']);
|
|
21
|
+
const ignored = new Set(['head', 'script', 'style', 'template', 'noscript', 'nav', 'footer']);
|
|
22
|
+
let hidden = 0;
|
|
23
|
+
let anchor;
|
|
24
|
+
const parser = new Parser({
|
|
25
|
+
onopentag(name, attrs) {
|
|
26
|
+
if (ignored.has(name)) hidden++;
|
|
27
|
+
if (hidden) return;
|
|
28
|
+
if (block.has(name)) parts.push('\n');
|
|
29
|
+
if (name === 'a' && attrs.href) {
|
|
30
|
+
anchor = { url: attrs.href, label: '' };
|
|
31
|
+
links.push(anchor);
|
|
32
|
+
}
|
|
33
|
+
if (['iframe', 'embed', 'object'].includes(name) && (attrs.src || attrs.data)) {
|
|
34
|
+
links.push({ url: attrs.src || attrs.data, label: attrs.title || '', embedded: true });
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
ontext(value) { if (!hidden) { parts.push(value); if (anchor) anchor.label += value; } },
|
|
38
|
+
onclosetag(name) {
|
|
39
|
+
if (ignored.has(name)) hidden = Math.max(0, hidden - 1);
|
|
40
|
+
if (!hidden && block.has(name)) parts.push('\n');
|
|
41
|
+
if (name === 'a') anchor = undefined;
|
|
42
|
+
},
|
|
43
|
+
}, { decodeEntities: true });
|
|
44
|
+
parser.end(String(html || ''));
|
|
45
|
+
return { text: parts.join('').replace(/[\t \u00a0]+/g, ' ').replace(/ *\n */g, '\n').replace(/\n{3,}/g, '\n\n').trim(), links };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function descriptionContent(description) {
|
|
49
|
+
if (description?.Html) return htmlContent(description.Html);
|
|
50
|
+
return { text: String(description?.Text || '').trim(), links: [] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function pdfText(bytes) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const worker = new Worker(new URL('./pdf-worker.mjs', import.meta.url), {
|
|
56
|
+
workerData: bytes, stdout: true, stderr: true, resourceLimits: { maxOldGenerationSizeMb: 256 },
|
|
57
|
+
});
|
|
58
|
+
worker.stdout.resume();
|
|
59
|
+
worker.stderr.resume();
|
|
60
|
+
let settled = false;
|
|
61
|
+
const timer = setTimeout(() => finish(new AccessError('extraction_failed', 'PDF extraction exceeded its 30-second limit.')), 30_000);
|
|
62
|
+
const finish = (error, value) => {
|
|
63
|
+
if (settled) return;
|
|
64
|
+
settled = true;
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
void worker.terminate();
|
|
67
|
+
if (error) reject(error); else resolve(value);
|
|
68
|
+
};
|
|
69
|
+
worker.once('message', value => value.error ? finish(new AccessError('extraction_failed', value.error)) : finish(null, value));
|
|
70
|
+
worker.once('error', () => finish(new AccessError('extraction_failed', 'PDF text extraction failed.')));
|
|
71
|
+
worker.once('exit', code => { if (code !== 0) finish(new AccessError('extraction_failed', 'PDF extraction worker stopped.')); });
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function readDocument(client, path) {
|
|
76
|
+
const response = await client.response(path);
|
|
77
|
+
const contentType = response.headers.get('content-type') || 'application/octet-stream';
|
|
78
|
+
if (!response.body) throw new AccessError('invalid_response', 'Document response has no body.');
|
|
79
|
+
const reader = response.body.getReader();
|
|
80
|
+
const chunks = [];
|
|
81
|
+
let size = 0;
|
|
82
|
+
try {
|
|
83
|
+
while (true) {
|
|
84
|
+
const { done, value } = await reader.read();
|
|
85
|
+
if (done) break;
|
|
86
|
+
size += value.length;
|
|
87
|
+
if (size > 20 * 1024 * 1024) throw new AccessError('partial', 'Document exceeds the 20 MiB read limit.');
|
|
88
|
+
chunks.push(value);
|
|
89
|
+
}
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (error instanceof AccessError) throw error;
|
|
92
|
+
throw new AccessError('retry_wait', 'Document body read failed or timed out.');
|
|
93
|
+
} finally { await reader.cancel().catch(() => {}); }
|
|
94
|
+
const bytes = Buffer.concat(chunks);
|
|
95
|
+
const isPdf = bytes.subarray(0, 5).toString() === '%PDF-' || /application\/pdf/i.test(contentType);
|
|
96
|
+
const isHtml = /(?:text\/html|application\/xhtml\+xml)/i.test(contentType);
|
|
97
|
+
const isOffice = /openxmlformats|msword|ms-excel|ms-powerpoint/i.test(contentType) || bytes.subarray(0, 2).toString() === 'PK';
|
|
98
|
+
if (isHtml && bytes.toString().includes('/d2l/login?sessionExpired=1')) throw new AccessError('needs_login', 'School session has expired.');
|
|
99
|
+
const result = { contentType, size, sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
100
|
+
base64: size <= 2 * 1024 * 1024 ? bytes.toString('base64') : undefined, bytesRetained: size <= 2 * 1024 * 1024,
|
|
101
|
+
text: null, links: [], resolvedUrl: response.url || undefined,
|
|
102
|
+
format: isPdf ? 'pdf' : isHtml ? 'html' : 'other', textStatus: 'unsupported' };
|
|
103
|
+
if (isPdf) {
|
|
104
|
+
try {
|
|
105
|
+
const extracted = await pdfText(bytes);
|
|
106
|
+
const { images, pageTexts, ...metadata } = extracted;
|
|
107
|
+
let ocrFailed = false;
|
|
108
|
+
let ocrPages = 0;
|
|
109
|
+
for (const image of images) {
|
|
110
|
+
try { pageTexts[image.index] = await recognizeImage(image.bytes); ocrPages++; }
|
|
111
|
+
catch (error) { ocrFailed = true; result.textError = error.message; break; }
|
|
112
|
+
}
|
|
113
|
+
if (images.length) {
|
|
114
|
+
metadata.text = pageTexts.join('\n\n').slice(0, 1_000_000);
|
|
115
|
+
metadata.ocrPages = ocrPages;
|
|
116
|
+
metadata.ocrPendingPages = images.length - ocrPages;
|
|
117
|
+
metadata.needsReview = true;
|
|
118
|
+
metadata.extractionMethod = 'embedded_text_and_windows_ocr';
|
|
119
|
+
}
|
|
120
|
+
if (ocrFailed) metadata.truncated = true;
|
|
121
|
+
Object.assign(result, metadata);
|
|
122
|
+
result.textStatus = metadata.truncated ? 'partial' : result.text.trim() ? 'available' : 'empty';
|
|
123
|
+
} catch (error) {
|
|
124
|
+
result.textStatus = 'failed';
|
|
125
|
+
result.textError = error.message;
|
|
126
|
+
}
|
|
127
|
+
} else if (isOffice) {
|
|
128
|
+
try {
|
|
129
|
+
Object.assign(result, officeText(bytes));
|
|
130
|
+
result.textStatus = result.truncated ? 'partial' : result.text ? 'available' : 'empty';
|
|
131
|
+
} catch (error) {
|
|
132
|
+
result.textStatus = /No supported/.test(error.message) ? 'unsupported' : 'failed';
|
|
133
|
+
result.format = result.textStatus === 'unsupported' ? 'archive' : 'other';
|
|
134
|
+
result.textError = result.textStatus === 'unsupported' ? 'Archive has no supported Office document parts; archive contents were not extracted.' :
|
|
135
|
+
'Office extraction failed, exceeded safety limits, or uses a legacy binary format; export to DOCX/PPTX/XLSX or PDF.';
|
|
136
|
+
}
|
|
137
|
+
} else if (/^image\/(?:png|jpeg|bmp|tiff)/i.test(contentType)) {
|
|
138
|
+
try {
|
|
139
|
+
result.text = await recognizeImage(bytes);
|
|
140
|
+
Object.assign(result, { format: 'image', extractionMethod: 'windows_ocr', needsReview: true, textStatus: result.text ? 'available' : 'empty' });
|
|
141
|
+
} catch (error) { result.textStatus = 'failed'; result.textError = error.message; }
|
|
142
|
+
} else if (isHtml) {
|
|
143
|
+
Object.assign(result, htmlContent(bytes.toString('utf8')));
|
|
144
|
+
result.textStatus = result.text ? 'available' : 'empty';
|
|
145
|
+
} else if (/^(?:text\/|application\/json)/i.test(contentType)) {
|
|
146
|
+
const text = bytes.toString('utf8').trim();
|
|
147
|
+
result.text = text.slice(0, 1_000_000);
|
|
148
|
+
result.format = 'text';
|
|
149
|
+
result.textStatus = text.length > 1_000_000 ? 'partial' : result.text ? 'available' : 'empty';
|
|
150
|
+
}
|
|
151
|
+
if (result.text?.length > 1_000_000) { result.text = result.text.slice(0, 1_000_000); result.textStatus = 'partial'; result.truncated = true; }
|
|
152
|
+
if (result.links.length > 200) { result.links = result.links.slice(0, 200); result.linksLimited = true; }
|
|
153
|
+
return result;
|
|
154
|
+
}
|
package/src/links.mjs
CHANGED
|
@@ -1,99 +1,99 @@
|
|
|
1
|
-
import { AccessError, checkedResponse } from './client.mjs';
|
|
2
|
-
import { readDocument } from './documents.mjs';
|
|
3
|
-
import { courseNodes } from './discovery.mjs';
|
|
4
|
-
|
|
5
|
-
// Only observed public reference hosts; private providers need their own sign-in.
|
|
6
|
-
const publicHosts = new Set(['www.queensu.ca', 'smithengineering.queensu.ca', 'www.cs.queensu.ca',
|
|
7
|
-
'www.cs.cmu.edu', 'www.perfeng.com', 'sunnyday.mit.edu', 'www.instructables.com',
|
|
8
|
-
'www.sciencedaily.com', 'www.protoexpress.com', 'www.youtube.com', 'www.youtube-nocookie.com', 'youtu.be']);
|
|
9
|
-
|
|
10
|
-
export function linkUrl(client, target, base = client.origin) {
|
|
11
|
-
let url;
|
|
12
|
-
try { url = new URL(target, base); } catch { throw new AccessError('invalid_link', 'Invalid document link.'); }
|
|
13
|
-
if (url.username || url.password) throw new AccessError('invalid_link', 'Credentials in a document URL are not supported.');
|
|
14
|
-
url.hash = '';
|
|
15
|
-
if (/%(?:2f|5c|25(?:2e|2f|5c))/i.test(url.pathname)) throw new AccessError('invalid_link', 'Encoded path traversal is not a supported document target.');
|
|
16
|
-
if (url.username || url.password || url.protocol !== 'https:') {
|
|
17
|
-
if (url.protocol === 'http:' && publicHosts.has(url.hostname)) url.protocol = 'https:';
|
|
18
|
-
else throw new AccessError('unsupported_link', 'Only HTTPS document links can be read.');
|
|
19
|
-
}
|
|
20
|
-
if (url.origin === client.origin && (url.pathname.startsWith('/content/') || url.pathname.startsWith('/shared/'))) return url;
|
|
21
|
-
if (url.origin !== client.origin && publicHosts.has(url.hostname) && (!url.port || url.port === '443')) {
|
|
22
|
-
for (const key of [...url.searchParams.keys()]) if (/token|session|csrf|xsrf|authorization/i.test(key)) url.searchParams.delete(key);
|
|
23
|
-
return url;
|
|
24
|
-
}
|
|
25
|
-
throw new AccessError('external_auth_required', 'Link needs a provider-specific reader or is an unsupported school action; it was not followed.');
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function actionLink(target, origin) {
|
|
29
|
-
try {
|
|
30
|
-
const url = new URL(target, origin);
|
|
31
|
-
return ['mailto:', 'tel:', 'javascript:'].includes(url.protocol) || target.startsWith('#') ||
|
|
32
|
-
(url.origin === origin && /quickLink\.d2l$/i.test(url.pathname) &&
|
|
33
|
-
/survey|enroll|dropbox|quiz|discussion/i.test(url.searchParams.get('type') || ''));
|
|
34
|
-
} catch { return false; }
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export async function linkedResponse(client, target) {
|
|
38
|
-
let url = linkUrl(client, target);
|
|
39
|
-
for (let redirects = 0; redirects <= 5; redirects++) {
|
|
40
|
-
const headers = { accept: '*/*' };
|
|
41
|
-
if (url.origin === client.origin) {
|
|
42
|
-
const session = await client.session.load();
|
|
43
|
-
headers.cookie = session.cookies.filter(cookie => {
|
|
44
|
-
const domain = cookie.domain.replace(/^\./, '');
|
|
45
|
-
const path = cookie.path || '/';
|
|
46
|
-
return (url.hostname === domain || url.hostname.endsWith(`.${domain}`)) &&
|
|
47
|
-
(url.pathname === path || url.pathname.startsWith(path.endsWith('/') ? path : `${path}/`)) &&
|
|
48
|
-
(cookie.expires === -1 || !cookie.expires || cookie.expires > Date.now() / 1000);
|
|
49
|
-
}).map(cookie => `${cookie.name}=${cookie.value}`).join('; ');
|
|
50
|
-
}
|
|
51
|
-
let response;
|
|
52
|
-
try { response = await client.fetch(url, { headers, redirect: 'manual', signal: AbortSignal.timeout(30_000) }); }
|
|
53
|
-
catch { throw new AccessError('retry_wait', 'Linked document request failed or timed out.'); }
|
|
54
|
-
if (response.status >= 300 && response.status < 400) {
|
|
55
|
-
const next = response.headers.get('location');
|
|
56
|
-
await response.body?.cancel();
|
|
57
|
-
if (!next) throw new AccessError('invalid_link', 'Redirect did not identify a target.');
|
|
58
|
-
url = linkUrl(client, next, url);
|
|
59
|
-
continue;
|
|
60
|
-
}
|
|
61
|
-
if ([401, 403].includes(response.status) && url.origin !== client.origin) throw new AccessError('external_auth_required', 'Public provider refused the reader or requires its own sign-in.');
|
|
62
|
-
return checkedResponse(response, url);
|
|
63
|
-
}
|
|
64
|
-
throw new AccessError('partial', 'Linked document exceeded the redirect limit.');
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export async function readLinkedDocument(client, target, { le, courseId, tree, base = client.origin } = {}) {
|
|
68
|
-
let url;
|
|
69
|
-
try { url = new URL(target, base); } catch { throw new AccessError('invalid_link', 'Invalid document link.'); }
|
|
70
|
-
const topic = url.origin === client.origin && url.pathname.match(/^\/d2l\/le\/content\/(\d+)\/viewContent\/(\d+)\/(?:View|Home)\/?$/i);
|
|
71
|
-
const apiFile = url.origin === client.origin && url.pathname.match(/^\/d2l\/api\/le\/\d+\.\d+\/(\d+)\/(?:content\/topics\/(\d+)\/file|overview\/attachment)$/);
|
|
72
|
-
const linkedCourse = topic?.[1] || apiFile?.[1];
|
|
73
|
-
const linkedTopic = topic?.[2] || apiFile?.[2];
|
|
74
|
-
if (linkedCourse && linkedCourse !== String(courseId)) throw new AccessError('unsupported_link', 'Linked document belongs to a different course.');
|
|
75
|
-
const known = courseNodes(tree).find(node => {
|
|
76
|
-
if (linkedTopic) return String(node.TopicId) === linkedTopic;
|
|
77
|
-
try { return node.Url && new URL(node.Url, client.origin).href === url.href; } catch { return false; }
|
|
78
|
-
});
|
|
79
|
-
if (known?.restricted) throw new AccessError('restricted', 'Linked source or its ancestor is outside release conditions.');
|
|
80
|
-
if (known?.ActivityType === 1 && /^\d+$/.test(String(known.TopicId))) return readDocument(client, `${le}/content/topics/${known.TopicId}/file`);
|
|
81
|
-
if (apiFile) return readDocument(client, linkedTopic ? `${le}/content/topics/${linkedTopic}/file` : `${le}/overview/attachment`);
|
|
82
|
-
if (topic) {
|
|
83
|
-
if (topic[1] !== String(courseId)) throw new AccessError('unsupported_link', 'Linked topic belongs to a different course.');
|
|
84
|
-
const path = `${le}/content/topics/${topic[2]}`;
|
|
85
|
-
const metadata = await client.get(path);
|
|
86
|
-
if (metadata.IsHidden || metadata.IsLocked || Date.parse(metadata.StartDate) > Date.now() || Date.parse(metadata.EndDate) < Date.now()) {
|
|
87
|
-
throw new AccessError('restricted', 'Linked topic is outside its release conditions.');
|
|
88
|
-
}
|
|
89
|
-
if (metadata.Type === 1 || metadata.ActivityType === 1) return readDocument(client, `${path}/file`);
|
|
90
|
-
if (metadata.Url && metadata.Url !== target && metadata.Url !== url.href) {
|
|
91
|
-
// File/static/public links only here; another topic cannot create a recursion loop.
|
|
92
|
-
return readDocument({ response: value => linkedResponse(client, value) }, linkUrl(client, metadata.Url).href);
|
|
93
|
-
}
|
|
94
|
-
throw new AccessError('unsupported_link', 'Linked topic has no directly readable document target.');
|
|
95
|
-
}
|
|
96
|
-
const safe = linkUrl(client, url.href);
|
|
97
|
-
const document = await readDocument({ response: value => linkedResponse(client, value) }, safe.href);
|
|
98
|
-
return { ...document, resolvedUrl: safe.href };
|
|
99
|
-
}
|
|
1
|
+
import { AccessError, checkedResponse } from './client.mjs';
|
|
2
|
+
import { readDocument } from './documents.mjs';
|
|
3
|
+
import { courseNodes } from './discovery.mjs';
|
|
4
|
+
|
|
5
|
+
// Only observed public reference hosts; private providers need their own sign-in.
|
|
6
|
+
const publicHosts = new Set(['www.queensu.ca', 'smithengineering.queensu.ca', 'www.cs.queensu.ca',
|
|
7
|
+
'www.cs.cmu.edu', 'www.perfeng.com', 'sunnyday.mit.edu', 'www.instructables.com',
|
|
8
|
+
'www.sciencedaily.com', 'www.protoexpress.com', 'www.youtube.com', 'www.youtube-nocookie.com', 'youtu.be']);
|
|
9
|
+
|
|
10
|
+
export function linkUrl(client, target, base = client.origin) {
|
|
11
|
+
let url;
|
|
12
|
+
try { url = new URL(target, base); } catch { throw new AccessError('invalid_link', 'Invalid document link.'); }
|
|
13
|
+
if (url.username || url.password) throw new AccessError('invalid_link', 'Credentials in a document URL are not supported.');
|
|
14
|
+
url.hash = '';
|
|
15
|
+
if (/%(?:2f|5c|25(?:2e|2f|5c))/i.test(url.pathname)) throw new AccessError('invalid_link', 'Encoded path traversal is not a supported document target.');
|
|
16
|
+
if (url.username || url.password || url.protocol !== 'https:') {
|
|
17
|
+
if (url.protocol === 'http:' && publicHosts.has(url.hostname)) url.protocol = 'https:';
|
|
18
|
+
else throw new AccessError('unsupported_link', 'Only HTTPS document links can be read.');
|
|
19
|
+
}
|
|
20
|
+
if (url.origin === client.origin && (url.pathname.startsWith('/content/') || url.pathname.startsWith('/shared/'))) return url;
|
|
21
|
+
if (url.origin !== client.origin && publicHosts.has(url.hostname) && (!url.port || url.port === '443')) {
|
|
22
|
+
for (const key of [...url.searchParams.keys()]) if (/token|session|csrf|xsrf|authorization/i.test(key)) url.searchParams.delete(key);
|
|
23
|
+
return url;
|
|
24
|
+
}
|
|
25
|
+
throw new AccessError('external_auth_required', 'Link needs a provider-specific reader or is an unsupported school action; it was not followed.');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function actionLink(target, origin) {
|
|
29
|
+
try {
|
|
30
|
+
const url = new URL(target, origin);
|
|
31
|
+
return ['mailto:', 'tel:', 'javascript:'].includes(url.protocol) || target.startsWith('#') ||
|
|
32
|
+
(url.origin === origin && /quickLink\.d2l$/i.test(url.pathname) &&
|
|
33
|
+
/survey|enroll|dropbox|quiz|discussion/i.test(url.searchParams.get('type') || ''));
|
|
34
|
+
} catch { return false; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function linkedResponse(client, target) {
|
|
38
|
+
let url = linkUrl(client, target);
|
|
39
|
+
for (let redirects = 0; redirects <= 5; redirects++) {
|
|
40
|
+
const headers = { accept: '*/*' };
|
|
41
|
+
if (url.origin === client.origin) {
|
|
42
|
+
const session = await client.session.load();
|
|
43
|
+
headers.cookie = session.cookies.filter(cookie => {
|
|
44
|
+
const domain = cookie.domain.replace(/^\./, '');
|
|
45
|
+
const path = cookie.path || '/';
|
|
46
|
+
return (url.hostname === domain || url.hostname.endsWith(`.${domain}`)) &&
|
|
47
|
+
(url.pathname === path || url.pathname.startsWith(path.endsWith('/') ? path : `${path}/`)) &&
|
|
48
|
+
(cookie.expires === -1 || !cookie.expires || cookie.expires > Date.now() / 1000);
|
|
49
|
+
}).map(cookie => `${cookie.name}=${cookie.value}`).join('; ');
|
|
50
|
+
}
|
|
51
|
+
let response;
|
|
52
|
+
try { response = await client.fetch(url, { headers, redirect: 'manual', signal: client.signal ? AbortSignal.any([client.signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000) }); }
|
|
53
|
+
catch { throw new AccessError('retry_wait', 'Linked document request failed or timed out.'); }
|
|
54
|
+
if (response.status >= 300 && response.status < 400) {
|
|
55
|
+
const next = response.headers.get('location');
|
|
56
|
+
await response.body?.cancel();
|
|
57
|
+
if (!next) throw new AccessError('invalid_link', 'Redirect did not identify a target.');
|
|
58
|
+
url = linkUrl(client, next, url);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if ([401, 403].includes(response.status) && url.origin !== client.origin) throw new AccessError('external_auth_required', 'Public provider refused the reader or requires its own sign-in.');
|
|
62
|
+
return checkedResponse(response, url);
|
|
63
|
+
}
|
|
64
|
+
throw new AccessError('partial', 'Linked document exceeded the redirect limit.');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function readLinkedDocument(client, target, { le, courseId, tree, base = client.origin } = {}) {
|
|
68
|
+
let url;
|
|
69
|
+
try { url = new URL(target, base); } catch { throw new AccessError('invalid_link', 'Invalid document link.'); }
|
|
70
|
+
const topic = url.origin === client.origin && url.pathname.match(/^\/d2l\/le\/content\/(\d+)\/viewContent\/(\d+)\/(?:View|Home)\/?$/i);
|
|
71
|
+
const apiFile = url.origin === client.origin && url.pathname.match(/^\/d2l\/api\/le\/\d+\.\d+\/(\d+)\/(?:content\/topics\/(\d+)\/file|overview\/attachment)$/);
|
|
72
|
+
const linkedCourse = topic?.[1] || apiFile?.[1];
|
|
73
|
+
const linkedTopic = topic?.[2] || apiFile?.[2];
|
|
74
|
+
if (linkedCourse && linkedCourse !== String(courseId)) throw new AccessError('unsupported_link', 'Linked document belongs to a different course.');
|
|
75
|
+
const known = courseNodes(tree).find(node => {
|
|
76
|
+
if (linkedTopic) return String(node.TopicId) === linkedTopic;
|
|
77
|
+
try { return node.Url && new URL(node.Url, client.origin).href === url.href; } catch { return false; }
|
|
78
|
+
});
|
|
79
|
+
if (known?.restricted) throw new AccessError('restricted', 'Linked source or its ancestor is outside release conditions.');
|
|
80
|
+
if (known?.ActivityType === 1 && /^\d+$/.test(String(known.TopicId))) return readDocument(client, `${le}/content/topics/${known.TopicId}/file`);
|
|
81
|
+
if (apiFile) return readDocument(client, linkedTopic ? `${le}/content/topics/${linkedTopic}/file` : `${le}/overview/attachment`);
|
|
82
|
+
if (topic) {
|
|
83
|
+
if (topic[1] !== String(courseId)) throw new AccessError('unsupported_link', 'Linked topic belongs to a different course.');
|
|
84
|
+
const path = `${le}/content/topics/${topic[2]}`;
|
|
85
|
+
const metadata = await client.get(path);
|
|
86
|
+
if (metadata.IsHidden || metadata.IsLocked || Date.parse(metadata.StartDate) > Date.now() || Date.parse(metadata.EndDate) < Date.now()) {
|
|
87
|
+
throw new AccessError('restricted', 'Linked topic is outside its release conditions.');
|
|
88
|
+
}
|
|
89
|
+
if (metadata.Type === 1 || metadata.ActivityType === 1) return readDocument(client, `${path}/file`);
|
|
90
|
+
if (metadata.Url && metadata.Url !== target && metadata.Url !== url.href) {
|
|
91
|
+
// File/static/public links only here; another topic cannot create a recursion loop.
|
|
92
|
+
return readDocument({ response: value => linkedResponse(client, value) }, linkUrl(client, metadata.Url).href);
|
|
93
|
+
}
|
|
94
|
+
throw new AccessError('unsupported_link', 'Linked topic has no directly readable document target.');
|
|
95
|
+
}
|
|
96
|
+
const safe = linkUrl(client, url.href);
|
|
97
|
+
const document = await readDocument({ response: value => linkedResponse(client, value) }, safe.href);
|
|
98
|
+
return { ...document, resolvedUrl: safe.href };
|
|
99
|
+
}
|