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