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.", "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(); + }); }); });