@intellectif/lk-core 1.10.0 → 1.11.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/CHANGELOG.md +27 -0
- package/dist/{ai-zhc9yhRj.d.cts → ai-Cu17iVHa.d.cts} +429 -2
- package/dist/ai-Cu17iVHa.d.cts.map +1 -0
- package/dist/{ai-zhc9yhRj.d.ts → ai-Cu17iVHa.d.ts} +429 -2
- package/dist/ai-Cu17iVHa.d.ts.map +1 -0
- package/dist/ai-check.cjs +2 -2
- package/dist/ai-check.d.cts +1 -1
- package/dist/ai-check.d.ts +1 -1
- package/dist/ai-check.js +2 -2
- package/dist/{ai-writing-B1pKVkG7.cjs → ai-writing-C7f6aKNW.cjs} +27 -4
- package/dist/ai-writing-C7f6aKNW.cjs.map +1 -0
- package/dist/{ai-writing-DLvzBiZN.js → ai-writing-Dl1bJQbL.js} +22 -5
- package/dist/ai-writing-Dl1bJQbL.js.map +1 -0
- package/dist/{index-CxkqS62X.d.ts → index-B1zEN1Ur.d.ts} +25 -3
- package/dist/index-B1zEN1Ur.d.ts.map +1 -0
- package/dist/{index-Byar5VVs.d.ts → index-BTZWUwOe.d.ts} +2 -2
- package/dist/{index-Byar5VVs.d.ts.map → index-BTZWUwOe.d.ts.map} +1 -1
- package/dist/{index-f4YuMgFb.d.ts → index-BfUyl6ml.d.ts} +2 -2
- package/dist/{index-f4YuMgFb.d.ts.map → index-BfUyl6ml.d.ts.map} +1 -1
- package/dist/{index-DLsUnGHt.d.cts → index-CpamCGyZ.d.cts} +25 -3
- package/dist/index-CpamCGyZ.d.cts.map +1 -0
- package/dist/{index-BeC8tAWf.d.cts → index-DYnacygI.d.cts} +2 -2
- package/dist/{index-BeC8tAWf.d.cts.map → index-DYnacygI.d.cts.map} +1 -1
- package/dist/{index-Ccj8oL_E.d.cts → index-DjZ1vIVg.d.cts} +2 -2
- package/dist/{index-Ccj8oL_E.d.cts.map → index-DjZ1vIVg.d.cts.map} +1 -1
- package/dist/index.cjs +763 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +189 -5
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +189 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +735 -7
- package/dist/index.js.map +1 -1
- package/dist/item-group-D7M3E7K2.cjs +1506 -0
- package/dist/item-group-D7M3E7K2.cjs.map +1 -0
- package/dist/item-group-uNAT5EX5.js +1338 -0
- package/dist/item-group-uNAT5EX5.js.map +1 -0
- package/dist/{registry-C9O9txy5.cjs → registry-RPfZZEqi.cjs} +28 -1
- package/dist/registry-RPfZZEqi.cjs.map +1 -0
- package/dist/{registry-Bw9grsCN.js → registry-kTqbi4Lb.js} +28 -1
- package/dist/registry-kTqbi4Lb.js.map +1 -0
- package/dist/{schemas-DcEAuAeU.cjs → schemas-C4V5wfOS.cjs} +3 -3
- package/dist/{schemas-DcEAuAeU.cjs.map → schemas-C4V5wfOS.cjs.map} +1 -1
- package/dist/{schemas-D14-tnNl.js → schemas-rDgIN4Vu.js} +3 -3
- package/dist/{schemas-D14-tnNl.js.map → schemas-rDgIN4Vu.js.map} +1 -1
- package/dist/schemas.cjs +3 -3
- package/dist/schemas.d.cts +1 -1
- package/dist/schemas.d.ts +1 -1
- package/dist/schemas.js +3 -3
- package/dist/{scoring-BGKdff2i.js → scoring-DshU9i7e.js} +49 -17
- package/dist/scoring-DshU9i7e.js.map +1 -0
- package/dist/{scoring-Db1CZeAn.cjs → scoring-KpYfR0lO.cjs} +49 -17
- package/dist/scoring-KpYfR0lO.cjs.map +1 -0
- package/dist/scoring.cjs +2 -2
- package/dist/scoring.d.cts +2 -2
- package/dist/scoring.d.ts +2 -2
- package/dist/scoring.js +2 -2
- package/dist/{xapi-DOAqH9__.cjs → xapi-CaM7f1sD.cjs} +2 -2
- package/dist/{xapi-DOAqH9__.cjs.map → xapi-CaM7f1sD.cjs.map} +1 -1
- package/dist/{xapi-BtBm8AWZ.js → xapi-fFq9KEXG.js} +2 -2
- package/dist/{xapi-BtBm8AWZ.js.map → xapi-fFq9KEXG.js.map} +1 -1
- package/dist/xapi.cjs +1 -1
- package/dist/xapi.d.cts +1 -1
- package/dist/xapi.d.ts +1 -1
- package/dist/xapi.js +1 -1
- package/package.json +1 -1
- package/dist/ai-writing-B1pKVkG7.cjs.map +0 -1
- package/dist/ai-writing-DLvzBiZN.js.map +0 -1
- package/dist/ai-zhc9yhRj.d.cts.map +0 -1
- package/dist/ai-zhc9yhRj.d.ts.map +0 -1
- package/dist/index-CxkqS62X.d.ts.map +0 -1
- package/dist/index-DLsUnGHt.d.cts.map +0 -1
- package/dist/item-group-C9boy5Mk.cjs +0 -513
- package/dist/item-group-C9boy5Mk.cjs.map +0 -1
- package/dist/item-group-Cqng1LPl.js +0 -453
- package/dist/item-group-Cqng1LPl.js.map +0 -1
- package/dist/registry-Bw9grsCN.js.map +0 -1
- package/dist/registry-C9O9txy5.cjs.map +0 -1
- package/dist/scoring-BGKdff2i.js.map +0 -1
- package/dist/scoring-Db1CZeAn.cjs.map +0 -1
|
@@ -0,0 +1,1338 @@
|
|
|
1
|
+
import { $ as GROUP_CAPTIONS_REVEAL_DICTATION, B as countWords, Ct as passageWithoutPlaceholders, Mt as maxUnits, Ot as MediaSchema, St as numberedPassage, Tt as revealsAnswer, a as readSchema, bt as containsWords, et as groupCaptionsField, jt as ScriptSchema, kt as RedactedMediaSchema, t as getActivityTypeDescriptor, wt as placeholderOrder, xt as fold$1 } from "./registry-kTqbi4Lb.js";
|
|
2
|
+
import * as z from "zod/v4";
|
|
3
|
+
// src/reading-route/layout.ts
|
|
4
|
+
/** Names the segmentation rules below. A change to them is a new name. */
|
|
5
|
+
const ROUTE_SEGMENTER = "lk-sentences@1";
|
|
6
|
+
const TERMINALS = /* @__PURE__ */ new Set([
|
|
7
|
+
".",
|
|
8
|
+
"!",
|
|
9
|
+
"?",
|
|
10
|
+
"…",
|
|
11
|
+
"‼",
|
|
12
|
+
"⁇",
|
|
13
|
+
"⁈",
|
|
14
|
+
"⁉"
|
|
15
|
+
]);
|
|
16
|
+
/** Terminals of scripts written without spaces: a sentence ends at them outright. */
|
|
17
|
+
const CLOSED_TERMINALS = /* @__PURE__ */ new Set([
|
|
18
|
+
"。",
|
|
19
|
+
"!",
|
|
20
|
+
"?",
|
|
21
|
+
"。"
|
|
22
|
+
]);
|
|
23
|
+
const CLOSERS = /* @__PURE__ */ new Set([
|
|
24
|
+
"\"",
|
|
25
|
+
"'",
|
|
26
|
+
"”",
|
|
27
|
+
"’",
|
|
28
|
+
")",
|
|
29
|
+
"]",
|
|
30
|
+
"}",
|
|
31
|
+
"»",
|
|
32
|
+
"›",
|
|
33
|
+
"」",
|
|
34
|
+
"』",
|
|
35
|
+
")"
|
|
36
|
+
]);
|
|
37
|
+
/**
|
|
38
|
+
* Words a full stop follows without ending a sentence. Lower case, without
|
|
39
|
+
* their dots. `etc` is not one: it ends sentences as often as not, and a
|
|
40
|
+
* capital after it says which.
|
|
41
|
+
*/
|
|
42
|
+
const ABBREVIATIONS = /* @__PURE__ */ new Set([
|
|
43
|
+
"mr",
|
|
44
|
+
"mrs",
|
|
45
|
+
"ms",
|
|
46
|
+
"dr",
|
|
47
|
+
"prof",
|
|
48
|
+
"sr",
|
|
49
|
+
"jr",
|
|
50
|
+
"st",
|
|
51
|
+
"mt",
|
|
52
|
+
"vs",
|
|
53
|
+
"fig",
|
|
54
|
+
"figs",
|
|
55
|
+
"vol",
|
|
56
|
+
"approx",
|
|
57
|
+
"ca",
|
|
58
|
+
"cf",
|
|
59
|
+
"eg",
|
|
60
|
+
"ie",
|
|
61
|
+
"inc",
|
|
62
|
+
"ltd",
|
|
63
|
+
"co",
|
|
64
|
+
"corp",
|
|
65
|
+
"jan",
|
|
66
|
+
"feb",
|
|
67
|
+
"mar",
|
|
68
|
+
"apr",
|
|
69
|
+
"jun",
|
|
70
|
+
"jul",
|
|
71
|
+
"aug",
|
|
72
|
+
"sep",
|
|
73
|
+
"sept",
|
|
74
|
+
"oct",
|
|
75
|
+
"nov",
|
|
76
|
+
"dec",
|
|
77
|
+
"gen",
|
|
78
|
+
"gov",
|
|
79
|
+
"rev",
|
|
80
|
+
"ed",
|
|
81
|
+
"eds",
|
|
82
|
+
"pp",
|
|
83
|
+
"no"
|
|
84
|
+
]);
|
|
85
|
+
const isSpace = (char) => char !== void 0 && /\s/u.test(char);
|
|
86
|
+
const isLower = (char) => char !== void 0 && /\p{Ll}/u.test(char);
|
|
87
|
+
/** The word before a full stop at `dot`, letters and inner dots only ("e.g", "U.S"). */
|
|
88
|
+
function wordBefore(text, dot) {
|
|
89
|
+
let start = dot;
|
|
90
|
+
while (start > 0 && /[\p{L}.]/u.test(text[start - 1])) start -= 1;
|
|
91
|
+
return text.slice(start, dot);
|
|
92
|
+
}
|
|
93
|
+
/** Whether the full stop at `dot` follows an abbreviation or an initial ("J. K.", "U.S."). */
|
|
94
|
+
function abbreviated(text, dot) {
|
|
95
|
+
const word = wordBefore(text, dot);
|
|
96
|
+
if (word === "") return false;
|
|
97
|
+
const last = word.split(".").at(-1);
|
|
98
|
+
if (last.length === 1 && /\p{Lu}/u.test(last)) return true;
|
|
99
|
+
return ABBREVIATIONS.has(word.replaceAll(".", "").toLowerCase());
|
|
100
|
+
}
|
|
101
|
+
/** `range` with whitespace trimmed off both ends; `null` when nothing is left. */
|
|
102
|
+
function trimmed(text, start, end) {
|
|
103
|
+
let from = start;
|
|
104
|
+
let to = end;
|
|
105
|
+
while (from < to && isSpace(text[from])) from += 1;
|
|
106
|
+
while (to > from && isSpace(text[to - 1])) to -= 1;
|
|
107
|
+
return from < to ? {
|
|
108
|
+
start: from,
|
|
109
|
+
end: to
|
|
110
|
+
} : null;
|
|
111
|
+
}
|
|
112
|
+
/** The sentences of `text.slice(start, end)`: one paragraph. A line break always ends one. */
|
|
113
|
+
function sentencesIn(text, start, end) {
|
|
114
|
+
const found = [];
|
|
115
|
+
let from = start;
|
|
116
|
+
const close = (to, next) => {
|
|
117
|
+
const range = trimmed(text, from, to);
|
|
118
|
+
if (range !== null) found.push(range);
|
|
119
|
+
from = next;
|
|
120
|
+
};
|
|
121
|
+
let index = start;
|
|
122
|
+
while (index < end) {
|
|
123
|
+
const char = text[index];
|
|
124
|
+
if (char === "\n") {
|
|
125
|
+
close(index, index + 1);
|
|
126
|
+
index += 1;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (CLOSED_TERMINALS.has(char)) {
|
|
130
|
+
let after = index + 1;
|
|
131
|
+
while (after < end && CLOSERS.has(text[after])) after += 1;
|
|
132
|
+
close(after, after);
|
|
133
|
+
index = after;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (!TERMINALS.has(char)) {
|
|
137
|
+
index += 1;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
let after = index + 1;
|
|
141
|
+
while (after < end && TERMINALS.has(text[after])) after += 1;
|
|
142
|
+
while (after < end && CLOSERS.has(text[after])) after += 1;
|
|
143
|
+
if (after >= end) {
|
|
144
|
+
close(end, end);
|
|
145
|
+
index = end;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (!isSpace(text[after])) {
|
|
149
|
+
index = after;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
let next = after;
|
|
153
|
+
while (next < end && text[next] !== "\n" && isSpace(text[next])) next += 1;
|
|
154
|
+
if (!(isLower(text[next]) || char === "." && after === index + 1 && abbreviated(text, index))) close(after, after);
|
|
155
|
+
index = after;
|
|
156
|
+
}
|
|
157
|
+
close(end, end);
|
|
158
|
+
return found;
|
|
159
|
+
}
|
|
160
|
+
/** The paragraphs of `body`: runs of text between blank lines. */
|
|
161
|
+
function paragraphRanges(body) {
|
|
162
|
+
const ranges = [];
|
|
163
|
+
const blank = /\n[^\S\n]*\n\s*/gu;
|
|
164
|
+
let from = 0;
|
|
165
|
+
for (const match of body.matchAll(blank)) {
|
|
166
|
+
const range = trimmed(body, from, match.index);
|
|
167
|
+
if (range !== null) ranges.push(range);
|
|
168
|
+
from = match.index + match[0].length;
|
|
169
|
+
}
|
|
170
|
+
const last = trimmed(body, from, body.length);
|
|
171
|
+
if (last !== null) ranges.push(last);
|
|
172
|
+
return ranges;
|
|
173
|
+
}
|
|
174
|
+
/** How many paragraphs `body` has, by the rules {@link passageLayout} uses. */
|
|
175
|
+
function countParagraphs(body) {
|
|
176
|
+
return paragraphRanges(body).length;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The passage cut into paragraphs and sentences. Paragraph labels and sections
|
|
180
|
+
* are applied when they fit; the checks report when they do not.
|
|
181
|
+
*/
|
|
182
|
+
function passageLayout(body, route) {
|
|
183
|
+
const ranges = paragraphRanges(body);
|
|
184
|
+
const labels = route?.paragraphLabels;
|
|
185
|
+
const sectionList = route?.sections ?? [];
|
|
186
|
+
const sections = [];
|
|
187
|
+
const sectionOf = [];
|
|
188
|
+
if (sectionList.reduce((total, section) => total + section.paragraphs, 0) === ranges.length) {
|
|
189
|
+
let first = 0;
|
|
190
|
+
sectionList.forEach((section, index) => {
|
|
191
|
+
const last = first + section.paragraphs - 1;
|
|
192
|
+
sections.push({
|
|
193
|
+
label: section.label,
|
|
194
|
+
...section.title !== void 0 ? { title: section.title } : {},
|
|
195
|
+
first,
|
|
196
|
+
last
|
|
197
|
+
});
|
|
198
|
+
for (let paragraph = first; paragraph <= last; paragraph += 1) sectionOf[paragraph] = index;
|
|
199
|
+
first = last + 1;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
const labelled = labels !== void 0 && labels.length === ranges.length;
|
|
203
|
+
const paragraphs = [];
|
|
204
|
+
const sentences = [];
|
|
205
|
+
ranges.forEach((range, index) => {
|
|
206
|
+
const label = labelled ? labels[index] : "";
|
|
207
|
+
const section = sectionOf[index];
|
|
208
|
+
paragraphs.push({
|
|
209
|
+
index,
|
|
210
|
+
start: range.start,
|
|
211
|
+
end: range.end,
|
|
212
|
+
...label !== "" ? { label } : {},
|
|
213
|
+
...section !== void 0 ? { section } : {}
|
|
214
|
+
});
|
|
215
|
+
for (const sentence of sentencesIn(body, range.start, range.end)) sentences.push({
|
|
216
|
+
index: sentences.length,
|
|
217
|
+
paragraph: index,
|
|
218
|
+
...sentence
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
return {
|
|
222
|
+
paragraphs,
|
|
223
|
+
sentences,
|
|
224
|
+
sections
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
/** Whether two ranges share at least one character. */
|
|
228
|
+
function overlaps(a, b) {
|
|
229
|
+
return a.start < b.end && b.start < a.end;
|
|
230
|
+
}
|
|
231
|
+
/** The sentences a range touches, in order. */
|
|
232
|
+
function sentencesTouching(layout, range) {
|
|
233
|
+
return layout.sentences.filter((sentence) => overlaps(sentence, range)).map((s) => s.index);
|
|
234
|
+
}
|
|
235
|
+
// src/reading-route/questions.ts
|
|
236
|
+
/**
|
|
237
|
+
* The questions of a routed group, read off its items: one per multiple-choice
|
|
238
|
+
* item, one per blank or gap of the others, numbered as on the paper. Read
|
|
239
|
+
* defensively, because the checks run on drafts and on redacted items.
|
|
240
|
+
*/
|
|
241
|
+
const isFields$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
242
|
+
const text = (value) => typeof value === "string" ? value : "";
|
|
243
|
+
const list$1 = (value) => Array.isArray(value) ? value.filter(isFields$1) : [];
|
|
244
|
+
/** `itemId`, or `itemId#partId`: a question's name in traces and summaries. */
|
|
245
|
+
function questionKey(itemId, partId) {
|
|
246
|
+
return partId === void 0 ? itemId : `${itemId}#${partId}`;
|
|
247
|
+
}
|
|
248
|
+
/** The ids of an item's parts in passage order: its blanks or gaps, or none for multiple choice. */
|
|
249
|
+
function partIdsOf(item) {
|
|
250
|
+
if (!isFields$1(item)) return null;
|
|
251
|
+
if (item.type === "multiple-choice") return null;
|
|
252
|
+
const declared = list$1(item.type === "gap-select" ? item.gaps : item.blanks).map((part) => part.id).filter((id) => typeof id === "string" && id !== "");
|
|
253
|
+
const order = placeholderOrder(text(item.passage)).filter((id) => declared.includes(id));
|
|
254
|
+
const rest = declared.filter((id) => !order.includes(id));
|
|
255
|
+
return [.../* @__PURE__ */ new Set([...order, ...rest])];
|
|
256
|
+
}
|
|
257
|
+
/** Every question of the group's items, in paper order. */
|
|
258
|
+
function itemQuestions(items) {
|
|
259
|
+
const found = [];
|
|
260
|
+
items.forEach((item, itemIndex) => {
|
|
261
|
+
if (!isFields$1(item) || typeof item.id !== "string") return;
|
|
262
|
+
const type = text(item.type);
|
|
263
|
+
const parts = partIdsOf(item);
|
|
264
|
+
if (parts === null) {
|
|
265
|
+
found.push({
|
|
266
|
+
key: questionKey(item.id),
|
|
267
|
+
itemId: item.id,
|
|
268
|
+
itemIndex,
|
|
269
|
+
position: found.length,
|
|
270
|
+
type
|
|
271
|
+
});
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
for (const partId of parts) found.push({
|
|
275
|
+
key: questionKey(item.id, partId),
|
|
276
|
+
itemId: item.id,
|
|
277
|
+
partId,
|
|
278
|
+
itemIndex,
|
|
279
|
+
position: found.length,
|
|
280
|
+
type
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
return found;
|
|
284
|
+
}
|
|
285
|
+
/** What the learner reads of an item: its instructions, question or passage, and its options or choices. */
|
|
286
|
+
function learnerTextOf(item) {
|
|
287
|
+
if (!isFields$1(item)) return "";
|
|
288
|
+
const parts = [text(item.instructions)];
|
|
289
|
+
if (item.type === "multiple-choice") parts.push(text(item.question), ...list$1(item.options).map((option) => text(option.text)));
|
|
290
|
+
else {
|
|
291
|
+
parts.push(passageWithoutPlaceholders(text(item.passage)));
|
|
292
|
+
for (const gap of list$1(item.gaps)) parts.push(...list$1(gap.choices).map((choice) => text(choice.text)));
|
|
293
|
+
for (const bank of list$1(item.banks)) parts.push(...list$1(bank.choices).map((choice) => text(choice.text)));
|
|
294
|
+
}
|
|
295
|
+
return parts.filter((part) => part !== "").join("\n");
|
|
296
|
+
}
|
|
297
|
+
/** The options or choices a question offers, by id, with their text and whether each is right. */
|
|
298
|
+
function choicesOf(item, partId) {
|
|
299
|
+
if (!isFields$1(item)) return [];
|
|
300
|
+
if (item.type === "multiple-choice") return list$1(item.options).filter((option) => typeof option.id === "string").map((option) => ({
|
|
301
|
+
id: option.id,
|
|
302
|
+
text: text(option.text),
|
|
303
|
+
correct: option.isCorrect === true
|
|
304
|
+
}));
|
|
305
|
+
if (item.type !== "gap-select") return [];
|
|
306
|
+
const gap = list$1(item.gaps).find((candidate) => candidate.id === partId);
|
|
307
|
+
if (gap === void 0) return [];
|
|
308
|
+
const bank = list$1(item.banks).find((candidate) => candidate.id === gap.bankId);
|
|
309
|
+
return (gap.choices !== void 0 ? list$1(gap.choices) : list$1(bank?.choices)).filter((choice) => typeof choice.id === "string").map((choice) => ({
|
|
310
|
+
id: choice.id,
|
|
311
|
+
text: text(choice.text),
|
|
312
|
+
correct: choice.id === gap.correctChoiceId
|
|
313
|
+
}));
|
|
314
|
+
}
|
|
315
|
+
/** The accepted answers of a blank. */
|
|
316
|
+
function acceptedAnswersOf(item, partId) {
|
|
317
|
+
if (!isFields$1(item) || item.type !== "fill-in-the-blanks") return [];
|
|
318
|
+
const blank = list$1(item.blanks).find((candidate) => candidate.id === partId);
|
|
319
|
+
return Array.isArray(blank?.acceptedAnswers) ? blank.acceptedAnswers.filter((answer) => typeof answer === "string") : [];
|
|
320
|
+
}
|
|
321
|
+
/** 1-based position of a blank or gap among its item's placeholders, for the leak check. */
|
|
322
|
+
function placeholderPosition(item, partId) {
|
|
323
|
+
if (!isFields$1(item) || partId === void 0) return;
|
|
324
|
+
const index = placeholderOrder(text(item.passage)).indexOf(partId);
|
|
325
|
+
return index === -1 ? void 0 : index + 1;
|
|
326
|
+
}
|
|
327
|
+
// src/reading-route/quotes.ts
|
|
328
|
+
const FOLDED = {
|
|
329
|
+
"‘": "'",
|
|
330
|
+
"’": "'",
|
|
331
|
+
"‛": "'",
|
|
332
|
+
"′": "'",
|
|
333
|
+
"“": "\"",
|
|
334
|
+
"”": "\"",
|
|
335
|
+
"„": "\"",
|
|
336
|
+
"″": "\""
|
|
337
|
+
};
|
|
338
|
+
function fold(text) {
|
|
339
|
+
let folded = "";
|
|
340
|
+
const origin = [];
|
|
341
|
+
let space = false;
|
|
342
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
343
|
+
const char = text[index];
|
|
344
|
+
if (/\s/u.test(char)) {
|
|
345
|
+
if (!space && folded !== "") {
|
|
346
|
+
folded += " ";
|
|
347
|
+
origin.push(index);
|
|
348
|
+
}
|
|
349
|
+
space = true;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
space = false;
|
|
353
|
+
folded += FOLDED[char] ?? char;
|
|
354
|
+
origin.push(index);
|
|
355
|
+
}
|
|
356
|
+
if (folded.endsWith(" ")) {
|
|
357
|
+
folded = folded.slice(0, -1);
|
|
358
|
+
origin.pop();
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
text: folded,
|
|
362
|
+
origin
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
/** The text of a quote, whichever form it takes. Non-strings read as empty. */
|
|
366
|
+
function quoteText(quote) {
|
|
367
|
+
if (typeof quote === "string") return quote;
|
|
368
|
+
if (typeof quote === "object" && quote !== null && typeof quote.text === "string") return quote.text;
|
|
369
|
+
return "";
|
|
370
|
+
}
|
|
371
|
+
function occurrenceOf(quote) {
|
|
372
|
+
if (typeof quote !== "object" || quote === null) return;
|
|
373
|
+
const occurrence = quote.occurrence;
|
|
374
|
+
return typeof occurrence === "number" ? occurrence : void 0;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Where `quote` is in `body`. Every occurrence is counted, overlapping ones
|
|
378
|
+
* included, so "a a" in "a a a" is ambiguous rather than silently the first.
|
|
379
|
+
*/
|
|
380
|
+
function findQuote(body, quote) {
|
|
381
|
+
const needle = fold(quoteText(quote)).text;
|
|
382
|
+
if (needle === "") return {
|
|
383
|
+
found: false,
|
|
384
|
+
reason: "empty",
|
|
385
|
+
count: 0
|
|
386
|
+
};
|
|
387
|
+
const hay = fold(body);
|
|
388
|
+
const starts = [];
|
|
389
|
+
let at = hay.text.indexOf(needle);
|
|
390
|
+
while (at !== -1) {
|
|
391
|
+
starts.push(at);
|
|
392
|
+
at = hay.text.indexOf(needle, at + 1);
|
|
393
|
+
}
|
|
394
|
+
if (starts.length === 0) return {
|
|
395
|
+
found: false,
|
|
396
|
+
reason: "missing",
|
|
397
|
+
count: 0
|
|
398
|
+
};
|
|
399
|
+
const occurrence = occurrenceOf(quote);
|
|
400
|
+
if (occurrence === void 0 && starts.length > 1) return {
|
|
401
|
+
found: false,
|
|
402
|
+
reason: "ambiguous",
|
|
403
|
+
count: starts.length
|
|
404
|
+
};
|
|
405
|
+
const chosen = starts[(occurrence ?? 1) - 1];
|
|
406
|
+
if (chosen === void 0) return {
|
|
407
|
+
found: false,
|
|
408
|
+
reason: "occurrence",
|
|
409
|
+
count: starts.length
|
|
410
|
+
};
|
|
411
|
+
return {
|
|
412
|
+
found: true,
|
|
413
|
+
range: {
|
|
414
|
+
start: hay.origin[chosen],
|
|
415
|
+
end: hay.origin[chosen + needle.length - 1] + 1
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
/** Whether `phrase` occurs in `text`, by the same folding as quotes, ignoring case. */
|
|
420
|
+
function containsPhrase(text, phrase) {
|
|
421
|
+
const needle = fold(phrase).text.toLowerCase();
|
|
422
|
+
return needle !== "" && fold(text).text.toLowerCase().includes(needle);
|
|
423
|
+
}
|
|
424
|
+
// src/reading-route/strategies.ts
|
|
425
|
+
/**
|
|
426
|
+
* The item types a reading route may hold: the three that every common exam
|
|
427
|
+
* reading task is written in. CLOSED on purpose, as an interactive video's
|
|
428
|
+
* list is: a type added later is not silently routable.
|
|
429
|
+
*/
|
|
430
|
+
const READING_ROUTE_ITEM_TYPES = [
|
|
431
|
+
"multiple-choice",
|
|
432
|
+
"fill-in-the-blanks",
|
|
433
|
+
"gap-select"
|
|
434
|
+
];
|
|
435
|
+
/** Whether `type` is one of the item types a reading route may hold. */
|
|
436
|
+
function isReadingRouteItemType(type) {
|
|
437
|
+
return READING_ROUTE_ITEM_TYPES.includes(type);
|
|
438
|
+
}
|
|
439
|
+
const CHOICE = ["multiple-choice", "gap-select"];
|
|
440
|
+
/**
|
|
441
|
+
* The built-in strategies. Data, versioned with lk-core: a route stores only
|
|
442
|
+
* a strategy's id, so a learner always meets the current steps.
|
|
443
|
+
*/
|
|
444
|
+
const ROUTE_STRATEGIES = Object.freeze({
|
|
445
|
+
tfng: {
|
|
446
|
+
id: "tfng",
|
|
447
|
+
title: "True / False / Not Given",
|
|
448
|
+
serves: "IELTS identifying information.",
|
|
449
|
+
itemTypes: CHOICE,
|
|
450
|
+
moves: [
|
|
451
|
+
"read-instructions",
|
|
452
|
+
"keywords",
|
|
453
|
+
"locate",
|
|
454
|
+
"read-around",
|
|
455
|
+
"paraphrase",
|
|
456
|
+
"decide"
|
|
457
|
+
],
|
|
458
|
+
followsOrder: true
|
|
459
|
+
},
|
|
460
|
+
ynng: {
|
|
461
|
+
id: "ynng",
|
|
462
|
+
title: "Yes / No / Not Given",
|
|
463
|
+
serves: "IELTS identifying the writer's views or claims.",
|
|
464
|
+
itemTypes: CHOICE,
|
|
465
|
+
moves: [
|
|
466
|
+
"read-instructions",
|
|
467
|
+
"keywords",
|
|
468
|
+
"locate",
|
|
469
|
+
"read-around",
|
|
470
|
+
"paraphrase",
|
|
471
|
+
"decide"
|
|
472
|
+
],
|
|
473
|
+
followsOrder: true
|
|
474
|
+
},
|
|
475
|
+
"multiple-choice": {
|
|
476
|
+
id: "multiple-choice",
|
|
477
|
+
title: "Multiple choice",
|
|
478
|
+
serves: "IELTS multiple choice; Cambridge C1 Advanced and C2 Proficiency Part 5.",
|
|
479
|
+
itemTypes: ["multiple-choice"],
|
|
480
|
+
moves: [
|
|
481
|
+
"keywords",
|
|
482
|
+
"predict",
|
|
483
|
+
"locate",
|
|
484
|
+
"read-around",
|
|
485
|
+
"paraphrase",
|
|
486
|
+
"eliminate"
|
|
487
|
+
],
|
|
488
|
+
followsOrder: true
|
|
489
|
+
},
|
|
490
|
+
"matching-headings": {
|
|
491
|
+
id: "matching-headings",
|
|
492
|
+
title: "Matching headings",
|
|
493
|
+
serves: "IELTS matching headings.",
|
|
494
|
+
itemTypes: CHOICE,
|
|
495
|
+
moves: [
|
|
496
|
+
"survey",
|
|
497
|
+
"locate",
|
|
498
|
+
"paraphrase",
|
|
499
|
+
"eliminate"
|
|
500
|
+
],
|
|
501
|
+
followsOrder: false
|
|
502
|
+
},
|
|
503
|
+
"matching-information": {
|
|
504
|
+
id: "matching-information",
|
|
505
|
+
title: "Matching information",
|
|
506
|
+
serves: "IELTS matching information (\"Which paragraph contains...\").",
|
|
507
|
+
itemTypes: CHOICE,
|
|
508
|
+
moves: [
|
|
509
|
+
"keywords",
|
|
510
|
+
"locate",
|
|
511
|
+
"read-around",
|
|
512
|
+
"paraphrase"
|
|
513
|
+
],
|
|
514
|
+
followsOrder: false
|
|
515
|
+
},
|
|
516
|
+
"matching-features": {
|
|
517
|
+
id: "matching-features",
|
|
518
|
+
title: "Matching features",
|
|
519
|
+
serves: "IELTS matching features (people, dates, places to statements).",
|
|
520
|
+
itemTypes: CHOICE,
|
|
521
|
+
moves: [
|
|
522
|
+
"keywords",
|
|
523
|
+
"locate",
|
|
524
|
+
"read-around",
|
|
525
|
+
"paraphrase"
|
|
526
|
+
],
|
|
527
|
+
followsOrder: false
|
|
528
|
+
},
|
|
529
|
+
"multiple-matching": {
|
|
530
|
+
id: "multiple-matching",
|
|
531
|
+
title: "Multiple matching",
|
|
532
|
+
serves: "Cambridge C1 Advanced Part 8 and C2 Proficiency Part 7.",
|
|
533
|
+
itemTypes: CHOICE,
|
|
534
|
+
moves: [
|
|
535
|
+
"survey",
|
|
536
|
+
"keywords",
|
|
537
|
+
"locate",
|
|
538
|
+
"read-around",
|
|
539
|
+
"paraphrase"
|
|
540
|
+
],
|
|
541
|
+
followsOrder: false
|
|
542
|
+
},
|
|
543
|
+
"sentence-endings": {
|
|
544
|
+
id: "sentence-endings",
|
|
545
|
+
title: "Matching sentence endings",
|
|
546
|
+
serves: "IELTS matching sentence endings.",
|
|
547
|
+
itemTypes: CHOICE,
|
|
548
|
+
moves: [
|
|
549
|
+
"keywords",
|
|
550
|
+
"predict",
|
|
551
|
+
"locate",
|
|
552
|
+
"read-around",
|
|
553
|
+
"eliminate"
|
|
554
|
+
],
|
|
555
|
+
followsOrder: true
|
|
556
|
+
},
|
|
557
|
+
completion: {
|
|
558
|
+
id: "completion",
|
|
559
|
+
title: "Completion",
|
|
560
|
+
serves: "IELTS sentence, note, table and flow-chart completion, and short answers.",
|
|
561
|
+
itemTypes: ["fill-in-the-blanks"],
|
|
562
|
+
moves: [
|
|
563
|
+
"read-instructions",
|
|
564
|
+
"keywords",
|
|
565
|
+
"predict",
|
|
566
|
+
"locate",
|
|
567
|
+
"read-around",
|
|
568
|
+
"check-answer"
|
|
569
|
+
],
|
|
570
|
+
followsOrder: true
|
|
571
|
+
},
|
|
572
|
+
"summary-word-list": {
|
|
573
|
+
id: "summary-word-list",
|
|
574
|
+
title: "Summary completion from a list",
|
|
575
|
+
serves: "IELTS summary completion with a list of words.",
|
|
576
|
+
itemTypes: ["gap-select"],
|
|
577
|
+
moves: [
|
|
578
|
+
"keywords",
|
|
579
|
+
"predict",
|
|
580
|
+
"locate",
|
|
581
|
+
"read-around",
|
|
582
|
+
"paraphrase",
|
|
583
|
+
"check-answer"
|
|
584
|
+
],
|
|
585
|
+
followsOrder: false
|
|
586
|
+
},
|
|
587
|
+
"gapped-text": {
|
|
588
|
+
id: "gapped-text",
|
|
589
|
+
title: "Gapped text",
|
|
590
|
+
serves: "Cambridge C1 Advanced Part 7 and C2 Proficiency Part 6.",
|
|
591
|
+
itemTypes: CHOICE,
|
|
592
|
+
moves: [
|
|
593
|
+
"survey",
|
|
594
|
+
"locate",
|
|
595
|
+
"cohesion",
|
|
596
|
+
"eliminate"
|
|
597
|
+
],
|
|
598
|
+
followsOrder: false
|
|
599
|
+
},
|
|
600
|
+
"cross-text": {
|
|
601
|
+
id: "cross-text",
|
|
602
|
+
title: "Cross-text matching",
|
|
603
|
+
serves: "Cambridge C1 Advanced Part 6.",
|
|
604
|
+
itemTypes: CHOICE,
|
|
605
|
+
moves: [
|
|
606
|
+
"survey",
|
|
607
|
+
"keywords",
|
|
608
|
+
"locate",
|
|
609
|
+
"compare-views"
|
|
610
|
+
],
|
|
611
|
+
followsOrder: false
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
/** Every built-in strategy id, in the library's order. */
|
|
615
|
+
const ROUTE_STRATEGY_IDS = Object.freeze(Object.keys(ROUTE_STRATEGIES));
|
|
616
|
+
/** Whether `id` names a built-in strategy. */
|
|
617
|
+
function isRouteStrategyId(id) {
|
|
618
|
+
return typeof id === "string" && Object.hasOwn(ROUTE_STRATEGIES, id);
|
|
619
|
+
}
|
|
620
|
+
/** The most characters an author's note on a step may have. */
|
|
621
|
+
const ROUTE_NOTE_MAX_LENGTH = 280;
|
|
622
|
+
/** The longest word limit a completion question may set. */
|
|
623
|
+
const ROUTE_WORD_LIMIT_MAX = 10;
|
|
624
|
+
// src/reading-route/check.ts
|
|
625
|
+
/**
|
|
626
|
+
* The rules a reading route keeps, beyond its shape: every question of the
|
|
627
|
+
* group has exactly one entry, every quote is in the passage once, the words
|
|
628
|
+
* an author adds never give the answer away, and the strategy fits the item.
|
|
629
|
+
*
|
|
630
|
+
* One list of rules serves the stored schema (every rule refuses) and the
|
|
631
|
+
* draft checks (each code with its own severity), so the two cannot drift.
|
|
632
|
+
* Read defensively: drafts and redacted groups reach it as they are.
|
|
633
|
+
*/
|
|
634
|
+
const isFields = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
635
|
+
const unset = (value) => value === void 0 || value === null || typeof value === "string" && value.trim() === "";
|
|
636
|
+
const list = (value) => Array.isArray(value) ? value : [];
|
|
637
|
+
/**
|
|
638
|
+
* Whether an author's note gives a question's answer away. For a choice, a
|
|
639
|
+
* note that names the right option and not every other one; naming them all —
|
|
640
|
+
* "True if it agrees, False if it contradicts, Not Given if it is silent" — is
|
|
641
|
+
* the rule, not the answer.
|
|
642
|
+
*/
|
|
643
|
+
function noteRevealsAnswer(item, partId, note) {
|
|
644
|
+
const accepted = acceptedAnswersOf(item, partId);
|
|
645
|
+
if (accepted.length > 0) {
|
|
646
|
+
const passage = numberedPassage(typeof item.passage === "string" ? item.passage : "");
|
|
647
|
+
const position = placeholderPosition(item, partId);
|
|
648
|
+
return accepted.some((answer) => revealsAnswer(note, answer, passage, position));
|
|
649
|
+
}
|
|
650
|
+
const choices = choicesOf(item, partId);
|
|
651
|
+
const words = fold$1(note);
|
|
652
|
+
const named = choices.filter((choice) => containsWords(words, fold$1(choice.text)));
|
|
653
|
+
return named.some((choice) => choice.correct) && named.length < choices.length;
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Every rule the route of `group` breaks. `scope: 'redacted'` checks only what
|
|
657
|
+
* a learner's copy still carries: the evidence, key words, traps and notes are
|
|
658
|
+
* gone from it.
|
|
659
|
+
*/
|
|
660
|
+
function routeIssues(group, scope = "content") {
|
|
661
|
+
if (!isFields(group) || !isFields(group.route)) return [];
|
|
662
|
+
const route = group.route;
|
|
663
|
+
const issues = [];
|
|
664
|
+
const add = (code, path, message) => {
|
|
665
|
+
issues.push({
|
|
666
|
+
code,
|
|
667
|
+
path,
|
|
668
|
+
message
|
|
669
|
+
});
|
|
670
|
+
};
|
|
671
|
+
const stimulus = isFields(group.stimulus) ? group.stimulus : void 0;
|
|
672
|
+
const body = typeof stimulus?.body === "string" ? stimulus.body : "";
|
|
673
|
+
if (stimulus !== void 0 && !unset(stimulus.kind) && stimulus.kind !== "text" && stimulus.kind !== "mixed") add("ig_route_stimulus_kind", ["stimulus", "kind"], "A reading route needs a text to read: make the stimulus a text.");
|
|
674
|
+
else if (stimulus !== void 0 && body.trim() === "") add("ig_route_body_required", ["stimulus", "body"], "Add the passage the questions are about.");
|
|
675
|
+
if (group.timeline !== void 0 && group.timeline !== null) add("ig_route_with_timeline", ["route"], "A group is a reading route or an interactive video, not both.");
|
|
676
|
+
if (group.shuffle === "within-group") add("ig_route_shuffle", ["shuffle"], "A reading route keeps its questions in paper order: many answers follow the order of the text.");
|
|
677
|
+
const items = list(group.items);
|
|
678
|
+
items.forEach((item, index) => {
|
|
679
|
+
if (isFields(item) && !unset(item.type) && !isReadingRouteItemType(item.type)) add("ig_route_item_type", [
|
|
680
|
+
"items",
|
|
681
|
+
index,
|
|
682
|
+
"type"
|
|
683
|
+
], `A reading route holds ${READING_ROUTE_ITEM_TYPES.join(", ")} questions; "${String(item.type)}" is not one of them.`);
|
|
684
|
+
});
|
|
685
|
+
const itemsById = /* @__PURE__ */ new Map();
|
|
686
|
+
for (const item of items) if (isFields(item) && typeof item.id === "string") itemsById.set(item.id, item);
|
|
687
|
+
if (body !== "" && route.paragraphLabels !== void 0) {
|
|
688
|
+
const paragraphs = countParagraphs(body);
|
|
689
|
+
if (!Array.isArray(route.paragraphLabels) || route.paragraphLabels.length !== paragraphs) add("ig_route_paragraph_labels", ["route", "paragraphLabels"], `The passage has ${paragraphs} paragraphs (separated by blank lines); give one label for each, or "" to leave one unlabelled.`);
|
|
690
|
+
}
|
|
691
|
+
if (body !== "" && Array.isArray(route.sections)) {
|
|
692
|
+
const paragraphs = countParagraphs(body);
|
|
693
|
+
const covered = route.sections.reduce((total, section) => total + (isFields(section) && typeof section.paragraphs === "number" ? section.paragraphs : 0), 0);
|
|
694
|
+
if (covered !== paragraphs) add("ig_route_sections", ["route", "sections"], `The sections cover ${covered} paragraphs, but the passage has ${paragraphs}.`);
|
|
695
|
+
}
|
|
696
|
+
const seen = /* @__PURE__ */ new Set();
|
|
697
|
+
list(route.questions).forEach((raw, index) => {
|
|
698
|
+
const at = (...rest) => [
|
|
699
|
+
"route",
|
|
700
|
+
"questions",
|
|
701
|
+
index,
|
|
702
|
+
...rest
|
|
703
|
+
];
|
|
704
|
+
if (!isFields(raw)) return;
|
|
705
|
+
const itemId = typeof raw.itemId === "string" ? raw.itemId : void 0;
|
|
706
|
+
const item = itemId === void 0 ? void 0 : itemsById.get(itemId);
|
|
707
|
+
if (itemId === void 0 || item === void 0) {
|
|
708
|
+
add("ig_route_question_item_unknown", at("itemId"), `No item of this group has the id "${String(raw.itemId)}".`);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
const parts = partIdsOf(item);
|
|
712
|
+
const partId = typeof raw.partId === "string" && raw.partId !== "" ? raw.partId : void 0;
|
|
713
|
+
if (parts === null && partId !== void 0) {
|
|
714
|
+
add("ig_route_question_part_not_allowed", at("partId"), "A multiple-choice item is one question: leave out partId.");
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
if (parts !== null && partId === void 0) {
|
|
718
|
+
add("ig_route_question_part_required", at("partId"), "Name the blank or gap this question is: each one is a question of its own.");
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
if (parts !== null && partId !== void 0 && !parts.includes(partId)) {
|
|
722
|
+
add("ig_route_question_part_unknown", at("partId"), `Item "${itemId}" has no blank or gap "${partId}".`);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
const key = questionKey(itemId, partId);
|
|
726
|
+
if (seen.has(key)) {
|
|
727
|
+
add("ig_route_question_duplicate", at(), `Question "${key}" has two entries; keep one.`);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
seen.add(key);
|
|
731
|
+
const type = item.type;
|
|
732
|
+
if (unset(raw.strategy)) add("ig_route_strategy_required", at("strategy"), "Choose the reading strategy this question practises.");
|
|
733
|
+
else if (!isRouteStrategyId(raw.strategy)) add("ig_route_strategy_unknown", at("strategy"), `"${String(raw.strategy)}" is not a reading strategy.`);
|
|
734
|
+
else if (isReadingRouteItemType(type) && !ROUTE_STRATEGIES[raw.strategy].itemTypes.includes(type)) add("ig_route_strategy_item_type", at("strategy"), `The ${ROUTE_STRATEGIES[raw.strategy].title} strategy is written as ${ROUTE_STRATEGIES[raw.strategy].itemTypes.join(" or ")}, not ${String(type)}.`);
|
|
735
|
+
if (raw.wordLimit !== void 0) {
|
|
736
|
+
if (type !== "fill-in-the-blanks") add("ig_route_word_limit_type", at("wordLimit"), "Only a question the learner writes has a word limit.");
|
|
737
|
+
else if (typeof raw.wordLimit === "number") {
|
|
738
|
+
const limit = raw.wordLimit;
|
|
739
|
+
for (const answer of acceptedAnswersOf(item, partId)) if (countWords(answer) > limit) add("ig_route_answer_over_limit", at("wordLimit"), `The accepted answer "${answer}" is longer than ${limit} word${limit === 1 ? "" : "s"}: a learner who writes it would lose the mark.`);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
if (scope === "redacted") return;
|
|
743
|
+
const quote = (value, path) => {
|
|
744
|
+
if (body === "") return;
|
|
745
|
+
const match = findQuote(body, value);
|
|
746
|
+
if (match.found) return;
|
|
747
|
+
const words = quoteText(value);
|
|
748
|
+
if (match.reason === "ambiguous") add("ig_route_quote_ambiguous", path, `"${words}" appears ${match.count} times in the passage. Quote more words, or say which occurrence: { "text": ..., "occurrence": 2 }.`);
|
|
749
|
+
else add("ig_route_quote_not_found", path, match.reason === "occurrence" ? `"${words}" appears only ${match.count} time${match.count === 1 ? "" : "s"} in the passage.` : `"${words}" is not in the passage. Copy the words exactly as the passage writes them.`);
|
|
750
|
+
};
|
|
751
|
+
const evidence = list(raw.evidence);
|
|
752
|
+
if (evidence.length === 0) add("ig_route_evidence_required", at("evidence"), "Quote where the passage justifies the answer — for a Not Given answer, where it discusses the topic.");
|
|
753
|
+
evidence.forEach((value, at2) => {
|
|
754
|
+
quote(value, at("evidence", at2));
|
|
755
|
+
});
|
|
756
|
+
const visible = learnerTextOf(item);
|
|
757
|
+
list(raw.keywords).forEach((keyword, at2) => {
|
|
758
|
+
if (!isFields(keyword)) return;
|
|
759
|
+
if (typeof keyword.question === "string" && !containsPhrase(visible, keyword.question)) add("ig_route_keyword_not_in_question", at("keywords", at2, "question"), `"${keyword.question}" is not in the question. Copy the words as the question writes them.`);
|
|
760
|
+
if (keyword.passage !== void 0) quote(keyword.passage, at("keywords", at2, "passage"));
|
|
761
|
+
});
|
|
762
|
+
list(raw.links).forEach((link, at2) => {
|
|
763
|
+
if (isFields(link)) {
|
|
764
|
+
quote(link.from, at("links", at2, "from"));
|
|
765
|
+
quote(link.to, at("links", at2, "to"));
|
|
766
|
+
}
|
|
767
|
+
});
|
|
768
|
+
const choices = choicesOf(item, partId);
|
|
769
|
+
list(raw.traps).forEach((trap, at2) => {
|
|
770
|
+
if (!isFields(trap)) return;
|
|
771
|
+
const choice = choices.find((candidate) => candidate.id === trap.optionId);
|
|
772
|
+
if (choice === void 0) add("ig_route_trap_option_unknown", at("traps", at2, "optionId"), `This question has no option or choice "${String(trap.optionId)}".`);
|
|
773
|
+
else if (choice.correct) add("ig_route_trap_on_answer", at("traps", at2, "optionId"), "A trap is a wrong option; this one is the answer.");
|
|
774
|
+
if (trap.lure !== void 0) quote(trap.lure, at("traps", at2, "lure"));
|
|
775
|
+
});
|
|
776
|
+
if (isFields(raw.notes) && isRouteStrategyId(raw.strategy)) {
|
|
777
|
+
const moves = ROUTE_STRATEGIES[raw.strategy].moves;
|
|
778
|
+
for (const [move, note] of Object.entries(raw.notes)) {
|
|
779
|
+
const path = at("notes", move);
|
|
780
|
+
if (!moves.includes(move) || move === "survey") {
|
|
781
|
+
add("ig_route_note_move_unknown", path, `The ${ROUTE_STRATEGIES[raw.strategy].title} strategy has no "${move}" step for a question.`);
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
if (typeof note !== "string") continue;
|
|
785
|
+
if (note.length > 280) add("ig_route_note_too_long", path, `Keep a step's note to 280 characters.`);
|
|
786
|
+
else if (noteRevealsAnswer(item, partId, note)) add("ig_route_note_reveals_answer", path, "This note gives the answer away. Say where to look and why, not what to answer.");
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
});
|
|
790
|
+
for (const question of itemQuestions(items)) if (!seen.has(question.key) && itemsById.has(question.itemId) && isReadingRouteItemType(question.type)) add("ig_route_question_missing", ["route", "questions"], question.partId === void 0 ? `Question "${question.itemId}" has no route entry.` : `Blank or gap "${question.partId}" of "${question.itemId}" has no route entry.`);
|
|
791
|
+
return issues;
|
|
792
|
+
}
|
|
793
|
+
// src/timeline-limits.ts
|
|
794
|
+
/**
|
|
795
|
+
* The bounds of an interactive video — an item group with a `timeline`.
|
|
796
|
+
*
|
|
797
|
+
* A leaf module on purpose: the schemas, the authoring checks and the timeline
|
|
798
|
+
* helpers all read these, and nothing here may import any of them back.
|
|
799
|
+
*/
|
|
800
|
+
/**
|
|
801
|
+
* The item types an interactive video may present. CLOSED on purpose: a type
|
|
802
|
+
* added to the SDK later, or a custom type a host registers, is not silently
|
|
803
|
+
* embeddable. Each one here was chosen, and each works inside a paused video —
|
|
804
|
+
* a read-aloud's and a spoken answer's microphone included, because every quiz
|
|
805
|
+
* pauses the video first.
|
|
806
|
+
*
|
|
807
|
+
* A question's own media is allowed as on any other item, under its own
|
|
808
|
+
* playback policy: a picture to describe, or a recording to answer, which
|
|
809
|
+
* plays only when the learner presses play while the video is paused. A
|
|
810
|
+
* dictation must carry its own recording (the timeline rule).
|
|
811
|
+
*
|
|
812
|
+
* `speaking-response` joined in 1.10.0: a host with an exhaustive `switch` or
|
|
813
|
+
* `Record` over {@link InteractiveVideoItemType} needs one more entry.
|
|
814
|
+
*/
|
|
815
|
+
const INTERACTIVE_VIDEO_ITEM_TYPES = [
|
|
816
|
+
"multiple-choice",
|
|
817
|
+
"fill-in-the-blanks",
|
|
818
|
+
"gap-select",
|
|
819
|
+
"dictation",
|
|
820
|
+
"read-aloud",
|
|
821
|
+
"speaking-response"
|
|
822
|
+
];
|
|
823
|
+
/** Whether `type` is one of the item types an interactive video may present. */
|
|
824
|
+
function isInteractiveVideoItemType(type) {
|
|
825
|
+
return INTERACTIVE_VIDEO_ITEM_TYPES.includes(type);
|
|
826
|
+
}
|
|
827
|
+
/** The most quizzes one video may hold. */
|
|
828
|
+
const TIMELINE_MAX_QUIZZES = 100;
|
|
829
|
+
/**
|
|
830
|
+
* The most questions one interactive video may hold, across all its quizzes.
|
|
831
|
+
* The player keeps every one of them mounted, so an answer survives the
|
|
832
|
+
* learner rewinding past its quiz.
|
|
833
|
+
*/
|
|
834
|
+
const TIMELINE_MAX_ITEMS = 200;
|
|
835
|
+
/** The most chapters one video may hold. */
|
|
836
|
+
const TIMELINE_MAX_CHAPTERS = 100;
|
|
837
|
+
/** The longest quiz or chapter title, in UTF-16 units. A title sits on one line of the player. */
|
|
838
|
+
const TIMELINE_MAX_TITLE_LENGTH = 120;
|
|
839
|
+
// src/schemas/item-group.ts
|
|
840
|
+
/**
|
|
841
|
+
* An authored slot key.
|
|
842
|
+
*
|
|
843
|
+
* A `.` is rejected here, not at render time: `flattenSequence` uses the first
|
|
844
|
+
* `.` to separate a group from its item, so a key containing one would produce
|
|
845
|
+
* an ambiguous slot id. `keyOf` already threw on it — this moves the rejection
|
|
846
|
+
* to `validateItemGroup`, where an author finds it, instead of leaving it to
|
|
847
|
+
* surface when a learner opens the paper. Content that would newly fail
|
|
848
|
+
* validation already threw at flatten time, so it can never have been sat.
|
|
849
|
+
*/
|
|
850
|
+
const SlotKeySchema = z.string().min(1).refine((key) => !key.includes("."), { error: "slotKey must not contain \".\" — that character separates a group from its item in a slot id, so a key containing one would make the slot ambiguous." });
|
|
851
|
+
function hasBody(stimulus) {
|
|
852
|
+
return typeof stimulus.body === "string" && stimulus.body.trim().length > 0;
|
|
853
|
+
}
|
|
854
|
+
/** Which media types can carry a stimulus of each kind. `text`/`mixed` accept any. */
|
|
855
|
+
function mediaFits(kind, mediaType) {
|
|
856
|
+
switch (kind) {
|
|
857
|
+
case "audio": return mediaType === "audio";
|
|
858
|
+
case "video": return mediaType === "video" || mediaType === "embed";
|
|
859
|
+
case "image": return mediaType === "image";
|
|
860
|
+
default: return true;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Zod schema for a {@link Stimulus}. Loose: unknown keys preserved.
|
|
865
|
+
*
|
|
866
|
+
* The semantic guards exist because a stimulus that does not carry what its
|
|
867
|
+
* `kind` promises is an authoring error that must not reach an exam: an
|
|
868
|
+
* "audio" stimulus with no recording renders as a blank panel above six
|
|
869
|
+
* listening questions. Unrepresentable in JSON Schema; dropped from
|
|
870
|
+
* `stimulusJsonSchema` by design.
|
|
871
|
+
*/
|
|
872
|
+
const StimulusSchema = z.looseObject({
|
|
873
|
+
id: z.string().min(1),
|
|
874
|
+
kind: z.enum([
|
|
875
|
+
"text",
|
|
876
|
+
"audio",
|
|
877
|
+
"video",
|
|
878
|
+
"image",
|
|
879
|
+
"mixed"
|
|
880
|
+
]),
|
|
881
|
+
title: z.string().optional(),
|
|
882
|
+
body: z.string().optional(),
|
|
883
|
+
bodyHtml: z.string().optional(),
|
|
884
|
+
media: MediaSchema.optional(),
|
|
885
|
+
transcript: z.string().optional(),
|
|
886
|
+
script: ScriptSchema.optional(),
|
|
887
|
+
locale: z.string().optional(),
|
|
888
|
+
attribution: z.string().optional()
|
|
889
|
+
}).refine((stimulus) => stimulus.bodyHtml === void 0 || hasBody(stimulus), {
|
|
890
|
+
error: "bodyHtml requires a plain-text body: it is the accessible fallback rendered when no sanitiser is supplied.",
|
|
891
|
+
path: ["body"]
|
|
892
|
+
}).refine((stimulus) => stimulus.kind !== "text" && stimulus.kind !== "mixed" || hasBody(stimulus), {
|
|
893
|
+
error: "A text or mixed stimulus needs a non-empty body.",
|
|
894
|
+
path: ["body"]
|
|
895
|
+
}).refine((stimulus) => stimulus.kind === "text" || stimulus.media !== void 0, {
|
|
896
|
+
error: "An audio, video, image or mixed stimulus needs media.",
|
|
897
|
+
path: ["media"]
|
|
898
|
+
}).refine((stimulus) => stimulus.media === void 0 || mediaFits(stimulus.kind, stimulus.media.type), {
|
|
899
|
+
error: "media.type does not fit the stimulus kind: audio needs audio; video needs video or embed; image needs image.",
|
|
900
|
+
path: ["media", "type"]
|
|
901
|
+
});
|
|
902
|
+
const TimelineTitleSchema = maxUnits(z.string(), 120).refine((title) => title.trim().length > 0, { error: "A title must not be empty." });
|
|
903
|
+
/** Seconds from the start of the video. zod rejects NaN and ±Infinity itself. */
|
|
904
|
+
const TimelineTimeSchema = z.number().min(0, { error: "A time is 0 seconds or more." });
|
|
905
|
+
/**
|
|
906
|
+
* A quiz. STRICT, like every part of a timeline: a misspelt `requried` would
|
|
907
|
+
* otherwise be kept and the quiz silently optional on a paper that believed it
|
|
908
|
+
* was required.
|
|
909
|
+
*/
|
|
910
|
+
const TimelineCueSchema = z.strictObject({
|
|
911
|
+
id: maxUnits(z.string().min(1), 64),
|
|
912
|
+
at: TimelineTimeSchema,
|
|
913
|
+
itemIds: z.array(z.string().min(1)).min(1).max(200),
|
|
914
|
+
title: TimelineTitleSchema.optional(),
|
|
915
|
+
required: z.boolean().optional()
|
|
916
|
+
});
|
|
917
|
+
const TimelineChapterSchema = z.strictObject({
|
|
918
|
+
at: TimelineTimeSchema,
|
|
919
|
+
title: TimelineTitleSchema
|
|
920
|
+
});
|
|
921
|
+
/**
|
|
922
|
+
* A timeline on its own: its quizzes, chapters and navigation. The rules that
|
|
923
|
+
* need the group — every item in exactly one quiz, a video stimulus, the five
|
|
924
|
+
* item types — are {@link timelineIssues}, which both group schemas run.
|
|
925
|
+
*/
|
|
926
|
+
const MediaTimelineSchema = z.strictObject({
|
|
927
|
+
cues: z.array(TimelineCueSchema).max(100),
|
|
928
|
+
chapters: z.array(TimelineChapterSchema).max(100).optional(),
|
|
929
|
+
navigation: z.enum(["free", "no-skip-ahead"]).optional()
|
|
930
|
+
}).check((ctx) => {
|
|
931
|
+
const seen = /* @__PURE__ */ new Set();
|
|
932
|
+
ctx.value.cues.forEach((cue, index) => {
|
|
933
|
+
if (seen.has(cue.id)) ctx.issues.push({
|
|
934
|
+
code: "custom",
|
|
935
|
+
input: cue.id,
|
|
936
|
+
message: `Quiz id "${cue.id}" is used twice: events, drafts and the contents panel name a quiz by its id.`,
|
|
937
|
+
path: [
|
|
938
|
+
"cues",
|
|
939
|
+
index,
|
|
940
|
+
"id"
|
|
941
|
+
]
|
|
942
|
+
});
|
|
943
|
+
seen.add(cue.id);
|
|
944
|
+
});
|
|
945
|
+
const chapters = ctx.value.chapters ?? [];
|
|
946
|
+
chapters.forEach((chapter, index) => {
|
|
947
|
+
const previous = chapters[index - 1];
|
|
948
|
+
if (previous !== void 0 && chapter.at <= previous.at) ctx.issues.push({
|
|
949
|
+
code: "custom",
|
|
950
|
+
input: chapter.at,
|
|
951
|
+
message: "Chapter times must be strictly increasing.",
|
|
952
|
+
path: [
|
|
953
|
+
"chapters",
|
|
954
|
+
index,
|
|
955
|
+
"at"
|
|
956
|
+
]
|
|
957
|
+
});
|
|
958
|
+
});
|
|
959
|
+
});
|
|
960
|
+
/** Words copied from the passage: the words, or the words and which occurrence of them. */
|
|
961
|
+
const RouteQuoteSchema = z.union([z.string().min(1), z.strictObject({
|
|
962
|
+
text: z.string().min(1),
|
|
963
|
+
occurrence: z.number().int().min(1).optional()
|
|
964
|
+
})]);
|
|
965
|
+
const ROUTE_TRAP_KINDS = [
|
|
966
|
+
"word-match",
|
|
967
|
+
"opposite",
|
|
968
|
+
"partial",
|
|
969
|
+
"outside-knowledge",
|
|
970
|
+
"too-general",
|
|
971
|
+
"too-specific",
|
|
972
|
+
"not-the-question"
|
|
973
|
+
];
|
|
974
|
+
const ROUTE_MOVE_IDS = [
|
|
975
|
+
"read-instructions",
|
|
976
|
+
"survey",
|
|
977
|
+
"keywords",
|
|
978
|
+
"predict",
|
|
979
|
+
"locate",
|
|
980
|
+
"read-around",
|
|
981
|
+
"paraphrase",
|
|
982
|
+
"decide",
|
|
983
|
+
"eliminate",
|
|
984
|
+
"cohesion",
|
|
985
|
+
"check-answer",
|
|
986
|
+
"compare-views"
|
|
987
|
+
];
|
|
988
|
+
const RouteSectionSchema = z.strictObject({
|
|
989
|
+
label: z.string().min(1),
|
|
990
|
+
title: z.string().optional(),
|
|
991
|
+
paragraphs: z.number().int().min(1)
|
|
992
|
+
});
|
|
993
|
+
/**
|
|
994
|
+
* One question of a route. STRICT, like every part of a route: a misspelt
|
|
995
|
+
* `evidense` would otherwise leave a question with no evidence and no error.
|
|
996
|
+
* The rules that need the passage or the items are {@link routeIssues}.
|
|
997
|
+
*/
|
|
998
|
+
const RouteQuestionSchema = z.strictObject({
|
|
999
|
+
itemId: z.string().min(1),
|
|
1000
|
+
partId: z.string().min(1).optional(),
|
|
1001
|
+
strategy: z.enum(ROUTE_STRATEGY_IDS),
|
|
1002
|
+
evidence: z.array(RouteQuoteSchema).min(1),
|
|
1003
|
+
evidenceMatch: z.enum(["any", "all"]).optional(),
|
|
1004
|
+
keywords: z.array(z.strictObject({
|
|
1005
|
+
question: z.string().min(1),
|
|
1006
|
+
passage: RouteQuoteSchema.optional()
|
|
1007
|
+
})).optional(),
|
|
1008
|
+
links: z.array(z.strictObject({
|
|
1009
|
+
from: RouteQuoteSchema,
|
|
1010
|
+
to: RouteQuoteSchema
|
|
1011
|
+
})).optional(),
|
|
1012
|
+
wordLimit: z.number().int().min(1).max(10).optional(),
|
|
1013
|
+
traps: z.array(z.strictObject({
|
|
1014
|
+
optionId: z.string().min(1),
|
|
1015
|
+
kind: z.enum(ROUTE_TRAP_KINDS),
|
|
1016
|
+
lure: RouteQuoteSchema.optional()
|
|
1017
|
+
})).optional(),
|
|
1018
|
+
notes: z.partialRecord(z.enum(ROUTE_MOVE_IDS), maxUnits(z.string().min(1), 280)).optional()
|
|
1019
|
+
});
|
|
1020
|
+
/** A route on its own. The rules that span the route, the passage and the items are {@link routeIssues}, which both group schemas run. */
|
|
1021
|
+
const ReadingRouteSchema = z.strictObject({
|
|
1022
|
+
routeVersion: z.literal("1.0"),
|
|
1023
|
+
startAt: z.number().int().min(1).optional(),
|
|
1024
|
+
paragraphLabels: z.array(z.string()).optional(),
|
|
1025
|
+
sections: z.array(RouteSectionSchema).min(1).optional(),
|
|
1026
|
+
questions: z.array(RouteQuestionSchema)
|
|
1027
|
+
});
|
|
1028
|
+
/** A route as a learner's copy carries it: which question practises which strategy, and nothing that locates an answer. */
|
|
1029
|
+
const RedactedReadingRouteSchema = z.strictObject({
|
|
1030
|
+
routeVersion: z.literal("1.0"),
|
|
1031
|
+
startAt: z.number().int().min(1).optional(),
|
|
1032
|
+
paragraphLabels: z.array(z.string()).optional(),
|
|
1033
|
+
sections: z.array(RouteSectionSchema).min(1).optional(),
|
|
1034
|
+
questions: z.array(z.strictObject({
|
|
1035
|
+
itemId: z.string().min(1),
|
|
1036
|
+
partId: z.string().min(1).optional(),
|
|
1037
|
+
strategy: z.enum(ROUTE_STRATEGY_IDS),
|
|
1038
|
+
wordLimit: z.number().int().min(1).max(10).optional()
|
|
1039
|
+
}))
|
|
1040
|
+
});
|
|
1041
|
+
/** A URL only this document can resolve, or one that inlines the bytes. */
|
|
1042
|
+
function isLocalOnlyUrl(url) {
|
|
1043
|
+
return typeof url === "string" && /^(data|blob):/i.test(url);
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* The rules of an interactive video that span the timeline, the stimulus and
|
|
1047
|
+
* the items. Run by the content schema and, for everything but the two
|
|
1048
|
+
* authoring-only rules, by the strict redacted schema — a projection built by
|
|
1049
|
+
* hand never passed through the content schema.
|
|
1050
|
+
*
|
|
1051
|
+
* Items are read defensively: the redacted schema leaves them opaque, and each
|
|
1052
|
+
* is proven against its own type's schema elsewhere.
|
|
1053
|
+
*/
|
|
1054
|
+
function timelineIssues(group, scope) {
|
|
1055
|
+
const timeline = group.timeline;
|
|
1056
|
+
if (timeline === void 0) return [];
|
|
1057
|
+
const issues = [];
|
|
1058
|
+
const media = group.stimulus.media;
|
|
1059
|
+
if (group.stimulus.kind !== "video") issues.push({
|
|
1060
|
+
path: ["stimulus", "kind"],
|
|
1061
|
+
input: group.stimulus.kind,
|
|
1062
|
+
message: "An interactive video needs a video stimulus: quizzes open at moments of a video, and nothing else has them."
|
|
1063
|
+
});
|
|
1064
|
+
else if (media !== void 0 && media.type !== "video") issues.push({
|
|
1065
|
+
path: [
|
|
1066
|
+
"stimulus",
|
|
1067
|
+
"media",
|
|
1068
|
+
"type"
|
|
1069
|
+
],
|
|
1070
|
+
input: media.type,
|
|
1071
|
+
message: "An interactive video needs a video file. An embedded provider player cannot be paused at the moment a quiz opens."
|
|
1072
|
+
});
|
|
1073
|
+
if (group.shuffle === "within-group") issues.push({
|
|
1074
|
+
path: ["shuffle"],
|
|
1075
|
+
input: group.shuffle,
|
|
1076
|
+
message: "An interactive video presents its questions in the order its quizzes open; shuffle: \"within-group\" cannot apply to it."
|
|
1077
|
+
});
|
|
1078
|
+
if (group.items.length > 200) issues.push({
|
|
1079
|
+
path: ["items"],
|
|
1080
|
+
input: group.items.length,
|
|
1081
|
+
message: `An interactive video holds at most 200 questions.`
|
|
1082
|
+
});
|
|
1083
|
+
const itemIds = /* @__PURE__ */ new Map();
|
|
1084
|
+
group.items.forEach((item, index) => {
|
|
1085
|
+
const { id, type, media: itemMedia } = item ?? {};
|
|
1086
|
+
if (typeof id === "string") itemIds.set(id, index);
|
|
1087
|
+
if (!isInteractiveVideoItemType(type)) issues.push({
|
|
1088
|
+
path: [
|
|
1089
|
+
"items",
|
|
1090
|
+
index,
|
|
1091
|
+
"type"
|
|
1092
|
+
],
|
|
1093
|
+
input: type,
|
|
1094
|
+
message: `An interactive video may hold only ${INTERACTIVE_VIDEO_ITEM_TYPES.join(", ")} questions.`
|
|
1095
|
+
});
|
|
1096
|
+
if (scope === "content" && type === "dictation" && itemMedia === void 0) issues.push({
|
|
1097
|
+
path: [
|
|
1098
|
+
"items",
|
|
1099
|
+
index,
|
|
1100
|
+
"media"
|
|
1101
|
+
],
|
|
1102
|
+
input: itemMedia,
|
|
1103
|
+
message: "A dictation inside an interactive video needs its own recording."
|
|
1104
|
+
});
|
|
1105
|
+
});
|
|
1106
|
+
const placed = /* @__PURE__ */ new Set();
|
|
1107
|
+
timeline.cues.forEach((cue, cueIndex) => {
|
|
1108
|
+
cue.itemIds.forEach((itemId, position) => {
|
|
1109
|
+
const path = [
|
|
1110
|
+
"timeline",
|
|
1111
|
+
"cues",
|
|
1112
|
+
cueIndex,
|
|
1113
|
+
"itemIds",
|
|
1114
|
+
position
|
|
1115
|
+
];
|
|
1116
|
+
if (!itemIds.has(itemId)) issues.push({
|
|
1117
|
+
path,
|
|
1118
|
+
input: itemId,
|
|
1119
|
+
message: `No item in this group has the id "${itemId}".`
|
|
1120
|
+
});
|
|
1121
|
+
else if (placed.has(itemId)) issues.push({
|
|
1122
|
+
path,
|
|
1123
|
+
input: itemId,
|
|
1124
|
+
message: `Item "${itemId}" is placed twice: every question belongs to exactly one quiz.`
|
|
1125
|
+
});
|
|
1126
|
+
placed.add(itemId);
|
|
1127
|
+
});
|
|
1128
|
+
});
|
|
1129
|
+
for (const [itemId, index] of itemIds) if (!placed.has(itemId)) issues.push({
|
|
1130
|
+
path: ["items", index],
|
|
1131
|
+
input: itemId,
|
|
1132
|
+
message: `Item "${itemId}" is in no quiz, so the video would never show it.`
|
|
1133
|
+
});
|
|
1134
|
+
if (scope === "content" && media !== void 0) {
|
|
1135
|
+
const urls = [
|
|
1136
|
+
[media.url, [
|
|
1137
|
+
"stimulus",
|
|
1138
|
+
"media",
|
|
1139
|
+
"url"
|
|
1140
|
+
]],
|
|
1141
|
+
[media.poster, [
|
|
1142
|
+
"stimulus",
|
|
1143
|
+
"media",
|
|
1144
|
+
"poster"
|
|
1145
|
+
]],
|
|
1146
|
+
...(media.tracks ?? []).map((track, index) => [track.src, [
|
|
1147
|
+
"stimulus",
|
|
1148
|
+
"media",
|
|
1149
|
+
"tracks",
|
|
1150
|
+
index,
|
|
1151
|
+
"src"
|
|
1152
|
+
]])
|
|
1153
|
+
];
|
|
1154
|
+
for (const [url, path] of urls) if (isLocalOnlyUrl(url)) issues.push({
|
|
1155
|
+
path,
|
|
1156
|
+
input: url,
|
|
1157
|
+
message: "An interactive video is stored and served: its URLs cannot be data: or blob: URLs."
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
return issues;
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* The structural minimum of an item as seen by the CONTAINER schema. Each
|
|
1164
|
+
* item's own contract is checked against its registered schema by
|
|
1165
|
+
* {@link validateItemGroup} — a zod schema cannot dispatch on a registry
|
|
1166
|
+
* that consumers extend at runtime.
|
|
1167
|
+
*/
|
|
1168
|
+
const ItemShapeSchema = z.looseObject({
|
|
1169
|
+
type: z.string().min(1),
|
|
1170
|
+
id: z.string().min(1),
|
|
1171
|
+
slotKey: SlotKeySchema.optional()
|
|
1172
|
+
});
|
|
1173
|
+
/**
|
|
1174
|
+
* Zod schema for an {@link ItemGroup} CONTAINER. Loose: unknown keys
|
|
1175
|
+
* preserved. Validates the group's own fields and the stimulus in full, and
|
|
1176
|
+
* each item only structurally (`type` and `id`); use {@link validateItemGroup}
|
|
1177
|
+
* to validate the items against their registered schemas as well.
|
|
1178
|
+
*/
|
|
1179
|
+
const ItemGroupSchema = z.looseObject({
|
|
1180
|
+
schemaVersion: z.literal("1.0"),
|
|
1181
|
+
type: z.literal("item-group"),
|
|
1182
|
+
id: z.string().min(1),
|
|
1183
|
+
title: z.string().optional(),
|
|
1184
|
+
slotKey: SlotKeySchema.optional(),
|
|
1185
|
+
stimulus: StimulusSchema,
|
|
1186
|
+
items: z.array(ItemShapeSchema).min(1),
|
|
1187
|
+
shuffle: z.enum(["none", "within-group"]).optional(),
|
|
1188
|
+
timeline: MediaTimelineSchema.optional(),
|
|
1189
|
+
route: ReadingRouteSchema.optional()
|
|
1190
|
+
}).refine((group) => group.items.every((item) => item.type !== "item-group"), {
|
|
1191
|
+
error: "Item groups do not nest: every item must be an activity.",
|
|
1192
|
+
path: ["items"]
|
|
1193
|
+
}).refine((group) => new Set(group.items.map((item) => item.id)).size === group.items.length, {
|
|
1194
|
+
error: "Item ids must be unique within a group.",
|
|
1195
|
+
path: ["items"]
|
|
1196
|
+
}).check((ctx) => {
|
|
1197
|
+
for (const issue of timelineIssues(ctx.value, "content")) ctx.issues.push({
|
|
1198
|
+
code: "custom",
|
|
1199
|
+
...issue
|
|
1200
|
+
});
|
|
1201
|
+
for (const { path, message } of routeIssues(ctx.value, "content")) ctx.issues.push({
|
|
1202
|
+
code: "custom",
|
|
1203
|
+
input: ctx.value.route,
|
|
1204
|
+
path,
|
|
1205
|
+
message
|
|
1206
|
+
});
|
|
1207
|
+
});
|
|
1208
|
+
/** Strict learner-safe stimulus: everything but the author-only `transcript`. */
|
|
1209
|
+
const RedactedStimulusSchema = z.strictObject({
|
|
1210
|
+
id: z.string().min(1),
|
|
1211
|
+
kind: z.enum([
|
|
1212
|
+
"text",
|
|
1213
|
+
"audio",
|
|
1214
|
+
"video",
|
|
1215
|
+
"image",
|
|
1216
|
+
"mixed"
|
|
1217
|
+
]),
|
|
1218
|
+
title: z.string().optional(),
|
|
1219
|
+
body: z.string().optional(),
|
|
1220
|
+
bodyHtml: z.string().optional(),
|
|
1221
|
+
media: RedactedMediaSchema.optional(),
|
|
1222
|
+
locale: z.string().optional(),
|
|
1223
|
+
attribution: z.string().optional()
|
|
1224
|
+
});
|
|
1225
|
+
/**
|
|
1226
|
+
* Strict learner-safe item group. Items are left opaque here — each is proven
|
|
1227
|
+
* learner-safe by `assertRedacted` against its OWN type's redacted schema,
|
|
1228
|
+
* which is the only place that knowledge lives. The one exception is the one
|
|
1229
|
+
* rule that spans the container and an item: a captioned stimulus recording
|
|
1230
|
+
* played to a dictation is not learner-safe, because the captions are the
|
|
1231
|
+
* dictation's answer.
|
|
1232
|
+
*/
|
|
1233
|
+
const RedactedItemGroupSchema = z.strictObject({
|
|
1234
|
+
redacted: z.literal(true),
|
|
1235
|
+
schemaVersion: z.literal("1.0"),
|
|
1236
|
+
type: z.literal("item-group"),
|
|
1237
|
+
id: z.string().min(1),
|
|
1238
|
+
title: z.string().optional(),
|
|
1239
|
+
slotKey: SlotKeySchema.optional(),
|
|
1240
|
+
stimulus: RedactedStimulusSchema,
|
|
1241
|
+
items: z.array(z.unknown()).min(1),
|
|
1242
|
+
shuffle: z.enum(["none", "within-group"]).optional(),
|
|
1243
|
+
timeline: MediaTimelineSchema.optional(),
|
|
1244
|
+
route: RedactedReadingRouteSchema.optional()
|
|
1245
|
+
}).check((ctx) => {
|
|
1246
|
+
for (const issue of timelineIssues(ctx.value, "redacted")) ctx.issues.push({
|
|
1247
|
+
code: "custom",
|
|
1248
|
+
...issue
|
|
1249
|
+
});
|
|
1250
|
+
for (const { path, message } of routeIssues(ctx.value, "redacted")) ctx.issues.push({
|
|
1251
|
+
code: "custom",
|
|
1252
|
+
input: ctx.value.route,
|
|
1253
|
+
path,
|
|
1254
|
+
message
|
|
1255
|
+
});
|
|
1256
|
+
const captions = groupCaptionsField(ctx.value);
|
|
1257
|
+
if (captions !== void 0) ctx.issues.push({
|
|
1258
|
+
code: "custom",
|
|
1259
|
+
input: ctx.value.stimulus.media?.[captions],
|
|
1260
|
+
message: GROUP_CAPTIONS_REVEAL_DICTATION,
|
|
1261
|
+
path: [
|
|
1262
|
+
"stimulus",
|
|
1263
|
+
"media",
|
|
1264
|
+
captions
|
|
1265
|
+
]
|
|
1266
|
+
});
|
|
1267
|
+
});
|
|
1268
|
+
function toValidationErrors(issues, prefix) {
|
|
1269
|
+
return issues.map((issue) => ({
|
|
1270
|
+
path: [...prefix, ...issue.path.map(String)],
|
|
1271
|
+
message: issue.message,
|
|
1272
|
+
code: issue.code
|
|
1273
|
+
}));
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* Validates an item group in full: the container and stimulus against
|
|
1277
|
+
* {@link ItemGroupSchema}, then every item against the schema registered for
|
|
1278
|
+
* its `type`. Errors from items are reported at `items.<index>.…`.
|
|
1279
|
+
*
|
|
1280
|
+
* Unlike `validateActivity`, an item whose type is not registered is REPORTED
|
|
1281
|
+
* (code `unknown_activity_type`) rather than thrown: a group is validated as
|
|
1282
|
+
* a whole, and an author fixing a six-item group wants every problem listed,
|
|
1283
|
+
* not the first one that happened to throw.
|
|
1284
|
+
*/
|
|
1285
|
+
function validateItemGroup(data) {
|
|
1286
|
+
const container = ItemGroupSchema.safeParse(data);
|
|
1287
|
+
if (!container.success) return {
|
|
1288
|
+
success: false,
|
|
1289
|
+
errors: toValidationErrors(container.error.issues, [])
|
|
1290
|
+
};
|
|
1291
|
+
const errors = [];
|
|
1292
|
+
const items = [];
|
|
1293
|
+
container.data.items.forEach((item, index) => {
|
|
1294
|
+
const descriptor = getActivityTypeDescriptor(item.type);
|
|
1295
|
+
if (descriptor === void 0) {
|
|
1296
|
+
errors.push({
|
|
1297
|
+
path: [
|
|
1298
|
+
"items",
|
|
1299
|
+
String(index),
|
|
1300
|
+
"type"
|
|
1301
|
+
],
|
|
1302
|
+
message: `Activity type "${item.type}" is not registered`,
|
|
1303
|
+
code: "unknown_activity_type"
|
|
1304
|
+
});
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
const parsed = readSchema(descriptor.schema, item, `Activity type "${descriptor.type}"`);
|
|
1308
|
+
if (!parsed.success) {
|
|
1309
|
+
errors.push(...toValidationErrors(parsed.issues, ["items", String(index)]));
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
items.push(parsed.data);
|
|
1313
|
+
});
|
|
1314
|
+
const captions = groupCaptionsField(container.data);
|
|
1315
|
+
if (captions !== void 0) errors.push({
|
|
1316
|
+
path: [
|
|
1317
|
+
"stimulus",
|
|
1318
|
+
"media",
|
|
1319
|
+
captions
|
|
1320
|
+
],
|
|
1321
|
+
message: GROUP_CAPTIONS_REVEAL_DICTATION,
|
|
1322
|
+
code: "custom"
|
|
1323
|
+
});
|
|
1324
|
+
if (errors.length > 0) return {
|
|
1325
|
+
success: false,
|
|
1326
|
+
errors
|
|
1327
|
+
};
|
|
1328
|
+
return {
|
|
1329
|
+
success: true,
|
|
1330
|
+
data: {
|
|
1331
|
+
...container.data,
|
|
1332
|
+
items
|
|
1333
|
+
}
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
export { questionKey as C, passageLayout as D, overlaps as E, sentencesTouching as O, itemQuestions as S, countParagraphs as T, isReadingRouteItemType as _, INTERACTIVE_VIDEO_ITEM_TYPES as a, quoteText as b, TIMELINE_MAX_QUIZZES as c, routeIssues as d, READING_ROUTE_ITEM_TYPES as f, ROUTE_WORD_LIMIT_MAX as g, ROUTE_STRATEGY_IDS as h, validateItemGroup as i, TIMELINE_MAX_TITLE_LENGTH as l, ROUTE_STRATEGIES as m, RedactedItemGroupSchema as n, TIMELINE_MAX_CHAPTERS as o, ROUTE_NOTE_MAX_LENGTH as p, StimulusSchema as r, TIMELINE_MAX_ITEMS as s, ItemGroupSchema as t, isInteractiveVideoItemType as u, isRouteStrategyId as v, ROUTE_SEGMENTER as w, choicesOf as x, findQuote as y };
|
|
1337
|
+
|
|
1338
|
+
//# sourceMappingURL=item-group-uNAT5EX5.js.map
|