@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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export type DraftState = "saved" | "pending" | "saving" | "failed" | "conflict";
|
|
2
|
+
export interface DraftWrite<T> {
|
|
3
|
+
value: T;
|
|
4
|
+
expectedDraftRevision: number;
|
|
5
|
+
}
|
|
6
|
+
const fingerprint = (value: object) =>
|
|
7
|
+
JSON.stringify(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)));
|
|
8
|
+
|
|
9
|
+
/** Serialize writes and preserve the exact unacknowledged request for retries. */
|
|
10
|
+
export class DraftQueue<T extends object> {
|
|
11
|
+
revision: number;
|
|
12
|
+
state: DraftState = "saved";
|
|
13
|
+
private latest: T;
|
|
14
|
+
private acknowledged: string;
|
|
15
|
+
private retry?: DraftWrite<T>;
|
|
16
|
+
private running?: Promise<void>;
|
|
17
|
+
private disposed = false;
|
|
18
|
+
constructor(
|
|
19
|
+
value: T,
|
|
20
|
+
revision: number,
|
|
21
|
+
private send: (write: DraftWrite<T>) => Promise<{ draftRevision: number }>,
|
|
22
|
+
private changed: (state: DraftState) => void,
|
|
23
|
+
) {
|
|
24
|
+
this.latest = structuredClone(value);
|
|
25
|
+
this.acknowledged = fingerprint(value);
|
|
26
|
+
this.revision = revision;
|
|
27
|
+
}
|
|
28
|
+
get dirty() {
|
|
29
|
+
return !!this.retry || fingerprint(this.latest) !== this.acknowledged;
|
|
30
|
+
}
|
|
31
|
+
private emit(state: DraftState) {
|
|
32
|
+
this.state = state;
|
|
33
|
+
if (!this.disposed) this.changed(state);
|
|
34
|
+
}
|
|
35
|
+
update(value: T) {
|
|
36
|
+
this.latest = structuredClone(value);
|
|
37
|
+
if (!["failed", "conflict", "saving"].includes(this.state))
|
|
38
|
+
this.emit(this.dirty ? "pending" : "saved");
|
|
39
|
+
}
|
|
40
|
+
flush(): Promise<void> {
|
|
41
|
+
if (this.disposed) return Promise.reject(new Error("This form has closed."));
|
|
42
|
+
if (this.state === "conflict")
|
|
43
|
+
return Promise.reject(new Error("Review the saved draft before continuing."));
|
|
44
|
+
if (this.running) return this.running;
|
|
45
|
+
this.running = this.drain().finally(() => {
|
|
46
|
+
this.running = undefined;
|
|
47
|
+
});
|
|
48
|
+
return this.running;
|
|
49
|
+
}
|
|
50
|
+
private async drain() {
|
|
51
|
+
while (this.dirty && !this.disposed) {
|
|
52
|
+
const write = this.retry ?? {
|
|
53
|
+
value: structuredClone(this.latest),
|
|
54
|
+
expectedDraftRevision: this.revision,
|
|
55
|
+
};
|
|
56
|
+
this.retry = write;
|
|
57
|
+
this.emit("saving");
|
|
58
|
+
try {
|
|
59
|
+
const result = await this.send(structuredClone(write));
|
|
60
|
+
this.revision = result.draftRevision;
|
|
61
|
+
this.acknowledged = fingerprint(write.value);
|
|
62
|
+
this.retry = undefined;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
const failure = error as { code?: string; status?: number };
|
|
65
|
+
// A definite rejection did not persist the write. Allow corrected values on retry.
|
|
66
|
+
if (
|
|
67
|
+
failure.status &&
|
|
68
|
+
failure.status < 500 &&
|
|
69
|
+
failure.status !== 408 &&
|
|
70
|
+
failure.status !== 429 &&
|
|
71
|
+
failure.code !== "DRAFT_CONFLICT"
|
|
72
|
+
)
|
|
73
|
+
this.retry = undefined;
|
|
74
|
+
this.emit((error as { code?: string })?.code === "DRAFT_CONFLICT" ? "conflict" : "failed");
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
this.emit(this.dirty ? "pending" : "saved");
|
|
79
|
+
}
|
|
80
|
+
conflict() {
|
|
81
|
+
this.emit("conflict");
|
|
82
|
+
}
|
|
83
|
+
/** Only after the learner has reviewed the current saved draft. */
|
|
84
|
+
reconcile(value: T, revision: number, keepLocal: boolean) {
|
|
85
|
+
if (this.running) throw new Error("Wait for the current save to finish.");
|
|
86
|
+
this.revision = revision;
|
|
87
|
+
this.acknowledged = fingerprint(value);
|
|
88
|
+
this.retry = undefined;
|
|
89
|
+
if (!keepLocal) this.latest = structuredClone(value);
|
|
90
|
+
this.emit(this.dirty ? "pending" : "saved");
|
|
91
|
+
}
|
|
92
|
+
dispose() {
|
|
93
|
+
this.disposed = true;
|
|
94
|
+
}
|
|
95
|
+
}
|
package/astro/media-player.ts
CHANGED
|
@@ -22,8 +22,23 @@ export function initializeMedia(call: Call) {
|
|
|
22
22
|
let busy = false;
|
|
23
23
|
let pending = false;
|
|
24
24
|
let failed = false;
|
|
25
|
+
let disposed = false;
|
|
25
26
|
let lastSave = Date.now();
|
|
26
27
|
const enabled = Boolean(lesson && lesson.dataset.preview !== "true");
|
|
28
|
+
const seekBookmark = () => {
|
|
29
|
+
const raw = new URLSearchParams(window.location.hash.slice(1)).get("t");
|
|
30
|
+
if (!ready || disposed || raw === null || !/^\d+(?:\.\d+)?$/.test(raw)) return;
|
|
31
|
+
const position = Number(raw);
|
|
32
|
+
if (!Number.isFinite(position) || position > 86_400) return;
|
|
33
|
+
try {
|
|
34
|
+
media.currentTime = Number.isFinite(media.duration)
|
|
35
|
+
? Math.min(position, Math.max(0, media.duration - 0.1))
|
|
36
|
+
: position;
|
|
37
|
+
status.textContent = `Bookmark at ${Math.floor(media.currentTime / 60)}:${String(Math.floor(media.currentTime % 60)).padStart(2, "0")}. Press play when ready.`;
|
|
38
|
+
} catch {
|
|
39
|
+
status.textContent = "This media is not seekable yet.";
|
|
40
|
+
}
|
|
41
|
+
};
|
|
27
42
|
button.hidden = !enabled;
|
|
28
43
|
const restore = () => {
|
|
29
44
|
if (ready) return;
|
|
@@ -41,9 +56,10 @@ export function initializeMedia(call: Call) {
|
|
|
41
56
|
speed.value = String(media.playbackRate);
|
|
42
57
|
status.textContent = `Resumed at ${Math.floor(position / 60)}:${String(Math.floor(position % 60)).padStart(2, "0")}.`;
|
|
43
58
|
}
|
|
59
|
+
seekBookmark();
|
|
44
60
|
};
|
|
45
61
|
const save = async () => {
|
|
46
|
-
if (!enabled || !ready || failed || !Number.isFinite(media.currentTime)) return;
|
|
62
|
+
if (disposed || !enabled || !ready || failed || !Number.isFinite(media.currentTime)) return;
|
|
47
63
|
if (busy) {
|
|
48
64
|
pending = true;
|
|
49
65
|
return;
|
|
@@ -95,5 +111,14 @@ export function initializeMedia(call: Call) {
|
|
|
95
111
|
button.addEventListener("click", () => {
|
|
96
112
|
void save();
|
|
97
113
|
});
|
|
114
|
+
window.addEventListener("hashchange", seekBookmark);
|
|
115
|
+
document.addEventListener(
|
|
116
|
+
"astro:before-swap",
|
|
117
|
+
() => {
|
|
118
|
+
disposed = true;
|
|
119
|
+
window.removeEventListener("hashchange", seekBookmark);
|
|
120
|
+
},
|
|
121
|
+
{ once: true },
|
|
122
|
+
);
|
|
98
123
|
}
|
|
99
124
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import Layout from "virtual:anantagnanam-lms/layout";
|
|
3
3
|
import LearningShell from "../components/LearningShell.astro";
|
|
4
4
|
import LessonContent from "../components/LessonContent.astro";
|
|
5
|
+
import AssignmentRubric from "../components/AssignmentRubric.astro";
|
|
5
6
|
import { lmsAuthorLessonPreview, loginUrl, LmsError } from "@anantagnanam/lms/server";
|
|
6
7
|
import { loginPath } from "virtual:anantagnanam-lms/config";
|
|
7
8
|
export const prerender = false;
|
|
@@ -49,6 +50,7 @@ try {
|
|
|
49
50
|
<section class="lms-assessment">
|
|
50
51
|
<h2>Your assignment</h2>
|
|
51
52
|
<p class="lms-text">{result.lesson.assignment.instructions}</p>
|
|
53
|
+
<AssignmentRubric rubric={result.lesson.assignment.rubric} />
|
|
52
54
|
<p>
|
|
53
55
|
{result.lesson.assignment.passingPoints}/{result.lesson.assignment.maxPoints} points
|
|
54
56
|
to pass
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Layout from "virtual:anantagnanam-lms/layout";
|
|
3
|
+
import LearningShell from "../components/LearningShell.astro";
|
|
4
|
+
import { lmsLearner, loginUrl, lessonUrl, LmsError } from "@anantagnanam/lms/server";
|
|
5
|
+
import { basePath, loginPath } from "virtual:anantagnanam-lms/config";
|
|
6
|
+
import type { Notebook, CourseAgenda, Page } from "@anantagnanam/lms/types";
|
|
7
|
+
export const prerender = false;
|
|
8
|
+
Astro.response.headers.set("Cache-Control", "private, no-store");
|
|
9
|
+
Astro.response.headers.set("X-Robots-Tag", "noindex, nofollow");
|
|
10
|
+
if (!Astro.locals.user)
|
|
11
|
+
return Astro.redirect(loginUrl(loginPath, Astro.url.pathname + Astro.url.search));
|
|
12
|
+
let courses: Page<{ courseId: string; title: string; slug: string | null }> = {
|
|
13
|
+
items: [],
|
|
14
|
+
hasMore: false,
|
|
15
|
+
};
|
|
16
|
+
let notebook: Notebook | undefined;
|
|
17
|
+
let agenda: CourseAgenda | undefined;
|
|
18
|
+
let error = "";
|
|
19
|
+
let agendaError = "";
|
|
20
|
+
const courseId = Astro.url.searchParams.get("courseId");
|
|
21
|
+
try {
|
|
22
|
+
courses = await lmsLearner(
|
|
23
|
+
Astro.locals,
|
|
24
|
+
"learner/study/courses",
|
|
25
|
+
{ cursor: Astro.url.searchParams.get("cursor") ?? undefined, limit: 24 },
|
|
26
|
+
Astro.url.origin,
|
|
27
|
+
);
|
|
28
|
+
if (courseId) {
|
|
29
|
+
notebook = await lmsLearner(Astro.locals, "learner/notebook", { courseId }, Astro.url.origin);
|
|
30
|
+
try {
|
|
31
|
+
agenda = await lmsLearner(Astro.locals, "learner/agenda", { courseId }, Astro.url.origin);
|
|
32
|
+
} catch (cause) {
|
|
33
|
+
agendaError = cause instanceof Error ? cause.message : "Course agenda is unavailable.";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
} catch (cause) {
|
|
37
|
+
Astro.response.status = cause instanceof LmsError ? cause.status : 503;
|
|
38
|
+
error = cause instanceof Error ? cause.message : "Study tools are unavailable.";
|
|
39
|
+
}
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
<Layout title="My notebook and agenda" robots="noindex, nofollow">
|
|
43
|
+
<LearningShell>
|
|
44
|
+
<h1>My notebook & agenda</h1>
|
|
45
|
+
<p>Revisit important ideas and plan your next course activity.</p>
|
|
46
|
+
{error && <p role="alert">{error}</p>}
|
|
47
|
+
<details open={!courseId}>
|
|
48
|
+
<summary>Choose an enrolled course</summary>
|
|
49
|
+
<ul>
|
|
50
|
+
{courses.items.map((course) => (
|
|
51
|
+
<li>
|
|
52
|
+
<a
|
|
53
|
+
href={`?courseId=${encodeURIComponent(course.courseId)}`}
|
|
54
|
+
aria-current={course.courseId === courseId ? "page" : undefined}
|
|
55
|
+
>
|
|
56
|
+
{course.title}
|
|
57
|
+
</a>
|
|
58
|
+
</li>
|
|
59
|
+
))}
|
|
60
|
+
</ul>
|
|
61
|
+
{!courses.items.length && !error && <p>No enrolled courses on this page.</p>}
|
|
62
|
+
{courses.hasMore && courses.cursor && (
|
|
63
|
+
<a href={`?cursor=${encodeURIComponent(courses.cursor)}`}>More courses →</a>
|
|
64
|
+
)}
|
|
65
|
+
</details>
|
|
66
|
+
{notebook && (
|
|
67
|
+
<>
|
|
68
|
+
<h2>{notebook.course.title}</h2>
|
|
69
|
+
<section
|
|
70
|
+
class="lms-learning-card"
|
|
71
|
+
data-lms-notebook
|
|
72
|
+
data-notebook={JSON.stringify(notebook)}
|
|
73
|
+
data-base-path={basePath}
|
|
74
|
+
>
|
|
75
|
+
<h3>Private notebook</h3>
|
|
76
|
+
<p>
|
|
77
|
+
Add notes from a lesson. Search this course’s notes, star important ideas, or archive
|
|
78
|
+
and restore entries. Saved lesson summaries are preserved below.
|
|
79
|
+
</p>
|
|
80
|
+
<div class="lms-actions">
|
|
81
|
+
<label>
|
|
82
|
+
Search notes <input type="search" maxlength={200} data-lms-note-search />
|
|
83
|
+
</label>
|
|
84
|
+
<label>
|
|
85
|
+
Show{" "}
|
|
86
|
+
<select data-lms-note-filter>
|
|
87
|
+
<option value="active">Active notes</option>
|
|
88
|
+
<option value="starred">Starred notes</option>
|
|
89
|
+
<option value="archived">Archived notes</option>
|
|
90
|
+
</select>
|
|
91
|
+
</label>
|
|
92
|
+
<button type="button" class="lms-btn lms-btn-outline" data-lms-notebook-refresh>
|
|
93
|
+
Reload saved notes
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
<p data-lms-feedback aria-live="polite"></p>
|
|
97
|
+
<div data-lms-note-list></div>
|
|
98
|
+
<noscript>
|
|
99
|
+
Enable JavaScript to search and edit notebook entries. Your learner data export also
|
|
100
|
+
includes these notes.
|
|
101
|
+
</noscript>
|
|
102
|
+
</section>
|
|
103
|
+
<section class="lms-learning-card" data-lms-agenda data-course-id={courseId}>
|
|
104
|
+
<h3>Course agenda</h3>
|
|
105
|
+
{agendaError && <p>{agendaError} Your private notebook remains available.</p>}
|
|
106
|
+
{agenda && (
|
|
107
|
+
<>
|
|
108
|
+
<p>
|
|
109
|
+
Assignment deadlines, live sessions and upcoming lesson releases from your enrolled
|
|
110
|
+
course edition. Lesson access and prerequisites still apply.
|
|
111
|
+
</p>
|
|
112
|
+
<div class="lms-actions">
|
|
113
|
+
<label>
|
|
114
|
+
Display timezone{" "}
|
|
115
|
+
<select data-lms-timezone>
|
|
116
|
+
<option value="UTC">UTC</option>
|
|
117
|
+
</select>
|
|
118
|
+
</label>
|
|
119
|
+
<label>
|
|
120
|
+
<input type="checkbox" data-lms-agenda-upcoming /> Upcoming only
|
|
121
|
+
</label>
|
|
122
|
+
<button type="button" class="lms-btn lms-btn-outline" data-lms-calendar-download>
|
|
123
|
+
Download upcoming calendar (.ics)
|
|
124
|
+
</button>
|
|
125
|
+
</div>
|
|
126
|
+
<p>
|
|
127
|
+
The download is a snapshot for your calendar app. It does not update automatically;
|
|
128
|
+
revisit the course for changes.
|
|
129
|
+
</p>
|
|
130
|
+
<p data-lms-feedback aria-live="polite"></p>
|
|
131
|
+
<ul class="lms-agenda-list">
|
|
132
|
+
{agenda.events.map((event) => (
|
|
133
|
+
<li data-lms-agenda-event data-status={event.status}>
|
|
134
|
+
<strong>
|
|
135
|
+
{
|
|
136
|
+
{
|
|
137
|
+
assignment: "Assignment due",
|
|
138
|
+
live: "Live session",
|
|
139
|
+
release: "Lesson release",
|
|
140
|
+
}[event.kind]
|
|
141
|
+
}
|
|
142
|
+
: {event.title}
|
|
143
|
+
</strong>
|
|
144
|
+
<p>
|
|
145
|
+
<time datetime={event.startsAt} data-lms-date>
|
|
146
|
+
{new Date(event.startsAt).toLocaleString("en-GB", { timeZone: "UTC" })} UTC
|
|
147
|
+
</time>
|
|
148
|
+
{event.endsAt && (
|
|
149
|
+
<>
|
|
150
|
+
{" "}
|
|
151
|
+
–{" "}
|
|
152
|
+
<time datetime={event.endsAt} data-lms-date>
|
|
153
|
+
{new Date(event.endsAt).toLocaleString("en-GB", { timeZone: "UTC" })}{" "}
|
|
154
|
+
UTC
|
|
155
|
+
</time>
|
|
156
|
+
</>
|
|
157
|
+
)}
|
|
158
|
+
</p>
|
|
159
|
+
<p class="lms-badge">{event.status}</p>
|
|
160
|
+
{event.available ? (
|
|
161
|
+
<a href={lessonUrl(basePath, agenda.course.slug, event.lessonId)}>
|
|
162
|
+
Open lesson →
|
|
163
|
+
</a>
|
|
164
|
+
) : (
|
|
165
|
+
<p>{event.reason}</p>
|
|
166
|
+
)}
|
|
167
|
+
</li>
|
|
168
|
+
))}
|
|
169
|
+
</ul>
|
|
170
|
+
{!agenda.events.length && <p>This course has no dated activities.</p>}
|
|
171
|
+
<p data-lms-agenda-empty hidden>
|
|
172
|
+
No upcoming activities in this view.
|
|
173
|
+
</p>
|
|
174
|
+
</>
|
|
175
|
+
)}
|
|
176
|
+
</section>
|
|
177
|
+
</>
|
|
178
|
+
)}
|
|
179
|
+
</LearningShell>
|
|
180
|
+
</Layout>
|
package/astro/quiz-drafts.ts
CHANGED
|
@@ -1,87 +1,23 @@
|
|
|
1
1
|
import type { Answers } from "@anantagnanam/lms/types";
|
|
2
|
-
|
|
3
|
-
export type DraftState
|
|
2
|
+
import { DraftQueue, type DraftState } from "./draft-queue.js";
|
|
3
|
+
export type { DraftState } from "./draft-queue.js";
|
|
4
4
|
export interface DraftWrite {
|
|
5
5
|
answers: Answers;
|
|
6
6
|
expectedDraftRevision: number;
|
|
7
7
|
}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
/** One writer per form. Keep an unacknowledged request intact for lost-response retries. */
|
|
12
|
-
export class QuizDraftQueue {
|
|
13
|
-
revision: number;
|
|
14
|
-
state: DraftState = "saved";
|
|
15
|
-
private latest: Answers;
|
|
16
|
-
private acknowledged: string;
|
|
17
|
-
private retry?: DraftWrite;
|
|
18
|
-
private running?: Promise<void>;
|
|
19
|
-
private disposed = false;
|
|
8
|
+
/** Retain the quiz interface while sharing the reliable writer with assignments. */
|
|
9
|
+
export class QuizDraftQueue extends DraftQueue<Answers> {
|
|
20
10
|
constructor(
|
|
21
11
|
answers: Answers,
|
|
22
12
|
revision: number,
|
|
23
|
-
|
|
24
|
-
|
|
13
|
+
send: (write: DraftWrite) => Promise<{ draftRevision: number }>,
|
|
14
|
+
changed: (state: DraftState) => void,
|
|
25
15
|
) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
private emit(state: DraftState) {
|
|
34
|
-
this.state = state;
|
|
35
|
-
if (!this.disposed) this.changed(state);
|
|
36
|
-
}
|
|
37
|
-
update(answers: Answers) {
|
|
38
|
-
this.latest = structuredClone(answers);
|
|
39
|
-
if (!["failed", "conflict", "saving"].includes(this.state))
|
|
40
|
-
this.emit(this.dirty ? "pending" : "saved");
|
|
41
|
-
}
|
|
42
|
-
flush(): Promise<void> {
|
|
43
|
-
if (this.disposed) return Promise.reject(new Error("This quiz form has closed."));
|
|
44
|
-
if (this.state === "conflict")
|
|
45
|
-
return Promise.reject(new Error("Review the saved answers before continuing."));
|
|
46
|
-
if (this.running) return this.running;
|
|
47
|
-
this.running = this.drain().finally(() => {
|
|
48
|
-
this.running = undefined;
|
|
49
|
-
});
|
|
50
|
-
return this.running;
|
|
51
|
-
}
|
|
52
|
-
private async drain() {
|
|
53
|
-
while (this.dirty && !this.disposed) {
|
|
54
|
-
const write = this.retry ?? {
|
|
55
|
-
answers: structuredClone(this.latest),
|
|
56
|
-
expectedDraftRevision: this.revision,
|
|
57
|
-
};
|
|
58
|
-
this.retry = write;
|
|
59
|
-
this.emit("saving");
|
|
60
|
-
try {
|
|
61
|
-
const result = await this.send(structuredClone(write));
|
|
62
|
-
this.revision = result.draftRevision;
|
|
63
|
-
this.acknowledged = fingerprint(write.answers);
|
|
64
|
-
this.retry = undefined;
|
|
65
|
-
} catch (error) {
|
|
66
|
-
this.emit((error as { code?: string })?.code === "DRAFT_CONFLICT" ? "conflict" : "failed");
|
|
67
|
-
throw error;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
this.emit(this.dirty ? "pending" : "saved");
|
|
71
|
-
}
|
|
72
|
-
/** Called only after the learner reviews the current remote draft. */
|
|
73
|
-
conflict() {
|
|
74
|
-
this.emit("conflict");
|
|
75
|
-
}
|
|
76
|
-
reconcile(answers: Answers, revision: number, keepLocal: boolean) {
|
|
77
|
-
if (this.running) throw new Error("Wait for the current save to finish.");
|
|
78
|
-
this.revision = revision;
|
|
79
|
-
this.acknowledged = fingerprint(answers);
|
|
80
|
-
this.retry = undefined;
|
|
81
|
-
if (!keepLocal) this.latest = structuredClone(answers);
|
|
82
|
-
this.emit(this.dirty ? "pending" : "saved");
|
|
83
|
-
}
|
|
84
|
-
dispose() {
|
|
85
|
-
this.disposed = true;
|
|
16
|
+
super(
|
|
17
|
+
answers,
|
|
18
|
+
revision,
|
|
19
|
+
(write) => send({ answers: write.value, expectedDraftRevision: write.expectedDraftRevision }),
|
|
20
|
+
changed,
|
|
21
|
+
);
|
|
86
22
|
}
|
|
87
23
|
}
|