Skip to content
Snippets Groups Projects
Commit 330ae492 authored by Ron Lucke's avatar Ron Lucke
Browse files

Archiv Upload für Bilder-Pool

Closes #4056

Merge request studip/studip!2904
parent 8bc88f75
No related branches found
No related tags found
No related merge requests found
Showing
with 386 additions and 67 deletions
......@@ -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
......
......@@ -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);
}
......
......@@ -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();
......
......@@ -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);
......
......@@ -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
*/
......
<?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
<?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
......@@ -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>
......
<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>
......@@ -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;
......
......@@ -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"
......
<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>
......@@ -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 };
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment