@pie-element/mc-populated-blank 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/README.md +31 -0
- package/dist/author/index.d.ts +11 -0
- package/dist/author/index.d.ts.map +1 -0
- package/dist/author/index.js +3641 -0
- package/dist/author/index.js.map +1 -0
- package/dist/controller/defaults.d.ts +38 -0
- package/dist/controller/defaults.d.ts.map +1 -0
- package/dist/controller/index.d.ts +11 -0
- package/dist/controller/index.d.ts.map +1 -0
- package/dist/controller/index.js +237 -0
- package/dist/controller/index.js.map +1 -0
- package/dist/delivery/index.d.ts +22 -0
- package/dist/delivery/index.d.ts.map +1 -0
- package/dist/delivery/index.js +5736 -0
- package/dist/delivery/index.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.iife.d.ts +6 -0
- package/dist/index.iife.d.ts.map +1 -0
- package/dist/index.iife.js +20 -0
- package/dist/index.js +5736 -0
- package/dist/index.js.map +1 -0
- package/dist/print/index.d.ts +8 -0
- package/dist/print/index.d.ts.map +1 -0
- package/dist/print/index.js +4952 -0
- package/dist/print/index.js.map +1 -0
- package/package.json +61 -0
- package/src/author/Author.svelte +41 -0
- package/src/author/index.ts +28 -0
- package/src/controller/defaults.ts +38 -0
- package/src/controller/index.ts +273 -0
- package/src/delivery/McPopulatedBlank.svelte +1003 -0
- package/src/delivery/index.ts +103 -0
- package/src/index.iife.ts +13 -0
- package/src/index.ts +1 -0
- package/src/print/Print.svelte +95 -0
- package/src/print/index.ts +18 -0
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pie-element/mc-populated-blank",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Multiple-choice option populates an inline blank in a sentence template (Svelte 5 PIE element)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"development": "./src/index.ts",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./delivery": {
|
|
15
|
+
"development": "./src/delivery/index.ts",
|
|
16
|
+
"types": "./dist/delivery/index.d.ts",
|
|
17
|
+
"default": "./dist/delivery/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./controller": {
|
|
20
|
+
"development": "./src/controller/index.ts",
|
|
21
|
+
"types": "./dist/controller/index.d.ts",
|
|
22
|
+
"default": "./dist/controller/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./author": {
|
|
25
|
+
"development": "./src/author/index.ts",
|
|
26
|
+
"types": "./dist/author/index.d.ts",
|
|
27
|
+
"default": "./dist/author/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./print": {
|
|
30
|
+
"development": "./src/print/index.ts",
|
|
31
|
+
"types": "./dist/print/index.d.ts",
|
|
32
|
+
"default": "./dist/print/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./iife": {
|
|
35
|
+
"default": "./dist/index.iife.js"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist",
|
|
40
|
+
"src"
|
|
41
|
+
],
|
|
42
|
+
"sideEffects": false,
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "vite build && vite build --config vite.delivery.config.ts && vite build --config vite.controller.config.ts && vite build --config vite.author.config.ts && vite build --config vite.print.config.ts && vite build --config vite.config.iife.ts && tsc --emitDeclarationOnly",
|
|
45
|
+
"dev": "vite",
|
|
46
|
+
"clean": "rm -rf dist tsconfig.tsbuildinfo"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"lodash-es": "^4.17.21"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"svelte": "^5.0.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
|
56
|
+
"@types/lodash-es": "^4.17.12",
|
|
57
|
+
"svelte": "^5.54.0",
|
|
58
|
+
"typescript": "^5.9.3",
|
|
59
|
+
"vite": "^8.0.1"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<svelte:options
|
|
2
|
+
customElement={{
|
|
3
|
+
shadow: 'none',
|
|
4
|
+
props: {
|
|
5
|
+
model: { type: 'Object' },
|
|
6
|
+
},
|
|
7
|
+
}}
|
|
8
|
+
/>
|
|
9
|
+
|
|
10
|
+
<script lang="ts">
|
|
11
|
+
// Keep the same prop shape expected by the wrapper.
|
|
12
|
+
let { model = $bindable(), onChange }: { model?: any; onChange?: (model: any) => void } = $props();
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<div class="mc-populated-blank-author-placeholder" role="status" aria-live="polite">
|
|
16
|
+
<p class="title">Author view placeholder</p>
|
|
17
|
+
<p class="body">This authoring interface is a placeholder and will be implemented later.</p>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<style>
|
|
21
|
+
.mc-populated-blank-author-placeholder {
|
|
22
|
+
max-width: 56rem;
|
|
23
|
+
margin: 24px auto;
|
|
24
|
+
padding: 20px;
|
|
25
|
+
border: 1px dashed #cbd5e1;
|
|
26
|
+
border-radius: 8px;
|
|
27
|
+
background: #f8fafc;
|
|
28
|
+
color: #334155;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.title {
|
|
32
|
+
margin: 0 0 8px;
|
|
33
|
+
font-size: 1rem;
|
|
34
|
+
font-weight: 600;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.body {
|
|
38
|
+
margin: 0;
|
|
39
|
+
font-size: 0.9rem;
|
|
40
|
+
}
|
|
41
|
+
</style>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import AuthorComponent from './Author.svelte';
|
|
2
|
+
|
|
3
|
+
const SvelteElementClass = (AuthorComponent as any).element;
|
|
4
|
+
|
|
5
|
+
class McPopulatedBlankAuthor extends SvelteElementClass {
|
|
6
|
+
private _model: any = null;
|
|
7
|
+
private _onChange: ((model: any) => void) | null = null;
|
|
8
|
+
|
|
9
|
+
set model(m: any) {
|
|
10
|
+
this._model = m;
|
|
11
|
+
super.model = m;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
get model() {
|
|
15
|
+
return this._model;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
set onChange(fn: (model: any) => void) {
|
|
19
|
+
this._onChange = fn;
|
|
20
|
+
(this as any).onChange = fn;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get onChange() {
|
|
24
|
+
return this._onChange || (() => {});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default McPopulatedBlankAuthor;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Token authors must include exactly once in `template` (HTML). */
|
|
2
|
+
export const BLANK_TOKEN = '{{blank}}';
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
model: {
|
|
6
|
+
id: '1',
|
|
7
|
+
element: 'mc-populated-blank',
|
|
8
|
+
prompt: '<p><strong>Demo prompt</strong></p>',
|
|
9
|
+
promptEnabled: true,
|
|
10
|
+
interactionMode: 'populate_blank' as const,
|
|
11
|
+
layoutProfile: '',
|
|
12
|
+
choiceLayout: '',
|
|
13
|
+
sentenceHtml: '',
|
|
14
|
+
template: `<p>The answer is ${BLANK_TOKEN}.</p>`,
|
|
15
|
+
choiceMode: 'text' as const,
|
|
16
|
+
choices: [
|
|
17
|
+
{ id: 'c1', labelHtml: '<p>Option A</p>' },
|
|
18
|
+
{ id: 'c2', labelHtml: '<p>Option B</p>' },
|
|
19
|
+
{ id: 'c3', labelHtml: '<p>Option C</p>' },
|
|
20
|
+
],
|
|
21
|
+
correctChoiceId: 'c2',
|
|
22
|
+
hasAudio: false,
|
|
23
|
+
autoplayAudioEnabled: false,
|
|
24
|
+
completeAudioEnabled: false,
|
|
25
|
+
audioUrl: '',
|
|
26
|
+
audioTranscript: '',
|
|
27
|
+
showVisibleTranscript: false,
|
|
28
|
+
locale: '',
|
|
29
|
+
teacherInstructions: '',
|
|
30
|
+
teacherInstructionsEnabled: false,
|
|
31
|
+
},
|
|
32
|
+
configuration: {
|
|
33
|
+
prompt: {
|
|
34
|
+
label: 'Prompt',
|
|
35
|
+
settings: true,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { isEmpty } from 'lodash-es';
|
|
2
|
+
import defaults, { BLANK_TOKEN } from './defaults';
|
|
3
|
+
|
|
4
|
+
export function countBlankTokens(template: string): number {
|
|
5
|
+
if (!template) return 0;
|
|
6
|
+
const re = /\{\{blank\}\}/g;
|
|
7
|
+
return (template.match(re) || []).length;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const getCorrectness = (question: any, session: any) => {
|
|
11
|
+
if (!session || !session.choiceId) {
|
|
12
|
+
return 'unanswered';
|
|
13
|
+
}
|
|
14
|
+
const correct = question?.correctChoiceId || '';
|
|
15
|
+
if (session.choiceId === correct) {
|
|
16
|
+
return 'correct';
|
|
17
|
+
}
|
|
18
|
+
return 'incorrect';
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const getPartialScore = (_question: any, session: any) => {
|
|
22
|
+
if (!session || isEmpty(session) || !session.choiceId) {
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
return 1;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const isComplete = (question: any, session: any, audioComplete = false) => {
|
|
29
|
+
if (!session || !session.choiceId) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
const autoplayAudioEnabled = !!question?.autoplayAudioEnabled;
|
|
33
|
+
const completeAudioEnabled = !!question?.completeAudioEnabled;
|
|
34
|
+
const requiresAudioCompletion =
|
|
35
|
+
autoplayAudioEnabled && completeAudioEnabled && !!question?.hasAudio;
|
|
36
|
+
if (requiresAudioCompletion && !audioComplete) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const outcome = (question: any, session: any, env: any) =>
|
|
43
|
+
new Promise((resolve) => {
|
|
44
|
+
if (!session || isEmpty(session)) {
|
|
45
|
+
resolve({
|
|
46
|
+
score: 0,
|
|
47
|
+
empty: true,
|
|
48
|
+
traceLog: ['Student did not select any answers. Score is 0.'],
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
session = normalizeSession(session);
|
|
54
|
+
const correctness = getCorrectness(question, session);
|
|
55
|
+
|
|
56
|
+
if (correctness === 'unanswered') {
|
|
57
|
+
resolve({
|
|
58
|
+
score: 0,
|
|
59
|
+
empty: true,
|
|
60
|
+
traceLog: ['Student did not select any answers. Score is 0.'],
|
|
61
|
+
});
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const score = correctness === 'correct' ? 1 : 0;
|
|
66
|
+
const traceLog = [
|
|
67
|
+
`Mode: ${env?.mode || 'unknown'}.`,
|
|
68
|
+
`Student selected choice: ${session.choiceId}.`,
|
|
69
|
+
`Correct choice: ${question?.correctChoiceId || 'none'}.`,
|
|
70
|
+
`Final score: ${score}.`,
|
|
71
|
+
];
|
|
72
|
+
resolve({ score, empty: false, traceLog });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export const createDefaultModel = (model: any = {}) => ({ ...defaults.model, ...model });
|
|
76
|
+
|
|
77
|
+
export const normalizeSession = (s: any) => ({ ...s });
|
|
78
|
+
|
|
79
|
+
const shouldShuffleChoices = (question: any) => !!question?.shuffle;
|
|
80
|
+
|
|
81
|
+
const shouldLockChoices = (question: any, env: any) => {
|
|
82
|
+
if (question?.lockChoiceOrder) return true;
|
|
83
|
+
if (env?.['@pie-element']?.lockChoiceOrder) return true;
|
|
84
|
+
return env?.role === 'instructor';
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const shuffleArray = <T>(items: T[]): T[] => {
|
|
88
|
+
const out = [...items];
|
|
89
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
90
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
91
|
+
[out[i], out[j]] = [out[j], out[i]];
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const getStoredShuffle = (session: any): string[] =>
|
|
97
|
+
Array.isArray(session?.data?.shuffledValues)
|
|
98
|
+
? session.data.shuffledValues
|
|
99
|
+
: Array.isArray(session?.shuffledValues)
|
|
100
|
+
? session.shuffledValues
|
|
101
|
+
: [];
|
|
102
|
+
|
|
103
|
+
const applyShuffledValues = (choices: any[], shuffledValues: string[], choiceKey: string) => {
|
|
104
|
+
const orderedChoices = shuffledValues
|
|
105
|
+
.map((value) => choices.find((choice) => choice?.[choiceKey] === value))
|
|
106
|
+
.filter(Boolean);
|
|
107
|
+
|
|
108
|
+
if (orderedChoices.length === choices.length) {
|
|
109
|
+
return orderedChoices;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const orderedValues = new Set(orderedChoices.map((choice: any) => choice[choiceKey]));
|
|
113
|
+
const leftovers = choices.filter((choice) => !orderedValues.has(choice?.[choiceKey]));
|
|
114
|
+
return [...orderedChoices, ...leftovers];
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const getOrderedChoices = async (question: any, session: any, env: any, updateSession?: any) => {
|
|
118
|
+
const choices = Array.isArray(question?.choices) ? [...question.choices] : [];
|
|
119
|
+
if (!choices.length || !shouldShuffleChoices(question)) {
|
|
120
|
+
return choices;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (shouldLockChoices(question, env || {})) {
|
|
124
|
+
return choices;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const shuffledValues = getStoredShuffle(session);
|
|
128
|
+
if (shuffledValues.length) {
|
|
129
|
+
return applyShuffledValues(choices, shuffledValues, 'id');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const shuffledChoices = shuffleArray(choices);
|
|
133
|
+
|
|
134
|
+
if (updateSession && typeof updateSession === 'function' && session?.id && session?.element) {
|
|
135
|
+
const shuffledIds = shuffledChoices.map((choice) => choice?.id).filter(Boolean);
|
|
136
|
+
if (shuffledIds.length) {
|
|
137
|
+
await updateSession(session.id, session.element, { shuffledValues: shuffledIds });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return shuffledChoices;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const model = async (question: any, session: any, env: any, updateSession?: any) => {
|
|
145
|
+
session = session || {};
|
|
146
|
+
const normalizedQuestion = createDefaultModel(question);
|
|
147
|
+
const choices = await getOrderedChoices(normalizedQuestion, session, env, updateSession);
|
|
148
|
+
|
|
149
|
+
const out: any = {
|
|
150
|
+
prompt: normalizedQuestion.promptEnabled ? normalizedQuestion.prompt : null,
|
|
151
|
+
interactionMode: normalizedQuestion.interactionMode || 'populate_blank',
|
|
152
|
+
layoutProfile: normalizedQuestion.layoutProfile || '',
|
|
153
|
+
choiceLayout: normalizedQuestion.choiceLayout || '',
|
|
154
|
+
sentenceHtml: normalizedQuestion.sentenceHtml || null,
|
|
155
|
+
template: normalizedQuestion.template,
|
|
156
|
+
choiceMode: normalizedQuestion.choiceMode,
|
|
157
|
+
choices,
|
|
158
|
+
hasAudio: normalizedQuestion.hasAudio,
|
|
159
|
+
autoplayAudioEnabled: !!normalizedQuestion.autoplayAudioEnabled,
|
|
160
|
+
completeAudioEnabled: !!normalizedQuestion.completeAudioEnabled,
|
|
161
|
+
audioUrl: normalizedQuestion.hasAudio ? normalizedQuestion.audioUrl : null,
|
|
162
|
+
audioTranscript: normalizedQuestion.hasAudio ? normalizedQuestion.audioTranscript : null,
|
|
163
|
+
showVisibleTranscript: !!normalizedQuestion.showVisibleTranscript,
|
|
164
|
+
locale: normalizedQuestion.locale || '',
|
|
165
|
+
disabled: env.mode !== 'gather',
|
|
166
|
+
view: env.mode === 'view',
|
|
167
|
+
mode: env.mode,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
if (env.mode === 'evaluate') {
|
|
171
|
+
const correctness = getCorrectness(normalizedQuestion, session);
|
|
172
|
+
out.correctness = correctness;
|
|
173
|
+
out.responseCorrect = correctness === 'correct';
|
|
174
|
+
out.correctChoiceId = normalizedQuestion.correctChoiceId;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (env.role === 'instructor' && (env.mode === 'view' || env.mode === 'evaluate')) {
|
|
178
|
+
out.teacherInstructions = normalizedQuestion.teacherInstructionsEnabled
|
|
179
|
+
? normalizedQuestion.teacherInstructions
|
|
180
|
+
: null;
|
|
181
|
+
} else {
|
|
182
|
+
out.teacherInstructions = null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return out;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
export const createCorrectResponseSession = (question: any, env: any) => {
|
|
189
|
+
return new Promise((resolve) => {
|
|
190
|
+
if (env.mode !== 'evaluate' && env.role === 'instructor') {
|
|
191
|
+
resolve({
|
|
192
|
+
id: '1',
|
|
193
|
+
element: 'mc-populated-blank',
|
|
194
|
+
choiceId: question?.correctChoiceId || '',
|
|
195
|
+
});
|
|
196
|
+
} else {
|
|
197
|
+
resolve(null);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export const validate = (model: any = {}, _config: any = {}) => {
|
|
203
|
+
const errors: any = {};
|
|
204
|
+
|
|
205
|
+
if (model.promptEnabled) {
|
|
206
|
+
const p = model.prompt?.trim() || '';
|
|
207
|
+
if (!p || p === '<p></p>') {
|
|
208
|
+
errors.prompt = 'Prompt is required when prompt is enabled';
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const interactionMode = model.interactionMode || 'populate_blank';
|
|
213
|
+
const template = model.template || '';
|
|
214
|
+
const n = countBlankTokens(template);
|
|
215
|
+
if (interactionMode === 'audio_mc_only') {
|
|
216
|
+
if (n > 0) {
|
|
217
|
+
errors.template = `Template cannot contain ${BLANK_TOKEN} in audio-only mode`;
|
|
218
|
+
}
|
|
219
|
+
} else if (interactionMode === 'populate_blank') {
|
|
220
|
+
if (n === 0) {
|
|
221
|
+
errors.template = `Template must contain exactly one ${BLANK_TOKEN} placeholder`;
|
|
222
|
+
} else if (n > 1) {
|
|
223
|
+
errors.template = `Template must contain only one ${BLANK_TOKEN} placeholder`;
|
|
224
|
+
}
|
|
225
|
+
} else {
|
|
226
|
+
errors.interactionMode = 'Unknown interaction mode';
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const choices = Array.isArray(model.choices) ? model.choices : [];
|
|
230
|
+
if (choices.length < 2) {
|
|
231
|
+
errors.choices = 'At least two choices are required';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const mode = model.choiceMode || 'text';
|
|
235
|
+
for (let i = 0; i < choices.length; i++) {
|
|
236
|
+
const c = choices[i];
|
|
237
|
+
if (!c?.id) {
|
|
238
|
+
errors.choices = `Choice ${i + 1} is missing an id`;
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
if (mode === 'text') {
|
|
242
|
+
const lbl = (c.labelHtml || '').trim();
|
|
243
|
+
if (!lbl || lbl === '<p></p>') {
|
|
244
|
+
errors.choices = `Choice ${i + 1} needs label text`;
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
if (!c.imageUrl?.trim()) {
|
|
249
|
+
errors.choices = `Choice ${i + 1} needs an image URL`;
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
if (!c.imageAlt?.trim()) {
|
|
253
|
+
errors.choices = `Choice ${i + 1} needs image alt text`;
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const correct = model.correctChoiceId;
|
|
260
|
+
if (!correct || !choices.some((c: any) => c.id === correct)) {
|
|
261
|
+
errors.correctChoiceId = 'Correct choice must match one of the choice ids';
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (model.hasAudio) {
|
|
265
|
+
const hasAudioUrl = !!model.audioUrl?.trim();
|
|
266
|
+
const hasTranscript = !!model.audioTranscript?.trim();
|
|
267
|
+
if (!hasAudioUrl && !hasTranscript) {
|
|
268
|
+
errors.audioTranscript = 'Audio transcript or audio URL is required when audio is enabled';
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return errors;
|
|
273
|
+
};
|