Skip to content

Commit

Permalink
Quiz exercises: Import existing quiz exercises from zip files (#9116)
Browse files Browse the repository at this point in the history
  • Loading branch information
EneaGore authored Jul 28, 2024
1 parent eac818a commit 0f5fdb8
Show file tree
Hide file tree
Showing 6 changed files with 126 additions and 21 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -258,12 +258,16 @@ export class QuizExerciseService {
questions.forEach((question, questionIndex) => {
if (question.type === QuizQuestionType.DRAG_AND_DROP) {
if ((question as DragAndDropQuestion).backgroundFilePath) {
filePromises.push(this.fetchFilePromise(`q${questionIndex}_background.png`, zip, (question as DragAndDropQuestion).backgroundFilePath!));
const filePath = (question as DragAndDropQuestion).backgroundFilePath!;
const fileNameExtension = filePath.split('.').last();
filePromises.push(this.fetchFilePromise(`q${questionIndex}_background.${fileNameExtension}`, zip, filePath));
}
if ((question as DragAndDropQuestion).dragItems) {
(question as DragAndDropQuestion).dragItems?.forEach((dragItem, drag_index) => {
if (dragItem.pictureFilePath) {
filePromises.push(this.fetchFilePromise(`q${questionIndex}_dragItem-${drag_index}.png`, zip, dragItem.pictureFilePath));
const filePath = dragItem.pictureFilePath!;
const fileNameExtension = filePath.split('.').last();
filePromises.push(this.fetchFilePromise(`q${questionIndex}_dragItem-${drag_index}.${fileNameExtension}`, zip, filePath));
}
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
<span jhiTranslate="artemisApp.quizExercise.importJSON" class="font-weight-bold colon-suffix no-flex-shrink"></span>
</div>
<div class="input-group flex-grow-1 col">
<input id="importFileInput" type="file" accept=".json" class="form-control" (change)="setImportFile($event)" placeholder="Upload file..." />
<input id="importFileInput" type="file" accept=".json,.zip" class="form-control" (change)="setImportFile($event)" placeholder="Upload file..." />
<button class="btn btn-outline-primary" (click)="importQuiz()" jhiTranslate="entity.action.import" [ngClass]="{ disabled: !importFile }"></button>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { QuizExercise } from 'app/entities/quiz/quiz-exercise.model';
import { onError } from 'app/shared/util/global.utils';
import { checkForInvalidFlaggedQuestions } from 'app/exercises/quiz/shared/quiz-manage-util.service';
import { FileService } from 'app/shared/http/file.service';
import JSZip from 'jszip';

export enum State {
COURSE = 'Course',
Expand Down Expand Up @@ -170,16 +171,58 @@ export class QuizQuestionListEditExistingComponent implements OnChanges {
if (!this.importFile) {
return;
}

const fileName = this.importFile.name;
const fileExtension = fileName.split('.').last()?.toLowerCase();

if (fileExtension === 'zip') {
await this.handleZipFile();
} else {
this.handleJsonFile();
}
}

handleJsonFile() {
const fileReader = this.generateFileReader();
fileReader.onload = () => this.onFileLoadImport(fileReader);
fileReader.readAsText(this.importFile);
fileReader.readAsText(this.importFile!);
}

async onFileLoadImport(fileReader: FileReader) {
async handleZipFile() {
const jszip = new JSZip();

try {
const questions = JSON.parse(fileReader.result as string) as QuizQuestion[];
await this.addQuestions(questions);
// Clearing html elements,
const zipContent = await jszip.loadAsync(this.importFile!);
const jsonFiles = Object.keys(zipContent.files).filter((fileName) => fileName.endsWith('.json'));

const images = await this.extractImagesFromZip(zipContent);
const jsonFile = zipContent.files[jsonFiles[0]];
const jsonContent = await jsonFile.async('string');
await this.processJsonContent(jsonContent, images);
} catch (error) {
alert('Import Quiz Failed! Invalid zip file.');
return;
}
}
async extractImagesFromZip(zipContent: JSZip) {
const images: Map<string, File> = new Map();
for (const [fileName, zipEntry] of Object.entries(zipContent.files)) {
if (!fileName.endsWith('.json')) {
const lastDotIndex = fileName.lastIndexOf('.');
const fileNameNoExtension = fileName.substring(0, lastDotIndex);
const imageData = await zipEntry.async('blob');
const imageFile = new File([imageData], fileName);
images.set(fileNameNoExtension, imageFile);
}
}
return images;
}

async processJsonContent(jsonContent: string, images: Map<string, File> = new Map()) {
try {
const questions = JSON.parse(jsonContent) as QuizQuestion[];
await this.addQuestions(questions, images);
// Clearing html elements
this.importFile = undefined;
this.importFileName = '';
const control = document.getElementById('importFileInput') as HTMLInputElement;
Expand All @@ -188,9 +231,13 @@ export class QuizQuestionListEditExistingComponent implements OnChanges {
}
} catch (e) {
alert('Import Quiz Failed! Invalid quiz file.');
return;
}
}

async onFileLoadImport(fileReader: FileReader) {
await this.processJsonContent(fileReader.result as string);
}
/**
* Move file reader creation to separate function to be able to mock
* https://fromanegg.com/post/2015/04/22/easy-testing-of-code-involving-native-methods-in-javascript/
Expand Down Expand Up @@ -222,7 +269,7 @@ export class QuizQuestionListEditExistingComponent implements OnChanges {
* Images are duplicated for drag and drop questions.
* @param quizQuestions questions to be added to currently edited quiz exercise
*/
async addQuestions(quizQuestions: Array<QuizQuestion>) {
async addQuestions(quizQuestions: Array<QuizQuestion>, images: Map<string, File> = new Map()) {
const invalidQuizQuestionMap = checkForInvalidFlaggedQuestions(quizQuestions);
const validQuizQuestions = quizQuestions.filter((quizQuestion) => !invalidQuizQuestionMap[quizQuestion.id!]);
const invalidQuizQuestions = quizQuestions.filter((quizQuestion) => invalidQuizQuestionMap[quizQuestion.id!]);
Expand All @@ -239,10 +286,10 @@ export class QuizQuestionListEditExistingComponent implements OnChanges {
});
modal.componentInstance.shouldImport.subscribe(async () => {
const newQuizQuestions = validQuizQuestions.concat(invalidQuizQuestions);
return this.handleConversionOfExistingQuestions(newQuizQuestions);
return this.handleConversionOfExistingQuestions(newQuizQuestions, images);
});
} else {
return this.handleConversionOfExistingQuestions(validQuizQuestions);
return this.handleConversionOfExistingQuestions(validQuizQuestions, images);
}
}

Expand Down Expand Up @@ -271,13 +318,15 @@ export class QuizQuestionListEditExistingComponent implements OnChanges {
* Convert the given list of existing QuizQuestions to a list of new QuizQuestions
*
* @param existingQuizQuestions the list of existing QuizQuestions to be converted
* @param images if a zip file was provided, the images exported will be used to create the Dnd
* @return the list of new QuizQuestions
*/
private async handleConversionOfExistingQuestions(existingQuizQuestions: Array<QuizQuestion>) {
private async handleConversionOfExistingQuestions(existingQuizQuestions: Array<QuizQuestion>, images: Map<string, File> = new Map()) {
const newQuizQuestions = new Array<QuizQuestion>();
const files: Map<string, { path: string; file: File }> = new Map<string, { path: string; file: File }>();
// To make sure all questions are duplicated (new resources are created), we need to remove some fields from the input questions,
// This contains removing all ids, duplicating images in case of dnd questions, the question statistic and the exercise
var questionIndex = 0;

Check failure on line 329 in src/main/webapp/app/exercises/quiz/manage/quiz-question-list-edit-existing.component.ts

View workflow job for this annotation

GitHub Actions / client-style

Unexpected var, use let or const instead

Check failure on line 329 in src/main/webapp/app/exercises/quiz/manage/quiz-question-list-edit-existing.component.ts

View workflow job for this annotation

GitHub Actions / client-style

Unexpected var, use let or const instead

Check failure on line 329 in src/main/webapp/app/exercises/quiz/manage/quiz-question-list-edit-existing.component.ts

View workflow job for this annotation

GitHub Actions / client-style

Unexpected var, use let or const instead

Check failure on line 329 in src/main/webapp/app/exercises/quiz/manage/quiz-question-list-edit-existing.component.ts

View workflow job for this annotation

GitHub Actions / client-style

Unexpected var, use let or const instead
for (const question of existingQuizQuestions) {
// do not set question.exercise = this.quizExercise, because it will cause a cycle when converting to json
question.exercise = undefined;
Expand All @@ -293,9 +342,16 @@ export class QuizQuestionListEditExistingComponent implements OnChanges {
} else if (question.type === QuizQuestionType.DRAG_AND_DROP) {
const dndQuestion = question as DragAndDropQuestion;
// Get image from the old question and duplicate it on the server and then save new image to the question,
const backgroundFile = await this.fileService.getFile(dndQuestion.backgroundFilePath!, this.filePool);
files.set(backgroundFile.name, { path: dndQuestion.backgroundFilePath!, file: backgroundFile });
dndQuestion.backgroundFilePath = backgroundFile.name;
const backgroundImageFile: File | undefined = images.get(`q${questionIndex}_background`);
if (backgroundImageFile) {
const backgroundFile = backgroundImageFile;
files.set(backgroundFile.name, { path: dndQuestion.backgroundFilePath!, file: backgroundFile });
dndQuestion.backgroundFilePath = backgroundFile.name;
} else {
const backgroundFile = await this.fileService.getFile(dndQuestion.backgroundFilePath!, this.filePool);
files.set(backgroundFile.name, { path: dndQuestion.backgroundFilePath!, file: backgroundFile });
dndQuestion.backgroundFilePath = backgroundFile.name;
}

// For DropLocations, DragItems and CorrectMappings we need to provide tempID,
// This tempID is used for keep tracking of mappings by server. The server removes tempID and generated a new id,
Expand All @@ -304,13 +360,21 @@ export class QuizQuestionListEditExistingComponent implements OnChanges {
dropLocation.id = undefined;
dropLocation.invalid = false;
});
let dragItemCounter = 0;
for (const dragItem of dndQuestion.dragItems || []) {
// Duplicating image on server. This is only valid for image drag items. For text drag items, pictureFilePath is undefined,
if (dragItem.pictureFilePath) {
const dragItemFile = await this.fileService.getFile(dragItem.pictureFilePath, this.filePool);
files.set(dragItemFile.name, { path: dragItem.pictureFilePath, file: dragItemFile });
dragItem.pictureFilePath = dragItemFile.name;
const exportedDragItemFile: File | undefined = images.get(`q${questionIndex}_dragItem-${dragItemCounter}`);
if (exportedDragItemFile) {
files.set(exportedDragItemFile.name, { path: dragItem.pictureFilePath, file: exportedDragItemFile });
dragItem.pictureFilePath = exportedDragItemFile.name;
} else {
const dragItemFile = await this.fileService.getFile(dragItem.pictureFilePath, this.filePool);
files.set(dragItemFile.name, { path: dragItem.pictureFilePath, file: dragItemFile });
dragItem.pictureFilePath = dragItemFile.name;
}
}
dragItemCounter += 1;
dragItem.tempID = dragItem.id;
dragItem.id = undefined;
dragItem.invalid = false;
Expand Down Expand Up @@ -361,6 +425,7 @@ export class QuizQuestionListEditExistingComponent implements OnChanges {
});
}
newQuizQuestions.push(question);
questionIndex += 1;
}
if (files.size > 0) {
this.onFilesAdded.emit(files);
Expand Down
2 changes: 1 addition & 1 deletion src/main/webapp/i18n/de/quizExercise.json
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@
"proportional_with_penalty": "Jede richtige Antwort führt zu einem Bruchteil der Gesamtpunktzahl. Um Rätselraten zu vermeiden, wird für jeden Fehler der gleiche Anteil von den erreichten Punkten abgezogen. Beispiel: Wenn die Punktzahl der Frage 3 ist und es 5 Optionen gibt, ergibt jede richtige Antwortmöglichkeit 0,6 Punkte. Jede falsche Antwortmöglichkeit zieht 0,6 Punkte ab. Studierende mit 3 richtigen und 2 falschen Antworten erhalten dann 0,6 Punkte.",
"proportional_without_penalty": "Jede richtige Antwort führt zu einem Bruchteil der Gesamtpunktzahl. Bei falschen Antworten werden keine Punkte abgezogen. Beispiel: Wenn die Punktzahl der Frage 3 ist und es 5 Optionen gibt, ergibt jede richtige Antwortmöglichkeit 0,6 Punkte. Studierende mit 3 richtigen und 2 falschen Antworten erhalten dann 1,8 Punkte."
},
"importJSON": "Fragen importieren (JSON)",
"importJSON": "Fragen importieren (JSON/Zip)",
"importWarningShort": "Ungültige Fragen gefunden",
"importWarningLong": "Bei den folgenden Fragen ist ein ungültiges Flag gesetzt. Bist du sicher, dass du fortfahren möchtest? <strong>In diesem Fall werden die ungültigen Flags zurückgesetzt</ strong>.",
"confirmImport": "Weiter",
Expand Down
2 changes: 1 addition & 1 deletion src/main/webapp/i18n/en/quizExercise.json
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@
"proportional_with_penalty": "Each correct answer is awarded with a fraction of the total score. To avoid guesswork, the same fraction is subtracted from the achieved points for each mistake. Example: if the question contains 3 points and 5 answer options, each correct answer option is awarded with 0.6 points. Each wrong answer option subtracts 0.6 points. A student with 3 correct and 2 wrong answers would then receive 0.6 points.",
"proportional_without_penalty": "Each correct answer is awarded with a fraction of the total score. No points are deducted for mistakes. Example: if the question contains 3 points and 5 answer options, each correct answer option is awarded with 0.6 points. A student with 3 correct and 2 wrong answers would then receive 1.8 points."
},
"importJSON": "Import Question(s) in JSON Format",
"importJSON": "Import Question(s) in JSON/Zip Format",
"importWarningShort": "Invalid questions found",
"importWarningLong": "The following questions have an invalid flag set. Are you sure you want to continue? <strong>If so, the invalid flags will be reset</strong>.",
"confirmImport": "Continue",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { ChangeDetectorRef, EventEmitter } from '@angular/core';
import { QuizQuestion } from 'app/entities/quiz/quiz-question.model';
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { FileService } from 'app/shared/http/file.service';
import JSZip from 'jszip';

const createValidMCQuestion = () => {
const question = new MultipleChoiceQuestion();
Expand Down Expand Up @@ -341,7 +342,7 @@ describe('QuizQuestionListEditExistingComponent', () => {
expect(generateFileReaderStub).toHaveBeenCalledOnce();
const addQuestionSpy = jest.spyOn(component, 'addQuestions').mockImplementation();
await component.onFileLoadImport(reader);
expect(addQuestionSpy).toHaveBeenCalledWith(questions);
expect(addQuestionSpy).toHaveBeenCalledWith(questions, new Map());
expect(component.importFile).toBeUndefined();
expect(component.importFileName).toBe('');
expect(getElementStub).toHaveBeenCalledOnce();
Expand Down Expand Up @@ -446,5 +447,40 @@ describe('QuizQuestionListEditExistingComponent', () => {
expect(onFilesAddedSpy).toHaveBeenCalledOnce();
expect(getFileMock).toHaveBeenCalledTimes(3);
});

it('should correctly differentiate between JSON and ZIP files', async () => {
const handleJsonFileSpy = jest.spyOn(component, 'handleJsonFile');
const handleZipFileSpy = jest.spyOn(component, 'handleZipFile');
const jsonFile = new File(['{}'], 'quiz.json', { type: 'application/json' });
component.importFile = jsonFile;
await component.importQuiz();
expect(handleJsonFileSpy).toHaveBeenCalledWith();
const zipFile = new File([], 'quiz.zip', { type: 'application/zip' });
component.importFile = zipFile;
await component.importQuiz();
expect(handleZipFileSpy).toHaveBeenCalledWith();
});

it('should correctly extract images from a ZIP file', async () => {
const extractImagesFromZipSpy = jest.spyOn(component, 'extractImagesFromZip');
const zip = new JSZip();
zip.file('image1.png', 'fakeImageData1');
zip.file('image2.jpg', 'fakeImageData2');
zip.file('data.json', '{}');
const zipContent = await zip.generateAsync({ type: 'blob' });

component.importFile = new File([zipContent], 'quiz.zip', { type: 'application/zip' });
const extractedImages = new Map();
extractedImages.set('image1', new File(['fakeImageData1'], 'image1.png'));
extractedImages.set('image2', new File(['fakeImageData2'], 'image2.jpg'));
jest.spyOn(JSZip.prototype, 'loadAsync').mockResolvedValue(zip);
jest.spyOn(zip.files['image1.png'], 'async').mockResolvedValue(new Blob(['fakeImageData1']));
jest.spyOn(zip.files['image2.jpg'], 'async').mockResolvedValue(new Blob(['fakeImageData2']));
const result = await component.extractImagesFromZip(zip);
expect(extractImagesFromZipSpy).toHaveBeenCalledWith(zip);
expect(result.size).toBe(2);
expect(result.has('image1')).toBeTrue();
expect(result.has('image2')).toBeTrue();
});
});
});

0 comments on commit 0f5fdb8

Please sign in to comment.