@anantagnanam/lms 0.1.0 → 0.3.0

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.
Files changed (51) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +40 -2
  3. package/astro/assignment-player.ts +231 -0
  4. package/astro/client.ts +97 -152
  5. package/astro/components/AssignmentFeedback.astro +32 -0
  6. package/astro/components/AssignmentRubric.astro +27 -0
  7. package/astro/components/CourseOverview.astro +25 -3
  8. package/astro/components/LearnerDashboard.astro +9 -0
  9. package/astro/components/LearningShell.astro +16 -1
  10. package/astro/components/LessonAvailabilityNote.astro +26 -0
  11. package/astro/components/LessonContent.astro +67 -0
  12. package/astro/components/LessonPlayer.astro +166 -99
  13. package/astro/components/MediaPlayer.astro +55 -4
  14. package/astro/draft-queue.ts +95 -0
  15. package/astro/media-player.ts +124 -0
  16. package/astro/pages/author-preview.astro +73 -0
  17. package/astro/pages/course.astro +10 -0
  18. package/astro/pages/lesson.astro +3 -0
  19. package/astro/pages/library.astro +173 -0
  20. package/astro/pages/paths.astro +73 -0
  21. package/astro/pages/study.astro +180 -0
  22. package/astro/quiz-drafts.ts +23 -0
  23. package/astro/quiz-player.ts +362 -0
  24. package/astro/study-tools.ts +386 -0
  25. package/astro/styles.css +30 -0
  26. package/astro/transcript-search.ts +56 -0
  27. package/dist/admin.d.ts +3 -1
  28. package/dist/admin.js +756 -127
  29. package/dist/admin.js.map +1 -1
  30. package/dist/assignments-CsuAjxGO.js +75 -0
  31. package/dist/assignments-CsuAjxGO.js.map +1 -0
  32. package/dist/astro.js +4 -0
  33. package/dist/astro.js.map +1 -1
  34. package/dist/{constants-C0kT94tc.js → constants-DyQbwNbP.js} +25 -3
  35. package/dist/constants-DyQbwNbP.js.map +1 -0
  36. package/dist/index.d.ts +1 -1
  37. package/dist/index.js +1 -1
  38. package/dist/runtime.d.ts +129 -16
  39. package/dist/runtime.js +1201 -130
  40. package/dist/runtime.js.map +1 -1
  41. package/dist/schemas.d.ts +136 -3
  42. package/dist/schemas.js +80 -7
  43. package/dist/schemas.js.map +1 -1
  44. package/dist/server.d.ts +18 -1
  45. package/dist/server.js +17 -3
  46. package/dist/server.js.map +1 -1
  47. package/dist/types.d.ts +172 -4
  48. package/package.json +1 -1
  49. package/dist/constants-C0kT94tc.js.map +0 -1
  50. package/dist/errors-ChzJS9al.js +0 -16
  51. package/dist/errors-ChzJS9al.js.map +0 -1
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Ananta Gnanam
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 Ananta Gnanam
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
@@ -1,13 +1,15 @@
1
1
  # @anantagnanam/lms
2
2
 
3
- A native LMS for EmDash 1.4 with Astro 7 and React 19. Version 0.1.0 provides free courses, flexible enrollment, cohorts, lessons, quizzes, assignments, progress, certificates, discussions, moderation, reporting, and notifications.
3
+ A native LMS for EmDash 1.4 with Astro 7 and React 19. Version 0.3.0 provides free courses, flexible enrollment, cohorts, lessons, quizzes, assignments, progress, certificates, discussions, moderation, reporting, notifications, published course editions, learning paths, a private library, native media resume, recoverable assignment drafts, rubric grading, private notebooks, course agendas and transcript search.
4
+
5
+ This is a free preview release. Before upgrading from 0.1.0, back up the full host database, rebuild the site, and run **LMS settings → Course edition migration** until complete. Do not downgrade after migration begins. Production database/auth/email verification, backup restoration, accessibility and capacity checks remain operator release gates. Payment processing is deferred.
4
6
 
5
7
  ## Installation
6
8
 
7
9
  Install the public npm release in an existing EmDash site:
8
10
 
9
11
  ```sh
10
- npm install @anantagnanam/lms
12
+ npm install --save-exact @anantagnanam/lms@0.3.0
11
13
  ```
12
14
 
