@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.
- package/README.md +19 -2
- package/astro/assignment-player.ts +231 -0
- package/astro/client.ts +24 -1
- package/astro/components/AssignmentFeedback.astro +32 -0
- package/astro/components/AssignmentRubric.astro +27 -0
- package/astro/components/LearnerDashboard.astro +3 -0
- package/astro/components/LearningShell.astro +6 -0
- package/astro/components/LessonContent.astro +16 -2
- package/astro/components/LessonPlayer.astro +126 -38
- package/astro/draft-queue.ts +95 -0
- package/astro/media-player.ts +26 -1
- package/astro/pages/author-preview.astro +2 -0
- package/astro/pages/study.astro +180 -0
- package/astro/quiz-drafts.ts +12 -76
- package/astro/study-tools.ts +386 -0
- package/astro/styles.css +18 -0
- package/astro/transcript-search.ts +56 -0
- package/dist/admin.js +172 -12
- package/dist/admin.js.map +1 -1
- package/dist/assignments-CsuAjxGO.js +75 -0
- package/dist/assignments-CsuAjxGO.js.map +1 -0
- package/dist/astro.js +1 -0
- package/dist/astro.js.map +1 -1
- package/dist/{constants-Cnx7k82Z.js → constants-DyQbwNbP.js} +2 -2
- package/dist/{constants-Cnx7k82Z.js.map → constants-DyQbwNbP.js.map} +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/runtime.d.ts +26 -5
- package/dist/runtime.js +307 -16
- package/dist/runtime.js.map +1 -1
- package/dist/schemas.d.ts +69 -3
- package/dist/schemas.js +47 -6
- package/dist/schemas.js.map +1 -1
- package/dist/server.d.ts +9 -2
- package/dist/server.js +6 -3
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +67 -2
- package/package.json +1 -1
- package/dist/errors-ChzJS9al.js +0 -16
- 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-
|
|
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 {
|
|
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
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
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
|
-
|
|
1348
|
-
submission.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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
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);
|