@anantagnanam/lms 0.2.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 (40) hide show
  1. package/README.md +19 -2
  2. package/astro/assignment-player.ts +231 -0
  3. package/astro/client.ts +24 -1
  4. package/astro/components/AssignmentFeedback.astro +32 -0
  5. package/astro/components/AssignmentRubric.astro +27 -0
  6. package/astro/components/LearnerDashboard.astro +3 -0
  7. package/astro/components/LearningShell.astro +6 -0
  8. package/astro/components/LessonContent.astro +16 -2
  9. package/astro/components/LessonPlayer.astro +126 -38
  10. package/astro/draft-queue.ts +95 -0
  11. package/astro/media-player.ts +26 -1
  12. package/astro/pages/author-preview.astro +2 -0
  13. package/astro/pages/study.astro +180 -0
  14. package/astro/quiz-drafts.ts +12 -76
  15. package/astro/study-tools.ts +386 -0
  16. package/astro/styles.css +18 -0
  17. package/astro/transcript-search.ts +56 -0
  18. package/dist/admin.js +172 -12
  19. package/dist/admin.js.map +1 -1
  20. package/dist/assignments-CsuAjxGO.js +75 -0
  21. package/dist/assignments-CsuAjxGO.js.map +1 -0
  22. package/dist/astro.js +1 -0
  23. package/dist/astro.js.map +1 -1
  24. package/dist/{constants-Cnx7k82Z.js → constants-DyQbwNbP.js} +2 -2
  25. package/dist/{constants-Cnx7k82Z.js.map → constants-DyQbwNbP.js.map} +1 -1
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.js +1 -1
  28. package/dist/runtime.d.ts +26 -5
  29. package/dist/runtime.js +307 -16
  30. package/dist/runtime.js.map +1 -1
  31. package/dist/schemas.d.ts +69 -3
  32. package/dist/schemas.js +47 -6
  33. package/dist/schemas.js.map +1 -1
  34. package/dist/server.d.ts +9 -2
  35. package/dist/server.js +6 -3
  36. package/dist/server.js.map +1 -1
  37. package/dist/types.d.ts +67 -2
  38. package/package.json +1 -1
  39. package/dist/errors-ChzJS9al.js +0 -16
  40. package/dist/errors-ChzJS9al.js.map +0 -1
package/dist/runtime.js CHANGED
@@ -1,7 +1,7 @@
1
- import { a as adminPages, i as PLUGIN_VERSION, n as MAX_DOCUMENT_BYTES, o as storage, r as PLUGIN_ID } from "./constants-Cnx7k82Z.js";
1
+ import { a as adminPages, i as PLUGIN_VERSION, n as MAX_DOCUMENT_BYTES, o as storage, r as PLUGIN_ID } from "./constants-DyQbwNbP.js";
2
2
  import { t as normalizeLmsOptions } from "./config-BX3beTpM.js";
3
- import { assignmentSubmitSchema, catalogSchema, certificateActionSchema, certificateRevokeSchema, cohortSaveSchema, courseDataSchema, courseIdSchema, courseSaveSchema, discussionSchema, enrollmentAdminSchema, enrollmentRequestSchema, gradeSchema, idSchema, keySchema, lessonIdSchema, moderateSchema, noteSchema, pageSchema, pathSaveSchema, playbackSchema, preferencesSchema, quizDraftSchema, quizSubmitSchema, reviewSchema, settingsSchema } from "./schemas.js";
4
- import { n as invariant, t as LmsError } from "./errors-ChzJS9al.js";
3
+ import { assignmentDraftSchema, assignmentSubmitSchema, catalogSchema, certificateActionSchema, certificateRevokeSchema, cohortSaveSchema, courseDataSchema, courseIdSchema, courseSaveSchema, discussionSchema, enrollmentAdminSchema, enrollmentRequestSchema, gradeSchema, idSchema, keySchema, lessonIdSchema, moderateSchema, noteSchema, pageSchema, pathSaveSchema, playbackSchema, preferencesSchema, quizDraftSchema, quizSubmitSchema, reviewSchema, settingsSchema, studyNoteSchema } from "./schemas.js";
4
+ import { a as invariant, i as LmsError, n as assignmentGrade, r as emptyAssignmentDraft, t as assignmentBlock } from "./assignments-CsuAjxGO.js";
5
5
  import { n as enrollmentCsvLines, t as enrollmentCsvHeader } from "./csv-DKq_4OWU.js";
