Skip to content

Commit a3e166b

Browse files
authored
feat: xlsx support feature (#205)
1 parent 0b9e12f commit a3e166b

File tree

6 files changed

+788
-54
lines changed

6 files changed

+788
-54
lines changed

apps/web/app/author/(components)/ImportModal.tsx

Lines changed: 212 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { cn } from "@/lib/strings";
1212
import type { QuestionAuthorStore, QuestionType } from "@/config/types";
1313
import { generateTempQuestionId } from "@/lib/utils";
1414
import { ResponseType } from "@/config/types";
15+
import * as XLSX from "xlsx";
1516
interface ImportModalProps {
1617
isOpen: boolean;
1718
onClose: () => void;
@@ -30,6 +31,7 @@ interface ImportOptions {
3031
importRubrics: boolean;
3132
importConfig: boolean;
3233
importAssignmentSettings: boolean;
34+
importChoiceFeedback: boolean;
3335
}
3436

3537
interface ParsedData {
@@ -60,11 +62,12 @@ const ImportModal: React.FC<ImportModalProps> = ({
6062
const [importOptions, setImportOptions] = useState<ImportOptions>({
6163
replaceExisting: false,
6264
appendToExisting: true,
63-
validateQuestions: true,
65+
validateQuestions: false,
6466
importChoices: true,
6567
importRubrics: true,
6668
importConfig: false,
6769
importAssignmentSettings: false,
70+
importChoiceFeedback: true,
6871
});
6972
const [isProcessing, setIsProcessing] = useState(false);
7073
const [importStep, setImportStep] = useState<
@@ -386,37 +389,47 @@ const ImportModal: React.FC<ImportModalProps> = ({
386389
setIsProcessing(true);
387390

388391
try {
389-
const text = await file.text();
390392
let data: ParsedData;
391393

392-
if (file.name.endsWith(".json")) {
393-
data = JSON.parse(text) as ParsedData;
394-
} else if (file.name.endsWith(".txt")) {
395-
if (
396-
text.includes("COURSERA ASSIGNMENT EXPORT") ||
397-
text.includes("[ASSIGNMENT_METADATA]") ||
398-
text.includes("[QUESTIONS]")
399-
) {
400-
data = parseCoursera(text);
394+
if (file.name.endsWith(".xlsx") || file.name.endsWith(".xls")) {
395+
data = await parseXLSX(file);
396+
setImportOptions((prev) => ({
397+
...prev,
398+
importChoices: true,
399+
validateQuestions: true,
400+
}));
401+
} else {
402+
const text = await file.text();
403+
404+
if (file.name.endsWith(".json")) {
405+
data = JSON.parse(text) as ParsedData;
406+
} else if (file.name.endsWith(".txt")) {
407+
if (
408+
text.includes("COURSERA ASSIGNMENT EXPORT") ||
409+
text.includes("[ASSIGNMENT_METADATA]") ||
410+
text.includes("[QUESTIONS]")
411+
) {
412+
data = parseCoursera(text);
413+
} else {
414+
throw new Error(
415+
"Unrecognized text file format. Expected Coursera format with section headers like [QUESTIONS].",
416+
);
417+
}
418+
} else if (file.name.endsWith(".xml")) {
419+
data = parseOLX(text);
420+
} else if (file.name.endsWith(".docx")) {
421+
throw new Error(
422+
"Microsoft Word documents not yet supported. Please export as text, YAML, or XML.",
423+
);
424+
} else if (file.name.endsWith(".zip")) {
425+
throw new Error(
426+
"IMS QTI zip files not yet supported. Please extract individual XML files from the package.",
427+
);
401428
} else {
402429
throw new Error(
403-
"Unrecognized text file format. Expected Coursera format with section headers like [QUESTIONS].",
430+
"Unsupported file format. Please use JSON, Excel (.xlsx), Coursera (.txt), QTI (.xml), or OLX (.xml) files.",
404431
);
405432
}
406-
} else if (file.name.endsWith(".xml")) {
407-
data = parseOLX(text);
408-
} else if (file.name.endsWith(".docx")) {
409-
throw new Error(
410-
"Microsoft Word documents not yet supported. Please export as text, YAML, or XML.",
411-
);
412-
} else if (file.name.endsWith(".zip")) {
413-
throw new Error(
414-
"IMS QTI zip files not yet supported. Please extract individual XML files from the package.",
415-
);
416-
} else {
417-
throw new Error(
418-
"Unsupported file format. Please use JSON, Coursera (.txt), QTI (.xml), or OLX (.xml) files.",
419-
);
420433
}
421434

422435
if (!data.questions || data.questions.length === 0) {
@@ -433,7 +446,6 @@ const ImportModal: React.FC<ImportModalProps> = ({
433446

434447
setImportStep("configure");
435448
} catch (error) {
436-
console.error("File parsing error:", error);
437449
alert(
438450
`Failed to parse file: ${error instanceof Error ? error.message : "Unknown error"}`,
439451
);
@@ -463,7 +475,6 @@ const ImportModal: React.FC<ImportModalProps> = ({
463475
"Unrecognized YAML format. Expected either Coursera variations format or custom export format.",
464476
);
465477
} catch (error) {
466-
console.error("Error parsing YAML:", error);
467478
throw new Error(
468479
`Invalid YAML format: ${error instanceof Error ? error.message : "Unknown error"}`,
469480
);
@@ -802,6 +813,124 @@ const ImportModal: React.FC<ImportModalProps> = ({
802813
};
803814
};
804815

816+
const parseXLSX = async (file: File): Promise<ParsedData> => {
817+
const questions: QuestionAuthorStore[] = [];
818+
819+
try {
820+
const data = await file.arrayBuffer();
821+
const workbook = XLSX.read(data, { type: "array" });
822+
823+
const quizSheetName =
824+
workbook.SheetNames.find((name) =>
825+
name.toLowerCase().includes("quiz questions"),
826+
) ||
827+
workbook.SheetNames.find((name) =>
828+
name.toLowerCase().includes("questions_master"),
829+
) ||
830+
workbook.SheetNames[0];
831+
832+
const worksheet = workbook.Sheets[quizSheetName];
833+
834+
if (!worksheet) {
835+
throw new Error(
836+
"Could not find a sheet with quiz questions. Expected a sheet named 'QUIZ QUESTIONS_MASTER'.",
837+
);
838+
}
839+
840+
const rows: any[][] = XLSX.utils.sheet_to_json(worksheet, {
841+
header: 1,
842+
defval: "",
843+
});
844+
845+
if (!rows.length) {
846+
throw new Error("Excel sheet is empty.");
847+
}
848+
849+
for (let i = 1; i < rows.length; i++) {
850+
const row = rows[i];
851+
852+
if (!row || row.length === 0) continue;
853+
854+
const questionText = (row[0] ?? "").toString().trim();
855+
const correctAnswer = (row[1] ?? "").toString().trim();
856+
const answer2 = (row[2] ?? "").toString().trim();
857+
const answer3 = (row[3] ?? "").toString().trim();
858+
const answer4 = (row[4] ?? "").toString().trim();
859+
const answerLocation = (row[5] ?? "").toString().trim();
860+
const additionalInfo = (row[6] ?? "").toString().trim();
861+
862+
if (!questionText) continue;
863+
864+
const question: Partial<QuestionAuthorStore> = {
865+
id: generateTempQuestionId(),
866+
alreadyInBackend: false,
867+
assignmentId: 0,
868+
index: questions.length + 1,
869+
numRetries: 1,
870+
type: "SINGLE_CORRECT" as QuestionType,
871+
responseType: "OTHER" as ResponseType,
872+
totalPoints: 1,
873+
question: questionText,
874+
scoring: { type: "CRITERIA_BASED", criteria: [] },
875+
};
876+
877+
const choices: any[] = [];
878+
879+
if (correctAnswer) {
880+
choices.push({
881+
choice: correctAnswer,
882+
isCorrect: true,
883+
points: 1,
884+
feedback: additionalInfo
885+
? `You may find answer for this question at ${additionalInfo}`
886+
: "",
887+
});
888+
}
889+
890+
[answer2, answer3, answer4].forEach((answer) => {
891+
if (answer) {
892+
choices.push({
893+
choice: answer,
894+
isCorrect: false,
895+
points: 0,
896+
feedback: answerLocation
897+
? `You may find answer for this question at ${answerLocation}`
898+
: "",
899+
});
900+
}
901+
});
902+
903+
if (choices.length < 2) {
904+
continue;
905+
}
906+
907+
question.choices = choices;
908+
909+
questions.push(question as QuestionAuthorStore);
910+
}
911+
912+
if (!questions.length) {
913+
throw new Error(
914+
"No questions parsed from Excel. Check that 'QUIZ QUESTIONS_MASTER' has data under the header row.",
915+
);
916+
}
917+
918+
return {
919+
questions,
920+
assignment: {
921+
name: "Imported from Excel",
922+
introduction: `Imported ${questions.length} multiple-choice questions from sheet "${quizSheetName}" in ${file.name}`,
923+
},
924+
};
925+
} catch (error) {
926+
throw new Error(
927+
`Failed to parse Excel file: ${
928+
error instanceof Error ? error.message : "Unknown error"
929+
}`,
930+
);
931+
}
932+
};
933+
805934
const parseCustomYAMLFormat = (yamlData: any): ParsedData => {
806935
const questions: QuestionAuthorStore[] = [];
807936
let assignment: any = {};
@@ -1182,6 +1311,16 @@ const ImportModal: React.FC<ImportModalProps> = ({
11821311
}));
11831312
}
11841313

1314+
if (!importOptions.importChoiceFeedback && importOptions.importChoices) {
1315+
questionsToImport = questionsToImport.map((q) => ({
1316+
...q,
1317+
choices: q.choices?.map((choice) => ({
1318+
...choice,
1319+
feedback: "",
1320+
})),
1321+
}));
1322+
}
1323+
11851324
if (!importOptions.importRubrics) {
11861325
questionsToImport = questionsToImport.map((q) => ({
11871326
...q,
@@ -1256,13 +1395,13 @@ const ImportModal: React.FC<ImportModalProps> = ({
12561395
</button>
12571396
</p>
12581397
<p className="text-sm text-gray-500">
1259-
Supports JSON, Open edX (.xml)
1398+
Supports JSON, Excel (.xlsx), Open edX (.xml)
12601399
</p>
12611400

12621401
<input
12631402
ref={fileInputRef}
12641403
type="file"
1265-
accept=".json,.txt,.xml,.zip"
1404+
accept=".json,.txt,.xml,.zip,.xlsx,.xls"
12661405
onChange={handleFileInput}
12671406
className="hidden"
12681407
/>
@@ -1299,6 +1438,10 @@ const ImportModal: React.FC<ImportModalProps> = ({
12991438
<strong>JSON:</strong> Complete assignment exports with all
13001439
question data
13011440
</li>
1441+
<li>
1442+
<strong>Excel (.xlsx):</strong> Quiz format with question
1443+
text and 4 answer choices (first column is correct answer)
1444+
</li>
13021445
<li>
13031446
<strong>Open edX OLX (.xml):</strong> Open Learning XML
13041447
format
@@ -1391,8 +1534,23 @@ const ImportModal: React.FC<ImportModalProps> = ({
13911534
{
13921535
id: "importChoices",
13931536
label: "Import question choices",
1394-
description: "Include multiple choice options",
1537+
description:
1538+
selectedFile?.name.endsWith(".xlsx") ||
1539+
selectedFile?.name.endsWith(".xls")
1540+
? "Required for Excel imports (always enabled)"
1541+
: "Include multiple choice options",
13951542
},
1543+
...(selectedFile?.name.endsWith(".xlsx") ||
1544+
selectedFile?.name.endsWith(".xls")
1545+
? [
1546+
{
1547+
id: "importChoiceFeedback",
1548+
label: "Import choice feedback from Excel",
1549+
description:
1550+
"Adds 'Additional Info' as feedback on correct answer, and 'Answer Location' as feedback on incorrect answers",
1551+
},
1552+
]
1553+
: []),
13961554
{
13971555
id: "importRubrics",
13981556
label: "Import rubrics and scoring",
@@ -1444,6 +1602,29 @@ const ImportModal: React.FC<ImportModalProps> = ({
14441602
</div>
14451603
</div>
14461604

1605+
{validationErrors.length > 0 && (
1606+
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
1607+
<div className="flex items-center gap-2 mb-2">
1608+
<ExclamationTriangleIcon className="w-5 h-5 text-red-600" />
1609+
<h4 className="font-medium text-red-900">
1610+
Validation Errors
1611+
</h4>
1612+
</div>
1613+
<div className="space-y-2 max-h-48 overflow-y-auto">
1614+
{validationErrors.map((error, idx) => (
1615+
<div key={idx} className="text-sm text-red-800">
1616+
<strong>Question {error.questionIndex + 1}:</strong>{" "}
1617+
{error.message} (field: {error.field})
1618+
</div>
1619+
))}
1620+
</div>
1621+
<p className="text-xs text-red-600 mt-2">
1622+
Note: Errors marked with "will be auto-generated" or "will
1623+
be auto-calculated" won't prevent import.
1624+
</p>
1625+
</div>
1626+
)}
1627+
14471628
{importOptions.replaceExisting && (
14481629
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
14491630
<div className="flex items-center gap-2">

0 commit comments

Comments
 (0)