@octolearn/training-ui 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +397 -3
- package/THIRD-PARTY-NOTICES.md +211 -0
- package/dist/chunks/core-DR1lxtEg.js +562 -0
- package/dist/chunks/define-elements-BKBBQkbv.js +5515 -0
- package/dist/chunks/font-data-DlFp9eSH.js +8 -0
- package/dist/core.js +11 -0
- package/dist/define.js +49 -0
- package/dist/index.js +47 -0
- package/dist/octolearn-training-ui.bundle.js +5893 -0
- package/dist/types/base-element.d.ts +28 -0
- package/dist/types/content.d.ts +51 -0
- package/dist/types/core.d.ts +13 -0
- package/dist/types/d-icons.d.ts +10 -0
- package/dist/types/define-elements.d.ts +18 -0
- package/dist/types/define.d.ts +1 -0
- package/dist/types/diff.d.ts +34 -0
- package/dist/types/elements/arithmetic-question.d.ts +31 -0
- package/dist/types/elements/card.d.ts +42 -0
- package/dist/types/elements/choice-pair.d.ts +21 -0
- package/dist/types/elements/dictation-question.d.ts +42 -0
- package/dist/types/elements/feedback-toast.d.ts +29 -0
- package/dist/types/elements/feedback.d.ts +87 -0
- package/dist/types/elements/flashcard.d.ts +46 -0
- package/dist/types/elements/matching-round.d.ts +61 -0
- package/dist/types/elements/multiple-choice.d.ts +33 -0
- package/dist/types/elements/picture-question.d.ts +18 -0
- package/dist/types/elements/progress.d.ts +24 -0
- package/dist/types/elements/question-answer.d.ts +22 -0
- package/dist/types/elements/question-parts.d.ts +80 -0
- package/dist/types/elements/question.d.ts +37 -0
- package/dist/types/elements/text-answer.d.ts +50 -0
- package/dist/types/elements/tile-answer.d.ts +43 -0
- package/dist/types/elements/today-stats.d.ts +17 -0
- package/dist/types/elements/trainer-overlay.d.ts +45 -0
- package/dist/types/elements/trainer-panel.d.ts +46 -0
- package/dist/types/elements/trainer.d.ts +57 -0
- package/dist/types/elements/true-false.d.ts +37 -0
- package/dist/types/events.d.ts +53 -0
- package/dist/types/feedback-message.d.ts +34 -0
- package/dist/types/font-data.d.ts +2 -0
- package/dist/types/fonts.d.ts +9 -0
- package/dist/types/i18n/strings.d.ts +228 -0
- package/dist/types/icons.d.ts +21 -0
- package/dist/types/images.d.ts +2 -0
- package/dist/types/index.d.ts +31 -0
- package/dist/types/keys.d.ts +6 -0
- package/dist/types/session/third-party-transport.d.ts +22 -0
- package/dist/types/session/training-session.d.ts +167 -0
- package/dist/types/session/transport.d.ts +44 -0
- package/dist/types/speech.d.ts +54 -0
- package/dist/types/styles/base.d.ts +9 -0
- package/dist/types/styles/d-parts.d.ts +12 -0
- package/dist/types/styles/tokens.d.ts +95 -0
- package/package.json +67 -6
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
const VOICE_STORAGE_KEY = "octolearn.training.voice";
|
|
2
|
+
const RATE_STORAGE_KEY = "octolearn.training.rate";
|
|
3
|
+
const MIN_RATE = 0.5;
|
|
4
|
+
const MAX_RATE = 1.5;
|
|
5
|
+
class SpeechController {
|
|
6
|
+
constructor(options = {}) {
|
|
7
|
+
this.voices = [];
|
|
8
|
+
this.selectedVoice = null;
|
|
9
|
+
this.pending = null;
|
|
10
|
+
this.requestId = 0;
|
|
11
|
+
this.connections = 0;
|
|
12
|
+
this.handleVoicesChanged = () => {
|
|
13
|
+
if (this.refreshVoices()) {
|
|
14
|
+
this.flushPending();
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
this.preferredVoices = options.preferredVoices ?? ["Google Deutsch"];
|
|
18
|
+
this.preferredLanguage = (options.preferredLanguage ?? "de").toLowerCase();
|
|
19
|
+
this.rate = readStoredRate();
|
|
20
|
+
}
|
|
21
|
+
static get supported() {
|
|
22
|
+
return typeof speechSynthesis !== "undefined" && typeof SpeechSynthesisUtterance !== "undefined";
|
|
23
|
+
}
|
|
24
|
+
/** Starts listening for the browser's voice list. Call disconnect() as often as connect(). */
|
|
25
|
+
connect() {
|
|
26
|
+
if (!SpeechController.supported) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (this.connections++ === 0) {
|
|
30
|
+
speechSynthesis.addEventListener("voiceschanged", this.handleVoicesChanged);
|
|
31
|
+
this.refreshVoices();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
disconnect() {
|
|
35
|
+
if (!SpeechController.supported || this.connections === 0) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (--this.connections === 0) {
|
|
39
|
+
speechSynthesis.removeEventListener("voiceschanged", this.handleVoicesChanged);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
speak(input, options = {}) {
|
|
43
|
+
const text = (input ?? "").trim();
|
|
44
|
+
if (text === "" || !SpeechController.supported) {
|
|
45
|
+
return "unavailable";
|
|
46
|
+
}
|
|
47
|
+
this.pending = { text, options };
|
|
48
|
+
if ("userActivation" in navigator && !navigator.userActivation.hasBeenActive) {
|
|
49
|
+
return "blocked";
|
|
50
|
+
}
|
|
51
|
+
if (this.refreshVoices()) {
|
|
52
|
+
this.flushPending();
|
|
53
|
+
}
|
|
54
|
+
return "spoken";
|
|
55
|
+
}
|
|
56
|
+
/** Speaks the text that was blocked; call it from a click. */
|
|
57
|
+
playPending() {
|
|
58
|
+
if (this.refreshVoices()) {
|
|
59
|
+
this.flushPending();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
stop() {
|
|
63
|
+
this.pending = null;
|
|
64
|
+
this.requestId++;
|
|
65
|
+
if (SpeechController.supported) {
|
|
66
|
+
speechSynthesis.cancel();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Remembers the voice (by name; the voice objects change when the browser reloads its list) and the speed. */
|
|
70
|
+
choose(voice, rate = this.rate) {
|
|
71
|
+
this.selectedVoice = voice;
|
|
72
|
+
this.rate = clampRate(rate);
|
|
73
|
+
try {
|
|
74
|
+
localStorage.setItem(VOICE_STORAGE_KEY, voice?.name ?? "");
|
|
75
|
+
localStorage.setItem(RATE_STORAGE_KEY, String(this.rate));
|
|
76
|
+
} catch {
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
refreshVoices() {
|
|
80
|
+
const available = speechSynthesis.getVoices();
|
|
81
|
+
if (available.length === 0) {
|
|
82
|
+
return this.selectedVoice !== null;
|
|
83
|
+
}
|
|
84
|
+
this.voices = available;
|
|
85
|
+
this.selectedVoice = this.preferredVoice(available);
|
|
86
|
+
return this.selectedVoice !== null;
|
|
87
|
+
}
|
|
88
|
+
voiceFor(lang) {
|
|
89
|
+
const wanted = lang.toLowerCase().split(/[-_]/)[0];
|
|
90
|
+
if (this.selectedVoice?.lang.toLowerCase().startsWith(wanted)) {
|
|
91
|
+
return this.selectedVoice;
|
|
92
|
+
}
|
|
93
|
+
return this.voices.find((voice) => voice.lang.toLowerCase().startsWith(wanted));
|
|
94
|
+
}
|
|
95
|
+
preferredVoice(available) {
|
|
96
|
+
const chosen = this.selectedVoice?.name ?? readStoredVoiceName();
|
|
97
|
+
const byName = (name) => name ? available.find((voice) => voice.name === name) : void 0;
|
|
98
|
+
return byName(chosen) ?? this.preferredVoices.map(byName).find((voice) => voice !== void 0) ?? available.find((voice) => voice.lang.toLowerCase().startsWith(this.preferredLanguage)) ?? available[0];
|
|
99
|
+
}
|
|
100
|
+
flushPending() {
|
|
101
|
+
if (!this.pending) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const requestId = ++this.requestId;
|
|
105
|
+
const { text, options } = this.pending;
|
|
106
|
+
this.pending = null;
|
|
107
|
+
const voice = (options.lang ? this.voiceFor(options.lang) : void 0) ?? this.selectedVoice;
|
|
108
|
+
const speakLatest = () => {
|
|
109
|
+
if (requestId !== this.requestId) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const utterance = new SpeechSynthesisUtterance(text);
|
|
113
|
+
if (voice) {
|
|
114
|
+
utterance.voice = voice;
|
|
115
|
+
}
|
|
116
|
+
if (options.lang) {
|
|
117
|
+
utterance.lang = options.lang;
|
|
118
|
+
}
|
|
119
|
+
utterance.rate = options.rate ?? this.rate;
|
|
120
|
+
speechSynthesis.speak(utterance);
|
|
121
|
+
};
|
|
122
|
+
if (!speechSynthesis.speaking && !speechSynthesis.pending) {
|
|
123
|
+
speakLatest();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
speechSynthesis.cancel();
|
|
127
|
+
setTimeout(speakLatest, 0);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function clampRate(rate) {
|
|
131
|
+
return Number.isFinite(rate) ? Math.min(MAX_RATE, Math.max(MIN_RATE, rate)) : 1;
|
|
132
|
+
}
|
|
133
|
+
function readStoredRate() {
|
|
134
|
+
try {
|
|
135
|
+
const rate = Number.parseFloat(localStorage.getItem(RATE_STORAGE_KEY) ?? "");
|
|
136
|
+
return Number.isFinite(rate) && rate >= MIN_RATE && rate <= MAX_RATE ? rate : 1;
|
|
137
|
+
} catch {
|
|
138
|
+
return 1;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function readStoredVoiceName() {
|
|
142
|
+
try {
|
|
143
|
+
const name = localStorage.getItem(VOICE_STORAGE_KEY);
|
|
144
|
+
return name === null || name === "" ? void 0 : name;
|
|
145
|
+
} catch {
|
|
146
|
+
return void 0;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
let defaultController;
|
|
150
|
+
function getSpeechController() {
|
|
151
|
+
defaultController ??= new SpeechController();
|
|
152
|
+
return defaultController;
|
|
153
|
+
}
|
|
154
|
+
const ANSWER_EVENT = "octolearn-answer";
|
|
155
|
+
const DONT_KNOW_ANSWER = "?";
|
|
156
|
+
class TransportError extends Error {
|
|
157
|
+
constructor(status, message, retryAfterSeconds) {
|
|
158
|
+
super(message);
|
|
159
|
+
this.status = status;
|
|
160
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
161
|
+
this.name = "TransportError";
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function createThirdPartyTransport(options) {
|
|
165
|
+
const base = options.baseUrl.replace(/\/+$/, "");
|
|
166
|
+
const fetchFn = options.fetch ?? ((url, init) => fetch(url, init));
|
|
167
|
+
async function request(path, init = {}) {
|
|
168
|
+
const headers = { Accept: "application/json", ...await options.headers?.(), ...init.headers };
|
|
169
|
+
const response = await fetchFn(`${base}${path}`, { ...init, headers });
|
|
170
|
+
if (!response.ok) {
|
|
171
|
+
const retryAfter = Number.parseInt(response.headers.get("X-Rate-Limit-Retry-After-Seconds") ?? "", 10);
|
|
172
|
+
throw new TransportError(
|
|
173
|
+
response.status,
|
|
174
|
+
`octolearn API: ${init.method ?? "GET"} ${path} failed with ${response.status}`,
|
|
175
|
+
Number.isFinite(retryAfter) ? retryAfter : void 0
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return await response.json();
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
nextQuestions() {
|
|
182
|
+
const params = new URLSearchParams({ numberOfNeededQuestions: String(options.batchSize ?? 5) });
|
|
183
|
+
if (options.queryFormats?.length) {
|
|
184
|
+
params.set("filterQueryFormat", options.queryFormats.join(","));
|
|
185
|
+
}
|
|
186
|
+
if (options.learningModes?.length) {
|
|
187
|
+
params.set("filterLearningMode", options.learningModes.join(","));
|
|
188
|
+
}
|
|
189
|
+
return request(`/third-party/next-relevant-questions?${params.toString()}`).then((list) => list ?? []);
|
|
190
|
+
},
|
|
191
|
+
submitAnswer(content, answer) {
|
|
192
|
+
return request("/third-party", {
|
|
193
|
+
method: "PUT",
|
|
194
|
+
headers: { "Content-Type": "application/json" },
|
|
195
|
+
body: JSON.stringify({ frontendContent: content, answer })
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
todaysStatistic() {
|
|
199
|
+
return request("/third-party/get-todays-statistic");
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
class TrainingSession extends EventTarget {
|
|
204
|
+
constructor(transport, options = {}) {
|
|
205
|
+
super();
|
|
206
|
+
this.transport = transport;
|
|
207
|
+
this.correct = 0;
|
|
208
|
+
this.wrong = 0;
|
|
209
|
+
this.practising = false;
|
|
210
|
+
this.streak = 0;
|
|
211
|
+
this.feedback = null;
|
|
212
|
+
this.round = null;
|
|
213
|
+
this.nextRound = null;
|
|
214
|
+
this.roundRequested = false;
|
|
215
|
+
this.answersSinceRound = 0;
|
|
216
|
+
this.roundResults = [];
|
|
217
|
+
this.queue = [];
|
|
218
|
+
this.buffer = null;
|
|
219
|
+
this.started = false;
|
|
220
|
+
this.loading = false;
|
|
221
|
+
this.loadFailed = false;
|
|
222
|
+
this.prefetching = false;
|
|
223
|
+
this.prefetchFailed = false;
|
|
224
|
+
this.finished = false;
|
|
225
|
+
this.submitting = false;
|
|
226
|
+
this.recentlyAnswered = [];
|
|
227
|
+
this.prefetchAt = options.prefetchAt ?? 2;
|
|
228
|
+
this.recentMemory = options.recentMemory ?? 3;
|
|
229
|
+
this.holdForFeedback = options.holdForFeedback ?? false;
|
|
230
|
+
this.roundSize = Math.max(1, options.roundSize ?? 5);
|
|
231
|
+
this.matchingEvery = Math.max(1, options.matchingEvery ?? 8);
|
|
232
|
+
}
|
|
233
|
+
get state() {
|
|
234
|
+
if (this.loadFailed) {
|
|
235
|
+
return "error";
|
|
236
|
+
}
|
|
237
|
+
if (this.feedback) {
|
|
238
|
+
return "feedback";
|
|
239
|
+
}
|
|
240
|
+
if (this.round) {
|
|
241
|
+
return "pairs";
|
|
242
|
+
}
|
|
243
|
+
if (this.loading) {
|
|
244
|
+
return "loading";
|
|
245
|
+
}
|
|
246
|
+
if (this.queue.length > 0) {
|
|
247
|
+
return this.dueDone ? "due-done" : "question";
|
|
248
|
+
}
|
|
249
|
+
if (!this.started) {
|
|
250
|
+
return "idle";
|
|
251
|
+
}
|
|
252
|
+
if (this.finished) {
|
|
253
|
+
return "done";
|
|
254
|
+
}
|
|
255
|
+
return this.prefetchFailed && !this.prefetching ? "error" : "loading";
|
|
256
|
+
}
|
|
257
|
+
/** The question to show. */
|
|
258
|
+
get current() {
|
|
259
|
+
return this.queue[0] ?? null;
|
|
260
|
+
}
|
|
261
|
+
/** The questions waiting, the current one first. */
|
|
262
|
+
get upcoming() {
|
|
263
|
+
return this.queue;
|
|
264
|
+
}
|
|
265
|
+
/** Only practice is left (nothing due) and the learner hasn't chosen to go on. */
|
|
266
|
+
get dueDone() {
|
|
267
|
+
return !this.practising && this.queue.length > 0 && !this.queue.some((content) => content.learningLevelShallBeChanged);
|
|
268
|
+
}
|
|
269
|
+
/** The stars: the current question of the round and the results so far. A new round starts after `roundSize` answers. */
|
|
270
|
+
get roundProgress() {
|
|
271
|
+
if (this.feedback) {
|
|
272
|
+
return { current: this.roundResults.length, total: this.roundSize, results: this.roundResults };
|
|
273
|
+
}
|
|
274
|
+
if (this.roundResults.length >= this.roundSize) {
|
|
275
|
+
return { current: 1, total: this.roundSize, results: [] };
|
|
276
|
+
}
|
|
277
|
+
return { current: this.roundResults.length + 1, total: this.roundSize, results: this.roundResults };
|
|
278
|
+
}
|
|
279
|
+
/** An answer is being sent; further answers are ignored until it is evaluated. */
|
|
280
|
+
get busy() {
|
|
281
|
+
return this.submitting;
|
|
282
|
+
}
|
|
283
|
+
/** Loads the first questions and today's counts. */
|
|
284
|
+
async start() {
|
|
285
|
+
this.started = true;
|
|
286
|
+
await Promise.all([this.load(), this.loadStatistic(), this.requestRound()]);
|
|
287
|
+
}
|
|
288
|
+
/** Asks for questions again, e.g. after an error ("Erneut versuchen"). */
|
|
289
|
+
async reload() {
|
|
290
|
+
this.started = true;
|
|
291
|
+
await this.load();
|
|
292
|
+
}
|
|
293
|
+
startPractising() {
|
|
294
|
+
this.practising = true;
|
|
295
|
+
this.changed();
|
|
296
|
+
}
|
|
297
|
+
/** With `holdForFeedback`: leaves the feedback and shows the next question ("Weiter"). */
|
|
298
|
+
continue() {
|
|
299
|
+
if (!this.feedback) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
this.feedback = null;
|
|
303
|
+
this.changed();
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Sends the answer to the current question and moves on to the next one (with `holdForFeedback`: to the feedback).
|
|
307
|
+
* Returns null when there is no question, the answer is blank or another answer is still being sent. Pass
|
|
308
|
+
* `{ dontKnow: true }` for "Weiß ich nicht" (the `octolearn-answer` event's detail).
|
|
309
|
+
*/
|
|
310
|
+
async answer(text, details = {}) {
|
|
311
|
+
const content = this.queue[0];
|
|
312
|
+
const answer = text.trim();
|
|
313
|
+
if (!content || answer === "" || this.submitting || this.feedback || this.round) {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
this.submitting = true;
|
|
317
|
+
this.changed();
|
|
318
|
+
if (content.id !== void 0) {
|
|
319
|
+
this.rememberAnswered(content.id);
|
|
320
|
+
}
|
|
321
|
+
let evaluation = null;
|
|
322
|
+
try {
|
|
323
|
+
evaluation = await this.transport.submitAnswer(content, answer);
|
|
324
|
+
} catch {
|
|
325
|
+
evaluation = null;
|
|
326
|
+
}
|
|
327
|
+
const correct = evaluation?.isCorrect === true;
|
|
328
|
+
if (correct) {
|
|
329
|
+
this.correct++;
|
|
330
|
+
this.streak++;
|
|
331
|
+
} else {
|
|
332
|
+
this.wrong++;
|
|
333
|
+
this.streak = 0;
|
|
334
|
+
}
|
|
335
|
+
this.submitting = false;
|
|
336
|
+
if (this.roundResults.length >= this.roundSize) {
|
|
337
|
+
this.roundResults = [];
|
|
338
|
+
}
|
|
339
|
+
this.roundResults = [...this.roundResults, correct ? "right" : "wrong"];
|
|
340
|
+
const counts = content.learningLevelShallBeChanged !== false;
|
|
341
|
+
const multipleChoice = content.queryFormat === "MultipleChoice";
|
|
342
|
+
const outcome = {
|
|
343
|
+
content,
|
|
344
|
+
answer,
|
|
345
|
+
evaluation,
|
|
346
|
+
correct,
|
|
347
|
+
feedback: {
|
|
348
|
+
result: correct ? "correct" : "wrong",
|
|
349
|
+
answer,
|
|
350
|
+
correctAnswer: correct ? void 0 : evaluation?.correctAnswer,
|
|
351
|
+
typo: correct && evaluation?.acceptedWithTypo === true,
|
|
352
|
+
note: correct && evaluation?.acceptedWithTypo ? evaluation.feedbackMessage : void 0,
|
|
353
|
+
dontKnow: details.dontKnow === true,
|
|
354
|
+
unchecked: evaluation === null,
|
|
355
|
+
// a typed answer moves the learning level two steps, every other format one (OC-120); practice moves nothing
|
|
356
|
+
points: correct && counts ? content.queryFormat === "TextInput" ? 2 : 1 : 0,
|
|
357
|
+
streak: this.streak,
|
|
358
|
+
options: multipleChoice ? content.options ?? null : null,
|
|
359
|
+
word: content.learningMode === "Dictation" || content.isItVocabulary === true,
|
|
360
|
+
listenable: content.learningMode === "Dictation" && !multipleChoice,
|
|
361
|
+
queryFormat: content.queryFormat
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
if (this.holdForFeedback) {
|
|
365
|
+
this.feedback = outcome;
|
|
366
|
+
}
|
|
367
|
+
this.advance();
|
|
368
|
+
this.answersSinceRound++;
|
|
369
|
+
if (this.nextRound) {
|
|
370
|
+
this.round = this.nextRound;
|
|
371
|
+
this.nextRound = null;
|
|
372
|
+
} else if (this.answersSinceRound >= this.matchingEvery) {
|
|
373
|
+
void this.requestRound();
|
|
374
|
+
}
|
|
375
|
+
this.dispatchEvent(new CustomEvent("answered", { detail: outcome }));
|
|
376
|
+
this.changed();
|
|
377
|
+
return outcome;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Checks one pair of the current round, like an answer to that question; counts it and fires `pair-checked`. Null when
|
|
381
|
+
* the pair is not part of the round or could not be checked (the pair then stays open).
|
|
382
|
+
*/
|
|
383
|
+
async checkPair(item, answer) {
|
|
384
|
+
if (!this.round?.items.some((roundItem) => roundItem.id === item.id)) {
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
let evaluation;
|
|
388
|
+
try {
|
|
389
|
+
evaluation = await this.transport.submitAnswer(
|
|
390
|
+
{ id: item.id, learningMode: item.learningMode, learningLevelShallBeChanged: item.learningLevelShallBeChanged },
|
|
391
|
+
answer
|
|
392
|
+
);
|
|
393
|
+
} catch {
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
const correct = evaluation.isCorrect;
|
|
397
|
+
this.rememberAnswered(item.id);
|
|
398
|
+
if (correct) {
|
|
399
|
+
this.correct++;
|
|
400
|
+
this.streak++;
|
|
401
|
+
} else {
|
|
402
|
+
this.wrong++;
|
|
403
|
+
this.streak = 0;
|
|
404
|
+
}
|
|
405
|
+
this.dispatchEvent(new CustomEvent("pair-checked", { detail: { item, answer, evaluation, correct } }));
|
|
406
|
+
this.changed();
|
|
407
|
+
return evaluation;
|
|
408
|
+
}
|
|
409
|
+
/** After a round ("Weiter"): the single questions go on; the ones the round just asked are dropped from the queue. */
|
|
410
|
+
finishRound() {
|
|
411
|
+
if (!this.round) {
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const asked = new Set(this.round.items.map((item) => item.id));
|
|
415
|
+
const notAsked = (content) => content.id === void 0 || !asked.has(content.id);
|
|
416
|
+
this.round = null;
|
|
417
|
+
this.answersSinceRound = 0;
|
|
418
|
+
this.queue = this.queue.filter(notAsked);
|
|
419
|
+
if (this.buffer) {
|
|
420
|
+
this.buffer = this.buffer.filter(notAsked);
|
|
421
|
+
}
|
|
422
|
+
if (this.queue.length === 0) {
|
|
423
|
+
if (this.buffer === null && !this.prefetching) {
|
|
424
|
+
void this.prefetch();
|
|
425
|
+
} else {
|
|
426
|
+
this.drainBuffer();
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
this.changed();
|
|
430
|
+
}
|
|
431
|
+
/** The answer of a Karteikarte (for `<octolearn-flashcard>`.reveal); rejects when the transport can't. */
|
|
432
|
+
revealFlashcard(content = this.current) {
|
|
433
|
+
if (!content || !this.transport.revealFlashcard) {
|
|
434
|
+
return Promise.reject(new Error("This transport cannot reveal flashcards."));
|
|
435
|
+
}
|
|
436
|
+
return this.transport.revealFlashcard(content);
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Asks for a round of "Paare finden" (OC-125). Before the first answer it is shown at once; later it waits until the
|
|
440
|
+
* question on screen is answered. A round without items, or an error, just means single questions go on.
|
|
441
|
+
*/
|
|
442
|
+
async requestRound() {
|
|
443
|
+
if (!this.transport.matchingRound || this.roundRequested || this.round || this.nextRound) {
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
this.roundRequested = true;
|
|
447
|
+
try {
|
|
448
|
+
const round = await this.transport.matchingRound();
|
|
449
|
+
if (round.items.length === 0) {
|
|
450
|
+
this.answersSinceRound = 0;
|
|
451
|
+
} else if (this.answersSinceRound === 0 && !this.feedback) {
|
|
452
|
+
this.round = round;
|
|
453
|
+
} else {
|
|
454
|
+
this.nextRound = round;
|
|
455
|
+
}
|
|
456
|
+
} catch {
|
|
457
|
+
this.answersSinceRound = 0;
|
|
458
|
+
} finally {
|
|
459
|
+
this.roundRequested = false;
|
|
460
|
+
this.changed();
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
async load() {
|
|
464
|
+
this.loading = true;
|
|
465
|
+
this.loadFailed = false;
|
|
466
|
+
this.changed();
|
|
467
|
+
try {
|
|
468
|
+
const batch = await this.transport.nextQuestions();
|
|
469
|
+
this.queue = this.preferNotRecentlyAnswered(batch);
|
|
470
|
+
this.buffer = null;
|
|
471
|
+
this.prefetchFailed = false;
|
|
472
|
+
this.finished = this.queue.length === 0;
|
|
473
|
+
} catch {
|
|
474
|
+
this.loadFailed = true;
|
|
475
|
+
} finally {
|
|
476
|
+
this.loading = false;
|
|
477
|
+
this.changed();
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
async loadStatistic() {
|
|
481
|
+
if (!this.transport.todaysStatistic) {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
try {
|
|
485
|
+
const statistic = await this.transport.todaysStatistic();
|
|
486
|
+
this.correct = statistic.correctAnswerCount ?? this.correct;
|
|
487
|
+
this.wrong = statistic.wrongAnswerCount ?? this.wrong;
|
|
488
|
+
this.changed();
|
|
489
|
+
} catch {
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
advance() {
|
|
493
|
+
this.queue.shift();
|
|
494
|
+
if (this.queue.length <= this.prefetchAt && !this.prefetching && this.buffer === null) {
|
|
495
|
+
void this.prefetch();
|
|
496
|
+
}
|
|
497
|
+
if (this.queue.length === 0) {
|
|
498
|
+
this.drainBuffer();
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
async prefetch() {
|
|
502
|
+
this.prefetching = true;
|
|
503
|
+
this.prefetchFailed = false;
|
|
504
|
+
const onScreenAtStart = new Set(this.queue.map((content) => content.id));
|
|
505
|
+
let droppedOnScreen = false;
|
|
506
|
+
try {
|
|
507
|
+
const received = await this.transport.nextQuestions();
|
|
508
|
+
const notOnScreen = received.filter((content) => !onScreenAtStart.has(content.id) && !this.queue.some((open) => open.id === content.id));
|
|
509
|
+
droppedOnScreen = notOnScreen.length < received.length;
|
|
510
|
+
this.buffer = notOnScreen.length === 0 && droppedOnScreen ? null : this.preferNotRecentlyAnswered(notOnScreen);
|
|
511
|
+
} catch {
|
|
512
|
+
this.prefetchFailed = true;
|
|
513
|
+
this.buffer = null;
|
|
514
|
+
}
|
|
515
|
+
this.prefetching = false;
|
|
516
|
+
if (this.buffer === null && droppedOnScreen) {
|
|
517
|
+
if (this.queue.length === 0) {
|
|
518
|
+
void this.prefetch();
|
|
519
|
+
}
|
|
520
|
+
} else if (this.queue.length === 0) {
|
|
521
|
+
this.drainBuffer();
|
|
522
|
+
}
|
|
523
|
+
this.changed();
|
|
524
|
+
}
|
|
525
|
+
drainBuffer() {
|
|
526
|
+
if (this.buffer === null) {
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (this.buffer.length > 0) {
|
|
530
|
+
this.queue = this.buffer;
|
|
531
|
+
} else {
|
|
532
|
+
this.finished = true;
|
|
533
|
+
}
|
|
534
|
+
this.buffer = null;
|
|
535
|
+
}
|
|
536
|
+
rememberAnswered(id) {
|
|
537
|
+
this.recentlyAnswered = [...this.recentlyAnswered.filter((known) => known !== id), id].slice(-this.recentMemory);
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Puts what was answered a moment ago behind the other questions of the batch (OC-224). It is not left out: a wrongly
|
|
541
|
+
* answered question comes back in the same session on purpose, and a batch that lost its only due question would say
|
|
542
|
+
* "done" while that question is still waiting (OC-251). A lone question may come back.
|
|
543
|
+
*/
|
|
544
|
+
preferNotRecentlyAnswered(batch) {
|
|
545
|
+
const isRecent = (content) => content.id !== void 0 && this.recentlyAnswered.includes(content.id);
|
|
546
|
+
return [...batch.filter((content) => !isRecent(content)), ...batch.filter(isRecent)];
|
|
547
|
+
}
|
|
548
|
+
changed() {
|
|
549
|
+
this.dispatchEvent(new Event("change"));
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
export {
|
|
553
|
+
ANSWER_EVENT as A,
|
|
554
|
+
DONT_KNOW_ANSWER as D,
|
|
555
|
+
MAX_RATE as M,
|
|
556
|
+
SpeechController as S,
|
|
557
|
+
TrainingSession as T,
|
|
558
|
+
MIN_RATE as a,
|
|
559
|
+
TransportError as b,
|
|
560
|
+
createThirdPartyTransport as c,
|
|
561
|
+
getSpeechController as g
|
|
562
|
+
};
|