6
6
  import { definePlugin, definePluginRoute, pluginResponse } from "emdash";
7
7
  import { z } from "zod";
@@ -356,9 +356,12 @@ function publicEnrollment(enrollment, now = /* @__PURE__ */ new Date()) {
356
356
  /** Staff need assessment and completion records, not a learner's private study notes. */
357
357
  function staffEnrollment(enrollment, now = /* @__PURE__ */ new Date()) {
358
358
  const view = publicEnrollment(enrollment, now);
359
+ delete view.studyNotes;
359
360
  for (const progress of Object.values(view.progress)) {
360
361
  progress.note = "";
362
+ delete progress.noteRevision;
361
363
  delete progress.playback;
364
+ delete progress.assignmentDraft;
362
365
  }
363
366
  return view;
364
367
  }
@@ -1295,6 +1298,54 @@ var LmsService = class {
1295
1298
  })
1296
1299
  };
1297
1300
  }
1301
+ async assignmentState(actor, courseId, lessonId) {
1302
+ const current = user$1(actor);
1303
+ const course = await this.course(courseId);
1304
+ const enrollment = await this.stores.enrollments.get(enrollmentKey(courseId, current.id));
1305
+ const lesson = await this.lessonAccess(course, lessonId, enrollment);
1306
+ invariant(lesson.kind === "assignment" && lesson.assignment, "NOT_AN_ASSIGNMENT", "This lesson has no assignment.");
1307
+ const progress = enrollment.progress[lessonId] ?? emptyProgress();
1308
+ const blocked = assignmentBlock(lesson.assignment, progress, this.now());
1309
+ return {
1310
+ draft: progress.assignmentDraft ?? emptyAssignmentDraft(),
1311
+ submission: progress.assignments.at(-1) ?? null,
1312
+ canSubmit: !blocked,
1313
+ closedReason: blocked?.message ?? null
1314
+ };
1315
+ }
1316
+ async saveAssignmentDraft(actor, input) {
1317
+ const current = user$1(actor);
1318
+ const parsed = parse(assignmentDraftSchema, input);
1319
+ const course = await this.course(parsed.courseId);
1320
+ return {
1321
+ saved: true,
1322
+ draftRevision: await change(this.stores.enrollments, enrollmentKey(course.id, current.id), async (stored) => {
1323
+ const lesson = await this.lessonAccess(course, parsed.lessonId, stored);
1324
+ invariant(lesson.kind === "assignment" && lesson.assignment, "NOT_AN_ASSIGNMENT", "This lesson has no assignment.");
1325
+ const next = structuredClone(stored);
1326
+ const progress = next.progress[parsed.lessonId] ?? emptyProgress();
1327
+ const draft = progress.assignmentDraft ?? emptyAssignmentDraft();
1328
+ const blocked = assignmentBlock(lesson.assignment, progress, this.now());
1329
+ if (blocked) throw new LmsError(blocked.code, blocked.message, blocked.status);
1330
+ if (draft.revision === parsed.expectedDraftRevision + 1 && draft.body === parsed.body && draft.attachmentUrl === parsed.attachmentUrl) return {
1331
+ value: next,
1332
+ result: draft.revision
1333
+ };
1334
+ invariant(draft.revision === parsed.expectedDraftRevision, "DRAFT_CONFLICT", "Your assignment draft changed in another request. Review it before saving.", 409);
1335
+ progress.assignmentDraft = {
1336
+ body: parsed.body,
1337
+ attachmentUrl: parsed.attachmentUrl,
1338
+ revision: draft.revision + 1,
1339
+ updatedAt: this.time()
1340
+ };
1341
+ next.progress[parsed.lessonId] = progress;
1342
+ return {
1343
+ value: next,
1344
+ result: progress.assignmentDraft.revision
1345
+ };
1346
+ })
1347
+ };
1348
+ }
1298
1349
  async submitAssignment(actor, input) {
1299
1350
  const current = user$1(actor);
1300
1351
  const parsed = parse(assignmentSubmitSchema, input);
@@ -1302,16 +1353,23 @@ var LmsService = class {
1302
1353
  const enrollment = await change(this.stores.enrollments, enrollmentKey(course.id, current.id), async (stored) => {
1303
1354
  const lesson = await this.lessonAccess(course, parsed.lessonId, stored);
1304
1355
  invariant(lesson.kind === "assignment" && lesson.assignment, "NOT_AN_ASSIGNMENT", "This lesson has no assignment.");
1305
- invariant(!lesson.assignment.dueAt || this.now().getTime() <= Date.parse(lesson.assignment.dueAt), "ASSIGNMENT_CLOSED", "The assignment deadline has passed.", 403);
1306
1356
  const next = structuredClone(stored);
1307
1357
  const progress = next.progress[parsed.lessonId] ?? emptyProgress();
1308
- const last = progress.assignments.at(-1);
1309
- invariant(!progress.completedAt, "ASSIGNMENT_PASSED", "This assignment has already been passed.", 409);
1310
- invariant(!last || last.status === "graded" && lesson.assignment.allowResubmission, "SUBMISSION_PENDING", "Wait for grading before submitting again.", 409);
1311
- invariant(progress.assignments.length < 10, "SUBMISSION_LIMIT", "The assignment submission limit has been reached.", 403);
1358
+ const previous = parsed.submissionId && progress.assignments.find((item) => item.id === parsed.submissionId);
1359
+ if (previous) {
1360
+ invariant(previous.body === parsed.body && previous.attachmentUrl === parsed.attachmentUrl, "SUBMISSION_CONFLICT", "This submission ID already belongs to different work.", 409);
1361
+ return {
1362
+ value: next,
1363
+ result: next
1364
+ };
1365
+ }
1366
+ const blocked = assignmentBlock(lesson.assignment, progress, this.now());
1367
+ if (blocked) throw new LmsError(blocked.code, blocked.message, blocked.status);
1368
+ const draft = progress.assignmentDraft ?? emptyAssignmentDraft();
1369
+ invariant(parsed.expectedDraftRevision === void 0 ? !draft.body && !draft.attachmentUrl : draft.revision === parsed.expectedDraftRevision, "DRAFT_CONFLICT", "Your assignment draft changed. Review it before submitting.", 409);
1312
1370
  progress.startedAt ??= this.time();
1313
1371
  progress.assignments.push({
1314
- id: crypto.randomUUID(),
1372
+ id: parsed.submissionId ?? crypto.randomUUID(),
1315
1373
  body: parsed.body,
1316
1374
  attachmentUrl: parsed.attachmentUrl,
1317
1375
  submittedAt: this.time(),
@@ -1321,8 +1379,16 @@ var LmsService = class {
1321
1379
  points: null,
1322
1380
  feedback: "",
1323
1381
  gradedAt: null,
1324
- gradedBy: null
1382
+ gradedBy: null,
1383
+ rubric: structuredClone(lesson.assignment.rubric ?? []),
1384
+ rubricScores: []
1325
1385
  });
1386
+ progress.assignmentDraft = {
1387
+ body: "",
1388
+ attachmentUrl: null,
1389
+ revision: draft.revision + 1,
1390
+ updatedAt: this.time()
1391
+ };
1326
1392
  next.progress[parsed.lessonId] = progress;
1327
1393
  next.lastActivityAt = this.time();
1328
1394
  return {
@@ -1344,13 +1410,14 @@ var LmsService = class {
1344
1410
  const submission = progress?.assignments.find((item) => item.id === parsed.submissionId);
1345
1411
  invariant(submission && progress, "NOT_FOUND", "Assignment submission not found.", 404);
1346
1412
  invariant(submission.status === "submitted", "ALREADY_GRADED", "This submission has already been graded.", 409);
1347
- invariant(parsed.points <= submission.maxPoints, "INVALID_GRADE", "The grade cannot exceed the assignment's maximum points.");
1348
- submission.points = parsed.points;
1413
+ const grade = assignmentGrade(submission, parsed.points, parsed.rubricScores);
1414
+ submission.points = grade.points;
1415
+ submission.rubricScores = grade.rubricScores;
1349
1416
  submission.feedback = parsed.feedback;
1350
1417
  submission.status = "graded";
1351
1418
  submission.gradedAt = this.time();
1352
1419
  submission.gradedBy = current.id;
1353
- if (parsed.points >= submission.passingPoints) progress.completedAt ??= this.time();
1420
+ if (grade.points >= submission.passingPoints) progress.completedAt ??= this.time();
1354
1421
  const access = effectiveAccess(await this.accessEnrollment(next), this.now());
1355
1422
  if (["active", "completed"].includes(next.status) && ![
1356
1423
  "suspended",
@@ -1366,7 +1433,8 @@ var LmsService = class {
1366
1433
  await this.notify(enrollment, `graded-${parsed.submissionId}`, "Assignment graded", "Your assignment feedback is ready in My Learning.");
1367
1434
  await this.audit(current, "assignment.grade", enrollment.id, {
1368
1435
  lessonId: parsed.lessonId,
1369
- points: parsed.points
1436
+ submissionId: parsed.submissionId,
1437
+ points: enrollment.progress[parsed.lessonId].assignments.find((item) => item.id === parsed.submissionId).points
1370
1438
  });
1371
1439
  return staffEnrollment(await this.accessEnrollment(enrollment), this.now());
1372
1440
  }
@@ -1374,17 +1442,28 @@ var LmsService = class {
1374
1442
  const current = user$1(actor);
1375
1443
  const parsed = parse(noteSchema, input);
1376
1444
  const course = await this.course(parsed.courseId);
1377
- return { note: (await change(this.stores.enrollments, enrollmentKey(course.id, current.id), async (stored) => {
1445
+ const enrollment = await change(this.stores.enrollments, enrollmentKey(course.id, current.id), async (stored) => {
1378
1446
  await this.lessonAccess(course, parsed.lessonId, stored);
1379
1447
  const next = structuredClone(stored);
1380
1448
  const progress = next.progress[parsed.lessonId] ?? emptyProgress();
1449
+ const revision = progress.noteRevision ?? 0;
1450
+ if (revision === parsed.expectedRevision + 1 && progress.note === parsed.note) return {
1451
+ value: next,
1452
+ result: next
1453
+ };
1454
+ invariant(revision === parsed.expectedRevision, "NOTE_CONFLICT", "Your lesson notes changed in another tab. Copy your work before reloading to compare it with the saved notes.", 409);
1381
1455
  progress.note = parsed.note;
1456
+ progress.noteRevision = revision + 1;
1382
1457
  next.progress[parsed.lessonId] = progress;
1383
1458
  return {
1384
1459
  value: next,
1385
1460
  result: next
1386
1461
  };
1387
- })).progress[parsed.lessonId].note };
1462
+ });
1463
+ return {
1464
+ note: enrollment.progress[parsed.lessonId].note,
1465
+ revision: enrollment.progress[parsed.lessonId].noteRevision ?? 0
1466
+ };
1388
1467
  }
1389
1468
  async certificate(actor, courseId) {
1390
1469
  const current = user$1(actor);
@@ -2411,6 +2490,191 @@ var LearningFeatures = class {
2411
2490
  }
2412
2491
  };
2413
2492
 
2493
+ //#endregion
2494
+ //#region src/study.ts
2495
+ function owner(actor) {
2496
+ invariant(actor, "UNAUTHORIZED", "Sign in to continue.", 401);
2497
+ return actor;
2498
+ }
2499
+ /** Personal study data is retained separately from access and completion decisions. */
2500
+ var StudyTools = class {
2501
+ constructor(service) {
2502
+ this.service = service;
2503
+ }
2504
+ async courses(actor, input) {
2505
+ const current = owner(actor);
2506
+ const page = await queryPage(this.service.stores.enrollments, {
2507
+ ...parse(pageSchema, input),
2508
+ where: { userId: current.id },
2509
+ orderBy: { enrolledAt: "desc" }
2510
+ });
2511
+ return {
2512
+ ...page,
2513
+ items: await Promise.all(page.items.map(async (enrollment) => {
2514
+ const source = await this.service.stores.courses.get(enrollment.courseId);
2515
+ const course = source ? await this.service.editions.learning(source, enrollment) : null;
2516
+ return {
2517
+ courseId: enrollment.courseId,
2518
+ title: course?.title ?? "Unavailable course",
2519
+ slug: course?.slug ?? null
2520
+ };
2521
+ }))
2522
+ };
2523
+ }
2524
+ async notebook(actor, input) {
2525
+ const current = owner(actor);
2526
+ const { courseId } = parse(courseIdSchema, input);
2527
+ const stored = await this.service.stores.enrollments.get(enrollmentKey(courseId, current.id));
2528
+ invariant(stored, "NOT_FOUND", "Your course notebook was not found.", 404);
2529
+ const source = await this.service.stores.courses.get(courseId);
2530
+ const course = source ? await this.service.editions.learning(source, stored) : null;
2531
+ const resolved = await this.service.accessEnrollment(stored);
2532
+ const all = course ? lessons(course) : [];
2533
+ return {
2534
+ course: {
2535
+ id: courseId,
2536
+ title: course?.title ?? "Unavailable course",
2537
+ slug: course?.slug ?? ""
2538
+ },
2539
+ notes: stored.studyNotes ?? [],
2540
+ summaries: Object.entries(stored.progress).flatMap(([lessonId, progress]) => progress.note ? [{
2541
+ lessonId,
2542
+ lessonTitle: all.find((lesson) => lesson.id === lessonId)?.title ?? "Lesson",
2543
+ body: progress.note
2544
+ }] : []),
2545
+ availableLessonIds: course ? all.filter((lesson) => lessonAvailability(course, lesson.id, resolved, this.service.now()).available).map((lesson) => lesson.id) : []
2546
+ };
2547
+ }
2548
+ async saveNote(actor, input) {
2549
+ const current = owner(actor);
2550
+ const parsed = parse(studyNoteSchema, input);
2551
+ const course = await this.service.course(parsed.courseId);
2552
+ return change(this.service.stores.enrollments, enrollmentKey(course.id, current.id), async (stored) => {
2553
+ const lesson = await this.service.lessonAccess(course, parsed.lessonId, stored);
2554
+ invariant(parsed.positionSeconds === null || ["video", "audio"].includes(lesson.kind), "INVALID_TIMESTAMP", "Timestamps belong to audio or video lessons.");
2555
+ const next = structuredClone(stored);
2556
+ const notes = next.studyNotes ??= [];
2557
+ const existing = notes.find((note) => note.id === parsed.id);
2558
+ invariant(!existing || existing.lessonId === lesson.id, "NOTE_CONFLICT", "This note belongs to another lesson.", 409);
2559
+ const same = existing && existing.body === parsed.body && existing.positionSeconds === parsed.positionSeconds && existing.starred === parsed.starred && existing.archived === parsed.archived;
2560
+ if (existing && same && existing.revision === parsed.expectedRevision + 1) return {
2561
+ value: next,
2562
+ result: existing
2563
+ };
2564
+ invariant((existing?.revision ?? 0) === parsed.expectedRevision, "NOTE_CONFLICT", "This note changed in another tab. Your edits are still here; reload the saved note to compare.", 409);
2565
+ invariant(existing || notes.length < 100, "NOTE_LIMIT", "This course notebook has reached its limit of 100 notes, including archived notes. Edit an existing note instead.", 409);
2566
+ const note = {
2567
+ id: parsed.id,
2568
+ lessonId: lesson.id,
2569
+ lessonTitle: existing?.lessonTitle ?? lesson.title,
2570
+ body: parsed.body,
2571
+ positionSeconds: parsed.positionSeconds,
2572
+ starred: parsed.starred,
2573
+ archived: parsed.archived,
2574
+ revision: (existing?.revision ?? 0) + 1,
2575
+ createdAt: existing?.createdAt ?? this.service.time(),
2576
+ updatedAt: this.service.time()
2577
+ };
2578
+ if (existing) notes[notes.indexOf(existing)] = note;
2579
+ else notes.push(note);
2580
+ invariant(new TextEncoder().encode(JSON.stringify(notes)).byteLength <= 15e4, "NOTE_LIMIT", "This course notebook has reached its storage limit. Shorten an existing note to make room; archived notes still count.", 409);
2581
+ return {
2582
+ value: next,
2583
+ result: note
2584
+ };
2585
+ });
2586
+ }
2587
+ async agenda(actor, input) {
2588
+ const current = owner(actor);
2589
+ const { courseId } = parse(courseIdSchema, input);
2590
+ const source = await this.service.course(courseId);
2591
+ const stored = await this.service.stores.enrollments.get(enrollmentKey(courseId, current.id));
2592
+ const enrollment = await this.service.accessEnrollment(stored);
2593
+ assertEnrollmentAccess(enrollment, this.service.now());
2594
+ const course = await this.service.editions.learning(source, enrollment);
2595
+ const now = this.service.now();
2596
+ const events = [];
2597
+ for (const lesson of lessons(course)) {
2598
+ const access = lessonAvailability(course, lesson.id, enrollment, now);
2599
+ const progress = enrollment.progress[lesson.id];
2600
+ const append = (kind, startsAt, endsAt) => {
2601
+ const past = Date.parse(startsAt) < now.getTime();
2602
+ events.push({
2603
+ id: `${course.id}-${lesson.id}-${kind}`,
2604
+ kind,
2605
+ lessonId: lesson.id,
2606
+ title: lesson.title,
2607
+ startsAt,
2608
+ endsAt,
2609
+ available: access.available,
2610
+ reason: access.reason,
2611
+ status: progress?.completedAt ? "completed" : kind === "assignment" && progress?.assignments.at(-1)?.status === "submitted" ? "submitted" : past ? kind === "assignment" ? "overdue" : "past" : "upcoming"
2612
+ });
2613
+ };
2614
+ if (lesson.assignment?.dueAt) append("assignment", lesson.assignment.dueAt, null);
2615
+ if (lesson.kind === "live" && lesson.liveStartsAt) append("live", lesson.liveStartsAt, new Date(Date.parse(lesson.liveStartsAt) + Math.max(1, lesson.durationMinutes) * 6e4).toISOString());
2616
+ if (access.code === "LESSON_LOCKED" && access.releaseAt) append("release", access.releaseAt, null);
2617
+ }
2618
+ events.sort((a, b) => Date.parse(a.startsAt) - Date.parse(b.startsAt) || a.id.localeCompare(b.id));
2619
+ return {
2620
+ course: {
2621
+ id: course.id,
2622
+ title: course.title,
2623
+ slug: course.slug
2624
+ },
2625
+ generatedAt: now.toISOString(),
2626
+ events
2627
+ };
2628
+ }
2629
+ };
2630
+
2631
+ //#endregion
2632
+ //#region src/calendar.ts
2633
+ const text = (value) => value.replace(/\r\n|\r/g, "\n").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/;/g, "\\;").replace(/,/g, "\\,");
2634
+ const date = (value) => new Date(value).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
2635
+ /** RFC 5545 folding counts UTF-8 octets, including the continuation space. */
2636
+ function foldCalendarLine(value) {
2637
+ const encoder = new TextEncoder();
2638
+ let line = "";
2639
+ let bytes = 0;
2640
+ const lines = [];
2641
+ for (const char of value) {
2642
+ const size = encoder.encode(char).byteLength;
2643
+ if (bytes + size > 75) {
2644
+ lines.push(line);
2645
+ line = " ";
2646
+ bytes = 1;
2647
+ }
2648
+ line += char;
2649
+ bytes += size;
2650
+ }
2651
+ lines.push(line);
2652
+ return lines.join("\r\n");
2653
+ }
2654
+ /** A private downloadable snapshot: no subscription token, meeting URL or alarm. */
2655
+ function courseCalendar(agenda, origin, basePath) {
2656
+ const lines = [
2657
+ "BEGIN:VCALENDAR",
2658
+ "VERSION:2.0",
2659
+ "PRODID:-//Ananta Gnanam//LMS//EN",
2660
+ "CALSCALE:GREGORIAN",
2661
+ "METHOD:PUBLISH"
2662
+ ];
2663
+ for (const event of agenda.events.filter((item) => item.status === "upcoming")) {
2664
+ const kind = {
2665
+ assignment: "Assignment due",
2666
+ live: "Live session",
2667
+ release: "Lesson release"
2668
+ }[event.kind];
2669
+ const url = new URL(`${basePath}/courses/${encodeURIComponent(agenda.course.slug)}/lessons/${encodeURIComponent(event.lessonId)}`, origin).href;
2670
+ lines.push("BEGIN:VEVENT", `UID:${event.id}@anantagnanam-lms`, `DTSTAMP:${date(agenda.generatedAt)}`, `DTSTART:${date(event.startsAt)}`);
2671
+ if (event.endsAt) lines.push(`DTEND:${date(event.endsAt)}`);
2672
+ lines.push(`SUMMARY:${text(`${kind}: ${event.title}`)}`, `DESCRIPTION:${text(`${agenda.course.title}. Check the course for current access and requirements. This downloaded calendar does not update automatically.`)}`, `URL:${url}`, "CLASS:PRIVATE", `STATUS:${event.available ? "CONFIRMED" : "TENTATIVE"}`, "END:VEVENT");
2673
+ }
2674
+ lines.push("END:VCALENDAR");
2675
+ return lines.map(foldCalendarLine).join("\r\n") + "\r\n";
2676
+ }
2677
+
2414
2678
  //#endregion
2415
2679
  //#region src/runtime.ts
2416
2680
  const listSchema = pageSchema.extend({
@@ -2513,6 +2777,28 @@ function createPlugin(_options = {}) {
2513
2777
  }
2514
2778
  },
2515
2779
  routes: {
2780
+ "learner/study/courses": get((ctx, service) => new StudyTools(service).courses(ctx.user, ctx.input)),
2781
+ "learner/notebook": get((ctx, service) => new StudyTools(service).notebook(ctx.user, ctx.input)),
2782
+ "learner/notebook/save": post((ctx, service) => new StudyTools(service).saveNote(ctx.user, ctx.input)),
2783
+ "learner/agenda": get((ctx, service) => new StudyTools(service).agenda(ctx.user, ctx.input)),
2784
+ "learner/agenda/calendar": definePluginRoute({
2785
+ methods: ["GET"],
2786
+ permission: "content:read",
2787
+ request: { body: "none" },
2788
+ response: "raw",
2789
+ handler: async (ctx) => {
2790
+ return pluginResponse({
2791
+ body: {
2792
+ kind: "text",
2793
+ value: courseCalendar(await new StudyTools(new LmsService(ctx)).agenda(ctx.user, ctx.input), new URL(ctx.request.url).origin, options.basePath)
2794
+ },
2795
+ headers: {
2796
+ "Content-Type": "text/calendar; charset=utf-8",
2797
+ "Content-Disposition": "attachment; filename=\"lms-course-agenda.ics\""
2798
+ }
2799
+ });
2800
+ }
2801
+ }),
2516
2802
  paths: get(async (ctx, service) => new LearningFeatures(service).paths(ctx.input), true),
2517
2803
  "learner/paths": get(async (ctx, service) => new LearningFeatures(service).learnerPaths(ctx.user, ctx.input)),
2518
2804
  "learner/path/join": post(async (ctx, service) => new LearningFeatures(service).joinPath(ctx.user, ctx.input)),
@@ -2573,6 +2859,11 @@ function createPlugin(_options = {}) {
2573
2859
  "learner/quiz/submit": post(async (ctx, service) => service.submitQuiz(ctx.user, ctx.input)),
2574
2860
  "learner/quiz/draft": post(async (ctx, service) => service.saveQuizDraft(ctx.user, ctx.input)),
2575
2861
  "learner/assignment": post(async (ctx, service) => service.submitAssignment(ctx.user, ctx.input)),
2862
+ "learner/assignment/state": get(async (ctx, service) => {
2863
+ const input = parse(lessonIdSchema, ctx.input);
2864
+ return service.assignmentState(ctx.user, input.courseId, input.lessonId);
2865
+ }),
2866
+ "learner/assignment/draft": post(async (ctx, service) => service.saveAssignmentDraft(ctx.user, ctx.input)),
2576
2867
  "learner/note": post(async (ctx, service) => service.saveNote(ctx.user, ctx.input)),
2577
2868
  "learner/discussions": get(async (ctx, service) => {
2578
2869
  const input = parse(lessonIdSchema.extend({ cursor: z.string().optional() }), ctx.input);