@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
|
@@ -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
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { mkdir, rm } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { parseArgs } from 'node:util';
|
|
4
|
-
import {
|
|
5
|
-
import { BrightspaceClient } from './client.mjs';
|
|
4
|
+
import { select, text, isCancel, cancel, log, outro } from '@clack/prompts';
|
|
5
|
+
import { BrightspaceClient, SCHOOL, schoolOrigin } from './client.mjs';
|
|
6
6
|
import { Session, login, atomicWrite } from './session.mjs';
|
|
7
7
|
import { Store, lock } from './store.mjs';
|
|
8
8
|
import { Connector, sync } from './connector.mjs';
|
|
9
9
|
import { runApp } from './app-runner.mjs';
|
|
10
|
+
import { renderBanner } from './cli-banner.mjs';
|
|
10
11
|
|
|
11
|
-
const help = `dd2l — direct, read-only
|
|
12
|
+
const help = `dd2l — direct, read-only Brightspace connector (Windows, Node 24+)
|
|
12
13
|
|
|
13
14
|
dd2l setup Guided school sign-in, then open the app and sync
|
|
14
15
|
dd2l start Open the app and sync with your saved school session
|
|
@@ -22,7 +23,7 @@ dd2l sync --all Rotate through all accessible courses in bou
|
|
|
22
23
|
dd2l status Show coverage and last successful reads
|
|
23
24
|
dd2l logout Delete dd2l's session and local inventory
|
|
24
25
|
|
|
25
|
-
Without a global install: npx dd2l <command>.
|
|
26
|
+
Without a global install: npx @ivanbard/dd2l <command>.
|
|
26
27
|
From source: npm start -- <command>, or npm run setup.
|
|
27
28
|
|
|
28
29
|
audit/sync: --from ISO --to ISO Calendar window (default: past 30 / next 120 days)
|
|
@@ -43,18 +44,25 @@ async function main() {
|
|
|
43
44
|
if (values.all && (!['audit', 'sync'].includes(command) || values.course)) throw new Error('--all applies to audit/sync and cannot be combined with --course.');
|
|
44
45
|
if (process.platform !== 'win32' || !process.env.LOCALAPPDATA) throw new Error('This first connector requires Windows with LOCALAPPDATA.');
|
|
45
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`);
|
|
46
49
|
if (command === 'setup') {
|
|
47
|
-
console.log(`Welcome to dd2l. Requires Google Chrome and a Queen's onQ account.
|
|
48
|
-
School sign-in and MFA happen in a separate Chrome window; no school password is saved.
|
|
49
|
-
Session credentials are encrypted for your Windows account. Coursework and grades
|
|
50
|
-
are cached unencrypted in ${directory}. The connector is read-only.
|
|
51
|
-
ChatGPT is optional: connect it later in Settings. Local search stays on this computer.`);
|
|
52
50
|
if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error('Run dd2l setup in an interactive terminal.');
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
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;
|
|
58
66
|
}
|
|
59
67
|
await mkdir(directory, { recursive: true });
|
|
60
68
|
// Status is read-only and can inspect a running worker without taking its lock.
|
|
@@ -62,18 +70,27 @@ ChatGPT is optional: connect it later in Settings. Local search stays on this co
|
|
|
62
70
|
let store;
|
|
63
71
|
try {
|
|
64
72
|
store = new Store(directory);
|
|
65
|
-
const
|
|
66
|
-
const
|
|
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 });
|
|
67
76
|
const connector = new Connector(client, store);
|
|
68
77
|
if (command === 'login' || command === 'setup') {
|
|
69
|
-
|
|
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');
|
|
70
86
|
store.meta('state', { state: 'ready', updatedAt: new Date().toISOString() });
|
|
71
87
|
}
|
|
72
88
|
if (command === 'start' || command === 'setup') {
|
|
89
|
+
if (command === 'setup') log.info(`Coursework and grades will be cached in:\n${directory}`);
|
|
73
90
|
await runApp(connector, { onUpdate: async summary => {
|
|
74
91
|
await atomicWrite(join(directory, 'summary.json'), JSON.stringify(summary, null, 2));
|
|
75
|
-
console.log(summary.state === 'retry_wait' ? 'Collection delayed; will retry automatically.' : 'Coursework pass saved. Reopen Courses or search to see updates.');
|
|
76
92
|
} });
|
|
93
|
+
outro('dd2l stopped. Your cached coursework is saved.');
|
|
77
94
|
} else if (command === 'courses') {
|
|
78
95
|
const { enrollments } = await connector.courses();
|
|
79
96
|
console.log(JSON.stringify({ complete: enrollments.complete, courses: enrollments.data.map(item => ({
|
|
@@ -117,4 +134,9 @@ ChatGPT is optional: connect it later in Settings. Local search stays on this co
|
|
|
117
134
|
} finally { store?.close(); await release(); }
|
|
118
135
|
}
|
|
119
136
|
|
|
120
|
-
main().catch(error => {
|
|
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
CHANGED
|
@@ -1,137 +1,150 @@
|
|
|
1
|
-
export const SCHOOL = 'https://onq.queensu.ca';
|
|
2
|
-
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
export
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
throw new AccessError('
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
+
}
|