@@ -12,6 +12,7 @@ import { cn } from "@/lib/strings";
1212import type { QuestionAuthorStore , QuestionType } from "@/config/types" ;
1313import { generateTempQuestionId } from "@/lib/utils" ;
1414import { ResponseType } from "@/config/types" ;
15+ import * as XLSX from "xlsx" ;
1516interface 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
3537interface 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