13
15
  This is a trusted native dependency configured by the site developer. Local builds can also be installed from their release tarball.
@@ -41,11 +43,37 @@ After rebuilding the site, visit:
41
43
 
42
44
  - `/academy`: course catalog
43
45
  - `/academy/my-learning`: learner dashboard
46
+ - `/academy/my-learning/library`: saved courses, joined path progress, preferences and own-data downloads
47
+ - `/academy/my-learning/study`: private course notebooks and agendas
48
+ - `/academy/paths`: published learning paths
44
49
  - `/academy/teach`: instructor course studio
50
+ - `/academy/teach/courses/<courseId>/lessons/<lessonId>`: private saved author preview
45
51
  - `/_emdash/admin/plugins/anantagnanam-lms`: administration
46
52
 
47
53
  The native plugin declares course, enrollment, cohort, certificate, discussion, review, notification, and audit storage. EmDash creates the plugin storage namespace. No LMS SQL migration or separate authentication store is needed.
48
54
 
55
+ The lesson editor identifies inline text, imported rich content, or linked published CMS content. Imported rich content is read-only in the studio; linked lessons can be edited and published in EmDash. Author previews show saved content. Quiz drafts expose save/retry/conflict state and use revisions to prevent stale overwrites. Older attempts are revision zero; integrations must send `expectedDraftRevision` after each acknowledged draft save.
56
+
57
+ Existing courses now have separate saved drafts and published editions. Save a draft, then use **Publish saved draft** to publish for new enrollments. **Saved revision history** can restore earlier material into a draft. Existing learners retain their original curriculum, grading rules, progress and certificates. Linked CMS lesson text is captured at course publication; media URLs remain references. Instructor assignments take effect when saved.
58
+
59
+ After upgrading, run **LMS settings → Course edition migration** batches until complete. Checkpoints survive interruption; courses also upgrade lazily before editing or enrollment. Keep a full database backup: course exports contain only draft curriculum, and this migration is not a full backup or cross-site import. Publication scheduling, editorial review and moving existing enrollments between editions remain future work.
60
+
61
+ ## Assignment workflow enhancements
62
+
63
+ Version 0.3.0 adds private assignment autosave/recovery, explicit conflict review, safe submission retries, optional grading rubrics, and feedback history across resubmissions. Rubrics support up to 20 criteria and 1000 total points; criteria and passing rules are frozen with each submission from the learner's enrolled course edition. Staff score each criterion and the server totals the grade. Existing submissions keep scalar grading.
64
+
65
+ Saved drafts are excluded from staff APIs and included in the learner's own data export. The browser must stay open for unacknowledged work; offline drafts, private file uploads, late policies and audited regrading are not included. Custom clients should send `expectedDraftRevision` for draft writes/submission and a stable `submissionId` for retryable submission. See the repository API and operator guides for recovery semantics.
66
+
67
+ ## Study tools
68
+
69
+ Version 0.3.0 adds searchable course notebooks with multiple notes, stars, archive/restore, and playback bookmarks. Native audio/video can capture the current time and jump back from a saved note without autoplay. Transcript search highlights plain-text matches with previous/next controls. Embedded providers retain their own playback behavior.
70
+
71
+ Notebooks preserve existing lesson summaries, stay private from staff, and remain readable in the notebook and learner export after access expires. Creating, editing, archiving and restoring require current lesson access. Each course supports up to 100 notes of 5000 characters, with a 150,000-byte serialized notebook budget; archived entries count toward both limits. Saves require the last acknowledged revision and preserve local text after conflicts. Keep the page open until a save is confirmed; no offline note store is included.
72
+
73
+ The course agenda lists assignment deadlines, live sessions and future lesson releases from the enrolled edition, displays the selected timezone and downloads upcoming events as `.ics`. This is a private snapshot, not a subscription or automatic reminder. It contains authenticated lesson links, not meeting credentials. Calendar apps retain downloaded data independently of later course access changes.
74
+
75
+ Custom clients must send `expectedRevision` to the existing `learner/note` route; legacy summaries begin at revision zero. New notebook clients use stable note IDs and retry identical unacknowledged requests. See the API guide for the full contract.
76
+
49
77
  ## Enrollment and access
50
78
 
51
79
  `open` courses enroll learners immediately. `approval` courses create pending requests. `invite` courses require administrator enrollment. Cohort membership always requires an administrator decision. Administrators can reject, suspend, restore, cancel, and date access without deleting progress.
