From f08cbbc79bedce4d397db55f1e484795b49d70f5 Mon Sep 17 00:00:00 2001 From: Ron Lucke <lucke@elan-ev.de> Date: Mon, 4 Oct 2021 09:35:30 +0000 Subject: [PATCH] Fixes #153 and #169 and #255 --- app/controllers/contents/courseware.php | 4 + app/views/contents/courseware/courseware.php | 1 + app/views/course/courseware/index.php | 1 + .../Routes/Courseware/ContainersCopy.php | 5 + .../Courseware/StructuralElementsCopy.php | 7 +- lib/models/Courseware/BlockTypes/Text.php | 94 +++++++++++ .../assets/stylesheets/scss/courseware.scss | 157 +++++++++++++++++- .../CoursewareAccordionContainer.vue | 16 +- .../courseware/CoursewareActionWidget.vue | 7 +- .../courseware/CoursewareAudioBlock.vue | 12 +- .../courseware/CoursewareCourseManager.vue | 85 ++++++---- .../courseware/CoursewareImageMapBlock.vue | 52 +++--- .../courseware/CoursewareListContainer.vue | 3 +- .../courseware/CoursewareManagerContainer.vue | 27 +-- .../courseware/CoursewareManagerElement.vue | 9 +- .../CoursewareStructuralElement.vue | 35 ++-- .../courseware/CoursewareTabsContainer.vue | 5 +- resources/vue/courseware-index-app.js | 6 + resources/vue/mixins/courseware/export.js | 58 ++++++- resources/vue/mixins/courseware/import.js | 108 +++++++++--- .../vue/store/courseware/courseware.module.js | 92 +++++++++- 21 files changed, 648 insertions(+), 136 deletions(-) diff --git a/app/controllers/contents/courseware.php b/app/controllers/contents/courseware.php index b27d510eccf..483f3c7ce1c 100755 --- a/app/controllers/contents/courseware.php +++ b/app/controllers/contents/courseware.php @@ -49,6 +49,8 @@ class Contents_CoursewareController extends AuthenticatedController */ public function courseware_action($action = false, $widgetId = null) { + global $perm; + Navigation::activateItem('/contents/courseware/courseware'); $this->user_id = $GLOBALS['user']->id; @@ -85,6 +87,8 @@ class Contents_CoursewareController extends AuthenticatedController array_push($this->licenses, $license->toArray()); } $this->licenses = json_encode($this->licenses); + + $this->oer_enabled = Config::get()->OERCAMPUS_ENABLED && $perm->have_perm(Config::get()->OER_PUBLIC_STATUS); } private function setCoursewareSidebar() diff --git a/app/views/contents/courseware/courseware.php b/app/views/contents/courseware/courseware.php index 2975b1b0a0e..b2f484744db 100755 --- a/app/views/contents/courseware/courseware.php +++ b/app/views/contents/courseware/courseware.php @@ -2,6 +2,7 @@ id="courseware-index-app" entry-element-id="<?= $entry_element_id ?>" entry-type="users" entry-id="<?= $user_id ?>" + oer-enabled='<?= $oer_enabled ?>' oer-title="<?= Config::get()->OER_TITLE ?>" licenses='<?= $licenses ?>' > diff --git a/app/views/course/courseware/index.php b/app/views/course/courseware/index.php index 46a949cd586..8f372d26763 100755 --- a/app/views/course/courseware/index.php +++ b/app/views/course/courseware/index.php @@ -3,6 +3,7 @@ entry-element-id="<?= $entry_element_id ?>" entry-type="courses" entry-id="<?= Context::getId() ?>" + oer-enabled="<?= Config::get()->OERCAMPUS_ENABLED?>" oer-title="<?= Config::get()->OER_TITLE ?>" licenses='<?= $licenses ?>' > diff --git a/lib/classes/JsonApi/Routes/Courseware/ContainersCopy.php b/lib/classes/JsonApi/Routes/Courseware/ContainersCopy.php index f476a038d35..9cfbf9dd73f 100755 --- a/lib/classes/JsonApi/Routes/Courseware/ContainersCopy.php +++ b/lib/classes/JsonApi/Routes/Courseware/ContainersCopy.php @@ -35,6 +35,11 @@ class ContainersCopy extends NonJsonApiController } $new_container = $this->copyContainer($user, $container, $element); + + $response = $response->withHeader('Content-Type', 'application/json'); + $response->getBody()->write((string) json_encode($new_container)); + + return $response; } private function copyContainer(\User $user, \Courseware\Container $remote_container, \Courseware\StructuralElement $element) diff --git a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsCopy.php b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsCopy.php index 96b0815db12..b63628e0114 100755 --- a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsCopy.php +++ b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsCopy.php @@ -32,7 +32,12 @@ class StructuralElementsCopy extends NonJsonApiController throw new AuthorizationFailedException(); } - $new_container = $this->copyElement($user, $remote_element, $parent_element); + $new_element = $this->copyElement($user, $remote_element, $parent_element); + + $response = $response->withHeader('Content-Type', 'application/json'); + $response->getBody()->write((string) json_encode($new_element)); + + return $response; } private function copyElement(\User $user, \Courseware\StructuralElement $remote_element, \Courseware\StructuralElement $parent_element) diff --git a/lib/models/Courseware/BlockTypes/Text.php b/lib/models/Courseware/BlockTypes/Text.php index c9811c36974..ce2a774af6a 100755 --- a/lib/models/Courseware/BlockTypes/Text.php +++ b/lib/models/Courseware/BlockTypes/Text.php @@ -3,6 +3,7 @@ namespace Courseware\BlockTypes; use Opis\JsonSchema\Schema; +require_once 'lib/classes/Markup.class.php'; /** * This class represents the content of a Courseware text block. @@ -41,6 +42,57 @@ class Text extends BlockType return Schema::fromJsonString(file_get_contents($schemaFile)); } + /** + * get all files related to this block. + * + * @return \FileRef[] list of file references realted to this block + */ + public function getFiles(): array + { + $payload = $this->getPayload(); + $document = new \DOMDocument(); + $files = []; + + if ($payload['text']) { + $document->loadHTML($payload['text']); + $imageElements = $document->getElementsByTagName('img'); + foreach ($imageElements as $element) { + if (!$element instanceof \DOMElement || !$element->hasAttribute('src')) { + continue; + } + $file = $this->extractFile($element->getAttribute('src')); + if ($file !== null) { + $files[] = $file; + } + } + } + return $files; + } + + public function copyPayload(string $rangeId = ''): array + { + $payload = $this->getPayload(); + $document = new \DOMDocument(); + + if ($payload['text']) { + $document->loadHTML(mb_convert_encoding($payload['text'], 'HTML-ENTITIES', 'UTF-8')); + $imageElements = $document->getElementsByTagName('img'); + foreach ($imageElements as $element) { + if (!$element instanceof \DOMElement || !$element->hasAttribute('src')) { + continue; + } + $file = $this->extractFile($element->getAttribute('src')); + if ($file !== null) { + $file_copy_id = $this->copyFileById($file->id, $rangeId); + $element->setAttribute('src', \FileRef::find($file_copy_id)->getDownloadURL()); + } + } + $payload['text'] = $document->saveHTML(); + } + + return $payload; + } + public static function getCategories(): array { return ['basis', 'text']; @@ -55,4 +107,46 @@ class Text extends BlockType { return []; } + + /** + * Calls a callback if a given URL is an internal URL. + * + * @param string $url The url to check + * @param callable $callback A callable to execute + * + * @return mixed The return value of the callback or null if the callback + * is not executed + */ + private function applyCallbackOnInternalUrl($url, $callback) + { + if (! \Studip\MarkupPrivate\MediaProxy\isInternalLink($url)) { + return null; + } + $components = parse_url($url); + if ( + isset($components['path']) + && substr($components['path'], -13) == '/sendfile.php' + && isset($components['query']) + && $components['query'] != '' + ) { + parse_str($components['query'], $queryParams); + + return $callback($components, $queryParams); + } + + return null; + } + + private function extractFile($url) + { + return $this->applyCallbackOnInternalUrl($url, function ($components, $queryParams) { + if (isset($queryParams['file_id'])) { + $file_ref = new \FileRef($queryParams['file_id']); + return $file_ref; + + } + + return array(); + }); + } } diff --git a/resources/assets/stylesheets/scss/courseware.scss b/resources/assets/stylesheets/scss/courseware.scss index 8e5e50ede8e..b986a700d7a 100755 --- a/resources/assets/stylesheets/scss/courseware.scss +++ b/resources/assets/stylesheets/scss/courseware.scss @@ -1657,7 +1657,7 @@ v i e w w i d g e t @include background-icon(export, clickable); } .cw-action-widget-oer{ - @include background-icon(service, clickable); + @include background-icon(oer-campus, clickable); } } @@ -2388,6 +2388,19 @@ m a n a g e r } } +.cw-import-zip { + margin-bottom: 1em; + + header { + font-size: 1.15; + font-weight: 700; + } + .progress-bar-wrapper { + width: 100%; + border: solid thin $content-color-40; + } +} + /* * * * * * * * * * * m a n a g e r e n d * * * * * * * * * * */ @@ -3654,6 +3667,7 @@ headline block .cw-block-headline { .cw-block-headline-content { min-height: 600px; + overflow: hidden; background-position: center; background-size: 1095px; @@ -3670,11 +3684,11 @@ headline block } h1 { font-size: 10em; - line-height: 1.6em; + line-height: 1.2em; } h2 { font-size: 2em; - line-height: 1.6em; + line-height: 1em; } } &.bigicon_top { @@ -3719,7 +3733,7 @@ headline block width: 100%; .cw-block-headline-title { h1 { - margin-top: 1.5em; + margin-top: 2em; border: none; font-size: 5em; text-align: center; @@ -3824,6 +3838,141 @@ headline block } } +.cw-container-colspan-half { + .cw-block-headline { + .cw-block-headline-content { + min-height: 300px; + + &.half { + min-height: 150px; + } + &.heavy { + h1 { + font-size: 4.5em; + } + h2 { + font-size: 1.25em; + } + } + &.bigicon_top { + .icon-layer { + background-position: center calc(50% - 4em); + min-height: 300px; + + &.half { + min-height: 150px; + } + + @each $icon in $icons { + &.icon-black-#{$icon} { + @include background-icon($icon, info, 98); + } + &.icon-white-#{$icon} { + @include background-icon($icon, info-alt, 98); + } + &.icon-studip-blue-#{$icon} { + @include background-icon($icon, clickable, 98); + } + &.icon-studip-red-#{$icon} { + @include background-icon($icon, status-red, 98); + } + &.icon-studip-yellow-#{$icon} { + @include background-icon($icon, status-yellow, 98); + } + &.icon-studip-green-#{$icon} { + @include background-icon($icon, status-green, 98); + } + }; + + &.half { + background-size: 72px; + background-position: center calc(50% - 2em); + } + } + + + .cw-block-headline-textbox { + .cw-block-headline-title { + h1 { + margin-top: 2.5em; + font-size: 2em; + } + } + + .cw-block-headline-subtitle { + h2 { + font-size: 12px; + } + } + } + } + &.bigicon_before { + .icon-layer { + min-height: 300px; + + &.half { + min-height: 150px; + } + background-position: 2em center; + @each $icon in $icons { + &.icon-black-#{$icon} { + @include background-icon($icon, info, 92); + } + &.icon-white-#{$icon} { + @include background-icon($icon, info-alt, 92); + } + &.icon-studip-blue-#{$icon} { + @include background-icon($icon, clickable, 92); + } + &.icon-studip-red-#{$icon} { + @include background-icon($icon, status-red, 92); + } + &.icon-studip-yellow-#{$icon} { + @include background-icon($icon, status-yellow, 92); + } + &.icon-studip-green-#{$icon} { + @include background-icon($icon, status-green, 92); + } + }; + } + + .cw-block-headline-textbox { + .cw-block-headline-title { + h1 { + font-size: 2.5em; + } + } + + } + } + + &.ribbon { + .icon-layer { + min-height: 300px; + + &.half { + min-height: 150px; + } + + .cw-block-headline-textbox { + .cw-block-headline-title { + h1 { + font-size: 2.5em; + } + } + + .cw-block-headline-subtitle { + h2 { + font-size: 12px; + } + } + } + } + } + } + } +} + /* headline block end */ diff --git a/resources/vue/components/courseware/CoursewareAccordionContainer.vue b/resources/vue/components/courseware/CoursewareAccordionContainer.vue index d69425af229..3f99e9b5a23 100755 --- a/resources/vue/components/courseware/CoursewareAccordionContainer.vue +++ b/resources/vue/components/courseware/CoursewareAccordionContainer.vue @@ -9,16 +9,15 @@ > <template v-slot:containerContent> <courseware-collapsible-box - v-for="(section, index) in container.attributes.payload.sections" + v-for="(section, index) in currentSections" :key="index" :title="section.name" :icon="section.icon" :open="index === 0" > <ul class="cw-container-accordion-block-list"> - <li v-for="block in blocks" :key="block.id" class="cw-block-item"> + <li v-for="block in section.blocks" :key="block.id" class="cw-block-item"> <component - v-if="section.blocks.includes(block.id)" :is="component(block)" :block="block" :canEdit="canEdit" @@ -92,6 +91,7 @@ export default { data() { return { currentContainer: {}, + currentSections: [], }; }, computed: { @@ -103,7 +103,7 @@ export default { return []; } - return this.container.relationships.blocks.data.map(({ id }) => this.blockById({ id })); + return this.container.relationships.blocks.data.map(({ id }) => this.blockById({ id })).filter((a) => a); }, showEditMode() { return this.$store.getters.viewMode === 'edit'; @@ -123,6 +123,14 @@ export default { initCurrentData() { // clone container to make edit reversible this.currentContainer = JSON.parse(JSON.stringify(this.container)); + + let view = this; + let sections = this.currentContainer.attributes.payload.sections; + sections.forEach(section => { + section.blocks = section.blocks.map((id) => view.blockById({id})).filter((a) => a); + }); + + this.currentSections = sections; }, addSection() { this.currentContainer.attributes.payload.sections.push({ name: '', icon: '', blocks: [] }); diff --git a/resources/vue/components/courseware/CoursewareActionWidget.vue b/resources/vue/components/courseware/CoursewareActionWidget.vue index 07238dd46cc..6238690bac4 100644 --- a/resources/vue/components/courseware/CoursewareActionWidget.vue +++ b/resources/vue/components/courseware/CoursewareActionWidget.vue @@ -5,7 +5,7 @@ <li class="cw-action-widget-info" @click="showElementInfo"><translate>Informationen anzeigen</translate></li> <li class="cw-action-widget-star" @click="createBookmark"><translate>Lesezeichen setzen</translate></li> <li v-show="canEdit" @click="exportElement" class="cw-action-widget-export"><translate>Seite exportieren</translate></li> - <li v-show="canEdit" @click="oerElement" class="cw-action-widget-oer"><translate>Seite auf %{oerTitle} veröffentlichen</translate></li> + <li v-show="canEdit && oerEnabled" @click="oerElement" class="cw-action-widget-oer"><translate>Seite auf %{oerTitle} veröffentlichen</translate></li> <li v-show="!isRoot && canEdit" class="cw-action-widget-trash" @click="deleteElement"><translate>Seite löschen</translate></li> </ul> </template> @@ -30,6 +30,7 @@ export default { computed: { ...mapGetters({ structuralElementById: 'courseware-structural-elements/byId', + oerEnabled: 'oerEnabled', oerTitle: 'oerTitle', }), structuralElement() { @@ -112,7 +113,5 @@ export default { this.setCurrentId(to.params.id); }, }, - - } -</script>2 \ No newline at end of file +</script> \ No newline at end of file diff --git a/resources/vue/components/courseware/CoursewareAudioBlock.vue b/resources/vue/components/courseware/CoursewareAudioBlock.vue index 33d84069693..e004f4aef13 100755 --- a/resources/vue/components/courseware/CoursewareAudioBlock.vue +++ b/resources/vue/components/courseware/CoursewareAudioBlock.vue @@ -41,8 +41,8 @@ <button class="cw-audio-button cw-audio-stopbutton" @click="stopAudio" /> </div> </div> - <div v-if="hasPlaylist" class="cw-audio-playlist-wrapper"> - <ul class="cw-audio-playlist"> + <div class="cw-audio-playlist-wrapper"> + <ul v-show="hasPlaylist" class="cw-audio-playlist"> <li v-for="(file, index) in files" :key="file.id" @@ -56,6 +56,9 @@ {{ file.name }} </li> </ul> + <div v-if="emptyAudio" class="cw-audio-empty"> + <p><translate>Es ist keine Audio-Datei verfügbar</translate></p> + </div> <div v-if="showRecorder && canGetMediaDevices" class="cw-audio-playlist-recorder"> <button v-show="!userRecorderEnabled" @@ -104,9 +107,6 @@ </span> </div> </div> - <div v-if="emptyAudio" class="cw-audio-empty"> - <p><translate>Es ist keine Audio-Datei verfügbar</translate></p> - </div> </template> <template v-if="canEdit" #edit> <form class="default" @submit.prevent=""> @@ -302,7 +302,7 @@ export default { return ''; }, emptyAudio() { - if (this.currentSource === 'studip_folder' && this.currentFolderId !== '') { + if (this.currentSource === 'studip_folder' && this.currentFolderId !== '' && this.files.length > 0) { return false; } if (this.currentSource === 'studip_file' && this.currentFileId !== '') { diff --git a/resources/vue/components/courseware/CoursewareCourseManager.vue b/resources/vue/components/courseware/CoursewareCourseManager.vue index 6005174984b..4f3f4575c9d 100755 --- a/resources/vue/components/courseware/CoursewareCourseManager.vue +++ b/resources/vue/components/courseware/CoursewareCourseManager.vue @@ -18,10 +18,13 @@ > <translate>Alles exportieren</translate> </button> - <br> - <translate v-if="exportRunning"> - Export läuft, bitte haben sie einen Moment Geduld... - </translate> + <courseware-companion-box v-show="exportRunning" :msgCompanion="$gettext('Export läuft, bitte haben sie einen Moment Geduld...')" mood="pointing"/> + <div v-if="exportRunning" class="cw-import-zip"> + <header>{{exportState}}:</header> + <div class="progress-bar-wrapper"> + <div class="progress-bar" role="progressbar" :style="{width: exportProgress + '%'}" :aria-valuenow="exportProgress" aria-valuemin="0" aria-valuemax="100">{{ exportProgress }}%</div> + </div> + </div> </courseware-tab> </courseware-tabs> @@ -88,34 +91,41 @@ </courseware-tab> <courseware-tab :name="$gettext('Importieren')"> + <courseware-companion-box v-show="!importRunning && importDone" :msgCompanion="$gettext('Import erfolgreich!')" mood="special"/> + <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" - :class="{ - disabled: importRunning, - }" > - Importdatei auswählen + <translate>Importdatei auswählen</translate> </button> - <div v-if="importZip"> - <b>{{ importZip.name }}</b - ><br /> - <translate>Größe</translate>: <span>{{ getFileSizeText(importZip.size) }}</span> + <div v-if="importZip" class="cw-import-zip"> + <header>{{ importZip.name }}</header> + <p><translate>Größe</translate>: {{ getFileSizeText(importZip.size) }}</p> </div> - <br v-else /> + <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="importState"> - {{ importState }} + <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" - :class="{ - disabled: importRunning || !importZip, - }" > <translate>Alles importieren</translate> </button> @@ -131,6 +141,7 @@ import CoursewareTab from './CoursewareTab.vue'; import CoursewareCollapsibleBox from './CoursewareCollapsibleBox.vue'; import CoursewareManagerElement from './CoursewareManagerElement.vue'; import CoursewareManagerCopySelector from './CoursewareManagerCopySelector.vue'; +import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; import CoursewareImport from '@/vue/mixins/courseware/import.js'; import CoursewareExport from '@/vue/mixins/courseware/export.js'; import { mapActions, mapGetters } from 'vuex'; @@ -146,6 +157,7 @@ export default { CoursewareCollapsibleBox, CoursewareManagerElement, CoursewareManagerCopySelector, + CoursewareCompanionBox, }, mixins: [CoursewareImport, CoursewareExport], @@ -155,8 +167,6 @@ export default { exportRunning: false, importRunning: false, importZip: null, - importState: '', - importPos: 0, currentElement: {}, currentId: null, selfElement: {}, @@ -169,6 +179,12 @@ export default { ...mapGetters({ courseware: 'courseware', structuralElementById: 'courseware-structural-elements/byId', + importFilesState: 'importFilesState', + importFilesProgress: 'importFilesProgress', + importStructuresState: 'importStructuresState', + importStructuresProgress: 'importStructuresProgress', + exportState: 'exportState', + exportProgress: 'exportProgress' }), moveSelfPossible() { if (this.selfElement.relationships === undefined) { @@ -186,6 +202,12 @@ export default { moveSelfChildPossible() { return this.currentId !== this.selfId; }, + fileImportDone() { + return this.importFilesProgress === 100; + }, + importDone() { + return this.importFilesProgress === 100 && this.importStructuresProgress === 100; + } }, methods: { @@ -199,6 +221,8 @@ export default { unlockObject: 'unlockObject', addBookmark: 'addBookmark', companionInfo: 'companionInfo', + setImportFilesProgress: 'setImportFilesProgress', + setImportStructuresProgress: 'setImportStructuresProgress', }), async reloadElements() { await this.setCurrentId(this.currentId); @@ -220,16 +244,6 @@ export default { initSelf() { this.selfElement = this.structuralElementById({ id: this.selfId }); }, - animateImport() { - // get number of dots - this.importPos++; - - if (this.importPos > 3) { - this.importPos = 0; - } - - this.importState = this.$gettext('Import läuft') + '.'.repeat(this.importPos); - }, async doExportCourseware() { if (this.exportRunning) { @@ -239,12 +253,15 @@ export default { this.exportRunning = true; await this.loadCoursewareStructure(); - await this.sendExportZip(); + await this.sendExportZip( + this.courseware.relationships.root.data.id, + {withChildren: true} + ); this.exportRunning = false; }, - setImport() { + setImport(event) { this.importZip = event.target.files[0]; }, @@ -254,7 +271,6 @@ export default { } this.importRunning = true; - this.animateImport(); let view = this; @@ -273,13 +289,14 @@ export default { await view.importCourseware(courseware, parent_id, files); }); - this.importState = this.$gettext('Import erfolgreich!'); this.importZip = null; this.importRunning = false; }, chooseFile() { this.$refs.importFile.click(); + this.setImportFilesProgress(0); + this.setImportStructuresProgress(0); }, getFileSizeText(size) { if (size / 1024 < 1000) { diff --git a/resources/vue/components/courseware/CoursewareImageMapBlock.vue b/resources/vue/components/courseware/CoursewareImageMapBlock.vue index f82bd410d13..082e2ac9ef3 100755 --- a/resources/vue/components/courseware/CoursewareImageMapBlock.vue +++ b/resources/vue/components/courseware/CoursewareImageMapBlock.vue @@ -372,31 +372,37 @@ export default { context.closePath(); }); }, - fitTextToShape(context, text, shape_width) { - let text_width = context.measureText(text).width; - if (text_width > shape_width) { - text = text.split(' '); - let line = ''; - let word = ' '; - let new_text = []; - do { - word = text.shift(); - if (context.measureText(word).width >= shape_width) { - return ['']; - } - line = line + word + ' '; - if (context.measureText(line).width > shape_width) { - text.unshift(word); - line = line.substring(0, line.lastIndexOf(word)); - new_text.push(line.trim()); - line = ''; - } - } while (text.length > 0); - new_text.push(line.trim()); - return new_text; - } else { + fitTextToShape( context , text, shapeWidth) { + shapeWidth = shapeWidth || 0; + + let newText = []; + + if (shapeWidth <= 0) { return [text]; } + let words = text.split(' '); + let i = 1; + while (words.length > 0 && i <= words.length) { + let word = words.slice(0, i).join(' '); + let wordWidth = context.measureText(word).width + 2; + if ( wordWidth > shapeWidth ) { + if (i === 1) { + i = 2; + } + newText.push(words.slice(0, i - 1).join(' ')); + words = words.splice(i - 1); + i = 1; + } + else { + i++; + } + } + if (i > 0) { + newText.push(words.join(' ')); + } + + return newText; + }, mapImage() { let view = this; diff --git a/resources/vue/components/courseware/CoursewareListContainer.vue b/resources/vue/components/courseware/CoursewareListContainer.vue index 794231c7b8c..096befe2b8e 100755 --- a/resources/vue/components/courseware/CoursewareListContainer.vue +++ b/resources/vue/components/courseware/CoursewareListContainer.vue @@ -43,7 +43,7 @@ export default { return []; } - return this.container.relationships.blocks.data.map(({ id }) => this.blockById({ id })); + return this.container.attributes.payload.sections[0].blocks.map((id) => this.blockById({ id })).filter((a) => a); }, showEditMode() { return this.$store.getters.viewMode === 'edit'; @@ -51,7 +51,6 @@ export default { }, methods: { storeContainer(data) { - console.log(data); }, component(block) { if (block.attributes["block-type"] !== undefined) { diff --git a/resources/vue/components/courseware/CoursewareManagerContainer.vue b/resources/vue/components/courseware/CoursewareManagerContainer.vue index 62a1015687f..9a8a6ddea04 100755 --- a/resources/vue/components/courseware/CoursewareManagerContainer.vue +++ b/resources/vue/components/courseware/CoursewareManagerContainer.vue @@ -127,7 +127,7 @@ export default { return this.container.attributes['container-type']; }, hasSections() { - return this.containerType() === 'tabs' || this.containerType() === 'accordion'; + return this.containerType === 'tabs' || this.containerType === 'accordion'; }, getBlocksCount() { if (this.sectionsWithBlocksCurrentState === null) { @@ -146,7 +146,7 @@ export default { } }, mounted() { - this.sectionsWithBlocksCurrentState = this.getSectionsWithBlocks(); + this.initSections(); }, methods: { ...mapActions({ @@ -181,36 +181,33 @@ export default { return this.blockById({ id }) ?? [] //remove blocks which could not be loaded } ); - - section.blocks.sort((a, b) => { - return a.attributes.position > b.attributes.position; - }); } }); return blockSections; }, + initSections() { + this.sectionsWithBlocksCurrentState = this.getSectionsWithBlocks(); + }, insertBlock(data) { this.$emit('insertBlock', data); + this.initSections(); }, sortBlocks() { this.sortBlocksActive = true; }, async storeBlocksSort() { - const container = this.container; + const container = JSON.parse(JSON.stringify(this.container)); this.sectionsWithBlocksCurrentState.forEach((section, index)=> { if (section.blocks !== undefined) { container.attributes.payload.sections[index].blocks = section.blocks.map(({ id }) => ( id )); } }); - await this.lockObject({id: container.id, type: 'courseware-containers'}); await this.updateContainer({ container: container, structuralElementId: this.container.relationships['structural-element'].data.id }); await this.unlockObject({id: container.id, type: 'courseware-containers'}); - await this.sortBlocksInContainer({ container: this.container, sections: this.sectionsWithBlocksCurrentState }); - this.sortBlocksActive = false; }, resetBlocksSort() { @@ -262,5 +259,15 @@ export default { }); }, }, + watch: { + container: { + handler(state, prevState) { + if (state.attributes.payload.sections[0].blocks !== prevState.attributes.payload.sections[0].blocks) { + this.initSections(); + } + }, + deep: true + } + }, }; </script> diff --git a/resources/vue/components/courseware/CoursewareManagerElement.vue b/resources/vue/components/courseware/CoursewareManagerElement.vue index 3794d701fe3..b703e8da78f 100755 --- a/resources/vue/components/courseware/CoursewareManagerElement.vue +++ b/resources/vue/components/courseware/CoursewareManagerElement.vue @@ -247,16 +247,11 @@ export default { } }, containers() { - if (!this.currentElement) { + if (!this.currentElement || !this.currentElement.relationships) { return []; } - const containers = this.$store.getters['courseware-containers/related']({ - parent: this.currentElement, - relationship: 'containers', - }); - - return containers; + return this.currentElement.relationships.containers.data.map(({id}) => this.containerById({ id })); }, children() { if (!this.currentElement) { diff --git a/resources/vue/components/courseware/CoursewareStructuralElement.vue b/resources/vue/components/courseware/CoursewareStructuralElement.vue index 00a24fb3158..78af7a4992d 100755 --- a/resources/vue/components/courseware/CoursewareStructuralElement.vue +++ b/resources/vue/components/courseware/CoursewareStructuralElement.vue @@ -297,22 +297,29 @@ :confirmClass="'accept'" :closeText="textExport.close" :closeClass="'cancel'" + height="350" @close="showElementExportDialog(false)" @confirm="exportCurrentElement" > <template v-slot:dialogContent> - <translate>Hiermit exportieren Sie die Seite "{{ currentElement.attributes.title }}" als ZIP-Datei.</translate> + <div v-show="!exportRunning"> + <translate>Hiermit exportieren Sie die Seite "{{ currentElement.attributes.title }}" als ZIP-Datei.</translate> - <div class="cw-element-export"> - <label> - <input type="checkbox" v-model="exportChildren"> - <translate>Unterseiten exportieren</translate> - </label> + <div class="cw-element-export"> + <label> + <input type="checkbox" v-model="exportChildren"> + <translate>Unterseiten exportieren</translate> + </label> + </div> </div> - <translate v-if="exportRunning"> - Export läuft... - </translate> + <courseware-companion-box v-show="exportRunning" :msgCompanion="$gettext('Export läuft, bitte haben sie einen Moment Geduld...')" mood="pointing"/> + <div v-show="exportRunning" class="cw-import-zip"> + <header>{{exportState}}:</header> + <div class="progress-bar-wrapper"> + <div class="progress-bar" role="progressbar" :style="{width: exportProgress + '%'}" :aria-valuenow="exportProgress" aria-valuemin="0" aria-valuemax="100">{{ exportProgress }}%</div> + </div> + </div> </template> </studip-dialog> @@ -483,8 +490,11 @@ export default { showInfoDialog: 'showStructuralElementInfoDialog', showDeleteDialog : 'showStructuralElementDeleteDialog', showOerDialog : 'showStructuralElementOerDialog', + oerEnabled: 'oerEnabled', oerTitle: 'oerTitle', - licenses: 'licenses' + licenses: 'licenses', + exportState: 'exportState', + exportProgress: 'exportProgress' }), textOer() { @@ -694,7 +704,10 @@ export default { menu.push({ id: 1, label: this.$gettext('Seite bearbeiten'), icon: 'edit', emit: 'editCurrentElement' }); menu.push({ id: 2, label: this.$gettext('Seite hinzufügen'), icon: 'add', emit: 'addElement' }); menu.push({ id: 5, label: this.$gettext('Seite exportieren'), icon: 'export', emit: 'showExportOptions' }); - menu.push({ id: 6, label: this.textOer.title, icon: 'service', emit: 'oerCurrentElement' }); + + } + if (this.canEdit && this.oerEnabled) { + menu.push({ id: 6, label: this.textOer.title, icon: 'oer-campus', emit: 'oerCurrentElement' }); } if(!this.isRoot && this.canEdit) { menu.push({ id: 7, label: this.$gettext('Seite löschen'), icon: 'trash', emit: 'deleteCurrentElement' }); diff --git a/resources/vue/components/courseware/CoursewareTabsContainer.vue b/resources/vue/components/courseware/CoursewareTabsContainer.vue index 397c7e0cd28..d14503e3d95 100755 --- a/resources/vue/components/courseware/CoursewareTabsContainer.vue +++ b/resources/vue/components/courseware/CoursewareTabsContainer.vue @@ -20,7 +20,6 @@ <ul class="cw-container-tabs-block-list"> <li v-for="block in section.blocks" :key="block.id" class="cw-block-item"> <component - v-if="section.blocks.includes(block.id)" :is="component(block)" :block="block" :canEdit="canEdit" @@ -111,7 +110,7 @@ export default { return []; } - return this.container.relationships.blocks.data.map(({ id }) => this.blockById({ id })); + return this.container.relationships.blocks.data.map(({ id }) => this.blockById({ id })).filter((a) => a); }, showEditMode() { return this.$store.getters.viewMode === 'edit'; @@ -135,7 +134,7 @@ export default { let view = this; let sections = this.currentContainer.attributes.payload.sections; sections.forEach(section => { - section.blocks = section.blocks.map((id) => view.blockById({id})); + section.blocks = section.blocks.map((id) => view.blockById({id})).filter((a) => a); }); this.currentSections = sections; diff --git a/resources/vue/courseware-index-app.js b/resources/vue/courseware-index-app.js index 1294de2cbb9..2594e66ec23 100755 --- a/resources/vue/courseware-index-app.js +++ b/resources/vue/courseware-index-app.js @@ -25,6 +25,7 @@ const mountApp = (STUDIP, createApp, element) => { let elem_id = null; let entry_id = null; let entry_type = null; + let oer_enabled = null; let oer_title = null; let licenses = null; let elem; @@ -43,6 +44,10 @@ const mountApp = (STUDIP, createApp, element) => { entry_id = elem.attributes['entry-id'].value; } + if (elem.attributes['oer-enabled'] !== undefined) { + oer_enabled = elem.attributes['oer-enabled'].value; + } + if (elem.attributes['oer-title'] !== undefined) { oer_title = elem.attributes['oer-title'].value; } @@ -116,6 +121,7 @@ const mountApp = (STUDIP, createApp, element) => { store.dispatch('coursewareCurrentElement', elem_id); + store.dispatch('oerEnabled', oer_enabled); store.dispatch('oerTitle', oer_title); store.dispatch('licenses', licenses); diff --git a/resources/vue/mixins/courseware/export.js b/resources/vue/mixins/courseware/export.js index 7a1a8e2efb0..65e8ece5b3d 100755 --- a/resources/vue/mixins/courseware/export.js +++ b/resources/vue/mixins/courseware/export.js @@ -20,13 +20,22 @@ export default { json: [], download: [], }, + elementCounter: 0, + exportElementCounter: 0, }; }, methods: { async sendExportZip(root_id = null, options) { + let view = this; let zip = await this.createExportFile(root_id, options); - await zip.generateAsync({ type: 'blob' }).then(function (content) { + this.setExportState(this.$gettext('Erstelle Zip-Archiv')); + this.setExportProgress(0); + await zip.generateAsync({ type: 'blob' }, function updateCallback(metadata) { + view.setExportProgress(metadata.percent.toFixed(0)); + }).then(function (content) { + view.setExportState(''); + view.setExportProgress(0); FileSaver.saveAs(content, 'courseware-export-' + new Date().toISOString().slice(0, 10) + '.zip'); }); }, @@ -38,7 +47,8 @@ export default { root_id = this.courseware.relationships.root.data.id; completeExport = true; } - + this.setExportState(this.$gettext('Exportiere Elemente')); + this.setExportProgress(0); let exportData = await this.exportCourseware(root_id, options); let zip = new JSZip(); @@ -50,6 +60,10 @@ export default { } // add all additional files from blocks + let i = 1; + let filesCounter = Object.keys(exportData.files.download).length; + this.setExportState(this.$gettext('Lade Dateien')); + this.setExportProgress(0); for (let id in exportData.files.download) { zip.file( id, @@ -59,6 +73,8 @@ export default { return textString; }) ); + this.setExportProgress(parseInt(i / filesCounter * 100)); + i++; } return zip; @@ -78,6 +94,8 @@ export default { // load whole courseware nonetheless, only export relevant elements let elements = await this.$store.getters['courseware-structural-elements/all']; + this.exportElementCounter = 0; + this.elementCounter = await this.countElements([root_element]); root_element.containers = []; if (root_element.relationships.containers?.data?.length) { @@ -89,6 +107,7 @@ export default { }) ) ); + this.exportElementCounter++; } } @@ -115,6 +134,28 @@ export default { }; }, + countElements(element) { + let counter = 0; + if (element.length) { + for (var i = 0; i < element.length; i++) { + counter++; + if (element[i].relationships.children?.data?.length > 0) { + let children = []; + element[i].relationships.children?.data.forEach(child => { + children.push(this.structuralElementById({id: child.id})); + }); + counter += this.countElements(children); + } + + if (element[i].relationships.containers?.data?.length > 0) { + counter += element[i].relationships.containers.data.length + } + } + } + + return counter; + }, + async exportToOER(element, options) { let formData = new FormData(); @@ -155,6 +196,7 @@ export default { for (var i = 0; i < data.length; i++) { if (data[i].relationships.parent.data?.id === parentId) { let new_childs = await this.exportStructuralElement(data[i].id, data); + this.exportElementCounter++; let content = { ...data[i] }; content.containers = []; @@ -172,6 +214,7 @@ export default { }) ) ); + this.exportElementCounter++; } } @@ -255,7 +298,16 @@ export default { 'loadStructuralElement', 'loadFileRefs', 'loadFolder', - 'companionInfo' + 'companionInfo', + 'setExportState', + 'setExportProgress' ]), }, + watch: { + exportElementCounter(counter) { + if (this.elementCounter !== 0) { + this.setExportProgress(parseInt(counter / this.elementCounter * 100)); + } + } + }, }; diff --git a/resources/vue/mixins/courseware/import.js b/resources/vue/mixins/courseware/import.js index c2b83c7d3ac..703f0100191 100755 --- a/resources/vue/mixins/courseware/import.js +++ b/resources/vue/mixins/courseware/import.js @@ -4,7 +4,9 @@ export default { data() { return { importFolder: null, - file_mapping: {} + file_mapping: {}, + elementCounter: 0, + importElementCounter: 0, }; }, @@ -16,30 +18,57 @@ export default { }, methods: { - animateImport() {}, async importCourseware(element, parent_id, files) { // import all files await this.uploadAllFiles(files); - this.animateImport(); + this.elementCounter = await this.countImportElements([element]); + this.setImportStructuresState(''); + this.importElementCounter = 0; await this.importStructuralElement([element], parent_id, files); }, + countImportElements(element) { + let counter = 0; + if (element.length) { + for (var i = 0; i < element.length; i++) { + counter++; + if (element[i].children?.length > 0) { + counter += this.countImportElements(element[i].children); + } + + if (element[i].containers?.length > 0) { + for (var j = 0; j < element[i].containers.length; j++) { + counter++; + let container = element[i].containers[j]; + if (container.blocks?.length) { + for (var k = 0; k < container.blocks.length; k++) { + counter++; + } + } + } + } + } + } + + return counter; + }, + async importStructuralElement(element, parent_id, files) { if (element.length) { for (var i = 0; i < element.length; i++) { // TODO: create element on server and fetch new id + this.setImportStructuresState('Lege Seite an: ' + element[i].attributes.title); await this.createStructuralElement({ attributes: element[i].attributes, parentId: parent_id, currentId: parent_id, }); - - this.animateImport(); + this.importElementCounter++; let new_element = this.$store.getters['courseware-structural-elements/lastCreated']; if (element[i].children?.length > 0) { @@ -50,19 +79,24 @@ export default { 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('Lege Abschnitt an: ' + container.attributes.title); await this.createContainer({ attributes: container.attributes, structuralElementId: new_element.id, }); - - this.animateImport(); + 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++) { - await this.importBlock(container.blocks[k], new_container, files); + new_block = await this.importBlock(container.blocks[k], new_container, files); + this.importElementCounter++; + await this.updateContainerPayload(new_container, new_element.id, container.blocks[k].id, new_block.id); } + } } } @@ -72,13 +106,12 @@ export default { async importBlock(block, block_container, files) { // TODO: create element + this.setImportStructuresState('Lege neuen Block an: ' + block.attributes.title); await this.createBlockInContainer({ container: {type: block_container.type, id: block_container.id}, blockType: block.attributes['block-type'], }); - this.animateImport(); - let new_block = this.$store.getters['courseware-blocks/lastCreated']; // update old id ids in payload part @@ -94,20 +127,41 @@ export default { block.attributes.payload = JSON.parse(payload); } } - + this.setImportStructuresState('Aktualisiere neuen Block: ' + block.attributes.title); await this.updateBlockInContainer({ attributes: block.attributes, blockId: new_block.id, containerId: block_container.id, }); - this.animateImport(); + return new_block; + }, + + async updateContainerPayload(container, structuralElementId, oldBlockId, newBlockId) { + + container.attributes.payload.sections.forEach((section, index) => { + let blockIndex = section.blocks.findIndex(blockID => blockID === oldBlockId); + + if(blockIndex > -1) { + container.attributes.payload.sections[index].blocks[blockIndex] = newBlockId; + } + }); + + await this.lockObject({ id: container.id, type: 'courseware-containers' }); + await this.updateContainer({ + container: container, + structuralElementId: structuralElementId + }); + await this.unlockObject({ id: container.id, type: 'courseware-containers' }); }, async uploadAllFiles(files) { // create folder for importing the files into + this.setImportFilesProgress(0); + this.setImportFilesState(''); let now = new Date(); + this.setImportFilesState('Lege Import Ordner an...'); let main_folder = await this.createRootFolder({ context: this.context, folder: { @@ -118,16 +172,14 @@ export default { } }); - this.animateImport(); - let folders = {}; // upload all files to the newly created folder if (main_folder) { for (var i = 0; i < files.length; i++) { - // if the subfolder with the referenced id does not exist yet, create it if (!folders[files[i].folder.id]) { + this.setImportFilesState(this.$gettext('Lege Ordner an') + ': ' + files[i].folder.name); folders[files[i].folder.id] = await this.createFolder({ context: this.context, parent: { @@ -137,7 +189,7 @@ export default { } }, folder: { - type: files[i].folder.type, + type: 'StandardFolder', name: files[i].folder.name } }); @@ -155,8 +207,8 @@ export default { filedata: filedata, folder: folders[files[i].folder.id] }); - - this.animateImport(); + this.setImportFilesState(this.$gettext('Erzeuge Datei') + ': ' + files[i].attributes.name); + this.setImportFilesProgress(parseInt(i / files.length * 100)); //file mapping this.file_mapping[files[i].id] = { @@ -168,6 +220,8 @@ export default { } else { return false; } + this.setImportFilesProgress(100); + this.setImportFilesState(''); return true; }, @@ -176,10 +230,26 @@ export default { 'createBlockInContainer', 'createContainer', 'createStructuralElement', + 'updateContainer', 'updateBlockInContainer', 'createFolder', 'createRootFolder', - 'createFile' + 'createFile', + 'lockObject', + 'unlockObject', + 'setImportFilesState', + 'setImportFilesProgress', + 'setImportStructuresState', + 'setImportStructuresProgress', ]), }, + watch: { + importElementCounter(counter) { + if (this.elementCounter !== 0) { + this.setImportStructuresProgress(parseInt(counter / this.elementCounter * 100)); + } else { + this.setImportStructuresProgress(100); + } + } + }, }; diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js index 90d6f91039f..3323ed421d5 100755 --- a/resources/vue/store/courseware/courseware.module.js +++ b/resources/vue/store/courseware/courseware.module.js @@ -8,6 +8,7 @@ const getDefaultState = () => { context: {}, courseware: {}, currentElement: {}, + oerEnabled: null, oerTitle: null, licenses: null, // we need a route for License SORM httpClient: null, @@ -31,6 +32,14 @@ const getDefaultState = () => { showStructuralElementInfoDialog: false, showStructuralElementDeleteDialog: false, showStructuralElementOerDialog: false, + + importFilesState: '', + importFilesProgress: 0, + importStructuresState: '', + importStructuresProgress: 0, + + exportState: '', + exportProgress: 0, }; }; @@ -49,6 +58,9 @@ const getters = { currentElement(state) { return state.currentElement; }, + oerEnabled(state) { + return state.oerEnabled; + }, oerTitle(state) { return state.oerTitle; }, @@ -129,7 +141,25 @@ const getters = { }, showStructuralElementDeleteDialog(state) { return state.showStructuralElementDeleteDialog; - } + }, + importFilesState(state) { + return state.importFilesState; + }, + importFilesProgress(state) { + return state.importFilesProgress; + }, + importStructuresState(state) { + return state.importStructuresState; + }, + importStructuresProgress(state) { + return state.importStructuresProgress; + }, + exportState(state) { + return state.exportState; + }, + exportProgress(state) { + return state.exportProgress; + }, }; export const state = { ...initialState }; @@ -205,7 +235,7 @@ export const actions = { async createRootFolder({ dispatch, rootGetters }, { context, folder }) { // get root folder for this context await dispatch( - 'courses/loadRelated', + `${context.type}/loadRelated`, { parent: context, relationship: 'folders', @@ -213,7 +243,7 @@ export const actions = { { root: true } ); - let folders = await rootGetters['courses/related']({ + let folders = await rootGetters[`${context.type}/related`]({ parent: context, relationship: 'folders', }); @@ -244,7 +274,7 @@ export const actions = { }, }; - return state.httpClient.post(`courses/${context.id}/folders`, newFolder).then((response) => { + return state.httpClient.post(`${context.type}/${context.id}/folders`, newFolder).then((response) => { return response.data.data; }); }, @@ -263,7 +293,7 @@ export const actions = { }, }; - return state.httpClient.post(`courses/${context.id}/folders`, newFolder).then((response) => { + return state.httpClient.post(`${context.type}/${context.id}/folders`, newFolder).then((response) => { return response.data.data; }); }, @@ -597,6 +627,10 @@ export const actions = { context.commit('coursewareContextSet', id); }, + oerEnabled(context, enabled) { + context.commit('oerEnabledSet', enabled); + }, + oerTitle(context, title) { context.commit('oerTitleSet', title); }, @@ -673,6 +707,26 @@ export const actions = { context.commit('setShowStructuralElementDeleteDialog', bool) }, + setImportFilesState({commit}, state ) { + commit('setImportFilesState', state) + }, + setImportFilesProgress({commit}, percent ) { + commit('setImportFilesProgress', percent) + }, + setImportStructuresState({commit}, state ) { + commit('setImportStructuresState', state) + }, + setImportStructuresProgress({commit}, percent ) { + commit('setImportStructuresProgress', percent) + }, + + setExportState({commit}, state) { + commit('setExportState', state) + }, + setExportProgress({commit}, percent) { + commit('setExportProgress', percent) + }, + addBookmark({ dispatch, rootGetters }, structuralElement) { const cw = rootGetters['courseware']; @@ -893,6 +947,10 @@ export const mutations = { state.context = data; }, + oerEnabledSet(state, data) { + state.oerEnabled = data; + }, + oerTitleSet(state, data) { state.oerTitle = data; }, @@ -979,7 +1037,31 @@ export const mutations = { setShowStructuralElementDeleteDialog(state, showDelete) { state.showStructuralElementDeleteDialog = showDelete; + }, + + setImportFilesState(state, importFilesState) { + state.importFilesState = importFilesState; + }, + + setImportFilesProgress(state, importFilesProgress) { + state.importFilesProgress = importFilesProgress; + }, + + setImportStructuresState(state, importStructuresState) { + state.importStructuresState = importStructuresState; + }, + + setImportStructuresProgress(state, importStructuresProgress) { + state.importStructuresProgress = importStructuresProgress; + }, + + setExportState(state, exportState) { + state.exportState = exportState; + }, + setExportProgress(state, exportProgress) { + state.exportProgress = exportProgress; } + }; export default { -- GitLab