@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 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
- - optionally connect ChatGPT for answers grounded in matching course excerpts
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
- If your school/educational faculty uses D2L Brightspace and is not available during setup, please open an issue in the GitHub repo with some details as to your
20
- provider's Brightspace link and faculty name. Feel free to also create a PR if you have added your own solution to your school not being listed.
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 question and matching course excerpts to OpenAI and uses
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 beside the course selector defaults to **Local search**, which matches keywords; choosing a connected ChatGPT model generates grounded answers when you submit.
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**. Complete registration and plan-use consent on OpenAI's website, then return to dd2l. No API key is needed. The small mode dropdown below the prompt starts on **Local search**, even with a connected account. Choose a model to see the coursework-sharing notice, then press Enter or the send button to ask it. Changing the selection never submits a prompt. Select **Local search** to return to local retrieval; reloading or starting a new search also restores that default. Plan and usage information stays in Settings.
69
-
70
- Each AI request sends the question and up to eight matching excerpts (at most 700 characters each), including titles, course names, retrieval dates and stale-cache labels, to OpenAI. The answer displays those sources with numbered references. The model is instructed to use only this evidence and report gaps; verify generated claims against the excerpts. Keyword retrieval can miss relevant material. No tool execution, Brightspace credentials, complete inventory upload, or persistent conversation history is provided. Each submission with an AI model selected grants permission for that answer; local mode never sends the question to an AI provider. Editing the question or changing modes or courses cancels an in-flight answer.
71
-
72
- AI answers can compose interactive checklists, comparison tables, and grouped source cards in the same matte printed style as the homescreen widgets. The selected model chooses useful sections or plain text for a simple question. The Responses request uses [Structured Outputs](https://developers.openai.com/api/docs/guides/structured-outputs) with a bounded JSON schema; the app validates the result and citation numbers before rendering trusted components. No model-generated HTML, scripts, URLs or styling execute. Invalid layouts fall back to a bounded text summary and the available sources, with a notice; incomplete streams still fail rather than appearing successful. Provider refusals display as text. A model must support the structured-output format; errors do not trigger another model or a second billed request.
73
-
74
- Checklist checks and progress belong to the current answer and reset on a new question. They do not update Brightspace or save to your homescreen. Every generated item or comparison row links to supplied source excerpts; stale cache labels remain visible. Valid structure and citations do not prove factual correctness or complete course coverage. Source retrieval still uses the existing keyword search and its limits.
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
- Improved semantic retrieval, additional model providers, private course-content provider integrations and unattended Windows startup remain later milestones.
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.0",
3
+ "version": "0.1.1",
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",
@@ -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
- function openBrowser(url) {
6
- return new Promise((resolve, reject) => {
7
- execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
8
- `Start-Process -FilePath '${url}' -WindowStyle Hidden`], { windowsHide: true, timeout: 15_000 },
9
- error => error ? reject(error) : resolve());
10
- });
11
- }
12
-
13
- export async function runApp(connector, { onUpdate, server = createUiServer(),
14
- open = openBrowser, signals = process, port = 4173, log = console.log } = {}) {
15
- const controller = new AbortController();
16
- const stop = () => controller.abort();
17
- signals.on('SIGINT', stop);
18
- signals.on('SIGTERM', stop);
19
- try {
20
- await new Promise((resolve, reject) => {
21
- server.once('error', reject);
22
- server.listen(port, '127.0.0.1', resolve);
23
- });
24
- const url = `http://127.0.0.1:${server.address().port}`;
25
- log(`dd2l: ${url}\nKeep this terminal open. Ctrl+C stops the app and sync.`);
26
- log('Collecting coursework now; the first view may be empty. Collection is incremental, up to five courses per pass, every 15 minutes.');
27
- try { await open(url); }
28
- catch { log(`Could not open your browser. Open ${url} manually.`); }
29
- await sync(connector, { all: true }, { signal: controller.signal, onUpdate });
30
- } finally {
31
- signals.off('SIGINT', stop);
32
- signals.off('SIGTERM', stop);
33
- await new Promise(resolve => server.close(resolve));
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
+ }