@ivanbard/dd2l 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +47 -8
- package/bin/dd2l.mjs +7 -7
- package/docs/README.md +108 -11
- package/package.json +3 -2
- package/src/app-runner.mjs +58 -35
- package/src/assignment-workspace.mjs +128 -0
- package/src/canvas.mjs +17 -0
- package/src/cli-banner.mjs +58 -0
- package/src/cli.mjs +41 -19
- package/src/client.mjs +150 -137
- package/src/connector.mjs +587 -345
- package/src/discovery.mjs +45 -45
- package/src/documents.mjs +154 -154
- package/src/links.mjs +99 -99
- package/src/llm.mjs +43 -6
- package/src/media.mjs +54 -54
- package/src/ocr.mjs +24 -24
- package/src/office.mjs +61 -61
- package/src/pdf-worker.mjs +26 -26
- package/src/research.mjs +70 -0
- package/src/session.mjs +5 -5
- package/src/store.mjs +108 -106
- package/src/syllabus.mjs +191 -191
- package/src/task-workspace.mjs +138 -0
- package/src/ui-data.mjs +36 -14
- package/src/ui-demo.mjs +17 -1
- package/src/ui-server.mjs +51 -15
- package/src/windows-ocr.ps1 +32 -32
- package/ui/answer-renderer.js +88 -88
- package/ui/answer-view.js +47 -47
- package/ui/app.js +96 -13
- package/ui/assets/widget-announcements.svg +4 -4
- package/ui/assets/widget-shortcuts.svg +5 -5
- package/ui/assignment-workspace.js +106 -0
- package/ui/canvas-contract.js +41 -0
- package/ui/canvas-runtime.js +158 -0
- package/ui/canvas.js +100 -0
- package/ui/index.html +15 -4
- package/ui/loading.js +33 -33
- package/ui/style.css +15 -0
- package/ui/widget-layout.js +39 -39
- package/ui/widgets.js +252 -252
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 ivanbard
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ivanbard
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -10,14 +10,53 @@ your materials, and ask questions with sources.
|
|
|
10
10
|
- sign in to onQ through Chrome, including your school's MFA
|
|
11
11
|
- collect course materials, assignments, grades, announcements and syllabus text
|
|
12
12
|
- search the local cache, browse sources, and customize your home screen widgets
|
|
13
|
-
-
|
|
13
|
+
- connect ChatGPT to compose adaptive workspaces around your goals
|
|
14
|
+
|
|
15
|
+
Connect ChatGPT in Settings to use your existing plan, then describe what you
|
|
16
|
+
want to do: **“Help me get ready for tomorrow's seminar”**, **“What should I
|
|
17
|
+
prioritize across my classes?”**, or **“I'm stuck on the filter design report.”**
|
|
18
|
+
The connected model interprets the goal, selects relevant records across
|
|
19
|
+
courses, and generates an interactive workspace: a planner, practice session,
|
|
20
|
+
rubric explorer, calculator, or another layout suited to the task. There is no required prompt format or fixed set of
|
|
21
|
+
supported task names. If it needs clarification, answer in the same composer.
|
|
22
|
+
|
|
23
|
+
Follow-ups carry the workspace's goal, generated interface and local progress, so
|
|
24
|
+
you can change the layout while retaining notes and checked items. Use **Sources**
|
|
25
|
+
to inspect verified coursework, or **Restart progress** to reset the current canvas. A connected model is selected by default;
|
|
26
|
+
requests run only when you send a prompt, with the sharing notice visible.
|
|
27
|
+
Choose **Local search** for keyword search and deterministic assignment views
|
|
28
|
+
without sending data to AI. New search clears the goal; changing course clears
|
|
29
|
+
context and sets a hard scope for the next request.
|
|
30
|
+
|
|
31
|
+
The model can discover course areas, read uncached documents, and follow supported
|
|
32
|
+
document links through the connector before composing the workspace. Retrieved
|
|
33
|
+
materials enter the same cache used by search and sync. This covers accessible
|
|
34
|
+
course content, assignment instructions/rubrics/attachments, your submission or
|
|
35
|
+
quiz history, discussions, grades, announcements and calendar information.
|
|
36
|
+
|
|
37
|
+
Adaptive requests use planning followed by interface generation from selected
|
|
38
|
+
excerpts. Live research adds planning turns, bounded to four read rounds and eight
|
|
39
|
+
connector operations per request. Generated HTML/CSS
|
|
40
|
+
renders inside an isolated frame; JavaScript runs in a network-blocked worker
|
|
41
|
+
through a small state/event interface. No frontend framework or external
|
|
42
|
+
inference service is required. Local progress lasts for the current visit. Missing, inaccessible,
|
|
43
|
+
and stale records remain explicit. Cached submissions do not confirm current
|
|
44
|
+
submission status, and generated guidance should be checked against its sources.
|
|
14
45
|
|
|
15
46
|
The connector is read-only. It does not submit work or change grades, posts,
|
|
16
47
|
quiz attempts or completion states. Coverage depends on what onQ makes accessible.
|
|
48
|
+
Live research uses approved API and document readers; it cannot click through
|
|
49
|
+
arbitrary pages or sign into private external course tools.
|
|
17
50
|
|
|
18
51
|
## School not listed?
|
|
19
|
-
|
|
20
|
-
|
|
52
|
+
Choose **My school isn't listed** during setup and paste your school's
|
|
53
|
+
Brightspace address; `https://` is added if omitted. dd2l uses that site for sign-in, syncing, and source links, and
|
|
54
|
+
remembers it for later launches. Other schools have not been verified; their
|
|
55
|
+
authentication or API access may differ. You can also [report a missing school](https://github.com/ivanbard/dd2l/issues/new?template=missing-url.yml)
|
|
56
|
+
using the guided issue form. Include your school's name and Brightspace URL if
|
|
57
|
+
known to help add official support.
|
|
58
|
+
Stop dd2l and run `dd2l logout` before switching schools; this deletes its saved
|
|
59
|
+
school session and coursework cache.
|
|
21
60
|
|
|
22
61
|
## Run
|
|
23
62
|
|
|
@@ -25,7 +64,7 @@ Requires Windows, Node.js 24 or newer, and Google Chrome. This repository includ
|
|
|
25
64
|
an npm CLI; the commands below work once the package is published to npm:
|
|
26
65
|
|
|
27
66
|
```powershell
|
|
28
|
-
npx dd2l setup
|
|
67
|
+
npx @ivanbard/dd2l setup
|
|
29
68
|
```
|
|
30
69
|
|
|
31
70
|
Setup explains local storage, asks before connecting onQ, opens Chrome for school
|
|
@@ -34,10 +73,10 @@ one terminal open; Ctrl+C stops both the app and sync. The initial view may be
|
|
|
34
73
|
empty while collection runs. Collection advances through up to five courses per
|
|
35
74
|
pass every 15 minutes, so the first pass is not a complete download.
|
|
36
75
|
|
|
37
|
-
Next time, run `npx dd2l start`. If the school session expires, stop the app and
|
|
38
|
-
run `npx dd2l setup` again. Optional ChatGPT sign-in is in Settings.
|
|
76
|
+
Next time, run `npx @ivanbard/dd2l start`. If the school session expires, stop the app and
|
|
77
|
+
run `npx @ivanbard/dd2l setup` again. Optional ChatGPT sign-in is in Settings.
|
|
39
78
|
|
|
40
|
-
Alternatively, install globally with `npm install -g dd2l`, then use `dd2l setup`
|
|
79
|
+
Alternatively, install globally with `npm install -g @ivanbard/dd2l`, then use `dd2l setup`
|
|
41
80
|
and `dd2l start`. Installation itself does not open browsers or connect accounts;
|
|
42
81
|
setup runs explicitly in an interactive terminal.
|
|
43
82
|
|
|
@@ -64,7 +103,7 @@ cache lives in `%LOCALAPPDATA%\dd2l` and is **not encrypted**. Local search does
|
|
|
64
103
|
not send your question to an AI provider.
|
|
65
104
|
|
|
66
105
|
For AI answers, connect ChatGPT in Settings and choose a model below the prompt.
|
|
67
|
-
Submitting sends your
|
|
106
|
+
Submitting sends your goal, previous workspace context, course previews and selected excerpts to OpenAI and uses
|
|
68
107
|
your plan's limits. Check generated answers against the supplied sources.
|
|
69
108
|
|
|
70
109
|
Stop the app, then run `dd2l logout` (or `npm start -- logout` from source) to
|
package/bin/dd2l.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
if (Number(process.versions.node.split('.')[0]) < 24) {
|
|
3
|
-
console.error('dd2l requires Node.js 24 or newer. Install it from https://nodejs.org, then retry.');
|
|
4
|
-
process.exitCode = 1;
|
|
5
|
-
} else {
|
|
6
|
-
await import('../src/cli.mjs');
|
|
7
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
if (Number(process.versions.node.split('.')[0]) < 24) {
|
|
3
|
+
console.error('dd2l requires Node.js 24 or newer. Install it from https://nodejs.org, then retry.');
|
|
4
|
+
process.exitCode = 1;
|
|
5
|
+
} else {
|
|
6
|
+
await import('../src/cli.mjs');
|
|
7
|
+
}
|
package/docs/README.md
CHANGED
|
@@ -6,14 +6,22 @@ The connector signs in using your installed Google Chrome browser, reuses a prot
|
|
|
6
6
|
|
|
7
7
|
## Run
|
|
8
8
|
|
|
9
|
+
Setup lists Queen's onQ and lets you enter another school's HTTPS Brightspace
|
|
10
|
+
URL. The `https://` prefix is optional and added automatically. Pasted course URLs are reduced to their site origin; credentials embedded
|
|
11
|
+
in a URL are rejected. The selected site is saved after successful sign-in and
|
|
12
|
+
reused by subsequent connector commands and cached source links. Existing
|
|
13
|
+
Queen's installations keep working without setup changes. Other schools remain
|
|
14
|
+
unverified; use the issue link shown during setup to request official support.
|
|
15
|
+
Stop the worker and run `logout` before switching accounts or schools.
|
|
16
|
+
|
|
9
17
|
The guided source-checkout path is `npm ci`, then `npm run setup`. Setup asks
|
|
10
18
|
before connecting onQ, completes school sign-in, opens the UI, and runs sync in
|
|
11
19
|
the same terminal. Use `npm start -- start` on later launches. Keep the terminal
|
|
12
20
|
open; Ctrl+C stops both. If sign-in expires, rerun setup. Collection remains
|
|
13
21
|
incremental, with the same batching and 15-minute cadence described below.
|
|
14
22
|
|
|
15
|
-
The npm package exposes `dd2l setup` and `dd2l start` (or `npx dd2l setup` and
|
|
16
|
-
`npx dd2l start` without a global install) once published. It has no interactive
|
|
23
|
+
The npm package exposes `dd2l setup` and `dd2l start` (or `npx @ivanbard/dd2l setup` and
|
|
24
|
+
`npx @ivanbard/dd2l start` without a global install) once published. It has no interactive
|
|
17
25
|
install hook. The repository has not been published by this setup change.
|
|
18
26
|
|
|
19
27
|
For manual operation:
|
|
@@ -55,7 +63,7 @@ npm run ui
|
|
|
55
63
|
|
|
56
64
|
Open the printed [local URL](http://127.0.0.1:4173). The home screen implements the [Figma Home design](https://www.figma.com/design/ASr0dnwupPLbXmeT1AiPGg/dd2l?node-id=1-3): prompt, course selector, sidebar and profile. Search finds keyword matches in cached course materials and displays source excerpts with retrieval dates and stale-cache labels. Courses opens a material browser; Settings and Profile show connection status and refresh instructions. The source link opens the course in onQ using your normal browser sign-in.
|
|
57
65
|
|
|
58
|
-
Local search reads the existing inventory without changing it or loading school credentials. Run `npm run audit -- --all` or keep `npm run sync -- --all` running separately to collect data. Each search reads the current cache. Only courses confirmed accessible in cached enrollments are shown; unavailable material stays unavailable, and coverage is limited to what the connector has collected. The mode dropdown
|
|
66
|
+
Local search reads the existing inventory without changing it or loading school credentials. Run `npm run audit -- --all` or keep `npm run sync -- --all` running separately to collect data. Each search reads the current cache. Only courses confirmed accessible in cached enrollments are shown; unavailable material stays unavailable, and coverage is limited to what the connector has collected. The mode dropdown uses an available ChatGPT model when connected, or **Local search** otherwise. Local search matches keywords; submitting with a connected model builds a workspace around your goal.
|
|
59
67
|
|
|
60
68
|
The homescreen includes upcoming deadlines, recent class announcements, and shortcuts to classes or cached materials. **Customize** enables a four-column, eight-row grid: drag a widget to move it live, or drag any edge or corner to resize it. A cell preview marks the proposed placement; overlapping or out-of-bounds drops return to the original position. Escape cancels the gesture. Focus a widget and use arrow keys to move or Shift + arrows to resize without a pointer. Remove widgets or add them back with **Add widget**. Narrow screens stack widgets normally and show a horizontally scrollable grid while customizing. **Pin shortcut** opens a searchable class/material picker; selecting a shortcut browses its cached sources. New search returns to the homescreen.
|
|
61
69
|
|
|
@@ -65,13 +73,102 @@ Layouts and up to 20 shortcut references save in browser local storage, separate
|
|
|
65
73
|
|
|
66
74
|
### Use your ChatGPT plan
|
|
67
75
|
|
|
68
|
-
In the live UI, open Settings or Profile and choose **Continue with ChatGPT**.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
76
|
+
In the live UI, open Settings or Profile and choose **Continue with ChatGPT**.
|
|
77
|
+
Complete registration and plan-use consent on OpenAI's website, then return to
|
|
78
|
+
dd2l. No API key is needed. An available connected model is selected by default;
|
|
79
|
+
the sharing notice identifies AI mode. Sending a prompt starts a request;
|
|
80
|
+
loading the app, typing and changing models never send coursework. Choose
|
|
81
|
+
**Local search** for local keyword retrieval and assignment views. New search
|
|
82
|
+
clears workspace context and restores the connected model default.
|
|
83
|
+
|
|
84
|
+
Adaptive mode has no assignment-specific prompt gate or fixed intent list.
|
|
85
|
+
The first model call receives the request, prior workspace goal and selected
|
|
86
|
+
source references, accessible course names, and up to 400 catalogue previews
|
|
87
|
+
(maximum 400 characters of source text each). Catalogue selection includes
|
|
88
|
+
keyword matches, previous references, and diverse course/category records.
|
|
89
|
+
When evidence is missing, stale, or needs a current check, the model can request
|
|
90
|
+
read-only connector operations and plan again. It can discover courses and course
|
|
91
|
+
areas, read discovered content files, assignment details/rubrics/own status and
|
|
92
|
+
attachments, quiz metadata/own attempts, discussion forums/topics/posts, overview
|
|
93
|
+
attachments, grades, announcements and calendar events. Supported document links
|
|
94
|
+
can be followed from those resources. Discovery returns request-local handles;
|
|
95
|
+
the model cannot supply arbitrary network URLs. New observations update the same
|
|
96
|
+
inventory, without changing the sync course or traversal cursors. Freshly retrieved
|
|
97
|
+
records receive priority in the next catalogue.
|
|
98
|
+
|
|
99
|
+
Research allows at most two operations per planning turn, four read rounds and
|
|
100
|
+
eight connector operations. A final planning turn must finish with available
|
|
101
|
+
evidence or explain what is missing. Requests have a five-minute overall deadline;
|
|
102
|
+
each operation may involve several bounded API/document reads. Calendar discovery
|
|
103
|
+
covers the previous 30 and next 120 days. Live reads share the connector queue with
|
|
104
|
+
sync, so an in-progress course pass can delay them. Use the UI opened by `dd2l start`
|
|
105
|
+
to share its connector; standalone `npm run ui` acquires the inventory lock for each
|
|
106
|
+
operation and reports if another command owns it.
|
|
107
|
+
|
|
108
|
+
The model chooses source IDs and purpose-specific groups in relevance order,
|
|
109
|
+
or asks for clarification. The server validates the plan, enforces the selected
|
|
110
|
+
course scope, and rechecks current access before assembling sources.
|
|
111
|
+
|
|
112
|
+
After planning, another model call generates a workspace program: title, short answer, HTML
|
|
113
|
+
fragment (up to 60,000 characters), CSS (20,000), and a JavaScript function body
|
|
114
|
+
(60,000), grounded in up to 24 selected excerpts of 8,000 characters each. The
|
|
115
|
+
interface can be a planner, practice session, rubric explorer, form, diagram or
|
|
116
|
+
calculator. It is not limited to a catalogue of prebuilt task widgets. All calls
|
|
117
|
+
use the selected model and plan limits: normally two calls, up to five planning
|
|
118
|
+
calls plus one canvas call when researching. Clarifications use only planning. Failed
|
|
119
|
+
generation retains the verified source view; no retries or fallback models are
|
|
120
|
+
billed automatically. Sources and cache coverage remain available from **Sources**.
|
|
121
|
+
|
|
122
|
+
The implementation uses browser primitives rather than introducing React and a
|
|
123
|
+
second agent runtime. The dedicated frame has an opaque origin and restrictive
|
|
124
|
+
CSP. Only the trusted frame runtime touches the DOM. Generated JavaScript runs
|
|
125
|
+
in a Blob worker with network access blocked, no DOM or frame navigation, opaque-origin storage restrictions,
|
|
126
|
+
and a two-second response watchdog. Generated HTML is sanitized to semantic HTML,
|
|
127
|
+
form controls and SVG geometry; scripts, embedded frames, event handlers and
|
|
128
|
+
navigation/resource URLs are removed. CSS remains confined to the frame. This
|
|
129
|
+
follows the isolated generative canvas pattern while keeping the current server
|
|
130
|
+
and ChatGPT authentication path.
|
|
131
|
+
|
|
132
|
+
The generated function receives `state`, `event`, and selected `sources`, and
|
|
133
|
+
returns `{html, state}`. Events include click actions (`data-action`), named input
|
|
134
|
+
changes and submissions. Named controls are saved/restored automatically in
|
|
135
|
+
`state.inputs`; text controls accept up to 4,000 characters each. State must be
|
|
136
|
+
JSON data under 32,000 characters and twelve levels deep. The app waits for
|
|
137
|
+
pending worker updates before collecting progress for a follow-up. Generated
|
|
138
|
+
programs can also keep tabs, revealed explanations, scenario values and other
|
|
139
|
+
local state. Rendering applies the newest queued input so older results do not
|
|
140
|
+
overwrite typing.
|
|
141
|
+
|
|
142
|
+
A `data-source` button requests inspection of a selected source. The host
|
|
143
|
+
validates that source ID, re-reads current access, then displays a trusted source
|
|
144
|
+
panel and a Brightspace link. The generated program receives no credential,
|
|
145
|
+
arbitrary fetch, file, submission, or host navigation capability. Source URLs
|
|
146
|
+
come from dd2l, not generated HTML. API source references stop resolving after
|
|
147
|
+
access is revoked.
|
|
148
|
+
|
|
149
|
+
Follow-ups send the previous goal, selected IDs, bounded guidance summary, prior
|
|
150
|
+
program, current progress and any runtime error to the selected model. Prior
|
|
151
|
+
context and coursework are untrusted data in model instructions. **New search**
|
|
152
|
+
and course changes clear the canvas and context; **Restart progress** reruns the
|
|
153
|
+
current program with empty state. Progress is held in browser memory and clears
|
|
154
|
+
on reload. Editing a prompt cancels generation without discarding the current
|
|
155
|
+
canvas. The app enforces bounded JSON input and output, but a valid program may
|
|
156
|
+
still contain factual, calculation or usability errors. Check its linked
|
|
157
|
+
coursework and identify what to fix in a follow-up.
|
|
158
|
+
|
|
159
|
+
Each explicit submission with a model selected authorizes live read-only discovery
|
|
160
|
+
and sharing its results, those previews, excerpts, the previous program and local
|
|
161
|
+
progress with OpenAI. Local mode sends
|
|
162
|
+
none of them. Brightspace remains read-only. The model cannot invent missing
|
|
163
|
+
course records or submit work. Catalogue coverage remains bounded and is shown
|
|
164
|
+
in the source view when incomplete.
|
|
165
|
+
|
|
166
|
+
School identity and current enrollment are checked for each operation; hidden or
|
|
167
|
+
removed parents cannot authorize child reads. Login expiry and rate limiting stop
|
|
168
|
+
further research, with the result shown in Sources; cached evidence may still be
|
|
169
|
+
used with its freshness labels. This is an API/document read graph, not arbitrary
|
|
170
|
+
browser clicking. It cannot start quizzes, submit assignments, enumerate class
|
|
171
|
+
rosters, or sign into private LTI providers. Unsupported links remain gaps.
|
|
75
172
|
|
|
76
173
|
This uses OpenAI's documented [local-app Sign in with ChatGPT flow](https://developers.openai.com/siwc/token-sharing-open-source), with PKCE, state/nonce and signed identity validation. A stable host ID and separate account registrations are saved under `%LOCALAPPDATA%\dd2l\llm\accounts.dpapi`, encrypted with Windows DPAPI. Refreshes are serialized across processes using a separate lock; the school sync can run alongside AI. Tokens stay on the server and never enter browser storage. `jose` handles JWT signature and claim verification. Remote Responses requests set `store: false` and `stream: true`; partial/failed streams do not become successful answers.
|
|
77
174
|
|
|
@@ -125,6 +222,6 @@ Syllabus content lives in individual `syllabus_source` resource rows; the `sylla
|
|
|
125
222
|
|
|
126
223
|
An audit can complete successfully as a command while reporting `complete: false`: resource-level denials, unavailable routes, or partial collections remain visible. A successful collection read establishes coverage of that endpoint, not of every activity the website or an external tool may contain.
|
|
127
224
|
|
|
128
|
-
|
|
225
|
+
Persistent semantic indexing, additional model providers, private course-content provider integrations and unattended Windows startup remain later milestones.
|
|
129
226
|
|
|
130
227
|
Do not commit credentials, session cookies, access tokens, downloaded coursework, grades, or classmates' personal information. Discovery evidence should contain access outcomes and field names rather than private payloads.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ivanbard/dd2l",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "A local, read-only student companion for Queen's onQ: sync coursework, search sources, and optionally connect ChatGPT.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"start": "node src/cli.mjs",
|
|
34
34
|
"setup": "node src/cli.mjs setup",
|
|
35
35
|
"ui": "node src/ui-server.mjs",
|
|
36
|
-
"ui:check": "node scripts/check-ui.mjs",
|
|
36
|
+
"ui:check": "node scripts/check-ui.mjs && node scripts/check-canvas.mjs",
|
|
37
37
|
"test": "node --test",
|
|
38
38
|
"package:check": "node scripts/check-package.mjs",
|
|
39
39
|
"release:check": "npm test && npm run ui:check && npm run package:check",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"soak": "node scripts/soak-sync.mjs"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
+
"@clack/prompts": "^1.8.1",
|
|
48
49
|
"@napi-rs/canvas": "^1.0.13",
|
|
49
50
|
"fflate": "^0.8.3",
|
|
50
51
|
"htmlparser2": "12.0.0",
|
package/src/app-runner.mjs
CHANGED
|
@@ -1,35 +1,58 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
2
|
-
import { createUiServer } from './ui-server.mjs';
|
|
3
|
-
import { sync } from './connector.mjs';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createUiServer } from './ui-server.mjs';
|
|
3
|
+
import { sync } from './connector.mjs';
|
|
4
|
+
import { log as terminalLog, spinner } from '@clack/prompts';
|
|
5
|
+
|
|
6
|
+
function openBrowser(url) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
|
|
9
|
+
`Start-Process -FilePath '${url}' -WindowStyle Hidden`], { windowsHide: true, timeout: 15_000 },
|
|
10
|
+
error => error ? reject(error) : resolve());
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function runApp(connector, { onUpdate, server,
|
|
15
|
+
open = openBrowser, signals = process, port = 4173, log = terminalLog.info } = {}) {
|
|
16
|
+
const controller = new AbortController();
|
|
17
|
+
server ??= createUiServer({ connector, shutdownSignal: controller.signal });
|
|
18
|
+
const progress = process.stdout.isTTY ? spinner({ signal: controller.signal }) : null;
|
|
19
|
+
let firstPass = true;
|
|
20
|
+
const stop = () => controller.abort();
|
|
21
|
+
signals.on('SIGINT', stop);
|
|
22
|
+
signals.on('SIGTERM', stop);
|
|
23
|
+
try {
|
|
24
|
+
await new Promise((resolve, reject) => {
|
|
25
|
+
server.once('error', reject);
|
|
26
|
+
server.listen(port, '127.0.0.1', resolve);
|
|
27
|
+
});
|
|
28
|
+
const url = `http://127.0.0.1:${server.address().port}`;
|
|
29
|
+
log(`Your workspace → ${url}\nKeep this terminal open; Ctrl+C stops the app and sync.`);
|
|
30
|
+
try { await open(url); }
|
|
31
|
+
catch { log(`Could not open your browser. Open ${url} manually.`); }
|
|
32
|
+
log('Collecting up to 5 courses per pass, every 15 minutes.\nThe app may be empty until the first batch is ready.');
|
|
33
|
+
progress?.start('Collecting your first batch of coursework');
|
|
34
|
+
await sync(connector, { all: true }, { signal: controller.signal, onUpdate: async summary => {
|
|
35
|
+
await onUpdate?.(summary);
|
|
36
|
+
const message = summary.state === 'retry_wait'
|
|
37
|
+
? 'Collection delayed · will retry automatically'
|
|
38
|
+
: 'Coursework saved · reopen Courses or search to see updates';
|
|
39
|
+
if (progress && firstPass) {
|
|
40
|
+
if (summary.state === 'retry_wait') { progress.clear(); terminalLog.warn(message); }
|
|
41
|
+
else progress.stop(message);
|
|
42
|
+
}
|
|
43
|
+
else log(message);
|
|
44
|
+
if (firstPass && summary.state !== 'retry_wait') {
|
|
45
|
+
log('Local search is ready. Connect ChatGPT in Settings for AI answers.');
|
|
46
|
+
}
|
|
47
|
+
firstPass = false;
|
|
48
|
+
} });
|
|
49
|
+
} catch (error) {
|
|
50
|
+
progress?.error('Collection stopped');
|
|
51
|
+
throw error;
|
|
52
|
+
} finally {
|
|
53
|
+
progress?.clear();
|
|
54
|
+
signals.off('SIGINT', stop);
|
|
55
|
+
signals.off('SIGTERM', stop);
|
|
56
|
+
await new Promise(resolve => { server.close(resolve); server.closeAllConnections(); });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -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
|
+
}
|