From d0a835f4a67c890cc0a53973ba5066a49b458f97 Mon Sep 17 00:00:00 2001 From: Ron Lucke <lucke@elan-ev.de> Date: Tue, 26 Apr 2022 07:23:04 +0000 Subject: [PATCH] CoursewareImport Closes #886 and #906 --- .../Courseware/StructuralElementsCopy.php | 17 +- lib/models/Courseware/StructuralElement.php | 80 ++++++- .../assets/stylesheets/scss/courseware.scss | 27 +++ .../courseware/CoursewareCourseManager.vue | 163 +------------ .../courseware/CoursewareDashboardTasks.vue | 3 +- .../CoursewareManagerCopySelector.vue | 58 +++-- .../courseware/CoursewareManagerElement.vue | 25 +- .../CoursewareManagerElementItem.vue | 14 +- .../courseware/CoursewareManagerImport.vue | 194 +++++++++++++++ resources/vue/mixins/courseware/import.js | 220 ++++++++++++++---- .../vue/store/courseware/courseware.module.js | 6 +- 11 files changed, 559 insertions(+), 248 deletions(-) create mode 100644 resources/vue/components/courseware/CoursewareManagerImport.vue diff --git a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsCopy.php b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsCopy.php index 2dfee89b08f..6e14b72d181 100755 --- a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsCopy.php +++ b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsCopy.php @@ -33,7 +33,11 @@ class StructuralElementsCopy extends NonJsonApiController throw new AuthorizationFailedException(); } - $newElement = $this->copyElement($user, $sourceElement, $newParent); + if ($data['migrate']) { + $newElement = $this->mergeElement($user, $sourceElement, $newParent); + } else { + $newElement = $this->copyElement($user, $sourceElement, $newParent); + } if ($data['remove_purpose']) { $newElement->purpose = ''; } @@ -43,9 +47,16 @@ class StructuralElementsCopy extends NonJsonApiController private function copyElement(\User $user, StructuralElement $sourceElement, StructuralElement $newParent) { - $new_element = $sourceElement->copy($user, $newParent); + $newElement = $sourceElement->copy($user, $newParent); + + return $newElement; + } + + private function mergeElement(\User $user, StructuralElement $sourceElement, StructuralElement $targetElement) + { + $newElement = $sourceElement->merge($user, $targetElement); - return $new_element; + return $newElement; } /** diff --git a/lib/models/Courseware/StructuralElement.php b/lib/models/Courseware/StructuralElement.php index b135e4a135a..ee5e92a79c8 100755 --- a/lib/models/Courseware/StructuralElement.php +++ b/lib/models/Courseware/StructuralElement.php @@ -662,17 +662,7 @@ SQL; */ public function copy(User $user, StructuralElement $parent): StructuralElement { - /** @var ?\FileRef $original_file_ref */ - $original_file_ref = \FileRef::find($this->image_id); - if ($original_file_ref) { - $instance = new Instance($this->getCourseware($parent->range_id, $parent->range_type)); - $folder = \Courseware\Filesystem\PublicFolder::findOrCreateTopFolder($instance); - /** @var \FileRef $file_ref */ - $file_ref = \FileManager::copyFile($original_file_ref->getFileType(), $folder, $user); - $file_ref_id = $file_ref->id; - } else { - $file_ref_id = null; - } + $file_ref_id = self::copyImage($user, $parent); $element = self::build([ 'parent_id' => $parent->id, @@ -697,6 +687,74 @@ SQL; return $element; } + private function copyImage(User $user, StructuralElement $parent) : ?String + { + $file_ref_id = null; + + /** @var ?\FileRef $original_file_ref */ + $original_file_ref = \FileRef::find($this->image_id); + if ($original_file_ref) { + $instance = new Instance($this->getCourseware($parent->range_id, $parent->range_type)); + $folder = \Courseware\Filesystem\PublicFolder::findOrCreateTopFolder($instance); + /** @var \FileRef $file_ref */ + $file_ref = \FileManager::copyFile($original_file_ref->getFileType(), $folder, $user); + $file_ref_id = $file_ref->id; + } + + return $file_ref_id; + } + + public function merge(User $user, StructuralElement $target): StructuralElement + { + // merge with target + if (!$target->image_id) { + $target->image_id = self::copyImage($user, $target); + } + + if ($target->title === 'neue Seite' || $target->title === 'New page') { + $target->title = $this->title; + } + + if (!$target->purpose) { + $target->purpose = $this->purpose; + } + + if (!$target->payload['color']) { + $target->payload['color'] = $this->payload['color']; + } + + if (!$target->payload['description']) { + $target->payload['description'] = $this->payload['description']; + } + + if (!$target->payload['license_type']) { + $target->payload['license_type'] = $this->payload['license_type']; + } + + if (!$target->payload['required_time']) { + $target->payload['required_time'] = $this->payload['required_time']; + } + + if (!$target->payload['difficulty_start']) { + $target->payload['difficulty_start'] = $this->payload['difficulty_start']; + } + + if (!$target->payload['difficulty_end']) { + $target->payload['difficulty_end'] = $this->payload['difficulty_end']; + } + + $target->store(); + + // add Containers to target + self::copyContainers($user, $target); + + // copy Children + + self::copyChildren($user, $target); + + return $this; + } + private function copyContainers(User $user, StructuralElement $newElement): void { $containers = \Courseware\Container::findBySQL('structural_element_id = ?', [$this->id]); diff --git a/resources/assets/stylesheets/scss/courseware.scss b/resources/assets/stylesheets/scss/courseware.scss index 384f7338c86..a57a2f389ee 100755 --- a/resources/assets/stylesheets/scss/courseware.scss +++ b/resources/assets/stylesheets/scss/courseware.scss @@ -4721,3 +4721,30 @@ cw tiles end } } /* contents courseware courses end*/ + +/* * * * * * * * * * + i n p u t f i l e +* * * * * * * * * */ + .cw-file-input { + width: stretch; + border: solid thin $base-color; + font-size: 14px; + cursor: pointer; + + &::file-selector-button { + border: none; + border-right: solid thin $base-color; + background-color: $white; + padding: 6px 15px; + margin-right: 10px; + color: $base-color; + + &:hover { + background-color: $base-color; + color: $white; + } + } + } + /* * * * * * * * * * * * * + i n p u t f i l e e n d +* * * * * * * * * * * * * */ diff --git a/resources/vue/components/courseware/CoursewareCourseManager.vue b/resources/vue/components/courseware/CoursewareCourseManager.vue index cd3f77421eb..7d6ab4fea56 100755 --- a/resources/vue/components/courseware/CoursewareCourseManager.vue +++ b/resources/vue/components/courseware/CoursewareCourseManager.vue @@ -93,53 +93,7 @@ </courseware-tab> <courseware-tab :name="$gettext('Importieren')" :index="3"> - <courseware-companion-box v-show="!importRunning && importDone && importErrors.length === 0" :msgCompanion="$gettext('Import erfolgreich!')" mood="special"/> - <courseware-companion-box v-show="!importRunning && importDone && importErrors.length > 0" :msgCompanion="$gettext('Import abgeschlossen. Es sind Fehler aufgetreten!')" mood="unsure"/> - <courseware-companion-box v-show="!importRunning && !importDone && importErrors.length > 0" :msgCompanion="$gettext('Import fehlgeschlagen. Es sind Fehler aufgetreten!')" mood="sad"/> - <courseware-companion-box v-show="importRunning" :msgCompanion="$gettext('Import läuft. Bitte verlassen Sie die Seite nicht bis der Import abgeschlossen wurde.')" mood="pointing"/> - <button - v-show="!importRunning" - class="button" - @click.prevent="chooseFile" - > - <translate>Importdatei auswählen</translate> - </button> - - <div v-if="importZip" class="cw-import-zip"> - <header>{{ importZip.name }}</header> - <p><translate>Größe</translate>: {{ getFileSizeText(importZip.size) }}</p> - </div> - - <div v-if="importRunning" class="cw-import-zip"> - <header><translate>Importiere Dateien</translate>:</header> - <div class="progress-bar-wrapper"> - <div class="progress-bar" role="progressbar" :style="{width: importFilesProgress + '%'}" :aria-valuenow="importFilesProgress" aria-valuemin="0" aria-valuemax="100">{{ importFilesProgress }}%</div> - </div> - {{ importFilesState }} - </div> - - <div v-if="fileImportDone && importRunning" class="cw-import-zip"> - <header><translate>Importiere Elemente</translate>:</header> - <div class="progress-bar-wrapper"> - <div class="progress-bar" role="progressbar" :style="{width: importStructuresProgress + '%'}" :aria-valuenow="importStructuresProgress" aria-valuemin="0" aria-valuemax="100">{{ importStructuresProgress }}%</div> - </div> - {{ importStructuresState }} - </div> - - <button - v-show="importZip && !importRunning" - class="button" - @click.prevent="doImportCourseware" - > - <translate>Alles importieren</translate> - </button> - <div v-if="importErrors.length > 0"> - <h3><translate>Fehlermeldungen:</translate></h3> - <ul> - <li v-for="(error, index) in importErrors" :key="index"> {{error}} </li> - </ul> - </div> - <input ref="importFile" type="file" accept=".zip" @change="setImport" style="visibility: hidden" /> + <courseware-manager-import /> </courseware-tab> <courseware-tab v-if="context.type === 'courses'" :name="$gettext('Aufgabe verteilen')" :index="4"> <courseware-manager-task-distributor /> @@ -158,13 +112,10 @@ import CoursewareManagerCopySelector from './CoursewareManagerCopySelector.vue'; import CoursewareManagerTaskDistributor from './CoursewareManagerTaskDistributor.vue'; import CoursewareCompanionOverlay from './CoursewareCompanionOverlay.vue'; import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; -import CoursewareImport from '@/vue/mixins/courseware/import.js'; +import CoursewareManagerImport from './CoursewareManagerImport.vue'; import CoursewareExport from '@/vue/mixins/courseware/export.js'; import { mapActions, mapGetters } from 'vuex'; -import JSZip from 'jszip'; -import FileSaver from 'file-saver'; - export default { name: 'courseware-course-manager', components: { @@ -175,21 +126,19 @@ export default { CoursewareManagerCopySelector, CoursewareCompanionOverlay, CoursewareCompanionBox, - CoursewareManagerTaskDistributor + CoursewareManagerTaskDistributor, + CoursewareManagerImport }, - mixins: [CoursewareImport, CoursewareExport], + mixins: [CoursewareExport], data() { return { exportRunning: false, - importRunning: false, - importZip: null, currentElement: {}, currentId: null, selfElement: {}, selfId: null, - zip: null }; }, @@ -198,11 +147,6 @@ export default { courseware: 'courseware', context: 'context', structuralElementById: 'courseware-structural-elements/byId', - importFilesState: 'importFilesState', - importFilesProgress: 'importFilesProgress', - importStructuresState: 'importStructuresState', - importStructuresProgress: 'importStructuresProgress', - importErrors: 'importErrors', exportState: 'exportState', exportProgress: 'exportProgress' }), @@ -222,12 +166,6 @@ export default { moveSelfChildPossible() { return this.currentId !== this.selfId; }, - fileImportDone() { - return this.importFilesProgress === 100; - }, - importDone() { - return this.importFilesProgress === 100 && this.importStructuresProgress === 100; - } }, methods: { @@ -241,9 +179,6 @@ export default { unlockObject: 'unlockObject', addBookmark: 'addBookmark', companionInfo: 'companionInfo', - setImportFilesProgress: 'setImportFilesProgress', - setImportStructuresProgress: 'setImportStructuresProgress', - setImportErrors: 'setImportErrors', }), async reloadElements() { await this.setCurrentId(this.currentId); @@ -282,88 +217,6 @@ export default { this.exportRunning = false; }, - - setImport(event) { - this.importZip = event.target.files[0]; - this.setImportErrors([]); - }, - - async doImportCourseware() { - if (this.importZip === null) { - return false; - } - - this.importRunning = true; - - let view = this; - - view.zip = new JSZip(); - - await view.zip.loadAsync(this.importZip).then(async function () { - let errors = []; - let missingFiles = false; - if (view.zip.file('courseware.json') === null) { - errors.push(view.$gettext('Das Archiv enthält keine courseware.json Datei.')); - missingFiles = true; - } - if (view.zip.file('files.json') === null) { - errors.push(view.$gettext('Das Archiv enthält keine files.json Datei.')); - missingFiles = true; - } - if (view.zip.file('data.xml') !== null) { - errors.push(view.$gettext('Das Archiv enthält eine data.xml Datei. Möglicherweise handelt es sich um einen Export aus dem Courseware-Plugin. Diese Archive sind nicht kompatibel mit dieser Courseware.')); - } - if (missingFiles) { - view.setImportErrors(errors); - return; - } - - let data = await view.zip.file('courseware.json').async('string'); - let courseware = null; - let data_files = await view.zip.file('files.json').async('string'); - let files = null; - let jsonErrors = false; - try { - courseware = JSON.parse(data); - } catch (error) { - jsonErrors = true; - errors.push(view.$gettext('Die Beschreibung der Courseware-Inhalte ist nicht valide.')); - errors.push(error); - } - try { - files = JSON.parse(data_files); - } catch (error) { - jsonErrors = true; - errors.push(view.$gettext('Die Beschreibung der Dateien ist nicht valide.')); - errors.push(error); - } - if (jsonErrors) { - view.setImportErrors(errors); - return; - } - - await view.loadCoursewareStructure(); - let parent_id = view.courseware.relationships.root.data.id; - - await view.importCourseware(courseware, parent_id, files); - }); - - this.importZip = null; - this.importRunning = false; - }, - - chooseFile() { - this.$refs.importFile.click(); - this.setImportFilesProgress(0); - this.setImportStructuresProgress(0); - }, - getFileSizeText(size) { - if (size / 1024 < 1000) { - return (size / 1024).toFixed(2) + ' kB'; - } else { - return (size / 1048576).toFixed(2) + ' MB'; - } - }, }, watch: { courseware(newValue, oldValue) { @@ -372,12 +225,6 @@ export default { this.setSelfId(currentId); }, }, - mounted() { - let view = this; - window.onbeforeunload = function() { - return view.importRunning ? true : null - } - } }; </script> diff --git a/resources/vue/components/courseware/CoursewareDashboardTasks.vue b/resources/vue/components/courseware/CoursewareDashboardTasks.vue index 02cc37e2525..b8b9730b926 100755 --- a/resources/vue/components/courseware/CoursewareDashboardTasks.vue +++ b/resources/vue/components/courseware/CoursewareDashboardTasks.vue @@ -241,8 +241,9 @@ export default { if (ownCoursewareInstance !== null) { await this.copyStructuralElement({ parentId: ownCoursewareInstance.relationships.root.data.id, - element: element, + elementId: element.id, removeType: true, + migrate: false }); this.companionSuccess({ info: this.$gettext('Die Inhalte wurden zu Ihren persönlichen Lernmaterialien hinzugefügt.'), diff --git a/resources/vue/components/courseware/CoursewareManagerCopySelector.vue b/resources/vue/components/courseware/CoursewareManagerCopySelector.vue index 01c2c36b7d7..3c22dcd9a14 100755 --- a/resources/vue/components/courseware/CoursewareManagerCopySelector.vue +++ b/resources/vue/components/courseware/CoursewareManagerCopySelector.vue @@ -5,8 +5,10 @@ <button class="button" @click="selectSource('remote')"><translate>Aus Veranstaltung kopieren</translate></button> </div> <div v-else> + <courseware-companion-box v-if="copyAllInProgress" :msgCompanion="copyAllInProgressText" mood="pointing" /> <button class="button" @click="reset"><translate>Quelle auswählen</translate></button> <button v-show="!sourceOwn && hasRemoteCid" class="button" @click="selectNewCourse"><translate>Veranstaltung auswählen</translate></button> + <button v-show="!sourceOwn && hasRemoteCid" class="button" @click="mergeContent"><translate>Alle Inhalte kopieren</translate></button> <div v-if="sourceRemote"> <h2 v-if="!hasRemoteCid"><translate>Veranstaltungen</translate></h2> <ul v-if="!hasRemoteCid && semesterMap.length > 0"> @@ -75,24 +77,28 @@ export default { CoursewareCompanionBox, }, props: {}, - data() {return{ - source: '', - courses: [], - remoteCid: '', - remoteCoursewareInstance: {}, - remoteId: '', - remoteElement: {}, - ownCoursewareInstance: {}, - ownId: '', - ownElement: {}, - semesterMap: [], - - }}, + data() { + return { + source: '', + courses: [], + remoteCid: '', + remoteCoursewareInstance: {}, + remoteId: '', + remoteElement: {}, + ownCoursewareInstance: {}, + ownId: '', + ownElement: {}, + semesterMap: [], + copyAllInProgress: false, + copyAllInProgressText: '' + } + }, computed: { ...mapGetters({ - userId: 'userId', - structuralElementById: 'courseware-structural-elements/byId', + courseware: 'courseware', semesterById: 'semesters/byId', + structuralElementById: 'courseware-structural-elements/byId', + userId: 'userId', }), sourceEmpty() { return this.source === ''; @@ -116,9 +122,11 @@ export default { loadUsersCourses: 'loadUsersCourses', loadStructuralElement: 'loadStructuralElement', loadSemester: 'semesters/loadById', + copyStructuralElement: 'copyStructuralElement', }), selectSource(source) { this.source = source; + this.copyAllInProgress = false; }, async loadRemoteCourseware(cid) { this.remoteCid = cid; @@ -140,10 +148,12 @@ export default { reset() { this.selectSource(''); this.remoteCid = ''; + this.copyAllInProgress = false; }, selectNewCourse() { this.remoteCid = ''; this.remoteId = ''; + this.copyAllInProgress = false; }, async setRemoteId(target) { this.remoteId = target; @@ -196,6 +206,24 @@ export default { }, reloadElement() { this.$emit("reloadElement"); + }, + async mergeContent() { + this.copyAllInProgressText = this.$gettext('Inhalte werden kopiert…'); + this.copyAllInProgress = true; + let parentId = this.courseware.relationships.root.data.id; //current root + let elementId = this.remoteCoursewareInstance.relationships.root.data.id; // remote root + try { + await this.copyStructuralElement({ + parentId: parentId, + elementId: elementId, + migrate: true + }); + } catch(error) { + console.debug(error); + this.copyAllInProgressText = this.$gettext('Beim Kopiervorgang sind Fehler aufgetreten.'); + } + this.copyAllInProgressText = this.$gettext('Kopiervorgang abgeschlossen.'); + this.reloadElement(); } }, async mounted() { diff --git a/resources/vue/components/courseware/CoursewareManagerElement.vue b/resources/vue/components/courseware/CoursewareManagerElement.vue index 021c8a6e702..4c0aa13d218 100755 --- a/resources/vue/components/courseware/CoursewareManagerElement.vue +++ b/resources/vue/components/courseware/CoursewareManagerElement.vue @@ -20,12 +20,12 @@ <a v-if="elementInserterActive && moveSelfPossible && canEdit" href="#" - :title="$gettextInterpolate('%{ elementTitle } verschieben', {elementTitle: elementTitle})" + :title="elementTitle" @click="insertElement({element: currentElement, source: type})" > <studip-icon shape="arr_2left" size="24" role="clickable" /> </a> - {{ elementTitle }} + {{ elementName }} </header> </div> <courseware-collapsible-box @@ -227,12 +227,24 @@ export default { return [...visitAncestors(this.currentElement)].reverse() }, - elementTitle() { + elementName() { if (this.currentElement.attributes) { return this.currentElement.attributes.title - } else { - return ''; } + + return ''; + }, + elementTitle() { + let title = this.elementName; + if (this.elementInserterActive && this.moveSelfPossible && this.canEdit) { + if (this.isRemote || this.isOwn) { + title = this.$gettextInterpolate('%{ elementTitle } kopieren', {elementTitle: this.elementName}); + } else { + title = this.$gettextInterpolate('%{ elementTitle } verschieben', {elementTitle: this.elementName}); + } + } + + return title; }, hasChildren() { if (this.children === null) { @@ -367,7 +379,8 @@ export default { let parentId = this.filingData.parentItem.id; await this.copyStructuralElement({ parentId: parentId, - element: element, + elementId: element.id, + migrate: false }).catch((error) => { let message = this.$gettextInterpolate('%{ pageTitle } konnte nicht kopiert werden.', {pageTitle: element.attributes.title}); this.text.copyProcessFailed.push(message); diff --git a/resources/vue/components/courseware/CoursewareManagerElementItem.vue b/resources/vue/components/courseware/CoursewareManagerElementItem.vue index 2435434e709..1a8bac8c9b7 100755 --- a/resources/vue/components/courseware/CoursewareManagerElementItem.vue +++ b/resources/vue/components/courseware/CoursewareManagerElementItem.vue @@ -5,7 +5,7 @@ href="#" class="cw-manager-element-item" :class="[inserter ? 'cw-manager-element-item-inserter' : '']" - :title="inserter ? $gettextInterpolate('%{ elementTitle } verschieben', {elementTitle: element.attributes.title}) : element.attributes.title" + :title="elementTitle" @click="clickItem"> {{ element.attributes.title }} <span v-if="task" class="cw-manager-element-item-solver-name">| {{ solverName }}</span> @@ -83,6 +83,18 @@ export default { return ''; }, + elementTitle() { + let title = this.element.attributes.title; + if (this.inserter) { + if (this.type === 'remote' || this.type === 'own') { + title = this.$gettextInterpolate('%{ elementTitle } kopieren', {elementTitle: this.element.attributes.title}); + } else { + title = this.$gettextInterpolate('%{ elementTitle } verschieben', {elementTitle: this.element.attributes.title}); + } + } + + return title; + } }, methods: { ...mapActions({ diff --git a/resources/vue/components/courseware/CoursewareManagerImport.vue b/resources/vue/components/courseware/CoursewareManagerImport.vue new file mode 100644 index 00000000000..6fd3b476be2 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareManagerImport.vue @@ -0,0 +1,194 @@ +<template> + <div> + <courseware-companion-box v-show="!importRunning && importDone && importErrors.length === 0" :msgCompanion="$gettext('Import erfolgreich!')" mood="special"/> + <courseware-companion-box v-show="!importRunning && importDone && importErrors.length > 0" :msgCompanion="$gettext('Import abgeschlossen. Es sind Fehler aufgetreten!')" mood="unsure"/> + <courseware-companion-box v-show="!importRunning && !importDone && importErrors.length > 0" :msgCompanion="$gettext('Import fehlgeschlagen. Es sind Fehler aufgetreten!')" mood="sad"/> + <courseware-companion-box v-show="importRunning" :msgCompanion="$gettext('Import läuft. Bitte verlassen Sie die Seite nicht bis der Import abgeschlossen wurde.')" mood="pointing"/> + <form class="default" @submit.prevent=""> + + <fieldset v-show="importRunning"> + <legend><translate>Import läuft...</translate></legend> + <div v-if="importRunning" class="cw-import-zip"> + <header><translate>Importiere Dateien</translate>:</header> + <div class="progress-bar-wrapper"> + <div class="progress-bar" role="progressbar" :style="{width: importFilesProgress + '%'}" :aria-valuenow="importFilesProgress" aria-valuemin="0" aria-valuemax="100">{{ importFilesProgress }}%</div> + </div> + {{ importFilesState }} + </div> + <div v-if="fileImportDone && importRunning" class="cw-import-zip"> + <header><translate>Importiere Elemente</translate>:</header> + <div class="progress-bar-wrapper"> + <div class="progress-bar" role="progressbar" :style="{width: importStructuresProgress + '%'}" :aria-valuenow="importStructuresProgress" aria-valuemin="0" aria-valuemax="100">{{ importStructuresProgress }}%</div> + </div> + {{ importStructuresState }} + </div> + </fieldset> + <fieldset v-show="importErrors.length > 0"> + <legend><translate>Fehlermeldungen</translate></legend> + <ul> + <li v-for="(error, index) in importErrors" :key="index"> {{error}} </li> + </ul> + </fieldset> + <fieldset v-show="!importRunning"> + <legend><translate>Import</translate></legend> + <label> + <translate>Importdatei</translate> + <input class="cw-file-input" ref="importFile" type="file" accept=".zip" @change="setImport" /> + </label> + <label> + <translate>Importverhalten</translate> + <select v-model="importBehavior"> + <option value="default"><translate>Inhalte anhängen</translate></option> + <option value="migrate"><translate>Inhalte zusammenführen</translate></option> + </select> + </label> + </fieldset> + <footer v-show="!importRunning"> + <button + class="button" + @click.prevent="doImportCourseware" + :disabled="!importZip" + > + <translate>Importieren</translate> + </button> + </footer> + </form> + </div> +</template> + +<script> +import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; + +import CoursewareImport from '@/vue/mixins/courseware/import.js'; +import { mapActions, mapGetters } from 'vuex'; +import JSZip from 'jszip'; + +export default { + name: 'courseware-manager-import', + components: { + CoursewareCompanionBox, + }, + mixins: [CoursewareImport], + data() { + return { + importBehavior: 'default', + importRunning: false, + importZip: null, + zip: null + } + }, + computed: { + ...mapGetters({ + courseware: 'courseware', + importFilesState: 'importFilesState', + importFilesProgress: 'importFilesProgress', + importStructuresState: 'importStructuresState', + importStructuresProgress: 'importStructuresProgress', + importErrors: 'importErrors', + }), + fileImportDone() { + return this.importFilesProgress === 100; + }, + importDone() { + return this.importFilesProgress === 100 && this.importStructuresProgress === 100; + } + }, + methods: { + ...mapActions({ + loadCoursewareStructure: 'courseware-structure/load', + setImportFilesProgress: 'setImportFilesProgress', + setImportStructuresProgress: 'setImportStructuresProgress', + setImportErrors: 'setImportErrors', + }), + + setImport(event) { + this.importZip = event.target.files[0]; + this.setImportFilesProgress(0); + this.setImportStructuresProgress(0); + this.setImportErrors([]); + }, + + async doImportCourseware() { + if (this.importZip === null) { + return false; + } + + this.importRunning = true; + + let view = this; + + view.zip = new JSZip(); + + await view.zip.loadAsync(this.importZip).then(async function () { + let errors = []; + let missingFiles = false; + if (view.zip.file('courseware.json') === null) { + errors.push(view.$gettext('Das Archiv enthält keine courseware.json Datei.')); + missingFiles = true; + } + if (view.zip.file('files.json') === null) { + errors.push(view.$gettext('Das Archiv enthält keine files.json Datei.')); + missingFiles = true; + } + if (view.zip.file('data.xml') !== null) { + errors.push(view.$gettext( + 'Das Archiv enthält eine data.xml Datei. Möglicherweise handelt es sich um einen Export aus dem Courseware-Plugin. Diese Archive sind nicht kompatibel mit dieser Courseware.' + )); + } + if (missingFiles) { + view.setImportErrors(errors); + return; + } + + let data = await view.zip.file('courseware.json').async('string'); + let courseware = null; + let data_files = await view.zip.file('files.json').async('string'); + let files = null; + let jsonErrors = false; + try { + courseware = JSON.parse(data); + } catch (error) { + jsonErrors = true; + errors.push(view.$gettext('Die Beschreibung der Courseware-Inhalte ist nicht valide.')); + errors.push(error); + } + try { + files = JSON.parse(data_files); + } catch (error) { + jsonErrors = true; + errors.push(view.$gettext('Die Beschreibung der Dateien ist nicht valide.')); + errors.push(error); + } + if (jsonErrors) { + view.setImportErrors(errors); + return; + } + + await view.loadCoursewareStructure(); + const rootId = view.courseware.relationships.root.data.id; + + await view.importCourseware(courseware, rootId, files, view.importBehavior); + }); + + this.importZip = null; + this.importRunning = false; + this.$refs.importFile.value = ''; + }, + + getFileSizeText(size) { + if (size / 1024 < 1000) { + return (size / 1024).toFixed(2) + ' kB'; + } else { + return (size / 1048576).toFixed(2) + ' MB'; + } + }, + }, + mounted() { + let view = this; + + window.onbeforeunload = function() { + return view.importRunning ? true : null + } + } +} +</script> diff --git a/resources/vue/mixins/courseware/import.js b/resources/vue/mixins/courseware/import.js index 9f849050879..406a3b81299 100755 --- a/resources/vue/mixins/courseware/import.js +++ b/resources/vue/mixins/courseware/import.js @@ -14,13 +14,18 @@ export default { computed: { ...mapGetters({ context: 'context', - courseware: 'courseware-instances/all' + courseware: 'courseware-instances/all', + structuralElementById: 'courseware-structural-elements/byId', }), }, methods: { + ...mapActions({ + loadStructuralElementById: 'courseware-structural-elements/loadById', + updateStructuralElement: 'updateStructuralElement' + }), - async importCourseware(element, parent_id, files) + async importCourseware(element, rootId, files, importBehavior) { // import all files await this.uploadAllFiles(files); @@ -30,8 +35,12 @@ export default { this.importElementCounter = 0; this.setImportErrors([]); - await this.importStructuralElement([element], parent_id, files); - + if (importBehavior === 'default') { + await this.importStructuralElement([element], rootId, files); + } + if (importBehavior === 'migrate') { + await this.migrateCourseware(element, rootId, files); + } }, countImportElements(element) { @@ -60,32 +69,94 @@ export default { return counter; }, + async migrateCourseware(element, rootId, files) { + let root = this.structuralElementById({ id: rootId }); + // add containers and blocks + if (element.containers?.length > 0) { + for (let i = 0; i < element.containers.length; i++) { + await this.importContainer(element.containers[i], root, files); + } + } + //compare payload + let changedData = false; + if (root.attributes.title === 'neue Seite') { + root.attributes.title = element.attributes.title; + changedData = true; + } + if (root.attributes.purpose === '') { + root.attributes.purpose = element.attributes.purpose; + changedData = true; + } + if (element.attributes.payload) { + if (!root.attributes.payload.color && element.attributes.payload.color) { + root.attributes.payload.color = element.attributes.payload.color; + changedData = true; + } + if (!root.attributes.payload.description && element.attributes.payload.description) { + root.attributes.payload.description = element.attributes.payload.description; + changedData = true; + } + if (!root.attributes.payload.license_type && element.attributes.payload.license_type) { + root.attributes.payload.license_type = element.attributes.payload.license_type; + changedData = true; + } + if (!root.attributes.payload.required_time && element.attributes.payload.required_time) { + root.attributes.payload.required_time = element.attributes.payload.required_time; + changedData = true; + } + if (!root.attributes.payload.difficulty_start && element.attributes.payload.difficulty_start) { + root.attributes.payload.difficulty_start = element.attributes.payload.difficulty_start; + changedData = true; + } + if (!root.attributes.payload.difficulty_end && element.attributes.payload.difficulty_end) { + root.attributes.payload.difficulty_end = element.attributes.payload.difficulty_end; + changedData = true; + } + } + if (changedData) { + await this.lockObject({ id: root.id, type: 'courseware-structural-elements' }); + await this.updateStructuralElement({ + element: root, + id: root.id, + }); + await this.unlockObject({ id: root.id, type: 'courseware-structural-elements' }); + } + // compare image + if (element.imageId && root.relationships.image.data === null) { + await this.setStructuralElementImage(root, element.imageId, files); + } + + // add children + if (element.children) { + await this.importStructuralElement(element.children, rootId, files); + } + + this.setImportStructuresProgress(100); + }, + async importStructuralElement(element, parent_id, files) { if (element.length) { for (var i = 0; i < element.length; i++) { this.setImportStructuresState(this.$gettext('Lege Seite an:') + ' ' + element[i].attributes.title); - await this.createStructuralElement({ - attributes: element[i].attributes, - parentId: parent_id, - currentId: parent_id, - }); + try { + await this.createStructuralElement({ + attributes: element[i].attributes, + parentId: parent_id, + currentId: parent_id, + }); + } catch(error) { + this.currentImportErrors.push(this.$gettext('Seite konnte nicht erstellt werden') + ': ' + + element.attributes.title); + + continue; + } + this.importElementCounter++; let new_element = this.$store.getters['courseware-structural-elements/lastCreated']; if (element[i].imageId) { - let imageFile = files.find((file) => { return file.id === element[i].imageId}); - let zip_filedata = await this.zip.file(imageFile.id).async('blob'); - // create new blob with correct type - let filedata = zip_filedata.slice(0, zip_filedata.size, imageFile.attributes['mime-type']); - this.setImportStructuresState(this.$gettext('Lade Vorschaubild hoch')); - this.uploadImageForStructuralElement({ - structuralElement: new_element, - file: filedata, - }).catch((error) => { - console.error(error); - this.currentImportErrors.push(this.$gettext('Fehler beim Hochladen des Vorschaubildes.')); - }); + await this.setStructuralElementImage(new_element, element[i].imageId, files); } @@ -96,28 +167,60 @@ export default { if (element[i].containers?.length > 0) { for (var j = 0; j < element[i].containers.length; j++) { let container = element[i].containers[j]; - // TODO: create element on server and fetch new id - this.setImportStructuresState(this.$gettext('Lege Abschnitt an:') + ' ' + container.attributes.title); - await this.createContainer({ - attributes: container.attributes, - structuralElementId: new_element.id, - }); - this.importElementCounter++; + await this.importContainer(container, new_element, files); + } + } + } + } + }, - let new_container = this.$store.getters['courseware-containers/lastCreated']; - await this.unlockObject({ id: new_container.id, type: 'courseware-containers' }); + async setStructuralElementImage(new_element, imageId, files) { + let imageFile = files.find((file) => { return file.id === imageId}); + let zip_filedata = await this.zip.file(imageFile.id).async('blob'); + // create new blob with correct type + let filedata = zip_filedata.slice(0, zip_filedata.size, imageFile.attributes['mime-type']); + this.setImportStructuresState(this.$gettext('Lade Vorschaubild hoch')); + this.uploadImageForStructuralElement({ + structuralElement: new_element, + file: filedata, + }).catch((error) => { + console.error(error); + this.currentImportErrors.push(this.$gettext('Fehler beim Hochladen des Vorschaubildes.')); + }); + }, - if (container.blocks?.length) { - let new_block = null; - for (var k = 0; k < container.blocks.length; k++) { - new_block = await this.importBlock(container.blocks[k], new_container, files, new_element); - if (new_block !== null) { - this.importElementCounter++; - await this.updateContainerPayload(new_container, new_element.id, container.blocks[k].id, new_block.id); - } - } + async importContainer(container, structuralElement, files) { + this.setImportStructuresState(this.$gettext('Lege Abschnitt an:') + ' ' + container.attributes.title); + try { + await this.createContainer({ + attributes: container.attributes, + structuralElementId: structuralElement.id, + }); + + } catch(error) { + this.currentImportErrors.push(this.$gettext('Abschnitt konnte nicht erstellt werden') + ': ' + + structuralElement.attributes.title + '→' + + block_container.attributes.title); - } + return null; + } + + this.importElementCounter++; + let new_container = this.$store.getters['courseware-containers/lastCreated']; + await this.unlockObject({ id: new_container.id, type: 'courseware-containers' }); + + if (container.blocks?.length) { + let new_block = null; + for (var k = 0; k < container.blocks.length; k++) { + new_block = await this.importBlock(container.blocks[k], new_container, files, structuralElement); + if (new_block !== null) { + this.importElementCounter++; + try { + await this.updateContainerPayload(new_container, structuralElement.id, container.blocks[k].id, new_block.id); + } catch(error) { + this.currentImportErrors.push(this.$gettext('Abschnittdaten sind beschädigt. Möglicherweise werden nicht alle Blöcke dargestellt') + ': ' + + structuralElement.attributes.title + '→' + + block_container.attributes.title); } } } @@ -164,7 +267,7 @@ export default { }); } catch(error) { - this.currentImportErrors.push(this.$gettext('Blockdaten sind beschädigt. Es werden die Standardwerte eingesetzt.') + ': ' + this.currentImportErrors.push(this.$gettext('Blockdaten sind beschädigt. Es werden die Standardwerte eingesetzt') + ': ' + element.attributes.title + '→' + block_container.attributes.title + '→' + block.attributes.title); @@ -194,20 +297,37 @@ export default { }, async uploadAllFiles(files) { + let createFolder = false; + for (let i = 0; i < files.length; i++) { + if (files[i].folder !== null) { + createFolder = true; + } + } + if (files.length === 0 || !createFolder) { + this.setImportFilesProgress(100); + this.setImportFilesState(''); + return true; + } // create folder for importing the files into this.setImportFilesProgress(0); this.setImportFilesState(''); let now = new Date(); this.setImportFilesState(this.$gettext('Lege Import Ordner an...')); - let main_folder = await this.createRootFolder({ - context: this.context, - folder: { - type: 'StandardFolder', - name: ' CoursewareImport ' - + now.toLocaleString('de-DE', { timeZone: 'UTC' }) - + ' ' + now.getMilliseconds(), - } - }); + let main_folder = null; + try { + main_folder = await this.createRootFolder({ + context: this.context, + folder: { + type: 'StandardFolder', + name: ' CoursewareImport ' + + now.toLocaleString('de-DE', { timeZone: 'UTC' }) + + ' ' + now.getMilliseconds(), + } + }); + } catch(error) { + this.currentImportErrors.push(this.$gettext('Anlegen des Import-Ordners fehlgeschlagen.')); + } + let folders = {}; diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js index b266d3a406f..b51276d5cc7 100755 --- a/resources/vue/store/courseware/courseware.module.js +++ b/resources/vue/store/courseware/courseware.module.js @@ -383,10 +383,10 @@ export const actions = { // console.log(resp); }); }, - async copyStructuralElement({ dispatch, getters, rootGetters }, { parentId, element, removePurpose }) { - const copy = { data: { parent_id: parentId, remove_purpose: removePurpose } }; + async copyStructuralElement({ dispatch, getters, rootGetters }, { parentId, elementId, removePurpose, migrate }) { + const copy = { data: { parent_id: parentId, remove_purpose: removePurpose, migrate: migrate } }; - const result = await state.httpClient.post(`courseware-structural-elements/${element.id}/copy`, copy); + const result = await state.httpClient.post(`courseware-structural-elements/${elementId}/copy`, copy); const id = result.data.data.id; await dispatch('loadStructuralElement', id); -- GitLab