@tinyweb_dev/oe-exam-sdk 0.2.5 → 0.2.7

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.
Files changed (91) hide show
  1. package/dist/components/exams/take/ExamTakingPageContainer.js +6 -0
  2. package/dist/components/exams/take/components/QuestionRenderer.js +10 -2
  3. package/dist/components/exams/take/components/question-renderers/MoversFillInBlankGroupRenderer.d.ts +22 -0
  4. package/dist/components/exams/take/components/question-renderers/MoversFillInBlankGroupRenderer.js +60 -0
  5. package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.d.ts +2 -2
  6. package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.js +58 -14
  7. package/dist/components/exams/take/components/question-renderers/MoversMatchingWithLinesGroupRenderer.d.ts +13 -0
  8. package/dist/components/exams/take/components/question-renderers/MoversMatchingWithLinesGroupRenderer.js +31 -0
  9. package/dist/components/exams/take/components/question-renderers/index.d.ts +2 -0
  10. package/dist/components/exams/take/components/question-renderers/index.js +2 -0
  11. package/dist/components/exams/take/types.d.ts +12 -5
  12. package/dist/components/exams/take/utils/answer-transformers.js +17 -1
  13. package/dist/components/exams/take/utils/question-transformers.d.ts +45 -1
  14. package/dist/components/exams/take/utils/question-transformers.js +109 -53
  15. package/dist/components/questions/_shared/config/question-types.config.js +12 -0
  16. package/dist/components/questions/_shared/types/fill-in-blank-group.type.d.ts +50 -0
  17. package/dist/components/questions/_shared/types/fill-in-blank-group.type.js +2 -0
  18. package/dist/components/questions/_shared/types/fill-in-blank.type.d.ts +16 -1
  19. package/dist/components/questions/_shared/types/index.d.ts +2 -0
  20. package/dist/components/questions/_shared/types/index.js +2 -0
  21. package/dist/components/questions/_shared/types/matching-with-lines-group.type.d.ts +40 -0
  22. package/dist/components/questions/_shared/types/matching-with-lines-group.type.js +1 -0
  23. package/dist/components/questions/components/QuestionSearchDropdown.js +4 -0
  24. package/dist/components/questions/components/QuestionTypeSelector.js +2 -0
  25. package/dist/components/questions/creator/question-type-registry.js +4 -0
  26. package/dist/components/questions/types/fill-in-blank/FillInBlankClient.d.ts +1 -1
  27. package/dist/components/questions/types/fill-in-blank/FillInBlankClient.js +37 -60
  28. package/dist/components/questions/types/fill-in-blank/FillInBlankCreator.js +122 -149
  29. package/dist/components/questions/types/fill-in-blank/answer-utils.d.ts +34 -0
  30. package/dist/components/questions/types/fill-in-blank/answer-utils.js +58 -0
  31. package/dist/components/questions/types/fill-in-blank/register.js +19 -6
  32. package/dist/components/questions/types/fill-in-blank/transform.js +23 -26
  33. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupClient.d.ts +2 -0
  34. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupClient.js +64 -0
  35. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupCreator.d.ts +2 -0
  36. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupCreator.js +158 -0
  37. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupMediaField.d.ts +7 -0
  38. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupMediaField.js +30 -0
  39. package/dist/components/questions/types/fill-in-blank-group/blank-utils.d.ts +20 -0
  40. package/dist/components/questions/types/fill-in-blank-group/blank-utils.js +99 -0
  41. package/dist/components/questions/types/fill-in-blank-group/index.d.ts +5 -0
  42. package/dist/components/questions/types/fill-in-blank-group/index.js +5 -0
  43. package/dist/components/questions/types/fill-in-blank-group/map-fill-in-blank-group-data.d.ts +4 -0
  44. package/dist/components/questions/types/fill-in-blank-group/map-fill-in-blank-group-data.js +102 -0
  45. package/dist/components/questions/types/fill-in-blank-group/register.d.ts +3 -0
  46. package/dist/components/questions/types/fill-in-blank-group/register.js +37 -0
  47. package/dist/components/questions/types/fill-in-blank-group/transform.d.ts +2 -0
  48. package/dist/components/questions/types/fill-in-blank-group/transform.js +68 -0
  49. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesBoard.d.ts +20 -0
  50. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesBoard.js +109 -0
  51. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupClient.d.ts +2 -0
  52. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupClient.js +30 -0
  53. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupCreator.d.ts +2 -0
  54. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupCreator.js +180 -0
  55. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesImageField.d.ts +6 -0
  56. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesImageField.js +26 -0
  57. package/dist/components/questions/types/matching-with-lines-group/index.d.ts +4 -0
  58. package/dist/components/questions/types/matching-with-lines-group/index.js +4 -0
  59. package/dist/components/questions/types/matching-with-lines-group/map-matching-with-lines-group-data.d.ts +8 -0
  60. package/dist/components/questions/types/matching-with-lines-group/map-matching-with-lines-group-data.js +104 -0
  61. package/dist/components/questions/types/matching-with-lines-group/register.d.ts +3 -0
  62. package/dist/components/questions/types/matching-with-lines-group/register.js +37 -0
  63. package/dist/components/questions/types/matching-with-lines-group/transform.d.ts +2 -0
  64. package/dist/components/questions/types/matching-with-lines-group/transform.js +44 -0
  65. package/dist/components/questions/viewer/FillInBlankGroupViewer.d.ts +10 -0
  66. package/dist/components/questions/viewer/FillInBlankGroupViewer.js +14 -0
  67. package/dist/components/questions/viewer/MatchingWithLinesGroupViewer.d.ts +7 -0
  68. package/dist/components/questions/viewer/MatchingWithLinesGroupViewer.js +48 -0
  69. package/dist/components/questions/viewer/QuestionViewer.js +6 -0
  70. package/dist/components/questions/viewer/index.d.ts +2 -0
  71. package/dist/components/questions/viewer/index.js +2 -0
  72. package/dist/components/results/renderers/ReviewListenAndWriteRenderer.d.ts +2 -3
  73. package/dist/components/results/renderers/ReviewListenAndWriteRenderer.js +17 -8
  74. package/dist/components/results/renderers/review-question-body-dedicated.js +2 -2
  75. package/dist/components/results/renderers/review-question-body-movers.js +37 -2
  76. package/dist/components/results/review-data.js +37 -0
  77. package/dist/index.d.ts +1 -1
  78. package/dist/index.js +1 -1
  79. package/dist/shared/constants/question-skills.js +4 -0
  80. package/dist/shared/lib/utils/fill-in-blank.d.ts +46 -0
  81. package/dist/shared/lib/utils/fill-in-blank.js +96 -0
  82. package/dist/shared/lib/utils/question-reverse-transform.js +150 -3
  83. package/dist/shared/lib/utils/question-transform.js +4 -0
  84. package/dist/shared/types/common.types.d.ts +1 -1
  85. package/dist/shared/types/questions/fill-in-blank-group.d.ts +45 -0
  86. package/dist/shared/types/questions/fill-in-blank-group.js +1 -0
  87. package/dist/shared/types/questions/index.d.ts +2 -0
  88. package/dist/shared/types/questions/index.js +4 -0
  89. package/dist/shared/types/questions/matching-with-lines-group.d.ts +39 -0
  90. package/dist/shared/types/questions/matching-with-lines-group.js +1 -0
  91. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import { FillInBlankCreator } from './FillInBlankCreator';
