From c99132085025a22bcc8ae3d71a1f2f3ea090e8b3 Mon Sep 17 00:00:00 2001
From: srosse <stephane.rosse@frentix.com>
Date: Mon, 2 Mar 2020 17:30:56 +0100
Subject: [PATCH] OO-4528: create test sections from item subjects in question
 pool

---
 .../ims/qti21/pool/QTI21ExportProcessor.java  | 46 +++++++++--
 .../qti21/pool/QTI21QPoolServiceProvider.java |  4 +-
 .../handlers/QTI21AssessmentTestHandler.java  |  3 +-
 .../olat/modules/qpool/model/QItemList.java   | 18 ++---
 .../ui/CreateTestOverviewController.java      | 79 +++++++++++++++++--
 .../qpool/ui/QuestionListController.java      |  9 ++-
 .../qpool/ui/_content/create_test.html        |  3 +
 .../qpool/ui/_i18n/LocalStrings_de.properties |  1 +
 .../qpool/ui/_i18n/LocalStrings_en.properties |  1 +
 9 files changed, 133 insertions(+), 31 deletions(-)

diff --git a/src/main/java/org/olat/ims/qti21/pool/QTI21ExportProcessor.java b/src/main/java/org/olat/ims/qti21/pool/QTI21ExportProcessor.java
index 08f5c5d6beb..fce4ef6f6dd 100644
--- a/src/main/java/org/olat/ims/qti21/pool/QTI21ExportProcessor.java
+++ b/src/main/java/org/olat/ims/qti21/pool/QTI21ExportProcessor.java
@@ -22,6 +22,7 @@ package org.olat.ims.qti21.pool;
 import java.io.File;
 import java.io.FileOutputStream;
 import java.io.IOException;
+import java.io.OutputStream;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.nio.file.FileVisitResult;
@@ -29,8 +30,10 @@ import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.SimpleFileVisitor;
 import java.nio.file.attribute.BasicFileAttributes;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Locale;
+import java.util.Map;
 import java.util.concurrent.atomic.DoubleAdder;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipOutputStream;
@@ -60,6 +63,7 @@ import org.olat.modules.qpool.manager.QPoolFileStorage;
 import uk.ac.ed.ph.jqtiplus.node.item.AssessmentItem;
 import uk.ac.ed.ph.jqtiplus.node.test.AssessmentSection;
 import uk.ac.ed.ph.jqtiplus.node.test.AssessmentTest;
+import uk.ac.ed.ph.jqtiplus.node.test.TestPart;
 import uk.ac.ed.ph.jqtiplus.resolution.ResolvedAssessmentItem;
 import uk.ac.ed.ph.jqtiplus.serialization.QtiSerializer;
 
