diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php index 4b61b3d27749b801b3e75b7a1ba022ee61b4a4d2..579b4e91f3eba024b9dcbbed1a26a9385fb5fdea 100644 --- a/lib/classes/JsonApi/RouteMap.php +++ b/lib/classes/JsonApi/RouteMap.php @@ -649,6 +649,7 @@ class RouteMap $group->delete('/stock-images/{id}', Routes\StockImages\StockImagesDelete::class); $group->post('/stock-images/{id}/blob', Routes\StockImages\StockImagesUpload::class); + $group->post('/stock-images/zip', Routes\StockImages\StockImagesZipUpload::class); } private function addAuthenticatedAvatarRoutes(RouteCollectorProxy $group): void diff --git a/lib/classes/JsonApi/Routes/StockImages/Authority.php b/lib/classes/JsonApi/Routes/StockImages/Authority.php index 77851fd1235938a144ee2fd818cbf799ec8d88fd..8b4b5372579a5766354ad702429010a8f495acd1 100644 --- a/lib/classes/JsonApi/Routes/StockImages/Authority.php +++ b/lib/classes/JsonApi/Routes/StockImages/Authority.php @@ -34,7 +34,7 @@ class Authority /** * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ - public static function canUpdateStockImage(User $user, StockImage $resource): bool + public static function canUpdateStockImage(User $user): bool { return self::canCreateStockImage($user); } @@ -42,7 +42,7 @@ class Authority /** * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ - public static function canUploadStockImage(User $user, StockImage $resource): bool + public static function canUploadStockImage(User $user): bool { return self::canCreateStockImage($user); } @@ -50,7 +50,7 @@ class Authority /** * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ - public static function canDeleteStockImage(User $user, StockImage $resource): bool + public static function canDeleteStockImage(User $user): bool { return self::canCreateStockImage($user); } diff --git a/lib/classes/JsonApi/Routes/StockImages/StockImagesDelete.php b/lib/classes/JsonApi/Routes/StockImages/StockImagesDelete.php index e0b5468e3aa7d3064537ca446f04893197708151..4487f4a472c8943fae3219ca1e441aa41bcab778 100644 --- a/lib/classes/JsonApi/Routes/StockImages/StockImagesDelete.php +++ b/lib/classes/JsonApi/Routes/StockImages/StockImagesDelete.php @@ -22,7 +22,7 @@ class StockImagesDelete extends JsonApiController throw new RecordNotFoundException(); } - if (!Authority::canDeleteStockImage($this->getUser($request), $resource)) { + if (!Authority::canDeleteStockImage($this->getUser($request))) { throw new AuthorizationFailedException(); } $resource->delete(); diff --git a/lib/classes/JsonApi/Routes/StockImages/StockImagesUpdate.php b/lib/classes/JsonApi/Routes/StockImages/StockImagesUpdate.php index e52370587a33d3c347a6c781c8836934b04a7840..942f7fdb2ce6806f9579b0d19cf67fc4533fc371 100644 --- a/lib/classes/JsonApi/Routes/StockImages/StockImagesUpdate.php +++ b/lib/classes/JsonApi/Routes/StockImages/StockImagesUpdate.php @@ -30,9 +30,9 @@ class StockImagesUpdate extends JsonApiController throw new RecordNotFoundException(); } - $json = $this->validate($request, $resource); + $json = $this->validate($request); $user = $this->getUser($request); - if (!Authority::canUpdateStockImage($user, $resource)) { + if (!Authority::canUpdateStockImage($user)) { throw new AuthorizationFailedException(); } $resource = $this->updateResource($resource, $json); diff --git a/lib/classes/JsonApi/Routes/StockImages/StockImagesUpload.php b/lib/classes/JsonApi/Routes/StockImages/StockImagesUpload.php index c42311794962398700d164506265e348e99f81f1..6cda4665d03f377b0cd4ca0fe4700ee5cee122ea 100644 --- a/lib/classes/JsonApi/Routes/StockImages/StockImagesUpload.php +++ b/lib/classes/JsonApi/Routes/StockImages/StockImagesUpload.php @@ -14,6 +14,7 @@ use Studip\StockImages\PaletteCreator; class StockImagesUpload extends NonJsonApiController { + use UploadHelpers; /** * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ @@ -24,7 +25,7 @@ class StockImagesUpload extends NonJsonApiController throw new RecordNotFoundException(); } - if (!Authority::canUploadStockImage($this->getUser($request), $resource)) { + if (!Authority::canUploadStockImage($this->getUser($request))) { throw new AuthorizationFailedException(); } @@ -36,9 +37,9 @@ class StockImagesUpload extends NonJsonApiController private function handleUpload(Request $request, \StockImage $resource): void { - $uploadedFile = $this->getUploadedFile($request); + $uploadedFile = self::getUploadedFile($request); if (UPLOAD_ERR_OK !== $uploadedFile->getError()) { - $error = $this->getErrorString($uploadedFile->getError()); + $error = self::getErrorString($uploadedFile->getError()); throw new BadRequestException($error); } @@ -58,58 +59,6 @@ class StockImagesUpload extends NonJsonApiController $resource->store(); } - private function getUploadedFile(Request $request): UploadedFileInterface - { - $files = iterator_to_array($this->getUploadedFiles($request)); - - if (0 === count($files)) { - throw new BadRequestException('File upload required.'); - } - - if (count($files) > 1) { - throw new BadRequestException('Multiple file upload not possible.'); - } - - $uploadedFile = reset($files); - if (UPLOAD_ERR_OK !== $uploadedFile->getError()) { - throw new BadRequestException('Upload error.'); - } - - return $uploadedFile; - } - - /** - * @return iterable<UploadedFileInterface> a list of uploaded files - */ - private function getUploadedFiles(Request $request): iterable - { - foreach ($request->getUploadedFiles() as $item) { - if (!is_array($item)) { - yield $item; - continue; - } - foreach ($item as $file) { - yield $file; - } - } - } - - private function getErrorString(int $errNo): string - { - $errors = [ - UPLOAD_ERR_OK => 'There is no error, the file uploaded with success', - UPLOAD_ERR_INI_SIZE => 'The uploaded file exceeds the upload_max_filesize directive in php.ini', - UPLOAD_ERR_FORM_SIZE => 'The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form', - UPLOAD_ERR_PARTIAL => 'The uploaded file was only partially uploaded', - UPLOAD_ERR_NO_FILE => 'No file was uploaded', - UPLOAD_ERR_NO_TMP_DIR => 'Missing a temporary folder', - UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk.', - UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the file upload.', - ]; - - return $errors[$errNo] ?? ''; - } - /** * @return string|null null, if the file is valid, otherwise a string containing the error */ diff --git a/lib/classes/JsonApi/Routes/StockImages/StockImagesZipUpload.php b/lib/classes/JsonApi/Routes/StockImages/StockImagesZipUpload.php new file mode 100644 index 0000000000000000000000000000000000000000..9cf24c795391a071637bc5dbbc38ddf32392e7c9 --- /dev/null +++ b/lib/classes/JsonApi/Routes/StockImages/StockImagesZipUpload.php @@ -0,0 +1,126 @@ +<?php + +namespace JsonApi\Routes\StockImages; + +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\BadRequestException; +use JsonApi\NonJsonApiController; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Nyholm\Psr7\UploadedFile; +use Studip\StockImages\Scaler; +use Studip\StockImages\PaletteCreator; + +class StockImagesZipUpload extends NonJsonApiController +{ + use UploadHelpers; + + public function __invoke(Request $request, Response $response, $args): Response + { + if (!Authority::canUploadStockImage($this->getUser($request))) { + throw new AuthorizationFailedException(); + } + $image_count = $this->handleUpload($request); + + $response = $response->withHeader('Content-Type', 'application/json'); + $response->getBody()->write(json_encode(['image-count' => $image_count])); + + return $response; + } + + private function handleUpload(Request $request): int + { + $uploadedFile = self::getUploadedFile($request); + if (UPLOAD_ERR_OK !== $uploadedFile->getError()) { + $error = self::getErrorString($uploadedFile->getError()); + throw new BadRequestException($error); + } + + $validateError = self::validate($uploadedFile); + if (!empty($validateError)) { + throw new BadRequestException($validateError); + } + + $tmp_path = $GLOBALS['TMP_PATH'] . '/stock-images/'; + + if (!file_exists($tmp_path)) { + mkdir($tmp_path); + } + $zip_path = $tmp_path . 'archiv.zip'; + $uploadedFile->moveTo($zip_path); + $zip = new \ZipArchive; + if ($zip->open($zip_path) === TRUE) { + $zip->extractTo($tmp_path); + $zip->close(); + } else { + $this->cleanTmp($tmp_path); + throw new BadRequestException('Can not extract Zip file.'); + } + $csv_file = file($tmp_path . 'meta.csv'); + if (!$csv_file) { + $this->cleanTmp($tmp_path); + throw new BadRequestException('No meta.csv file provided.'); + } + $rows = array_map( + fn($v) => str_getcsv($v, ';'), + $csv_file + ); + $header = array_shift($rows); + $images = []; + foreach ($rows as $row) { + $images[] = array_combine($header, $row); + } + + $image_counter = 0; + foreach ($images as $i => $meta) { + $filename = $meta['filename']; + if (!$filename) { + continue; + } + $filepath = $tmp_path . $filename; + $filesize = filesize($filepath); + $imagesize = getimagesize($filepath); + + $image = \StockImage::create([ + 'title' => $meta['title'] ?? 'unknown', + 'description' => $meta['description'] ?? '', + 'license' => $meta['license'] ?? '', + 'author' => $meta['author'] ?? '', + 'height' => $imagesize[1], + 'width' => $imagesize[0], + 'mime_type' => $imagesize['mime'], + 'size' => $filesize, + 'tags' => json_encode(explode(',', $meta['tags'])), + ]); + + copy($filepath, $image->getPath()); + $scaler = new \Studip\StockImages\Scaler(); + $scaler($image); + $paletteCreator = new \Studip\StockImages\PaletteCreator(); + $paletteCreator($image); + + $image_counter++; + } + + $this->cleanTmp($tmp_path); + + return $image_counter; + } + + private function cleanTmp(string $tmp_path): void + { + array_map('unlink', glob("$tmp_path/*.*")); + rmdir($tmp_path); + } + + /** + * @return string|null null, if the file is valid, otherwise a string containing the error + */ + private function validate(UploadedFile $file) + { + $mimeType = $file->getClientMediaType(); + if (!in_array($mimeType, ['application/x-zip-compressed', ' application/x-zip', 'application/zip'])) { + return 'Unsupported archive type.'; + } + } +} \ No newline at end of file diff --git a/lib/classes/JsonApi/Routes/StockImages/UploadHelpers.php b/lib/classes/JsonApi/Routes/StockImages/UploadHelpers.php new file mode 100644 index 0000000000000000000000000000000000000000..31e98dbe0fd22773b18ad47cd9db67f03adf9a2a --- /dev/null +++ b/lib/classes/JsonApi/Routes/StockImages/UploadHelpers.php @@ -0,0 +1,62 @@ +<?php + +namespace JsonApi\Routes\StockImages; + +use JsonApi\Errors\BadRequestException; +use Psr\Http\Message\ServerRequestInterface as Request; +use Nyholm\Psr7\UploadedFile; + +trait UploadHelpers +{ + protected static function getUploadedFile(Request $request): UploadedFile + { + $files = iterator_to_array(self::getUploadedFiles($request)); + + if (0 === count($files)) { + throw new BadRequestException('File upload required.'); + } + + if (count($files) > 1) { + throw new BadRequestException('Multiple file upload not possible.'); + } + + $uploadedFile = reset($files); + if (UPLOAD_ERR_OK !== $uploadedFile->getError()) { + throw new BadRequestException('Upload error.'); + } + + return $uploadedFile; + } + + /** + * @return iterable<UploadedFile> a list of uploaded files + */ + protected static function getUploadedFiles(Request $request): iterable + { + foreach ($request->getUploadedFiles() as $item) { + if (!is_array($item)) { + yield $item; + continue; + } + foreach ($item as $file) { + yield $file; + } + } + } + + protected static function getErrorString(int $errNo): string + { + $errors = [ + UPLOAD_ERR_OK => 'There is no error, the file uploaded with success', + UPLOAD_ERR_INI_SIZE => 'The uploaded file exceeds the upload_max_filesize directive in php.ini', + UPLOAD_ERR_FORM_SIZE => 'The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form', + UPLOAD_ERR_PARTIAL => 'The uploaded file was only partially uploaded', + UPLOAD_ERR_NO_FILE => 'No file was uploaded', + UPLOAD_ERR_NO_TMP_DIR => 'Missing a temporary folder', + UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk.', + UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the file upload.', + ]; + + return $errors[$errNo] ?? ''; + } +} \ No newline at end of file diff --git a/resources/vue/components/stock-images/ActionsWidget.vue b/resources/vue/components/stock-images/ActionsWidget.vue index 565c709db182a708d5723d946860a70a5d645ed4..07751a3a547f9812668c858501b621d55565ca93 100644 --- a/resources/vue/components/stock-images/ActionsWidget.vue +++ b/resources/vue/components/stock-images/ActionsWidget.vue @@ -6,6 +6,10 @@ <studip-icon shape="upload" class="widget-action-icon" /> <button @click="onUploadClick">{{ $gettext('Bild hinzufügen') }}</button> </li> + <li> + <studip-icon shape="import" class="widget-action-icon" /> + <button @click="onZipUploadClick">{{ $gettext('Bildersammlung importieren') }}</button> + </li> </ul> </template> </SidebarWidget> @@ -21,6 +25,9 @@ export default { onUploadClick() { this.$emit('initiateUpload'); }, + onZipUploadClick() { + this.$emit('initiateZipUpload'); + } }, }; </script> diff --git a/resources/vue/components/stock-images/Page.vue b/resources/vue/components/stock-images/Page.vue index 3d8ce54fc84491c4dc168c1f5be2dc3f554b41bb..3f06042b54fa5b1630636e2fe6f5fd2c7b2ba490 100644 --- a/resources/vue/components/stock-images/Page.vue +++ b/resources/vue/components/stock-images/Page.vue @@ -1,6 +1,14 @@ <template> - <div> - <ImagesPagination :per-page="perPage" :stock-images="filteredStockImages" v-model="page"> + <div class="stock-images-page"> + <studip-message-box v-if="showZipUploadMessage" :type="zipUploadMessageType"> + {{ zipUploadMessage }} + </studip-message-box> + <ImagesPagination + v-show="!showUploadIndicator" + :per-page="perPage" + :stock-images="filteredStockImages" + v-model="page" + > <ImagesList :checked-images="checkedImages" :page="page" @@ -12,11 +20,17 @@ @select="onSelectImage" /> </ImagesPagination> + <studip-progress-indicator + v-show="showUploadIndicator" + class="image-upload-indicator" + :description="$gettext('Bilder werden hochgeladen...')" + > + </studip-progress-indicator> <MountingPortal mountTo="#stock-images-widget" name="sidebar-stock-images"> <SearchWidget :query="query" @search="onSearch" /> <OrientationFilterWidget v-model="filters" /> <ColorFilterWidget v-model="filters" /> - <ActionsWidget @initiateUpload="onUploadDialogShow" /> + <ActionsWidget @initiateUpload="onUploadDialogShow" @initiateZipUpload="onZipUploadDialogShow" /> </MountingPortal> <EditDialog :stock-image="selectedImage" @@ -30,6 +44,11 @@ @confirm="onUploadDialogConfirm" @cancel="showUpload = false" /> + <ZipUploadDialog + :show="showZipUpload" + @confirm="onZipUploadDialogConfirm" + @cancel="showZipUpload = false" + ></ZipUploadDialog> </div> </template> @@ -43,7 +62,11 @@ import ImagesPagination from './ImagesPagination.vue'; import OrientationFilterWidget from './OrientationFilterWidget.vue'; import SearchWidget from './SearchWidget.vue'; import UploadDialog from './UploadDialog.vue'; +import ZipUploadDialog from './ZipUploadDialog.vue'; +import StudipMessageBox from '../StudipMessageBox.vue'; +import StudipProgressIndicator from '../StudipProgressIndicator.vue'; import { searchFilterAndSortImages } from './filters.js'; +import { $gettext } from '../../../assets/javascripts/lib/gettext'; export default { components: { @@ -55,6 +78,9 @@ export default { OrientationFilterWidget, SearchWidget, UploadDialog, + ZipUploadDialog, + StudipMessageBox, + StudipProgressIndicator, }, data: () => ({ checkedImages: [], @@ -67,6 +93,11 @@ export default { query: '', selectedImage: null, showUpload: false, + showZipUpload: false, + showZipUploadMessage: false, + zipUploadMessage: '', + zipUploadMessageType: 'success', + showUploadIndicator: false, }), computed: { ...mapGetters({ @@ -81,6 +112,7 @@ export default { methods: { ...mapActions({ createStockImage: 'studip/stockImages/create', + createStockImagesFromZip: 'studip/stockImages/createFromZip', loadStockImages: 'stock-images/loadWhere', updateStockImage: 'studip/stockImages/update', }), @@ -110,9 +142,44 @@ export default { console.error('Could not create stock image', error); }); }, + onZipUploadDialogConfirm({ file }) { + this.showZipUpload = false; + this.showUploadIndicator = true; + this.createStockImagesFromZip([file]) + .then((resp) => { + this.showUploadIndicator = false; + this.showZipUploadMessage = true; + this.zipUploadMessageType = 'success'; + this.zipUploadMessage = this.$gettextInterpolate( + this.$ngettext( + '%{length} Bild wurde hinzugefügt', + '%{length} Bilder wurden hinzugefügt', + resp.data['image-count'] + ), + { + length: resp.data['image-count'], + } + ); + this.$nextTick(() => { + this.fetchStockImages(); + }); + }) + .catch((error) => { + this.showUploadIndicator = false; + this.showZipUploadMessage = true; + this.zipUploadMessageType = 'error'; + this.zipUploadMessage = this.$gettext('Beim importieren der Bilder ist ein Fehler aufgetreten.'); + this.fetchStockImages(); + }); + }, onUploadDialogShow() { this.showUpload = true; }, + onZipUploadDialogShow() { + this.showZipUpload = true; + this.showZipUploadMessage = false; + this.zipUploadMessage = ''; + }, async fetchStockImages() { const loadLimit = 30; await this.loadPage(0, loadLimit); @@ -152,3 +219,13 @@ export default { }, }; </script> +<style lang="scss"> +.stock-images-page { + height: 100%; + + .image-upload-indicator { + top: 40%; + position: relative; + } +} +</style> diff --git a/resources/vue/components/stock-images/UploadBox.vue b/resources/vue/components/stock-images/UploadBox.vue index 80de74695b670b16022ca88afe796cd9e6050c0f..7eeb8631eeec512b36c091a35283ab1e368d11f2 100644 --- a/resources/vue/components/stock-images/UploadBox.vue +++ b/resources/vue/components/stock-images/UploadBox.vue @@ -5,9 +5,9 @@ <div class="icon-upload"> <studip-icon shape="upload" :size="100" alt="" :role="dragging ? 'info_alt' : 'clickable' "/> </div> - <strong>{{ $gettext('Bild auswählen oder per Drag & Drop hierher ziehen') }}</strong> + <strong>{{ text }}</strong> <div class="upload-button-holder"> - <input type="file" name="file" tabindex="-1" accept="image/*" ref="upload" + <input type="file" name="file" tabindex="-1" :accept="acceptedFileType" ref="upload" @change="onUpload" @dragenter="setDragging(true)" @dragleave="setDragging(false)" @@ -20,9 +20,30 @@ <script> export default { + props: { + type: { + type: String, + required: true, + validator: (type) => { + return ['image', '.zip'].includes(type); + }, + }, + text: { + type: String, + required: true, + } + }, data: () => ({ dragging: false, }), + computed: { + acceptedFileType() { + if (this.type === 'image') { + return 'image/gif, image/jpeg, image/png, image/webp'; + } + return this.type; + } + }, methods: { onUpload() { const files = this.$refs.upload.files; diff --git a/resources/vue/components/stock-images/UploadDialog.vue b/resources/vue/components/stock-images/UploadDialog.vue index d27e827510bb6b06b17fb3c6b09643f32654bf53..51040b265c9f6de44ac03741f429d71956aa5a63 100644 --- a/resources/vue/components/stock-images/UploadDialog.vue +++ b/resources/vue/components/stock-images/UploadDialog.vue @@ -10,7 +10,12 @@ > <template #dialogContent> <form id="stock-images-upload-form" class="default" @submit.prevent="onSubmit"> - <UploadBox v-if="state === STATES.IDLE" @upload="onUpload" /> + <UploadBox + v-if="state === STATES.IDLE" + type="image" + :text="$gettext('Bild auswählen oder per Drag & Drop hierher ziehen')" + @upload="onUpload" + /> <MetadataBox v-if="state === STATES.UPLOADED" :file="file" diff --git a/resources/vue/components/stock-images/ZipUploadDialog.vue b/resources/vue/components/stock-images/ZipUploadDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..e2818343f4fc55392d7d69242ee9f13b3f594f25 --- /dev/null +++ b/resources/vue/components/stock-images/ZipUploadDialog.vue @@ -0,0 +1,63 @@ +<template> + <studip-dialog + v-if="show" + height="270" + width="540" + :title="$gettext('Bildersammlung importieren')" + @close="onCancel" + closeClass="cancel" + :closeText="$gettext('Abbrechen')" + > + <template #dialogContent> + <form id="stock-images-zip-upload-form" class="default" @submit.prevent="onSubmit"> + <label> + {{ $gettext('Bildersammlung') }} + <input ref="upload_zip" type="file" accept=".zip" name="zip" class="cw-file-input" @change="checkUploadFile"> + </label> + </form> + </template> + <template #dialogButtons> + <button form="stock-images-zip-upload-form" type="submit" class="button accept" :disabled="!hasFile"> + {{ $gettext('Importieren') }} + </button> + </template> + </studip-dialog> +</template> + +<script> +export default { + name: 'ZipUploadDialog', + props: { + show: { + type: Boolean, + required: true, + }, + }, + data() { + return { + file: null, + }; + }, + computed: { + hasFile() { + return this.file !== null; + }, + }, + methods: { + onSubmit() { + this.$emit('confirm', { file: this.file }); + this.file = null; + }, + onCancel() { + this.$emit('cancel'); + }, + checkUploadFile() { + this.file = this.$refs?.upload_zip?.files[0]; + } + }, +}; +</script> +<style lang="scss"> +@import url('./../../../assets/stylesheets/scss/courseware/layouts/input-file.scss'); + +</style> diff --git a/resources/vue/store/stock-images.js b/resources/vue/store/stock-images.js index 4ba9a0bdef0aef7f06af0c59c077be6988915bd2..77aad9bb419404dd56d6d06bcff8cb86758bf42c 100644 --- a/resources/vue/store/stock-images.js +++ b/resources/vue/store/stock-images.js @@ -42,6 +42,14 @@ const actions = { return dispatch('stock-images/loadById', created, { root: true }); }, + async createFromZip({ dispatch, rootGetters, state }, [file]) { + const formData = new FormData(); + formData.append('zip', file); + const resp = await state.httpClient.post(`stock-images/zip`, formData); + + return resp; + }, + async update({ dispatch, rootGetters, state }, { stockImage, attributes }) { console.debug('stockImage', stockImage); stockImage.attributes = { ...stockImage.attributes, ...attributes };