2
+ import { normalizeFillInBlankAnswers } from './answer-utils';
2
3
  export const fillInBlankRegistration = {
3
4
  component: FillInBlankCreator,
4
5
  transformInitialData: (initialData) => {
@@ -18,14 +19,16 @@ export const fillInBlankRegistration = {
18
19
  const groupTitle = groupPayload?.title || answer?.title || '';
19
20
  const groupContent = groupPayload?.content || groupPayload?.passage || answer?.groupContent || '';
20
21
  const groupNumber = questionGroup?.groupNumber || answer?.groupNumber || 1;
22
+ const audioUrl = answer?.audioUrl || content?.audioUrl || '';
21
23
  // NEW FORMAT: question with {0}, {1}, {2}... placeholders and answers array
22
24
  if (answer?.question && Array.isArray(answer?.answers)) {
23
25
  creatorData = {
24
26
  question: answer.question,
25
- answers: answer.answers,
27
+ answers: normalizeFillInBlankAnswers(answer.answers),
26
28
  caseSensitive: answer?.caseSensitive ?? false,
27
29
  imageUrl: questionImageUrl,
28
30
  groupImageUrl,
31
+ audioUrl,
29
32
  showHints: answer?.showHints || false,
30
33
  hints: answer?.hints || [],
31
34
  explanation,
@@ -40,10 +43,11 @@ export const fillInBlankRegistration = {
40
43
  else if (content?.question && Array.isArray(answer?.answers)) {
41
44
  creatorData = {
42
45
  question: content.question,
43
- answers: answer.answers,
46
+ answers: normalizeFillInBlankAnswers(answer.answers),
44
47
  caseSensitive: answer?.caseSensitive ?? false,
45
48
  imageUrl: questionImageUrl,
46
49
  groupImageUrl,
50
+ audioUrl,
47
51
  showHints: content?.showHints || false,
48
52
  hints: content?.hints || [],
49
53
  explanation,
@@ -60,7 +64,7 @@ export const fillInBlankRegistration = {
60
64
  const firstBlank = answer.blanks[0];
61
65
  creatorData = {
62
66
  question: firstBlank?.text || '',
63
- answers: answer.blanks.map((b) => b.correctAnswer || ''),
67
+ answers: normalizeFillInBlankAnswers(answer.blanks.map((b) => b.correctAnswers ?? b.correctAnswer ?? '')),
64
68
  caseSensitive: answer?.caseSensitive ?? false,
65
69
  showHints: answer?.showHints || false,
66
70
  hints: answer?.hints || [],
@@ -69,6 +73,7 @@ export const fillInBlankRegistration = {
69
73
  blanks: answer.blanks,
70
74
  imageUrl: questionImageUrl,
71
75
  groupImageUrl,
76
+ audioUrl,
72
77
  title: groupTitle,
73
78
  groupContent,
74
79
  groupNumber,
@@ -79,7 +84,7 @@ export const fillInBlankRegistration = {
79
84
  // LEGACY Format 1: Single blank
80
85
  creatorData = {
81
86
  question: content.text || '',
82
- answers: [answer?.correctAnswer || ''],
87
+ answers: normalizeFillInBlankAnswers([answer?.correctAnswer || '']),
83
88
  caseSensitive: answer?.caseSensitive ?? false,
84
89
  showHints: content?.showHints || false,
85
90
  hints: content?.hints || [],
@@ -87,6 +92,7 @@ export const fillInBlankRegistration = {
87
92
  isExample: initialData?.isExample || answer?.isExample || false,
88
93
  imageUrl: questionImageUrl,
89
94
  groupImageUrl,
95
+ audioUrl,
90
96
  title: groupTitle,
91
97
  groupContent,
92
98
  groupNumber,
@@ -98,12 +104,17 @@ export const fillInBlankRegistration = {
98
104
  const answersMap = answer?.answers || {};
99
105
  const mergedBlanks = content.blanks.map((blank) => ({
100
106
  ...blank,
101
- correctAnswer: answersMap[blank.id] || blank.correctAnswer || '',
107
+ correctAnswer: Array.isArray(answersMap[blank.id])
108
+ ? (answersMap[blank.id][0] || blank.correctAnswer || '')
109
+ : (answersMap[blank.id] || blank.correctAnswer || ''),
110
+ correctAnswers: Array.isArray(answersMap[blank.id])
111
+ ? answersMap[blank.id]
112
+ : undefined,
102
113
  }));
103
114
  const firstBlank = mergedBlanks[0];
104
115
  creatorData = {
105
116
  question: firstBlank?.text || '',
106
- answers: mergedBlanks.map((b) => b.correctAnswer || ''),
117
+ answers: normalizeFillInBlankAnswers(mergedBlanks.map((b) => b.correctAnswers ?? b.correctAnswer ?? '')),
107
118
  caseSensitive: answer?.caseSensitive ?? false,
108
119
  showHints: content?.showHints || false,
109
120
  hints: content?.hints || [],
@@ -112,6 +123,7 @@ export const fillInBlankRegistration = {
112
123
  blanks: mergedBlanks,
113
124
  imageUrl: questionImageUrl,
114
125
  groupImageUrl,
126
+ audioUrl,
115
127
  title: groupTitle,
116
128
  groupContent,
117
129
  groupNumber,
@@ -126,6 +138,7 @@ export const fillInBlankRegistration = {
126
138
  groups: ctx.groups,
127
139
  questionIndexInPart: ctx.questionIndexInPart,
128
140
  partId: ctx.partId,
141
+ questionConfig: ctx.questionConfig,
129
142
  }),
130
143
  wrapOnSave: (data, ctx) => ({
131
144
  type: ctx.questionType,
@@ -1,21 +1,4 @@
1
- function extractPlaceholderIndices(question) {
2
- if (!question)
3
- return [];
4
- const regex = /\{(\d+)\}/g;
5
- const indices = [];
6
- let match;
7
- while ((match = regex.exec(question)) !== null) {
8
- const index = parseInt(match[1], 10);
9
- if (!indices.includes(index)) {
10
- indices.push(index);
11
- }
12
- }
13
- return indices.sort((a, b) => a - b);
14
- }
15
- function getMaxPlaceholderIndex(question) {
16
- const indices = extractPlaceholderIndices(question);
17
- return indices.length > 0 ? Math.max(...indices) : -1;
18
- }
1
+ import { compactFillInBlankAnswers, normalizeFillInBlankAnswers, getMaxPlaceholderIndex } from './answer-utils';
19
2
  export const transformFillInBlank = (question) => {
20
3
  const answerData = question.answer;
21
4
  // Helper: build basicGroupData if any group field is present.
@@ -32,12 +15,8 @@ export const transformFillInBlank = (question) => {
32
15
  : undefined;
33
16
  const groupNumber = answerData?.groupNumber || 1;
34
17
  // NEW FORMAT: question with {0}, {1}, {2}... placeholders and answers array
18
+ // answers: string[][] (canonical) or legacy string[]
35
19
  if (answerData?.question && Array.isArray(answerData?.answers)) {
36
- const maxIndex = getMaxPlaceholderIndex(answerData.question || '');
37
- const requiredLength = maxIndex >= 0 ? maxIndex + 1 : 1;
38
- const validAnswers = answerData.answers.length > requiredLength
39
- ? answerData.answers.slice(0, requiredLength)
40
- : answerData.answers;
41
20
  const apiContent = {
42
21
  question: answerData.question,
43
22
  showHints: answerData.showHints || false,
@@ -47,10 +26,17 @@ export const transformFillInBlank = (question) => {
47
26
  if (answerData.imageUrl) {
48
27
  apiContent.imageUrl = answerData.imageUrl;
49
28
  }
29
+ // Per-question audio (when questionConfig includes "audio")
30
+ if (answerData.audioUrl) {
31
+ apiContent.audioUrl = answerData.audioUrl;
32
+ }
33
+ const maxIndex = getMaxPlaceholderIndex(answerData.question || '');
34
+ const requiredLength = maxIndex >= 0 ? maxIndex + 1 : undefined;
35
+ const normalizedAnswers = compactFillInBlankAnswers(normalizeFillInBlankAnswers(answerData.answers, requiredLength), requiredLength);
50
36
  return {
51
37
  apiContent,
52
38
  apiCorrectAnswer: {
53
- answers: validAnswers,
39
+ answers: normalizedAnswers,
54
40
  caseSensitive: answerData.caseSensitive || false,
55
41
  },
56
42
  isExample: answerData.isExample || false,
@@ -66,9 +52,17 @@ export const transformFillInBlank = (question) => {
66
52
  id: blank.id,
67
53
  text: blank.text,
68
54
  }));
55
+ // Prefer correctAnswers[]; fall back to single correctAnswer string
69
56
  const answers = (answerData.blanks || []).reduce((acc, blank) => {
70
- if (blank.id && blank.correctAnswer) {
71
- acc[blank.id] = blank.correctAnswer;
57
+ if (!blank.id)
58
+ return acc;
59
+ const alts = blank.correctAnswers ?? blank.correctAnswer;
60
+ if (alts == null)
61
+ return acc;
62
+ const normalized = normalizeFillInBlankAnswers(Array.isArray(alts) ? [alts] : [alts])[0];
63
+ const cleaned = normalized.map((a) => a.trim()).filter(Boolean);
64
+ if (cleaned.length > 0) {
65
+ acc[blank.id] = cleaned;
72
66
  }
73
67
  return acc;
74
68
  }, {});
@@ -76,6 +70,9 @@ export const transformFillInBlank = (question) => {
76
70
  if (answerData.imageUrl) {
77
71
  legacyContent.imageUrl = answerData.imageUrl;
78
72
  }
73
+ if (answerData.audioUrl) {
74
+ legacyContent.audioUrl = answerData.audioUrl;
75
+ }
79
76
  return {
80
77
  apiContent: legacyContent,
81
78
  apiCorrectAnswer: {
@@ -0,0 +1,2 @@
1
+ import type { FillInBlankGroupClientProps } from '../../_shared/types/fill-in-blank-group.type';
2
+ export declare function FillInBlankGroupClient({ questionData, isReviewMode, userAnswers, onAnswerChange, }: FillInBlankGroupClientProps): import("react").JSX.Element;
@@ -0,0 +1,64 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Lightbulb, Star, Volume2 } from 'lucide-react';
4
+ import { cn } from '../../../../shared/lib/utils';
5
+ import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
6
+ import { blankSlotCount, extractPlaceholderIndices, isBlankMatch, joinSlashAnswers, padStudentAnswers, splitStem, } from './blank-utils';
7
+ function MediaPreview({ src, kind, alt }) {
8
+ const { previewUrl, isLoading } = usePresignedFileUrl(src);
9
+ if (isLoading) {
10
+ return _jsx("div", { className: "h-16 w-16 animate-pulse rounded-md bg-slate-100" });
11
+ }
12
+ const url = previewUrl || src;
13
+ if (kind === 'audio') {
14
+ return _jsx("audio", { controls: true, src: url, className: "h-8 max-w-full" });
15
+ }
16
+ return (_jsx("img", { src: url, alt: alt, className: "max-h-28 w-auto rounded-md object-contain", onError: (event) => {
17
+ const target = event.target;
18
+ target.onerror = null;
19
+ target.src = '/images/placeholder-image.svg';
20
+ } }));
21
+ }
22
+ export function FillInBlankGroupClient({ questionData, isReviewMode = false, userAnswers = [], onAnswerChange, }) {
23
+ const items = questionData.items || [];
24
+ const groupHints = questionData.showHints ? (questionData.hints || []) : [];
25
+ const handleBlankChange = (itemIndex, blankIndex, value) => {
26
+ const item = items[itemIndex];
27
+ if (isReviewMode || item?.isExample || !onAnswerChange) {
28
+ return;
29
+ }
30
+ const next = items.map((row, index) => ({
31
+ answers: padStudentAnswers(userAnswers[index], blankSlotCount(row.question || '{0}')),
32
+ }));
33
+ const current = [...next[itemIndex].answers];
34
+ current[blankIndex] = value;
35
+ next[itemIndex] = { answers: current };
36
+ onAnswerChange(next);
37
+ };
38
+ return (_jsxs("div", { className: "space-y-4", children: [questionData.instruction && (_jsx("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm font-medium text-indigo-800", children: questionData.instruction })), questionData.audioUrl && (_jsxs("div", { className: "flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2", children: [_jsx(Volume2, { className: "h-4 w-4 text-slate-500" }), _jsx(MediaPreview, { src: questionData.audioUrl, kind: "audio", alt: "Group audio" })] })), questionData.imageUrl && (_jsx("div", { className: "flex justify-center rounded-lg border border-slate-100 bg-slate-50 p-3", children: _jsx(MediaPreview, { src: questionData.imageUrl, kind: "image", alt: "Group image" }) })), groupHints.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2", children: groupHints.map((hint) => (_jsx("span", { className: "rounded-full border border-blue-200 bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700", children: hint }, hint))) })), _jsx("div", { className: "grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3", children: items.map((item, itemIndex) => {
39
+ const blankCount = blankSlotCount(item.question || '{0}');
40
+ const placeholderIndices = extractPlaceholderIndices(item.question || '{0}');
41
+ const userValue = padStudentAnswers(userAnswers[itemIndex], blankCount);
42
+ const stem = splitStem(item.question || '{0}');
43
+ const showItemAudio = Boolean(item.audioUrl) && !questionData.audioUrl;
44
+ return (_jsxs("div", { className: "overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-gray-100 bg-gray-50 px-3 py-2", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white", children: item.questionNumber || itemIndex + 1 }), item.isExample && (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700", children: [_jsx(Star, { className: "h-3 w-3" }), "Example"] }))] }), _jsxs("span", { className: "text-xs text-gray-500", children: [item.points || 0, " \u0111i\u1EC3m"] })] }), _jsxs("div", { className: "flex items-center gap-3 p-3", children: [item.imageUrl && (_jsx(MediaPreview, { src: item.imageUrl, kind: "image", alt: `Item ${item.questionNumber}` })), _jsxs("div", { className: "min-w-0 flex-1 space-y-2 text-sm leading-relaxed text-slate-800", children: [showItemAudio && item.audioUrl && (_jsx(MediaPreview, { src: item.audioUrl, kind: "audio", alt: `Item ${item.questionNumber} audio` })), stem.map((part, partIndex) => {
45
+ if (part.type === 'text') {
46
+ return _jsx("span", { children: part.value }, partIndex);
47
+ }
48
+ const alts = item.answers[part.index] || [];
49
+ const student = userValue[part.index] || '';
50
+ const canEdit = !isReviewMode && !item.isExample && Boolean(onAnswerChange);
51
+ const correct = isReviewMode && isBlankMatch(student, alts);
52
+ const wrong = isReviewMode && student.trim().length > 0 && !correct;
53
+ return (_jsx("input", { type: "text", disabled: !canEdit, value: item.isExample ? (alts[0] || '') : student, onChange: (event) => handleBlankChange(itemIndex, part.index, event.target.value), placeholder: item.isExample ? '' : '...', className: cn('mx-1 inline-block w-16 rounded-md border-2 px-1 py-0.5 text-center text-sm font-semibold outline-none', item.isExample
54
+ ? 'border-amber-300 bg-amber-50 text-amber-800'
55
+ : correct
56
+ ? 'border-emerald-400 bg-emerald-50 text-emerald-800'
57
+ : wrong
58
+ ? 'border-rose-400 bg-rose-50 text-rose-800'
59
+ : canEdit
60
+ ? 'border-blue-300 bg-white text-slate-800 focus:border-blue-500'
61
+ : 'border-blue-200 bg-blue-50 text-blue-900') }, partIndex));
62
+ })] })] }), isReviewMode && !item.isExample && (_jsxs("div", { className: "border-t border-emerald-100 bg-emerald-50 px-3 py-2 text-xs text-emerald-800", children: ["\u0110\u00E1p \u00E1n: ", placeholderIndices.map((index) => joinSlashAnswers(item.answers[index]) || '(trống)').join(' | ')] }))] }, `${item.questionNumber}-${itemIndex}`));
63
+ }) }), isReviewMode && questionData.explanation && (_jsxs("div", { className: "flex items-start gap-2 rounded-xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800", children: [_jsx(Lightbulb, { className: "mt-0.5 h-4 w-4 flex-shrink-0" }), questionData.explanation] }))] }));
64
+ }
@@ -0,0 +1,2 @@
1
+ import type { FillInBlankGroupCreatorProps } from '../../_shared/types/fill-in-blank-group.type';
2
+ export declare function FillInBlankGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }: FillInBlankGroupCreatorProps): import("react").JSX.Element;
@@ -0,0 +1,158 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { Button } from '../../../../components/ui/button';
5
+ import { Label } from '../../../../components/ui/label';
6
+ import { Input } from '../../../../components/ui/input';
7
+ import { Textarea } from '../../../../components/ui/textarea';
8
+ import { PointsInput } from '../../../../components/ui/points-input';
9
+ import { Card, CardContent, CardHeader, CardTitle } from '../../../../components/ui/card';
10
+ import { Switch } from '../../../../components/ui/switch';
11
+ import { useDebouncedCallback } from '../../../../shared/lib/hooks';
12
+ import { Eye, Pencil, Plus, Star, Trash2 } from 'lucide-react';
13
+ import { FillInBlankGroupClient } from './FillInBlankGroupClient';
14
+ import { FillInBlankGroupMediaField } from './FillInBlankGroupMediaField';
15
+ import { createEmptyFillInBlankGroupItem } from './map-fill-in-blank-group-data';
16
+ import { blankSlotCount, extractPlaceholderIndices, joinSlashAnswers, normalizeAnswersMatrix, parseSlashAnswers, } from './blank-utils';
17
+ import { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/fill-in-blank-group.type';
18
+ function parsePointsValue(raw, allowZero = false) {
19
+ if (raw === '')
20
+ return allowZero ? 0 : 1;
21
+ const parsed = parseInt(raw.replace(/[^0-9]/g, ''), 10);
22
+ if (Number.isNaN(parsed))
23
+ return allowZero ? 0 : 1;
24
+ const min = allowZero ? 0 : 1;
25
+ return Math.max(min, parsed);
26
+ }
27
+ function syncItemAnswers(item) {
28
+ const indices = extractPlaceholderIndices(item.question || '');
29
+ const requiredLength = blankSlotCount(item.question || '');
30
+ return {
31
+ ...item,
32
+ answers: normalizeAnswersMatrix(item.answers, requiredLength).map((alts, index) => {
33
+ if (!indices.includes(index))
34
+ return [];
35
+ return alts.length > 0 ? alts : [''];
36
+ }),
37
+ };
38
+ }
39
+ function normalizeItems(rawItems) {
40
+ if (!Array.isArray(rawItems) || rawItems.length === 0) {
41
+ return [createEmptyFillInBlankGroupItem(1)];
42
+ }
43
+ return rawItems.map((item, index) => syncItemAnswers({
44
+ ...createEmptyFillInBlankGroupItem(index + 1),
45
+ ...item,
46
+ question: item.question || '{0}',
47
+ isExample: item.isExample === true,
48
+ points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
49
+ questionNumber: item.questionNumber || index + 1,
50
+ }));
51
+ }
52
+ function sumPoints(items) {
53
+ return items.reduce((total, item) => (item.isExample ? total : total + (typeof item.points === 'number' ? item.points : 1)), 0);
54
+ }
55
+ export function FillInBlankGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }) {
56
+ const [instruction, setInstruction] = useState(initialData?.instruction || FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION);
57
+ const [audioUrl, setAudioUrl] = useState(initialData?.audioUrl || '');
58
+ const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
59
+ const [showHints, setShowHints] = useState(initialData?.showHints === true);
60
+ const [hintsText, setHintsText] = useState((initialData?.hints || []).join(' / '));
61
+ const [items, setItems] = useState(() => normalizeItems(initialData?.items));
62
+ const [explanation, setExplanation] = useState(initialData?.explanation || '');
63
+ const [isClientMode, setIsClientMode] = useState(false);
64
+ const [errors, setErrors] = useState([]);
65
+ const onChangeRef = useRef(onChange);
66
+ const previousPayloadRef = useRef('');
67
+ useEffect(() => {
68
+ onChangeRef.current = onChange;
69
+ }, [onChange]);
70
+ const parseHints = (value) => parseSlashAnswers(value);
71
+ const buildPayload = (nextInstruction = instruction, nextItems = items, nextExplanation = explanation, nextAudioUrl = audioUrl, nextImageUrl = imageUrl, nextShowHints = showHints, nextHintsText = hintsText) => ({
72
+ instruction: nextInstruction,
73
+ audioUrl: nextAudioUrl,
74
+ imageUrl: nextImageUrl,
75
+ showHints: nextShowHints,
76
+ hints: parseHints(nextHintsText),
77
+ items: nextItems,
78
+ explanation: nextExplanation,
79
+ points: sumPoints(nextItems),
80
+ });
81
+ const debouncedOnChange = useDebouncedCallback((payload) => {
82
+ onChangeRef.current?.(payload);
83
+ }, 300);
84
+ useEffect(() => {
85
+ const payload = buildPayload();
86
+ const serialized = JSON.stringify(payload);
87
+ if (serialized !== previousPayloadRef.current) {
88
+ previousPayloadRef.current = serialized;
89
+ debouncedOnChange(payload);
90
+ onUnsavedChangesChange?.(true);
91
+ }
92
+ }, [instruction, audioUrl, imageUrl, showHints, hintsText, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
93
+ const validate = () => {
94
+ const nextErrors = [];
95
+ if (!instruction.trim()) {
96
+ nextErrors.push('Hướng dẫn (Instruction) không được để trống.');
97
+ }
98
+ if (items.length === 0) {
99
+ nextErrors.push('Cần ít nhất một câu hỏi trong nhóm.');
100
+ }
101
+ items.forEach((item, index) => {
102
+ if (!item.question.trim()) {
103
+ nextErrors.push(`Câu ${index + 1}: Stem không được để trống.`);
104
+ }
105
+ else if (extractPlaceholderIndices(item.question).length === 0) {
106
+ nextErrors.push(`Câu ${index + 1}: Stem cần có chỗ trống {0}, {1}...`);
107
+ }
108
+ item.answers.forEach((alts, blankIndex) => {
109
+ if (!extractPlaceholderIndices(item.question).includes(blankIndex))
110
+ return;
111
+ if (!alts.some((alt) => alt.trim())) {
112
+ nextErrors.push(`Câu ${index + 1}: Blank {${blankIndex}} cần ít nhất một đáp án.`);
113
+ }
114
+ });
115
+ });
116
+ setErrors(nextErrors);
117
+ return nextErrors.length === 0;
118
+ };
119
+ useEffect(() => {
120
+ if (validationRef) {
121
+ validationRef.current = {
122
+ triggerValidation: async () => validate(),
123
+ getFormData: () => buildPayload(),
124
+ };
125
+ }
126
+ }, [validationRef, instruction, audioUrl, imageUrl, showHints, hintsText, items, explanation]);
127
+ const updateItem = (index, patch) => {
128
+ setItems((prev) => {
129
+ const next = [...prev];
130
+ const current = next[index];
131
+ if (!current)
132
+ return prev;
133
+ next[index] = syncItemAnswers({ ...current, ...patch });
134
+ return next;
135
+ });
136
+ };
137
+ const addItem = () => {
138
+ setItems((prev) => [...prev, createEmptyFillInBlankGroupItem(prev.length + 1)]);
139
+ };
140
+ const removeItem = (index) => {
141
+ setItems((prev) => {
142
+ if (prev.length <= 1)
143
+ return prev;
144
+ return prev
145
+ .filter((_, i) => i !== index)
146
+ .map((item, i) => ({ ...item, questionNumber: i + 1 }));
147
+ });
148
+ };
149
+ const currentPayload = buildPayload();
150
+ const displayErrors = externalErrors && externalErrors.length > 0 ? externalErrors : errors;
151
+ return (_jsxs("div", { className: "space-y-6", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("h3", { className: "text-lg font-medium text-gray-900", children: "T\u1EA1o c\u00E2u h\u1ECFi \u0110i\u1EC1n ch\u1ED7 tr\u1ED1ng (Nh\u00F3m)" }), _jsxs("p", { className: "text-sm text-gray-500", children: ["M\u1ED7i d\u00F2ng l\u00E0 1 stem c\u00F3 ", '{0}', ". C\u00F3 th\u1EC3 th\u00EAm audio/\u1EA3nh chung ho\u1EB7c t\u1EEBng item."] })] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setIsClientMode((prev) => !prev), className: "gap-2", children: [isClientMode ? _jsx(Pencil, { className: "h-4 w-4" }) : _jsx(Eye, { className: "h-4 w-4" }), isClientMode ? 'Chế độ chỉnh sửa' : 'Xem trước học sinh'] })] }), displayErrors.length > 0 && (_jsxs("div", { className: "rounded-lg border border-red-200 bg-red-50 p-4", children: [_jsx("h3", { className: "text-sm font-medium text-red-800", children: "C\u1EA7n s\u1EEDa c\u00E1c l\u1ED7i sau:" }), _jsx("ul", { className: "mt-2 list-disc space-y-1 pl-5 text-sm text-red-700", children: displayErrors.map((err, i) => (_jsx("li", { children: err }, i))) })] })), isClientMode ? (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base font-semibold", children: "Xem tr\u01B0\u1EDBc hi\u1EC3n th\u1ECB h\u1ECDc sinh" }) }), _jsx(CardContent, { children: _jsx(FillInBlankGroupClient, { questionData: currentPayload }) })] })) : (_jsxs("div", { className: "space-y-6", children: [_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base font-semibold", children: "H\u01B0\u1EDBng d\u1EABn v\u00E0 media chung" }) }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { children: [_jsx(Label, { htmlFor: "instruction", children: "H\u01B0\u1EDBng d\u1EABn b\u00E0i l\u00E0m" }), _jsx(Input, { id: "instruction", value: instruction, onChange: (e) => setInstruction(e.target.value), placeholder: FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION, className: "mt-1" })] }), _jsxs("div", { className: "grid gap-4 md:grid-cols-2", children: [_jsx(FillInBlankGroupMediaField, { id: "fib-group-audio", label: "Audio chung (optional)", accept: "audio/*", value: audioUrl, onChange: setAudioUrl }), _jsx(FillInBlankGroupMediaField, { id: "fib-group-image", label: "\u1EA2nh chung (optional)", accept: "image/*", value: imageUrl, onChange: setImageUrl })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: "show-hints", checked: showHints, onCheckedChange: setShowHints }), _jsx(Label, { htmlFor: "show-hints", children: "Hi\u1EC7n h\u1ED9p t\u1EEB g\u1EE3i \u00FD chung" })] }), showHints && (_jsx(Input, { value: hintsText, onChange: (e) => setHintsText(e.target.value), placeholder: "am / is / are" }))] })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between", children: [_jsxs(CardTitle, { className: "text-base font-semibold", children: ["Danh s\u00E1ch c\u00E2u (", items.length, ")"] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: addItem, className: "gap-2", children: [_jsx(Plus, { className: "h-4 w-4" }), "Th\u00EAm c\u00E2u"] })] }), _jsx(CardContent, { className: "space-y-4", children: items.map((item, index) => (_jsxs("div", { className: "space-y-3 rounded-lg border border-gray-200 bg-gray-50/50 p-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-4", children: [_jsxs("span", { className: "flex items-center gap-2 text-sm font-medium text-gray-700", children: [_jsx("span", { className: "flex h-7 w-7 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700", children: item.questionNumber || index + 1 }), "C\u00E2u ", index + 1] }), _jsxs("div", { className: "flex items-center gap-4", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: `example-${index}`, checked: item.isExample || false, onCheckedChange: (checked) => updateItem(index, { isExample: checked, points: checked ? 0 : 1 }) }), _jsxs(Label, { htmlFor: `example-${index}`, className: "flex cursor-pointer items-center gap-1 text-xs", children: [_jsx(Star, { className: "h-3 w-3 text-amber-500" }), "Example"] })] }), !item.isExample && (_jsx("div", { className: "w-24", children: _jsx(PointsInput, { value: item.points, allowZero: true, onChange: (event) => updateItem(index, {
152
+ points: parsePointsValue(event.target.value, true),
153
+ }) }) })), items.length > 1 && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeItem(index), className: "text-red-500 hover:bg-red-50", children: _jsx(Trash2, { className: "h-4 w-4" }) }))] })] }), _jsxs("div", { children: [_jsxs(Label, { htmlFor: `question-${index}`, children: ["Stem (d\u00F9ng ", '{0}', ", ", '{1}', "...)"] }), _jsx(Textarea, { id: `question-${index}`, value: item.question, onChange: (e) => updateItem(index, { question: e.target.value }), placeholder: "{0}", className: "mt-1" })] }), extractPlaceholderIndices(item.question).map((blankIndex) => (_jsxs("div", { children: [_jsxs(Label, { htmlFor: `answer-${index}-${blankIndex}`, children: ["\u0110\u00E1p \u00E1n blank ", '{' + blankIndex + '}', " (c\u00E1ch nhau b\u1EDFi /)"] }), _jsx(Input, { id: `answer-${index}-${blankIndex}`, value: joinSlashAnswers(item.answers[blankIndex]), onChange: (e) => {
154
+ const next = [...item.answers];
155
+ next[blankIndex] = parseSlashAnswers(e.target.value);
156
+ updateItem(index, { answers: next });
157
+ }, placeholder: "is / 's", className: "mt-1" })] }, blankIndex))), _jsxs("div", { className: "grid gap-4 md:grid-cols-2", children: [_jsx(FillInBlankGroupMediaField, { id: `fib-item-image-${index}`, label: "\u1EA2nh item (optional)", accept: "image/*", value: item.imageUrl || '', onChange: (url) => updateItem(index, { imageUrl: url }) }), !audioUrl && (_jsx(FillInBlankGroupMediaField, { id: `fib-item-audio-${index}`, label: "Audio item (khi kh\u00F4ng c\u00F3 audio nh\u00F3m)", accept: "audio/*", value: item.audioUrl || '', onChange: (url) => updateItem(index, { audioUrl: url }) }))] })] }, index))) })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base", children: "Gi\u1EA3i th\u00EDch" }) }), _jsx(CardContent, { children: _jsx(Textarea, { id: "explanation", value: explanation, onChange: (event) => setExplanation(event.target.value), placeholder: "VD: C\u00E2u tr\u1EA3 l\u1EDDi \u0111\u00FAng l\u00E0... v\u00EC...", rows: 3 }) })] })] }))] }));
158
+ }
@@ -0,0 +1,7 @@
1
+ export declare function FillInBlankGroupMediaField({ id, label, accept, value, onChange, }: {
2
+ id: string;
3
+ label: string;
4
+ accept: 'image/*' | 'audio/*';
5
+ value: string;
6
+ onChange: (url: string) => void;
7
+ }): import("react").JSX.Element;
@@ -0,0 +1,30 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState } from 'react';
4
+ import { Button } from '../../../../components/ui/button';
5
+ import { FileUpload } from '../../../../components/ui/file-upload';
6
+ import { ImagePreview } from '../../../../components/ui/image-preview';
7
+ import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
8
+ import { Trash2 } from 'lucide-react';
9
+ function resolveDisplayUrl(fileUrl, previewOverride, fetchedPreviewUrl) {
10
+ if (previewOverride)
11
+ return previewOverride;
12
+ if (fetchedPreviewUrl)
13
+ return fetchedPreviewUrl;
14
+ if (fileUrl.startsWith('http') || fileUrl.startsWith('blob:'))
15
+ return fileUrl;
16
+ return '';
17
+ }
18
+ export function FillInBlankGroupMediaField({ id, label, accept, value, onChange, }) {
19
+ const [previewOverride, setPreviewOverride] = useState('');
20
+ const { previewUrl: fetchedPreviewUrl } = usePresignedFileUrl(value);
21
+ const displayUrl = resolveDisplayUrl(value, previewOverride, fetchedPreviewUrl);
22
+ const isImage = accept === 'image/*';
23
+ return (_jsxs("div", { className: "space-y-2", children: [_jsx(FileUpload, { id: id, label: label, accept: accept, value: value, onChange: onChange, onPresignedUrlChange: setPreviewOverride, maxSize: accept === 'image/*' ? 5 : 20, placeholder: isImage ? 'Upload ảnh hoặc paste URL' : 'Upload audio hoặc paste URL', autoUpload: true, prefix: "questions" }), value && isImage && (_jsxs("div", { className: "flex items-start gap-2", children: [_jsx(ImagePreview, { src: displayUrl || value, alt: label, className: "h-20 w-20 rounded-md object-contain" }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => {
24
+ setPreviewOverride('');
25
+ onChange('');
26
+ }, className: "text-red-500 hover:bg-red-50", children: _jsx(Trash2, { className: "h-4 w-4" }) })] })), value && !isImage && (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("audio", { controls: true, src: displayUrl || value, className: "h-8 max-w-full" }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => {
27
+ setPreviewOverride('');
28
+ onChange('');
29
+ }, className: "text-red-500 hover:bg-red-50", children: _jsx(Trash2, { className: "h-4 w-4" }) })] }))] }));
30
+ }
@@ -0,0 +1,20 @@
1
+ import { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/fill-in-blank-group.type';
2
+ export { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION };
3
+ export type FillBlankStemPart = {
4
+ type: 'text';
5
+ value: string;
6
+ } | {
7
+ type: 'blank';
8
+ index: number;
9
+ };
10
+ export declare function extractPlaceholderIndices(question: string): number[];
11
+ /** answers[j] aligns with placeholder {j}; length = max(N)+1 */
12
+ export declare function blankSlotCount(question: string): number;
13
+ export declare function normalizeBlankAlts(value: unknown): string[];
14
+ export declare function normalizeAnswersMatrix(raw: unknown, requiredLength?: number): string[][];
15
+ export declare function joinSlashAnswers(alts: string[] | undefined): string;
16
+ export declare function parseSlashAnswers(text: string): string[];
17
+ export declare function splitStem(question: string): FillBlankStemPart[];
18
+ export declare function normalizeStudentText(value: string): string;
19
+ export declare function isBlankMatch(studentValue: string | undefined, alts: string[] | undefined): boolean;
20
+ export declare function padStudentAnswers(raw: unknown, blankCount: number): string[];
@@ -0,0 +1,99 @@
1
+ import { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/fill-in-blank-group.type';
2
+ export { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION };
3
+ export function extractPlaceholderIndices(question) {
4
+ if (!question)
5
+ return [];
6
+ const regex = /\{(\d+)\}/g;
7
+ const indices = [];
8
+ let match;
9
+ while ((match = regex.exec(question)) !== null) {
10
+ const index = Number.parseInt(match[1], 10);
11
+ if (!indices.includes(index)) {
12
+ indices.push(index);
13
+ }
14
+ }
15
+ return indices.sort((a, b) => a - b);
16
+ }
17
+ /** answers[j] aligns with placeholder {j}; length = max(N)+1 */
18
+ export function blankSlotCount(question) {
19
+ const indices = extractPlaceholderIndices(question);
20
+ if (indices.length === 0)
21
+ return 1;
22
+ return Math.max(...indices, 0) + 1;
23
+ }
24
+ export function normalizeBlankAlts(value) {
25
+ if (Array.isArray(value)) {
26
+ return value
27
+ .map((item) => (item == null ? '' : String(item).trim()))
28
+ .filter((item) => item.length > 0);
29
+ }
30
+ if (value == null)
31
+ return [];
32
+ const text = String(value).trim();
33
+ return text ? [text] : [];
34
+ }
35
+ export function normalizeAnswersMatrix(raw, requiredLength) {
36
+ let result = [];
37
+ if (Array.isArray(raw)) {
38
+ result = raw.map((item) => normalizeBlankAlts(item));
39
+ }
40
+ if (typeof requiredLength === 'number' && requiredLength > 0) {
41
+ if (result.length > requiredLength) {
42
+ result = result.slice(0, requiredLength);
43
+ }
44
+ while (result.length < requiredLength) {
45
+ result.push([]);
46
+ }
47
+ }
48
+ return result;
49
+ }
50
+ export function joinSlashAnswers(alts) {
51
+ return (alts || []).map((item) => item.trim()).filter(Boolean).join(' / ');
52
+ }
53
+ export function parseSlashAnswers(text) {
54
+ return text
55
+ .split('/')
56
+ .map((item) => item.trim())
57
+ .filter(Boolean);
58
+ }
59
+ export function splitStem(question) {
60
+ if (!question)
61
+ return [];
62
+ const parts = [];
63
+ const regex = /\{(\d+)\}/g;
64
+ let lastIndex = 0;
65
+ let match;
66
+ while ((match = regex.exec(question)) !== null) {
67
+ if (match.index > lastIndex) {
68
+ parts.push({ type: 'text', value: question.slice(lastIndex, match.index) });
69
+ }
70
+ parts.push({ type: 'blank', index: Number.parseInt(match[1], 10) });
71
+ lastIndex = match.index + match[0].length;
72
+ }
73
+ if (lastIndex < question.length) {
74
+ parts.push({ type: 'text', value: question.slice(lastIndex) });
75
+ }
76
+ return parts;
77
+ }
78
+ export function normalizeStudentText(value) {
79
+ return value.toString().trim().toLowerCase().replace(/\s+/g, ' ');
80
+ }
81
+ export function isBlankMatch(studentValue, alts) {
82
+ const user = normalizeStudentText(studentValue ?? '');
83
+ if (!user || !alts?.length)
84
+ return false;
85
+ return alts.some((alt) => normalizeStudentText(alt) === user);
86
+ }
87
+ export function padStudentAnswers(raw, blankCount) {
88
+ const values = Array.isArray(raw?.answers)
89
+ ? raw.answers.map((item) => String(item ?? ''))
90
+ : Array.isArray(raw)
91
+ ? raw.map((item) => String(item ?? ''))
92
+ : typeof raw === 'string'
93
+ ? [raw]
94
+ : [];
95
+ const next = [...values];
96
+ while (next.length < blankCount)
97
+ next.push('');
98
+ return next.slice(0, Math.max(blankCount, 0));
99
+ }
@@ -0,0 +1,5 @@
1
+ export * from './FillInBlankGroupCreator';
2
+ export * from './FillInBlankGroupClient';
3
+ export * from './transform';
4
+ export * from './register';
5
+ export * from './map-fill-in-blank-group-data';
@@ -0,0 +1,5 @@
1
+ export * from './FillInBlankGroupCreator';
2
+ export * from './FillInBlankGroupClient';
3
+ export * from './transform';
4
+ export * from './register';
5
+ export * from './map-fill-in-blank-group-data';
@@ -0,0 +1,4 @@
1
+ import type { FillInBlankGroupData, FillInBlankGroupItemData } from '../../_shared/types/fill-in-blank-group.type';
2
+ export declare function createEmptyFillInBlankGroupItem(questionNumber: number): FillInBlankGroupItemData;
3
+ export declare function mapFillInBlankGroupItem(item: unknown, index: number, fallbackAnswer?: unknown): FillInBlankGroupItemData;
4
+ export declare function mapQuestionToFillInBlankGroupData(question: Record<string, unknown> | null | undefined): FillInBlankGroupData;