@@ -104,9 +108,9 @@ public class QTI21ExportProcessor {
 				.loadAndResolveAssessmentItemForCopy(assessmentItemUri, rootDirectory);
 		enrichWithMetadata(qitem, resolvedAssessmentItem, manifestBuilder);
 		
-		try {
+		try(OutputStream out = new ShieldOutputStream(zout)) {
 			zout.putNextEntry(new ZipEntry(rootDir + "/imsmanifest.xml"));
-			manifestBuilder.write(new ShieldOutputStream(zout));
+			manifestBuilder.write(out);
 			zout.closeEntry();
 		} catch (Exception e) {
 			log.error("", e);
@@ -180,7 +184,7 @@ public class QTI21ExportProcessor {
 		metadataBuilder.appendMetadataFrom(qitem, resolvedAssessmentItem, locale);	
 	}
 	
-	public void assembleTest(String title, List<QuestionItemFull> fullItems, File directory) {
+	public void assembleTest(String title, List<QuestionItemFull> fullItems, boolean groupByTaxonomyLevel, File directory) {
 		try {
 			QtiSerializer qtiSerializer = qtiService.qtiSerializer();
 			//imsmanifest
@@ -200,7 +204,9 @@ public class QTI21ExportProcessor {
 			manifest.appendAssessmentTest(assessmentTestFilename);
 
 			//make a section
-			AssessmentSection section = assessmentTest.getTestParts().get(0).getAssessmentSections().get(0);
+			final TestPart testPart = assessmentTest.getTestParts().get(0);
+			AssessmentSection defaultSection = testPart.getAssessmentSections().get(0);
+			Map<String,AssessmentSection> sectionByTitles = new HashMap<>();
 
 			//assessment items
 			for(QuestionItemFull qitem:fullItems) {
@@ -217,6 +223,12 @@ public class QTI21ExportProcessor {
 				File newItemFile = new File(containerDir, assessmentItem.getIdentifier() + ".xml");
 				String newItemFilename = container  + "/" + newItemFile.getName();
 				qtiService.persistAssessmentObject(newItemFile, assessmentItem);
+				
+				AssessmentSection section = defaultSection;
+				if(groupByTaxonomyLevel && StringHelper.containsNonWhitespace(qitem.getTaxonomyLevelName())) {
+					section = sectionByTitles.computeIfAbsent(qitem.getTaxonomyLevelName(), level
+							-> AssessmentTestFactory.appendAssessmentSection(level, testPart));
+				}
 
 				AssessmentTestFactory.appendAssessmentItem(section, newItemFilename);
 				manifest.appendAssessmentItem(newItemFilename);
@@ -242,6 +254,10 @@ public class QTI21ExportProcessor {
 				}
 			}
 			
+			if(defaultSection.getSectionParts().isEmpty()) {
+				testPart.getChildAbstractParts().remove(defaultSection);
+			}
+			
 			AssessmentTestBuilder assessmentTestBuilder = new AssessmentTestBuilder(assessmentTest);
 			double sumMaxScore = atomicMaxScore.sum();
 			if(sumMaxScore > 0.0d) {
@@ -263,7 +279,6 @@ public class QTI21ExportProcessor {
 	
 	public void assembleTest(List<QuestionItemFull> fullItems, ZipOutputStream zout) {
 		try {
-			QtiSerializer qtiSerializer = qtiService.qtiSerializer();
 			//imsmanifest
 			ManifestBuilder manifest = ManifestBuilder.createAssessmentTestBuilder();
 			
@@ -310,14 +325,31 @@ public class QTI21ExportProcessor {
 			}
 
 			zout.putNextEntry(new ZipEntry(assessmentTestFilename));
-			qtiSerializer.serializeJqtiObject(assessmentTest, new ShieldOutputStream(zout));
+			serializeAssessmentTest(assessmentTest, zout);
 			zout.closeEntry();
 
 			zout.putNextEntry(new ZipEntry("imsmanifest.xml"));
-			manifest.write(new ShieldOutputStream(zout));
+			writeManifest(manifest, zout);
 			zout.closeEntry();
 		} catch (IOException | URISyntaxException e) {
 			log.error("", e);
 		}
 	}
+	
+	private void writeManifest(ManifestBuilder manifest, ZipOutputStream zout) {
+		try(OutputStream out = new ShieldOutputStream(zout)) {
+			manifest.write(out);
+		} catch(IOException e) {
+			log.error("Cannot write manifest", e);
+		}
+	}
+	
+	private void serializeAssessmentTest(AssessmentTest assessmentTest, ZipOutputStream zout) {
+		try(OutputStream out = new ShieldOutputStream(zout)) {
+			QtiSerializer qtiSerializer = qtiService.qtiSerializer();
+			qtiSerializer.serializeJqtiObject(assessmentTest, out);
+		} catch(IOException e) {
+			log.error("Cannot write manifest", e);
+		}
+	}
 }
diff --git a/src/main/java/org/olat/ims/qti21/pool/QTI21QPoolServiceProvider.java b/src/main/java/org/olat/ims/qti21/pool/QTI21QPoolServiceProvider.java
index eaab55e6202..5cc21534b8e 100644
--- a/src/main/java/org/olat/ims/qti21/pool/QTI21QPoolServiceProvider.java
+++ b/src/main/java/org/olat/ims/qti21/pool/QTI21QPoolServiceProvider.java
@@ -482,10 +482,10 @@ public class QTI21QPoolServiceProvider implements QPoolSPI {
 	 * @param items The list of questions to export
 	 * @param locale The language
 	 */
-	public void exportToEditorPackage(String testTitle, File exportDir, List<QuestionItemShort> items, Locale locale) {
+	public void exportToEditorPackage(String testTitle, File exportDir, List<QuestionItemShort> items, boolean groupByTaxonomyLevel, Locale locale) {
 		List<QuestionItemFull> fullItems = loadQuestionFullItems(items);
 		QTI21ExportProcessor processor = new QTI21ExportProcessor(qtiService, qpoolFileStorage, locale);
-		processor.assembleTest(testTitle, fullItems, exportDir);
+		processor.assembleTest(testTitle, fullItems, groupByTaxonomyLevel, exportDir);
 	}
 	
 	private List<QuestionItemFull> loadQuestionFullItems(List<QuestionItemShort> items) {
diff --git a/src/main/java/org/olat/ims/qti21/repository/handlers/QTI21AssessmentTestHandler.java b/src/main/java/org/olat/ims/qti21/repository/handlers/QTI21AssessmentTestHandler.java
index 5165706b610..d5657989d2f 100644
--- a/src/main/java/org/olat/ims/qti21/repository/handlers/QTI21AssessmentTestHandler.java
+++ b/src/main/java/org/olat/ims/qti21/repository/handlers/QTI21AssessmentTestHandler.java
@@ -167,7 +167,8 @@ public class QTI21AssessmentTestHandler extends FileHandler {
 		}
 		if(createObject instanceof QItemList) {
 			QItemList itemToImport = (QItemList)createObject;
-			qpoolServiceProvider.exportToEditorPackage(displayname, repositoryDir, itemToImport.getItems(), locale);
+			qpoolServiceProvider.exportToEditorPackage(displayname, repositoryDir,
+					itemToImport.getItems(), itemToImport.isGroupByTaxonomyLevel(), locale);
 		} else if(createObject instanceof QTIEditorPackage) {
 			QTIEditorPackage testToConvert = (QTIEditorPackage)createObject;
 			QTI21DeliveryOptions options = qtiService.getDeliveryOptions(re);
diff --git a/src/main/java/org/olat/modules/qpool/model/QItemList.java b/src/main/java/org/olat/modules/qpool/model/QItemList.java
index 921a8d107ce..0976f861508 100644
--- a/src/main/java/org/olat/modules/qpool/model/QItemList.java
+++ b/src/main/java/org/olat/modules/qpool/model/QItemList.java
@@ -31,22 +31,20 @@ import org.olat.modules.qpool.QuestionItemShort;
  */
 public class QItemList {
 
-	private List<QuestionItemShort> items;
+	private final boolean groupByTaxonomyLevel;
+	private final List<QuestionItemShort> items;
 	
-	public QItemList() {
-		//
-	}
-	
-	public QItemList(List<QuestionItemShort> items) {
+	public QItemList(List<QuestionItemShort> items, boolean groupByTaxonomyLevel) {
 		this.items = items;
+		this.groupByTaxonomyLevel = groupByTaxonomyLevel;
 	}
 
-	public List<QuestionItemShort> getItems() {
-		return items;
+	public boolean isGroupByTaxonomyLevel() {
+		return groupByTaxonomyLevel;
 	}
 
-	public void setItems(List<QuestionItemShort> items) {
-		this.items = items;
+	public List<QuestionItemShort> getItems() {
+		return items;
 	}
 
 }
diff --git a/src/main/java/org/olat/modules/qpool/ui/CreateTestOverviewController.java b/src/main/java/org/olat/modules/qpool/ui/CreateTestOverviewController.java
index 7144d4f8c1d..e09a8e7776b 100644
--- a/src/main/java/org/olat/modules/qpool/ui/CreateTestOverviewController.java
+++ b/src/main/java/org/olat/modules/qpool/ui/CreateTestOverviewController.java
@@ -36,13 +36,14 @@ import org.olat.core.commons.services.license.ResourceLicense;
 import org.olat.core.commons.services.license.ui.LicenseUIFactory;
 import org.olat.core.gui.UserRequest;
 import org.olat.core.gui.components.form.flexible.FormItemContainer;
+import org.olat.core.gui.components.form.flexible.elements.MultipleSelectionElement;
 import org.olat.core.gui.components.form.flexible.impl.FormBasicController;
 import org.olat.core.gui.components.form.flexible.impl.elements.table.BooleanCellRenderer;
 import org.olat.core.gui.components.form.flexible.impl.elements.table.CSSIconFlexiCellRenderer;
 import org.olat.core.gui.components.form.flexible.impl.elements.table.DefaultFlexiColumnModel;
 import org.olat.core.gui.components.form.flexible.impl.elements.table.DefaultFlexiTableDataModel;
-import org.olat.core.gui.components.form.flexible.impl.elements.table.FlexiColumnDef;
 import org.olat.core.gui.components.form.flexible.impl.elements.table.FlexiColumnModel;
+import org.olat.core.gui.components.form.flexible.impl.elements.table.FlexiSortableColumnDef;
 import org.olat.core.gui.components.form.flexible.impl.elements.table.FlexiTableColumnModel;
 import org.olat.core.gui.components.form.flexible.impl.elements.table.FlexiTableDataModelFactory;
 import org.olat.core.gui.control.Controller;
@@ -52,8 +53,10 @@ import org.olat.core.util.Formatter;
 import org.olat.core.util.StringHelper;
 import org.olat.modules.qpool.ExportFormatOptions;
 import org.olat.modules.qpool.QPoolSPI;
+import org.olat.modules.qpool.QPoolSecurityCallback;
 import org.olat.modules.qpool.QuestionItemShort;
 import org.olat.modules.qpool.QuestionPoolModule;
+import org.olat.modules.qpool.QuestionStatus;
 import org.olat.modules.qpool.manager.QuestionPoolLicenseHandler;
 import org.springframework.beans.factory.annotation.Autowired;
 
@@ -64,12 +67,15 @@ import org.springframework.beans.factory.annotation.Autowired;
  *
  */
 public class CreateTestOverviewController extends FormBasicController {
+	
+	private static final String[] groupByKeys = new String[] { "on" };
 
 	private final boolean withLicenses;
+	private final boolean withTaxonomy;
 	private final ExportFormatOptions format;
-	private QItemDataModel itemsModel;
-	
 	
+	private QItemDataModel itemsModel;
+	private MultipleSelectionElement groupByEl;
 	
 	@Autowired
 	private LicenseModule licenseModule;
@@ -78,11 +84,11 @@ public class CreateTestOverviewController extends FormBasicController {
 	@Autowired
 	private QuestionPoolLicenseHandler licenseHandler;
 	
-
 	public CreateTestOverviewController(UserRequest ureq, WindowControl wControl, List<QuestionItemShort> items,
-			ExportFormatOptions format) {
+			ExportFormatOptions format, QPoolSecurityCallback secCallback) {
 		super(ureq, wControl, "create_test");
 		this.format = format;
+		withTaxonomy = secCallback.canUseTaxonomy();
 		withLicenses = licenseModule.isEnabled(licenseHandler);
 		initForm(ureq);
 		loadModel(items);
@@ -98,15 +104,26 @@ public class CreateTestOverviewController extends FormBasicController {
 						new CSSIconFlexiCellRenderer("o_icon_failed"))
 		));
 		columnsModel.addFlexiColumnModel(new DefaultFlexiColumnModel(Cols.title));
+		columnsModel.addFlexiColumnModel(new DefaultFlexiColumnModel(Cols.topic));
+		if(withTaxonomy) {
+			columnsModel.addFlexiColumnModel(new DefaultFlexiColumnModel(Cols.taxonomyLevel));
+			columnsModel.addFlexiColumnModel(new DefaultFlexiColumnModel(false, Cols.taxonomyPath));
+		}
+		columnsModel.addFlexiColumnModel(new DefaultFlexiColumnModel(Cols.type));
 		columnsModel.addFlexiColumnModel(new DefaultFlexiColumnModel(Cols.format));
+		columnsModel.addFlexiColumnModel(new DefaultFlexiColumnModel(Cols.status, new QuestionStatusCellRenderer()));
 		if(withLicenses) {
 			columnsModel.addFlexiColumnModel(new DefaultFlexiColumnModel(Cols.license));
 		}
 		itemsModel = new QItemDataModel(columnsModel, format, getLocale());
 		uifactory.addTableElement(getWindowControl(), "shares", itemsModel, getTranslator(), formLayout);
 		
-		uifactory.addFormSubmitButton("create.test", formLayout);
+		String[] groupByValues = new String[] { translate("group.by.taxonomy.level") };
+		groupByEl = uifactory.addCheckboxesHorizontal("group.by", null, formLayout, groupByKeys, groupByValues);
+		groupByEl.setVisible(withTaxonomy);
+		
 		uifactory.addFormCancelButton("cancel", formLayout, ureq, getWindowControl());
+		uifactory.addFormSubmitButton("create.test", formLayout);
 	}
 	
 	private void loadModel(List<QuestionItemShort> items) {
@@ -156,6 +173,10 @@ public class CreateTestOverviewController extends FormBasicController {
 		}
 		return exportableItems;
 	}
+	
+	public boolean isGroupByTaxonomyLevel() {
+		return groupByEl.isVisible() && groupByEl.isAtLeastSelected(1);
+	}
 
 	@Override
 	protected void formOK(UserRequest ureq) {
@@ -218,6 +239,18 @@ public class CreateTestOverviewController extends FormBasicController {
 			return question.getTitle();
 		}
 		
+		public String getTopic() {
+			return question.getTopic();
+		}
+		
+		public String getTaxonomyLevelName() {
+			return question.getTaxonomyLevelName();
+		}
+		
+		public String getTaxonomyPath() {
+			return question.getTaxonomicPath();
+		}
+		
 		public String getFormat() {
 			return question.getFormat();
 		}
@@ -225,6 +258,18 @@ public class CreateTestOverviewController extends FormBasicController {
 		public ResourceLicense getLicense() {
 			return license;
 		}
+		
+		public QuestionStatus getQuestionStatus() {
+			return question.getQuestionStatus();
+		}
+		
+		public String getItemType() {
+			String type = question.getItemType();
+			if(type == null) {
+				return "";
+			}
+			return type;
+		}
 	
 		public QuestionItemShort getQuestion() {
 			return question;
@@ -261,7 +306,12 @@ public class CreateTestOverviewController extends FormBasicController {
 					return Boolean.FALSE;	
 				} 
 				case title: return share.getTitle();
+				case topic: return share.getTopic();
+				case taxonomyLevel: return share.getTaxonomyLevelName();
+				case taxonomyPath: return share.getTaxonomyPath();
 				case format: return share.getFormat();
+				case type: return share.getItemType();
+				case status: return share.getQuestionStatus();
 				case license: return shortenedLicense(share);
 				default : return share;
 			}
@@ -286,10 +336,15 @@ public class CreateTestOverviewController extends FormBasicController {
 		}
 	}
 	
-	private enum Cols implements FlexiColumnDef {
+	private enum Cols implements FlexiSortableColumnDef {
 		accept("export.overview.accept"),
 		title("general.title"),
+		topic("general.topic"),
+		taxonomyLevel("classification.taxonomy.level"),
+		taxonomyPath("classification.taxonomic.path"),
 		format("technical.format"),
+		status("lifecycle.status"),
+		type("question.type"),
 		license("rights.license");
 		
 		private final String i18nKey;
@@ -302,5 +357,15 @@ public class CreateTestOverviewController extends FormBasicController {
 		public String i18nHeaderKey() {
 			return i18nKey;
 		}
+
+		@Override
+		public boolean sortable() {
+			return false;
+		}
+
+		@Override
+		public String sortKey() {
+			return name();
+		}
 	}
 }
diff --git a/src/main/java/org/olat/modules/qpool/ui/QuestionListController.java b/src/main/java/org/olat/modules/qpool/ui/QuestionListController.java
index 813f607bac5..4204cbd40b3 100644
--- a/src/main/java/org/olat/modules/qpool/ui/QuestionListController.java
+++ b/src/main/java/org/olat/modules/qpool/ui/QuestionListController.java
@@ -520,10 +520,11 @@ public class QuestionListController extends AbstractItemListController implement
 			List<QuestionItemShort> items = createTestOverviewCtrl.getExportableQuestionItems();
 			String typeFormat = createTestOverviewCtrl.getResourceTypeFormat();
 			LicenseType licenseType = createTestOverviewCtrl.getLicenseType();
+			boolean groupBy = createTestOverviewCtrl.isGroupByTaxonomyLevel();
 			cmc.deactivate();
 			cleanUp();
 			if (event == Event.DONE_EVENT) {
-				doOpenCreateRepositoryTest(ureq, items, typeFormat, licenseType);
+				doOpenCreateRepositoryTest(ureq, items, typeFormat, licenseType, groupBy);
 			}
 		} else if(source == exportWizard) {
 			if(event == Event.CANCELLED_EVENT || event == Event.DONE_EVENT || event == Event.CHANGED_EVENT) {
@@ -1042,7 +1043,7 @@ public class QuestionListController extends AbstractItemListController implement
 
 	private void doShowCreateTestOverview(UserRequest ureq, List<QuestionItemShort> items, ExportFormatOptions format) {
 		removeAsListenerAndDispose(createTestOverviewCtrl);
-		createTestOverviewCtrl = new CreateTestOverviewController(ureq, getWindowControl(), items, format);
+		createTestOverviewCtrl = new CreateTestOverviewController(ureq, getWindowControl(), items, format, getSecurityCallback());
 		listenTo(createTestOverviewCtrl);
 		
 		removeAsListenerAndDispose(cmc);
@@ -1052,13 +1053,13 @@ public class QuestionListController extends AbstractItemListController implement
 		listenTo(cmc);
 	}
 	
-	private void doOpenCreateRepositoryTest(UserRequest ureq, List<QuestionItemShort> items, String type, LicenseType licenseType) {
+	private void doOpenCreateRepositoryTest(UserRequest ureq, List<QuestionItemShort> items, String type, LicenseType licenseType, boolean groupBy) {
 		removeAsListenerAndDispose(cmc);
 		removeAsListenerAndDispose(addController);
 
 		RepositoryHandler handler = repositoryHandlerFactory.getRepositoryHandler(type);
 		addController = handler.createCreateRepositoryEntryController(ureq, getWindowControl());
-		addController.setCreateObject(new QItemList(items));
+		addController.setCreateObject(new QItemList(items, groupBy));
 		addController.setLicenseType(licenseType);
 		listenTo(addController);
 		cmc = new CloseableModalController(getWindowControl(), translate("close"), addController.getInitialComponent());
diff --git a/src/main/java/org/olat/modules/qpool/ui/_content/create_test.html b/src/main/java/org/olat/modules/qpool/ui/_content/create_test.html
index 5e3ebd6d6bd..c0a5f52c911 100644
--- a/src/main/java/org/olat/modules/qpool/ui/_content/create_test.html
+++ b/src/main/java/org/olat/modules/qpool/ui/_content/create_test.html
@@ -3,6 +3,9 @@
 	$r.translate("warning.different.licenses")</div>
 #end
 $r.render("shares")
+#if($r.visible("group.by"))
+	$r.render("group.by")
+#end
 <div class="o_button_group">
 	$r.render("cancel")
 	$r.render("create.test")
diff --git a/src/main/java/org/olat/modules/qpool/ui/_i18n/LocalStrings_de.properties b/src/main/java/org/olat/modules/qpool/ui/_i18n/LocalStrings_de.properties
index 0960dec9316..4cba538728e 100644
--- a/src/main/java/org/olat/modules/qpool/ui/_i18n/LocalStrings_de.properties
+++ b/src/main/java/org/olat/modules/qpool/ui/_i18n/LocalStrings_de.properties
@@ -115,6 +115,7 @@ general.taxonomy.level=Fachbereich
 general.taxonomy.path={0}
 general.title=Titel
 general.topic=Thema
+group.by.taxonomy.level=Fragen nach Fachbereich gruppieren
 import.excellike.12=QTI 1.2 Excelimport \u00FCber Copy&Paste
 import.excellike.21=QTI 2.1 Excelimport \u00FCber Copy&Paste
 import.failed=Frage konnte nicht importiert werden.
diff --git a/src/main/java/org/olat/modules/qpool/ui/_i18n/LocalStrings_en.properties b/src/main/java/org/olat/modules/qpool/ui/_i18n/LocalStrings_en.properties
index 11f9173265e..f514b7bec39 100644
--- a/src/main/java/org/olat/modules/qpool/ui/_i18n/LocalStrings_en.properties
+++ b/src/main/java/org/olat/modules/qpool/ui/_i18n/LocalStrings_en.properties
@@ -115,6 +115,7 @@ general.taxonomy.level=Subject
 general.taxonomy.path={0}
 general.title=Title
 general.topic=Topic
+group.by.taxonomy.level=Group items by subject
 import.excellike.12=QTI 1.2 Excel import via copy&paste
 import.excellike.21=QTI 2.1 Excel import via copy&paste
 import.failed=No questions were imported
-- 
GitLab