From 0f5fdb84eaf1ff788f4ab045c1ab69a93abb7ae9 Mon Sep 17 00:00:00 2001
From: EneaGore <73840596+EneaGore@users.noreply.github.com>
Date: Sun, 28 Jul 2024 23:03:32 +0200
Subject: [PATCH] Quiz exercises: Import existing quiz exercises from zip files
(#9116)
---
.../quiz/manage/quiz-exercise.service.ts | 8 +-
...question-list-edit-existing.component.html | 2 +-
...z-question-list-edit-existing.component.ts | 95 ++++++++++++++++---
src/main/webapp/i18n/de/quizExercise.json | 2 +-
src/main/webapp/i18n/en/quizExercise.json | 2 +-
...stion-list-edit-existing.component.spec.ts | 38 +++++++-
6 files changed, 126 insertions(+), 21 deletions(-)
diff --git a/src/main/webapp/app/exercises/quiz/manage/quiz-exercise.service.ts b/src/main/webapp/app/exercises/quiz/manage/quiz-exercise.service.ts
index 9baf08387e69..91a252bd6eb3 100644
--- a/src/main/webapp/app/exercises/quiz/manage/quiz-exercise.service.ts
+++ b/src/main/webapp/app/exercises/quiz/manage/quiz-exercise.service.ts
@@ -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));
}
});
}
diff --git a/src/main/webapp/app/exercises/quiz/manage/quiz-question-list-edit-existing.component.html b/src/main/webapp/app/exercises/quiz/manage/quiz-question-list-edit-existing.component.html
index b5f9988d8b8e..03113f9e7d75 100644
--- a/src/main/webapp/app/exercises/quiz/manage/quiz-question-list-edit-existing.component.html
+++ b/src/main/webapp/app/exercises/quiz/manage/quiz-question-list-edit-existing.component.html
@@ -41,7 +41,7 @@
-
+
diff --git a/src/main/webapp/app/exercises/quiz/manage/quiz-question-list-edit-existing.component.ts b/src/main/webapp/app/exercises/quiz/manage/quiz-question-list-edit-existing.component.ts
index afadf080870e..ed16b706639b 100644
--- a/src/main/webapp/app/exercises/quiz/manage/quiz-question-list-edit-existing.component.ts
+++ b/src/main/webapp/app/exercises/quiz/manage/quiz-question-list-edit-existing.component.ts
@@ -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',
@@ -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 = 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 = 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;
@@ -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/
@@ -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) {
+ async addQuestions(quizQuestions: Array, images: Map = new Map()) {
const invalidQuizQuestionMap = checkForInvalidFlaggedQuestions(quizQuestions);
const validQuizQuestions = quizQuestions.filter((quizQuestion) => !invalidQuizQuestionMap[quizQuestion.id!]);
const invalidQuizQuestions = quizQuestions.filter((quizQuestion) => invalidQuizQuestionMap[quizQuestion.id!]);
@@ -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);
}
}
@@ -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) {
+ private async handleConversionOfExistingQuestions(existingQuizQuestions: Array, images: Map = new Map()) {
const newQuizQuestions = new Array();
const files: Map = new Map();
// 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;
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;
@@ -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,
@@ -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;
@@ -361,6 +425,7 @@ export class QuizQuestionListEditExistingComponent implements OnChanges {
});
}
newQuizQuestions.push(question);
+ questionIndex += 1;
}
if (files.size > 0) {
this.onFilesAdded.emit(files);
diff --git a/src/main/webapp/i18n/de/quizExercise.json b/src/main/webapp/i18n/de/quizExercise.json
index 8c0592b2fa90..3ede02b8ae3e 100644
--- a/src/main/webapp/i18n/de/quizExercise.json
+++ b/src/main/webapp/i18n/de/quizExercise.json
@@ -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? In diesem Fall werden die ungültigen Flags zurückgesetzt strong>.",
"confirmImport": "Weiter",
diff --git a/src/main/webapp/i18n/en/quizExercise.json b/src/main/webapp/i18n/en/quizExercise.json
index 96a60bc4b0b0..6b9d14db482f 100644
--- a/src/main/webapp/i18n/en/quizExercise.json
+++ b/src/main/webapp/i18n/en/quizExercise.json
@@ -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? If so, the invalid flags will be reset.",
"confirmImport": "Continue",
diff --git a/src/test/javascript/spec/component/exercises/quiz/manage/quiz-question-list-edit-existing.component.spec.ts b/src/test/javascript/spec/component/exercises/quiz/manage/quiz-question-list-edit-existing.component.spec.ts
index 1557bb5fc672..e0c07d841d71 100644
--- a/src/test/javascript/spec/component/exercises/quiz/manage/quiz-question-list-edit-existing.component.spec.ts
+++ b/src/test/javascript/spec/component/exercises/quiz/manage/quiz-question-list-edit-existing.component.spec.ts
@@ -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();
@@ -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();
@@ -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();
+ });
});
});