@@ -75,4 +103,14 @@ Private HTTP calls use an EmDash session and `X-EmDash-Request: 1`. API routes b
75
103
 
76
104
  Notifications always appear in the learner dashboard. Email is optional and disabled by default. Configure an EmDash email transport and working cron scheduler before enabling email or inactivity reminders. Certificates use the browser's print/save-as-PDF flow.
77
105
 
106
+ Learners can opt out of email from their library; this also suppresses queued email when maintenance next runs. Personal weekly lesson goals are stored targets, not measured activity reports. The library exports the learner's own records in pages of up to five records per section, with quiz answer keys and scoring snapshots removed. Continue through every page and section for a complete download; it is not a full database backup.
107
+
108
+ Administrators can publish ordered learning paths. Joining saves the path's original course list but does not enroll the learner in its courses or bypass approval, prerequisites or access dates. Course completion contributes to path progress even when access later expires. Course enrollment still pins the actual curriculum edition.
109
+
110
+ Native audio/video supports resume and playback speed, revision-fenced saves, WebVTT caption tracks and readable transcripts. Captions and media hosted elsewhere require appropriate CORS. YouTube/Vimeo controls and resume remain provider-managed. Playback reports never complete lessons or verify attendance. New caption/transcript fields have compatible defaults; enrollment playback fields are optional for existing records.
111
+
112
+ The studio can duplicate a saved course as a new draft, duplicate lessons and move lessons between modules. Review copied access/date settings before publishing. **LMS settings → Operational health** shows maintenance freshness, notification backlog/failures and migration status. A healthy heartbeat does not certify the deployment.
113
+
114
+ See the [release-readiness assessment](https://github.com/anantagnanam/anantagnanam-emdash-plugins/blob/main/docs/release-readiness.md), [platform feature matrix](https://github.com/anantagnanam/anantagnanam-emdash-plugins/blob/main/docs/platform-feature-matrix.md), and [security policy](https://github.com/anantagnanam/anantagnanam-emdash-plugins/blob/main/SECURITY.md) for remaining release gates and capabilities.
115
+
78
116
  Full documentation: [repository](https://github.com/anantagnanam/anantagnanam-emdash-plugins), [installation](https://github.com/anantagnanam/anantagnanam-emdash-plugins/blob/main/docs/installation.md), [architecture](https://github.com/anantagnanam/anantagnanam-emdash-plugins/blob/main/docs/architecture.md), and [release scope](https://github.com/anantagnanam/anantagnanam-emdash-plugins/blob/main/docs/release-scope.md).
@@ -0,0 +1,231 @@
1
+ import { DraftQueue, type DraftState } from "./draft-queue.js";
2
+ import type { AssignmentState } from "@anantagnanam/lms/types";
3
+
4
+ type Call = <T>(route: string, input: Record<string, unknown>, method?: string) => Promise<T>;
5
+ type Values = { body: string; attachmentUrl: string | null };
6
+
7
+ export function initializeAssignments(call: Call) {
8
+ for (const form of document.querySelectorAll<HTMLFormElement>(
9
+ "form[data-lms-assignment]:not([data-lms-bound])",
10
+ )) {
11
+ form.dataset.lmsBound = "true";
12
+ bindAssignment(form, call);
13
+ }
14
+ }
15
+
16
+ function bindAssignment(form: HTMLFormElement, call: Call) {
17
+ const body = form.elements.namedItem("body") as HTMLTextAreaElement;
18
+ const attachment = form.elements.namedItem("attachmentUrl") as HTMLInputElement;
19
+ const save = form.querySelector<HTMLButtonElement>("[data-lms-save-draft]")!;
20
+ const submit = form.querySelector<HTMLButtonElement>('button[type="submit"]')!;
21
+ const status = form.querySelector<HTMLElement>("[data-lms-feedback]")!;
22
+ const conflict = form.querySelector<HTMLElement>("[data-lms-assignment-conflict]")!;
23
+ const savedText = conflict.querySelector<HTMLElement>("[data-lms-saved-draft]")!;
24
+ const review = form.querySelector<HTMLButtonElement>("[data-lms-review-draft]")!;
25
+ const useSaved = conflict.querySelector<HTMLButtonElement>("[data-lms-use-saved]")!;
26
+ const keepLocal = conflict.querySelector<HTMLButtonElement>("[data-lms-keep-local]")!;
27
+ const input = { courseId: form.dataset.courseId!, lessonId: form.dataset.lessonId! };
28
+ const values = (): Values => ({
29
+ body: body.value,
30
+ attachmentUrl: attachment.value.trim() || null,
31
+ });
32
+ let timer: ReturnType<typeof setTimeout> | undefined;
33
+ let busy = false;
34
+ let closed = false;
35
+ let disposed = false;
36
+ let submitted = false;
37
+ let remote: AssignmentState | undefined;
38
+ let reviewing: Promise<void> | undefined;
39
+ let request:
40
+ (Values & typeof input & { submissionId: string; expectedDraftRevision: number }) | undefined;
41
+ const tell = (text: string, error = false) => {
42
+ status.textContent = text;
43
+ status.setAttribute("role", error ? "alert" : "status");
44
+ };
45
+ const controls = () => {
46
+ body.readOnly = attachment.readOnly = busy || !!request;
47
+ save.disabled = busy || closed || !!request || queue.state === "conflict";
48
+ submit.disabled = busy || closed || queue.state === "conflict";
49
+ review.disabled = busy;
50
+ useSaved.disabled = keepLocal.disabled = busy || closed;
51
+ review.hidden = queue.state !== "conflict";
52
+ };
53
+ const changed = (state: DraftState) => {
54
+ const messages: Record<DraftState, string> = {
55
+ saved: "Draft saved. Only you can see it until submission.",
56
+ pending: "Unsaved changes…",
57
+ saving: "Saving draft…",
58
+ failed: "Draft not saved. Keep this page open and choose Retry save.",
59
+ conflict: "Another request changed this assignment. Review the saved work before continuing.",
60
+ };
61
+ tell(messages[state], state === "failed" || state === "conflict");
62
+ save.textContent = state === "failed" ? "Retry save" : "Save draft";
63
+ controls();
64
+ };
65
+ const queue = new DraftQueue<Values>(
66
+ values(),
67
+ Number(form.dataset.draftRevision ?? 0),
68
+ (write) =>
69
+ call("learner/assignment/draft", {
70
+ ...input,
71
+ ...write.value,
72
+ expectedDraftRevision: write.expectedDraftRevision,
73
+ }),
74
+ changed,
75
+ );
76
+
77
+ const refreshConflict = () =>
78
+ (reviewing ??= loadConflict().finally(() => {
79
+ reviewing = undefined;
80
+ }));
81
+ const loadConflict = async () => {
82
+ busy = true;
83
+ controls();
84
+ try {
85
+ remote = await call<AssignmentState>("learner/assignment/state", input, "GET");
86
+ if (disposed) return;
87
+ conflict.hidden = false;
88
+ closed = !remote.canSubmit;
89
+ savedText.textContent =
90
+ remote.submission && closed
91
+ ? `Submitted work:\n${remote.submission.body}\n${remote.submission.attachmentUrl ?? ""}`
92
+ : `${remote.draft.body || "(Empty saved draft)"}\n${remote.draft.attachmentUrl ?? ""}`;
93
+ if (closed)
94
+ tell(
95
+ `${remote.closedReason} Your local text is still in the form; copy it before leaving.`,
96
+ true,
97
+ );
98
+ else tell("Compare the saved draft below with your work, then choose which to keep.", true);
99
+ } catch (error) {
100
+ if (disposed) return;
101
+ tell(
102
+ `Could not load the saved work. ${error instanceof Error ? error.message : "Try again."}`,
103
+ true,
104
+ );
105
+ } finally {
106
+ busy = false;
107
+ controls();
108
+ }
109
+ };
110
+ const saveDraft = async () => {
111
+ if (busy || closed || request || disposed) return;
112
+ clearTimeout(timer);
113
+ queue.update(values());
114
+ if (!attachment.checkValidity()) {
115
+ tell("Correct the attachment URL before saving.", true);
116
+ return;
117
+ }
118
+ try {
119
+ await queue.flush();
120
+ } catch (error) {
121
+ if (disposed) return;
122
+ if (queue.state === "conflict") await refreshConflict();
123
+ else
124
+ tell(
125
+ `Draft not saved. ${error instanceof Error ? error.message : "Try again."} Use Retry save.`,
126
+ true,
127
+ );
128
+ }
129
+ };
130
+ for (const field of [body, attachment])
131
+ field.addEventListener("input", () => {
132
+ queue.update(values());
133
+ clearTimeout(timer);
134
+ if (!["failed", "conflict"].includes(queue.state))
135
+ timer = setTimeout(() => void saveDraft(), 900);
136
+ });
137
+ save.addEventListener("click", () => void saveDraft());
138
+ review.addEventListener("click", () => void refreshConflict());
139
+ for (const [button, keep] of [
140
+ [useSaved, false],
141
+ [keepLocal, true],
142
+ ] as const)
143
+ button.addEventListener("click", () => {
144
+ if (!remote || busy || closed) return;
145
+ if (!keep) {
146
+ body.value = remote.draft.body;
147
+ attachment.value = remote.draft.attachmentUrl ?? "";
148
+ }
149
+ queue.update(values());
150
+ queue.reconcile(
151
+ { body: remote.draft.body, attachmentUrl: remote.draft.attachmentUrl },
152
+ remote.draft.revision,
153
+ keep,
154
+ );
155
+ conflict.hidden = true;
156
+ remote = undefined;
157
+ if (keep) void saveDraft();
158
+ });
159
+ form.addEventListener("submit", async (event) => {
160
+ event.preventDefault();
161
+ if (busy || closed || queue.state === "conflict") return;
162
+ if (!request && (!form.reportValidity() || !body.value.trim())) {
163
+ tell("Write your assignment before submitting.", true);
164
+ return;
165
+ }
166
+ clearTimeout(timer);
167
+ busy = true;
168
+ controls();
169
+ try {
170
+ if (!request) {
171
+ queue.update(values());
172
+ await queue.flush();
173
+ if (disposed) return;
174
+ request = {
175
+ ...input,
176
+ ...values(),
177
+ submissionId: crypto.randomUUID(),
178
+ expectedDraftRevision: queue.revision,
179
+ };
180
+ }
181
+ tell("Submitting assignment…");
182
+ await call("learner/assignment", request);
183
+ if (disposed) return;
184
+ submitted = true;
185
+ queue.dispose();
186
+ window.location.reload();
187
+ } catch (error) {
188
+ if (disposed) return;
189
+ const failure = error as { code?: string; status?: number };
190
+ if (failure.code === "DRAFT_CONFLICT") {
191
+ request = undefined;
192
+ queue.conflict();
193
+ await refreshConflict();
194
+ } else if (
195
+ !request ||
196
+ (failure.status && failure.status < 500 && failure.status !== 408 && failure.status !== 429)
197
+ ) {
198
+ request = undefined;
199
+ tell(
200
+ error instanceof Error ? error.message : "Could not submit. Your work is kept here.",
201
+ true,
202
+ );
203
+ } else {
204
+ tell(
205
+ "Submission status is unconfirmed. Retry submission to check the same request. Your work is kept here.",
206
+ true,
207
+ );
208
+ submit.textContent = "Retry submission";
209
+ }
210
+ } finally {
211
+ busy = false;
212
+ controls();
213
+ }
214
+ });
215
+ const warn = (event: BeforeUnloadEvent) => {
216
+ if (!submitted && (queue.dirty || busy || request)) {
217
+ event.preventDefault();
218
+ }
219
+ };
220
+ window.addEventListener("beforeunload", warn);
221
+ document.addEventListener(
222
+ "astro:before-swap",
223
+ () => {
224
+ disposed = true;
225
+ clearTimeout(timer);
226
+ queue.dispose();
227
+ window.removeEventListener("beforeunload", warn);
228
+ },
229
+ { once: true },
230
+ );
231
+ }
package/astro/client.ts CHANGED
@@ -1,11 +1,10 @@
1
1
  import { API_BASE } from "@anantagnanam/lms";
2
- import type {
3
- Answers,
4
- Discussion,
5
- Notification,
6
- Page,
7
- PublicQuizAttempt,
8
- } from "@anantagnanam/lms/types";
2
+ import { renderQuiz } from "./quiz-player.js";
3
+ import { initializeMedia } from "./media-player.js";
4
+ import { initializeAssignments } from "./assignment-player.js";
5
+ import { initializeStudyTools, protectStudyWork } from "./study-tools.js";
6
+ import { initializeTranscripts } from "./transcript-search.js";
7
+ import type { Discussion, Notification, Page, PublicQuizAttempt } from "@anantagnanam/lms/types";
9
8
 
10
9
  async function call<T>(route: string, input: Record<string, unknown>, method = "POST"): Promise<T> {
11
10
  let url = `${API_BASE}/${route}`;
@@ -18,6 +17,7 @@ async function call<T>(route: string, input: Record<string, unknown>, method = "
18
17
  } else headers["Content-Type"] = "application/json";
19
18
  const response = await fetch(url, {
20
19
  method,
20
+ signal: AbortSignal.timeout(20_000),
21
21
  credentials: "same-origin",
22
22
  headers,
23
23
  ...(method === "POST" ? { body: JSON.stringify(input) } : {}),
@@ -25,10 +25,13 @@ async function call<T>(route: string, input: Record<string, unknown>, method = "
25
25
  const result = (await response.json()) as {
26
26
  success: boolean;
27
27
  data?: T;
28
- error?: { message?: string };
28
+ error?: { message?: string; code?: string };
29
29
  };
30
30
  if (!response.ok || !result.success)
31
- throw new Error(result.error?.message ?? "Please try again.");
31
+ throw Object.assign(new Error(result.error?.message ?? "Please try again."), {
32
+ code: result.error?.code,
33
+ status: response.status,
34
+ });
32
35
  return result.data as T;
33
36
  }
34
37
  function node<K extends keyof HTMLElementTagNameMap>(
@@ -58,6 +61,16 @@ function forms() {
58
61
  "form[data-lms-route]:not([data-lms-bound])",
59
62
  )) {
60
63
  form.dataset.lmsBound = "true";
64
+ const noteField =
65
+ form.dataset.lmsRoute === "learner/note"
66
+ ? (form.elements.namedItem("note") as HTMLTextAreaElement)
67
+ : null;
68
+ let savedNote = noteField?.value;
69
+ if (noteField)
70
+ protectStudyWork(
71
+ form,
72
+ () => noteField.value !== savedNote || form.dataset.lmsBusy === "true",
73
+ );
61
74
  form.addEventListener("submit", async (event) => {
62
75
  event.preventDefault();
63
76
  if (form.dataset.lmsBusy === "true") return;
@@ -65,6 +78,18 @@ function forms() {
65
78
  const input: Record<string, unknown> = Object.fromEntries(new FormData(form));
66
79
  if ("rating" in input) input.rating = Number(input.rating);
67
80
  if ("shared" in input) input.shared = input.shared === "true";
81
+ if ("saved" in input) input.saved = input.saved === "true";
82
+ if (form.dataset.lmsRoute === "learner/note")
83
+ input.expectedRevision = Number(input.expectedRevision);
84
+ if (form.dataset.lmsRoute === "learner/preferences/save") {
85
+ input.data = {
86
+ emailNotifications: input.emailNotifications === "true",
87
+ weeklyGoalLessons: Number(input.weeklyGoalLessons),
88
+ };
89
+ input.expectedRevision = input.expectedRevision || null;
90
+ delete input.emailNotifications;
91
+ delete input.weeklyGoalLessons;
92
+ }
68
93
  if ("attachmentUrl" in input && input.attachmentUrl === "") input.attachmentUrl = null;
69
94
  if ("parentId" in input && input.parentId === "") input.parentId = null;
70
95
  const buttons = [...form.querySelectorAll<HTMLButtonElement>('button[type="submit"]')];
@@ -73,7 +98,12 @@ function forms() {
73
98
  feedback(form, "Saving…");
74
99
  try {
75
100
  const route = form.dataset.lmsRoute!;
76
- const result = await call<{ status?: string }>(route, input);
101
+ const result = await call<{ status?: string; revision?: number }>(route, input);
102
+ if (route === "learner/note" && result.revision !== undefined) {
103
+ const revision = form.elements.namedItem("expectedRevision") as HTMLInputElement;
104
+ revision.value = String(result.revision);
105
+ savedNote = String(input.note);
106
+ }
77
107
  feedback(
78
108
  form,
79
109
  route === "learner/enroll"
@@ -172,147 +202,6 @@ async function renderDiscussions(root: HTMLElement, cursor?: string, append = fa
172
202
  }
173
203
  }
174
204
 
175
- function readAnswers(form: HTMLFormElement, attempt: PublicQuizAttempt): Answers {
176
- const answers: Answers = {};
177
- const values = new FormData(form);
178
- for (const question of attempt.questions) {
179
- const picked = values.getAll(question.id).map(String);
180
- if (question.type === "multiple") answers[question.id] = picked;
181
- else if (picked[0]) answers[question.id] = picked[0];
182
- }
183
- return answers;
184
- }
185
- function renderQuiz(root: HTMLElement, attempt: PublicQuizAttempt) {
186
- const content = root.querySelector<HTMLElement>("[data-lms-quiz-content]")!;
187
- content.replaceChildren();
188
- const form = node("form");
189
- const timer = node("p", "", "lms-quiz-timer");
190
- timer.setAttribute("aria-live", "off");
191
- form.append(timer);
192
- for (const question of attempt.questions) {
193
- const fieldset = node("fieldset", undefined, "lms-quiz-question");
194
- fieldset.append(node("legend", question.prompt));
195
- if (question.type === "short") {
196
- const input = node("input");
197
- input.name = question.id;
198
- input.type = "text";
199
- input.maxLength = 2000;
200
- input.setAttribute("aria-label", "Your answer");
201
- const saved = attempt.answers?.[question.id];
202
- if (typeof saved === "string") input.value = saved;
203
- fieldset.append(input);
204
- } else
205
- for (const option of question.options) {
206
- const label = node("label", undefined, "lms-quiz-option");
207
- const input = node("input");
208
- input.name = question.id;
209
- input.type = question.type === "multiple" ? "checkbox" : "radio";
210
- input.value = option.id;
211
- const saved = attempt.answers?.[question.id];
212
- input.checked = Array.isArray(saved) ? saved.includes(option.id) : saved === option.id;
213
- label.append(input, node("span", option.text));
214
- fieldset.append(label);
215
- }
216
- form.append(fieldset);
217
- }
218
- const button = node("button", "Submit answers", "lms-btn");
219
- button.type = "submit";
220
- form.append(button);
221
- content.append(form);
222
- let interval: ReturnType<typeof setInterval> | undefined;
223
- let saving: ReturnType<typeof setTimeout> | undefined;
224
- let submitting = false;
225
- const stop = () => {
226
- if (interval) clearInterval(interval);
227
- if (saving) clearTimeout(saving);
228
- };
229
- form.addEventListener("input", () => {
230
- if (saving) clearTimeout(saving);
231
- saving = setTimeout(() => {
232
- if (!submitting && form.isConnected)
233
- void call("learner/quiz/draft", {
234
- ...data(root),
235
- attemptId: attempt.id,
236
- answers: readAnswers(form, attempt),
237
- }).catch(() => {});
238
- }, 700);
239
- });
240
- form.addEventListener("submit", async (event) => {
241
- event.preventDefault();
242
- if (submitting) return;
243
- submitting = true;
244
- button.disabled = true;
245
- stop();
246
- feedback(root, "Checking your answers…");
247
- try {
248
- const result = await call<{ attempt: PublicQuizAttempt }>("learner/quiz/submit", {
249
- ...data(root),
250
- attemptId: attempt.id,
251
- answers: readAnswers(form, attempt),
252
- });
253
- feedback(
254
- root,
255
- `${result.attempt.passed ? "Passed" : "Not passed yet"} · ${result.attempt.percent}%`,
256
- );
257
- const outcome = node("div");
258
- for (const item of result.attempt.feedback) {
259
- const section = node("div", undefined, "lms-notice");
260
- const question = attempt.questions.find((entry) => entry.id === item.questionId);
261
- section.append(
262
- node("strong", `${item.correct ? "✓" : "○"} ${question?.prompt ?? "Question"}`),
263
- );
264
- if (item.explanation) section.append(node("p", item.explanation));
265
- outcome.append(section);
266
- }
267
- content.replaceChildren(outcome);
268
- const start = root.querySelector<HTMLButtonElement>("[data-lms-quiz-start]");
269
- if (start) {
270
- start.hidden = result.attempt.passed;
271
- start.disabled = false;
272
- start.textContent = "Try another attempt";
273
- }
274
- if (result.attempt.passed) {
275
- const continueLink = node("a", "Continue learning →", "lms-btn");
276
- continueLink.href = root.dataset.nextUrl ?? window.location.pathname;
277
- content.append(continueLink);
278
- }
279
- } catch (cause) {
280
- feedback(
281
- root,
282
- cause instanceof Error ? cause.message : "Could not submit your answers.",
283
- true,
284
- );
285
- button.disabled = false;
286
- submitting = false;
287
- const start = root.querySelector<HTMLButtonElement>("[data-lms-quiz-start]");
288
- if (start) {
289
- start.disabled = false;
290
- start.hidden = false;
291
- start.textContent = "Start a new attempt";
292
- }
293
- }
294
- });
295
- if (attempt.expiresAt) {
296
- const tick = () => {
297
- if (!form.isConnected) {
298
- stop();
299
- return;
300
- }
301
- const remaining = Math.max(
302
- 0,
303
- Math.ceil((Date.parse(attempt.expiresAt!) - Date.now()) / 1000),
304
- );
305
- timer.textContent = `Time remaining ${Math.floor(remaining / 60)}:${String(remaining % 60).padStart(2, "0")}`;
306
- if (!remaining) {
307
- stop();
308
- form.requestSubmit();
309
- }
310
- };
311
- interval = setInterval(tick, 1000);
312
- tick();
313
- } else timer.textContent = "Take your time. This quiz is untimed.";
314
- }
315
-
316
205
  function quizzes() {
317
206
  for (const root of document.querySelectorAll<HTMLElement>(
318
207
  "[data-lms-quiz]:not([data-lms-bound])",
@@ -327,7 +216,7 @@ function quizzes() {
327
216
  const attempt = await call<PublicQuizAttempt>("learner/quiz/start", data(root));
328
217
  start.hidden = true;
329
218
  feedback(root, "Your attempt has started.");
330
- renderQuiz(root, attempt);
219
+ renderQuiz(root, attempt, call);
331
220
  } catch (cause) {
332
221
  start.disabled = false;
333
222
  feedback(root, cause instanceof Error ? cause.message : "Could not start quiz.", true);
@@ -380,8 +269,64 @@ async function updates(root: HTMLElement, cursor?: string, append = false) {
380
269
  }
381
270
 
382
271
  function initialize() {
272
+ initializeAssignments(call);
383
273
  forms();
384
274
  quizzes();
275
+ initializeMedia(call);
276
+ initializeStudyTools(call);
277
+ initializeTranscripts();
278
+ for (const root of document.querySelectorAll<HTMLElement>(
279
+ "[data-lms-export]:not([data-lms-bound])",
280
+ )) {
281
+ root.dataset.lmsBound = "true";
282
+ const select = root.querySelector<HTMLSelectElement>("[data-lms-export-section]")!;
283
+ const button = root.querySelector<HTMLButtonElement>("[data-lms-export-download]")!;
284
+ let cursor: string | undefined;
285
+ let pageNumber = 1;
286
+ select.addEventListener("change", () => {
287
+ cursor = undefined;
288
+ pageNumber = 1;
289
+ button.disabled = false;
290
+ button.textContent = "Download first page";
291
+ feedback(root, "");
292
+ });
293
+ button.addEventListener("click", async () => {
294
+ button.disabled = true;
295
+ select.disabled = true;
296
+ try {
297
+ const page = await call<Page<unknown>>(
298
+ "learner/export",
299
+ { section: select.value, cursor, limit: 5 },
300
+ "GET",
301
+ );
302
+ const url = URL.createObjectURL(
303
+ new Blob([JSON.stringify(page, null, 2)], { type: "application/json" }),
304
+ );
305
+ const link = node("a");
306
+ link.href = url;
307
+ link.download = `lms-${select.value}-${pageNumber}.json`;
308
+ document.body.append(link);
309
+ link.click();
310
+ link.remove();
311
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
312
+ cursor = page.cursor;
313
+ pageNumber++;
314
+ button.disabled = !page.hasMore;
315
+ button.textContent = page.hasMore ? "Download next page" : "Section downloaded";
316
+ feedback(
317
+ root,
318
+ page.hasMore
319
+ ? "Page downloaded. Download the next page to continue."
320
+ : "All pages in this section downloaded. Choose another section to continue.",
321
+ );
322
+ } catch (error) {
323
+ button.disabled = false;
324
+ feedback(root, error instanceof Error ? error.message : "Export failed.", true);
325
+ } finally {
326
+ select.disabled = false;
327
+ }
328
+ });
329
+ }
385
330
  for (const root of document.querySelectorAll<HTMLElement>(
386
331
  "[data-lms-lesson]:not([data-lms-started])",
387
332
  )) {