diff --git a/.docker/all-php.ini b/.docker/all-php.ini deleted file mode 100644 index ca8d0b189..000000000 --- a/.docker/all-php.ini +++ /dev/null @@ -1,3 +0,0 @@ -# Turn off serializing of PHP objects in YAML. -yaml.decode_php = 0 -yaml.decode_binary = 0 diff --git a/.docker/app.dockerfile b/.docker/app.dockerfile index bf76aaca2..2021257bf 100644 --- a/.docker/app.dockerfile +++ b/.docker/app.dockerfile @@ -1,11 +1,10 @@ -# PHP 8.1.13 -#FROM php:8.1-fpm-alpine -FROM php@sha256:88407bcb4821e7a9da273d9dad746e1f795e9a6480d9cba5ba502d7836e23718 -MAINTAINER Martin Zurowietz -LABEL org.opencontainers.image.source https://github.com/biigle/core +# PHP 8.2.21 +#FROM php:8.2-fpm-alpine +FROM php@sha256:95c34aeeef07aa9774e0b70d5b70065ab0647ece183ebe007c5f2e6b5db16725 +LABEL org.opencontainers.image.authors="Martin Zurowietz " +LABEL org.opencontainers.image.source="https://github.com/biigle/core" RUN ln -s "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" -ADD ".docker/all-php.ini" "$PHP_INI_DIR/conf.d/all.ini" ADD ".docker/app-php.ini" "$PHP_INI_DIR/conf.d/app.ini" RUN apk add --no-cache \ @@ -29,17 +28,7 @@ RUN apk add --no-cache \ RUN apk add --no-cache exiftool -# Configure proxy if there is any. See: https://stackoverflow.com/a/2266500/1796523 -RUN [ -z "$HTTP_PROXY" ] || pear config-set http_proxy $HTTP_PROXY -RUN apk add --no-cache yaml \ - && apk add --no-cache --virtual .build-deps g++ make autoconf yaml-dev \ - && pecl install yaml \ - && docker-php-ext-enable yaml \ - && apk del --purge .build-deps -# Unset proxy configuration again. -RUN [ -z "$HTTP_PROXY" ] || pear config-set http_proxy "" - -ARG PHPREDIS_VERSION=5.3.7 +ARG PHPREDIS_VERSION=6.0.2 RUN curl -L -o /tmp/redis.tar.gz https://github.com/phpredis/phpredis/archive/${PHPREDIS_VERSION}.tar.gz \ && tar -xzf /tmp/redis.tar.gz \ && rm /tmp/redis.tar.gz \ diff --git a/.docker/requirements.txt b/.docker/requirements.txt index faffb90b9..ba4e5d835 100644 --- a/.docker/requirements.txt +++ b/.docker/requirements.txt @@ -1,13 +1,13 @@ # This file is just used to get security alerts from GitHub. Make sure the versions match # in worker.dockerfile. numpy==1.24.* -opencv-contrib-python-headless==4.6.0 +opencv-contrib-python-headless==4.8.1.78 scipy==1.10.* scikit-learn==1.2.* matplotlib==3.6.* PyExcelerate==0.6.7 Pillow==10.3.0 Shapely==1.8.1 -torch==2.1.* -torchvision==0.16.* +torch==2.2.* +torchvision==0.17.* pandas==1.5.3 diff --git a/.docker/web.dockerfile b/.docker/web.dockerfile index 7260dc22d..aa1c55f47 100644 --- a/.docker/web.dockerfile +++ b/.docker/web.dockerfile @@ -1,7 +1,7 @@ # FROM nginx:1.21-alpine FROM nginx@sha256:5a0df7fb7c8c03e4158ae9974bfbd6a15da2bdfdeded4fb694367ec812325d31 -MAINTAINER Martin Zurowietz -LABEL org.opencontainers.image.source https://github.com/biigle/core +LABEL org.opencontainers.image.authors="Martin Zurowietz " +LABEL org.opencontainers.image.source="https://github.com/biigle/core" ADD .docker/vhost.conf /etc/nginx/conf.d/default.conf ADD .docker/ffdhe2048.txt /etc/nginx/conf.d/ffdhe2048.txt diff --git a/.docker/worker.dockerfile b/.docker/worker.dockerfile index 8147c4327..97c2cfc30 100644 --- a/.docker/worker.dockerfile +++ b/.docker/worker.dockerfile @@ -1,8 +1,8 @@ -# PHP 8.1.27 -# FROM php:8.1 -FROM php@sha256:9b5dfb7deef3e48d67b2599e4d3967bb3ece19fd5ba09cb8e7ee10f5facf36e0 -MAINTAINER Martin Zurowietz -LABEL org.opencontainers.image.source https://github.com/biigle/core +# PHP 8.2.21 +# FROM php:8.2 +FROM php@sha256:a61daae986bdf9bbeff9a514e3598a4f72bb2e3d01a0b3d0eff960bbfe85acdf +LABEL org.opencontainers.image.authors="Martin Zurowietz " +LABEL org.opencontainers.image.source="https://github.com/biigle/core" RUN LC_ALL=C.UTF-8 apt-get update \ && apt-get install -y --no-install-recommends \ @@ -21,17 +21,21 @@ RUN LC_ALL=C.UTF-8 apt-get update \ && rm -r /var/lib/apt/lists/* RUN ln -s "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" -ADD ".docker/all-php.ini" "$PHP_INI_DIR/conf.d/all.ini" +# Enable FFI for jcupitt/vips. +# See: https://github.com/libvips/php-vips?tab=readme-ov-file#install +RUN echo "ffi.enable = true" > "$PHP_INI_DIR/conf.d/vips.ini" RUN LC_ALL=C.UTF-8 apt-get update \ && apt-get install -y --no-install-recommends \ libxml2-dev \ libzip-dev \ libpq-dev \ + libffi-dev \ && apt-get install -y --no-install-recommends \ libxml2 \ libzip4 \ postgresql-client \ + libffi8 \ && docker-php-ext-configure pgsql -with-pgsql=/usr/bin/pgsql \ && docker-php-ext-install -j$(nproc) \ exif \ @@ -41,10 +45,12 @@ RUN LC_ALL=C.UTF-8 apt-get update \ pgsql \ soap \ zip \ + ffi \ && apt-get purge -y \ libxml2-dev \ libzip-dev \ libpq-dev \ + libffi-dev \ && apt-get -y autoremove \ && apt-get clean \ && rm -r /var/lib/apt/lists/* @@ -52,20 +58,7 @@ RUN LC_ALL=C.UTF-8 apt-get update \ # Configure proxy if there is any. See: https://stackoverflow.com/a/2266500/1796523 RUN [ -z "$HTTP_PROXY" ] || pear config-set http_proxy $HTTP_PROXY -RUN LC_ALL=C.UTF-8 apt-get update \ - && apt-get install -y --no-install-recommends \ - libyaml-dev \ - && apt-get install -y --no-install-recommends \ - libyaml-0-2 \ - && pecl install yaml \ - && printf "\n" | docker-php-ext-enable yaml \ - && apt-get purge -y \ - libyaml-dev \ - && apt-get -y autoremove \ - && apt-get clean \ - && rm -r /var/lib/apt/lists/* - -ARG PHPREDIS_VERSION=5.3.7 +ARG PHPREDIS_VERSION=6.0.2 RUN curl -L -o /tmp/redis.tar.gz https://github.com/phpredis/phpredis/archive/${PHPREDIS_VERSION}.tar.gz \ && tar -xzf /tmp/redis.tar.gz \ && rm /tmp/redis.tar.gz \ @@ -73,18 +66,8 @@ RUN curl -L -o /tmp/redis.tar.gz https://github.com/phpredis/phpredis/archive/${ && mv phpredis-${PHPREDIS_VERSION} /usr/src/php/ext/redis \ && docker-php-ext-install -j$(nproc) redis -# ENV PKG_CONFIG_PATH="/usr/local/lib/pkgconfig:${PKG_CONFIG_PATH}" - RUN LC_ALL=C.UTF-8 apt-get update \ - && apt-get install -y --no-install-recommends \ - libvips-dev \ - && apt-get install -y --no-install-recommends \ - libvips42 \ - && pecl install vips \ - && docker-php-ext-enable vips \ - && apt-get purge -y \ - libvips-dev \ - && apt-get -y autoremove \ + && apt-get install -y --no-install-recommends libvips42 \ && apt-get clean \ && rm -r /var/lib/apt/lists/* @@ -98,8 +81,8 @@ RUN LC_ALL=C.UTF-8 apt-get update \ PyExcelerate==0.6.7 \ Pillow==10.2.0 \ && pip3 install --no-cache-dir --break-system-packages --index-url https://download.pytorch.org/whl/cpu \ - torch==2.1.* \ - torchvision==0.16.* \ + torch==2.2.* \ + torchvision==0.17.* \ && apt-get purge -y \ python3-pip \ && apt-get -y autoremove \ diff --git a/.env.example b/.env.example index 95fc1dfb5..bfaa548c0 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,17 @@ APP_DEBUG=true APP_URL="http://localhost:8000" APP_TIMEZONE="Europe/Berlin" +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file +# APP_MAINTENANCE_STORE=database + +BCRYPT_ROUNDS=12 + LOG_CHANNEL=single +LOG_STACK=single LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug @@ -40,23 +50,29 @@ MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" #HTTP_PROXY=host:port # normally you don't have to edit these -CACHE_DRIVER="file" -FILESYSTEM_DISK=local SESSION_DRIVER="file" SESSION_LIFETIME=120 SESSION_SECURE_COOKIE=false -QUEUE_CONNECTION="database" +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database + +CACHE_STORE=file +CACHE_PREFIX= MEMCACHED_HOST=127.0.0.1 +REDIS_CLIENT=phpredis REDIS_HOST="127.0.0.1" REDIS_PASSWORD=null REDIS_PORT=6379 -# see config/mail.php for what drivers are available -# default is the PHP mail function which doesn't require any credentials -MAIL_MAILER="log" -MAIL_HOST="smtp.mailtrap.io" +MAIL_MAILER=log +MAIL_HOST=127.0.0.1 MAIL_PORT=2525 MAIL_USERNAME=null MAIL_PASSWORD=null diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d3030564e..f2a28ef7e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -35,7 +35,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.1' + php-version: '8.2' - uses: actions/checkout@v1 with: @@ -50,8 +50,8 @@ jobs: - name: Set testing key run: echo "APP_KEY=base64:STZFA4bQKDjE2mlpRPmsJ/okG0eCh4RHd9BghtZeYmQ=" >> .env - - name: Run Psalm - run: composer lint + - name: Run Linter + run: composer lint -- --error-format=github cs-php: @@ -61,7 +61,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.1' + php-version: '8.2' tools: cs2pr - uses: actions/checkout@v1 diff --git a/.gitignore b/.gitignore index de7c717d3..2cf34b81e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ /public/vendor /storage/*.key /storage/largo_patches +/storage/metadata +/storage/pending-metadata /vendor .env .env.backup diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index 45a52dcba..1a0af90a7 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -14,6 +14,7 @@ ->ignoreVCS(true); return (new PhpCsFixer\Config()) + ->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect()) ->setRules([ '@PSR2' => true, 'ordered_imports' => true, diff --git a/DEVELOPING.md b/DEVELOPING.md index f5fadd97f..e9f514615 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -7,14 +7,15 @@ To develop BIIGLE on your local machine you can use Docker containers. This way First, install the following software: - PHP >= 8.0 -- [Docker](https://docs.docker.com/install/) +- [Docker Engine](https://docs.docker.com/engine/install/) - [Docker Compose](https://docs.docker.com/compose/install/) - [Composer](https://getcomposer.org/doc/00-intro.md#installation-linux-unix-macos) - Recommended: The `unzip` tool -On Linux: Make sure to add your user to the new `docker` group with `sudo usermod -aG docker $(whoami)`, then log out and back in. Otherwise you have to call all `docker` or `docker compose` commands with `sudo`. +> [!IMPORTANT] +> The link above points to Docker Engine on purpose. Docker Desktop (on Linux) may have problems mounting the filesystem with the correct permissions. -**Note:** Older versions of Docker Compose used the `docker-compose` command. Newer versions use `docker compose`. +On Linux: Make sure to add your user to the new `docker` group with `sudo usermod -aG docker $(whoami)`, then log out and back in. Otherwise you have to call all `docker` or `docker compose` commands with `sudo`. Now you can proceed with the development setup: diff --git a/app/Annotation.php b/app/Annotation.php index 440edd9a2..b915d69f4 100644 --- a/app/Annotation.php +++ b/app/Annotation.php @@ -11,6 +11,11 @@ /** * An image annotation is a region of an image that can be labeled by the users. * It consists of one or many points and has a specific shape. + * + * @property int $id + * @property array $points + * @property string $created_at + * @property int $shape_id */ abstract class Annotation extends Model implements AnnotationContract { @@ -19,7 +24,7 @@ abstract class Annotation extends Model implements AnnotationContract /** * The attributes excluded from the model's JSON form. * - * @var array + * @var array */ protected $hidden = [ 'pivot', @@ -28,7 +33,7 @@ abstract class Annotation extends Model implements AnnotationContract /** * The attributes that should be casted to native types. * - * @var array + * @var array */ protected $casts = [ 'points' => 'array', @@ -40,7 +45,7 @@ abstract class Annotation extends Model implements AnnotationContract * @param \Illuminate\Database\Query\Builder $query * @param User $user The user to whom the restrictions should apply ('own' user) * - * @return \Illuminate\Database\Eloquent\Builder + * @return \Illuminate\Database\Query\Builder */ public function scopeVisibleFor($query, User $user) { @@ -73,7 +78,7 @@ public function scopeVisibleFor($query, User $user) * @param \Illuminate\Database\Query\Builder $query * @param Label $label * - * @return \Illuminate\Database\Eloquent\Builder + * @return \Illuminate\Database\Query\Builder */ public function scopeWithLabel($query, Label $label) { @@ -93,7 +98,7 @@ public function scopeWithLabel($query, Label $label) * @param \Illuminate\Database\Query\Builder $query * @param AnnotationSession $session * @param User $user The user to whom the restrictions should apply ('own' user) - * @return \Illuminate\Database\Eloquent\Builder + * @return \Illuminate\Database\Query\Builder */ public function scopeAllowedBySession($query, AnnotationSession $session, User $user) { @@ -153,14 +158,14 @@ public function scopeAllowedBySession($query, AnnotationSession $session, User $ /** * The file, this annotation belongs to. * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ abstract public function file(); /** * The labels, this annotation got assigned by the users. * - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @return \Illuminate\Database\Eloquent\Relations\HasMany */ abstract public function labels(); @@ -174,7 +179,7 @@ abstract public function getFileIdAttribute(); /** * The shape of this annotation. * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function shape() { diff --git a/app/AnnotationLabel.php b/app/AnnotationLabel.php index c630ccd64..754dc2139 100644 --- a/app/AnnotationLabel.php +++ b/app/AnnotationLabel.php @@ -5,6 +5,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +/** + * @property int $id + * @property int $annotation_id + * @property int $user_id + * @property int $label_id + */ abstract class AnnotationLabel extends Model { use HasFactory; @@ -12,7 +18,7 @@ abstract class AnnotationLabel extends Model /** * The attributes that are mass assignable. * - * @var array + * @var array */ protected $fillable = [ 'label_id', @@ -23,7 +29,7 @@ abstract class AnnotationLabel extends Model /** * The attributes that should be casted to native types. * - * @var array + * @var array */ protected $casts = [ 'user_id' => 'int', @@ -33,14 +39,14 @@ abstract class AnnotationLabel extends Model /** * The annotation, this annotation label belongs to. * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ abstract public function annotation(); /** * The label, this annotation label belongs to. * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function label() { @@ -50,7 +56,7 @@ public function label() /** * The user who created this annotation label. * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function user() { diff --git a/app/AnnotationSession.php b/app/AnnotationSession.php index f583681ed..26bf601fc 100644 --- a/app/AnnotationSession.php +++ b/app/AnnotationSession.php @@ -18,7 +18,7 @@ class AnnotationSession extends Model /** * The attributes that should be casted to native types. * - * @var array + * @var array */ protected $casts = [ 'starts_at' => 'datetime', @@ -31,7 +31,7 @@ class AnnotationSession extends Model /** * The accessors to append to the model's array form. * - * @var array + * @var array */ protected $appends = [ 'starts_at_iso8601', @@ -41,7 +41,7 @@ class AnnotationSession extends Model /** * The volume, this annotation session belongs to. * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function volume() { @@ -51,7 +51,7 @@ public function volume() /** * The users, this annotation session is restricted to. * - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function users() { @@ -65,7 +65,7 @@ public function users() * @param VolumeFile $file The file to get the annotations from * @param User $user The user to whom the restrictions should apply ('own' user) * - * @return \Illuminate\Support\Collection + * @return \Illuminate\Database\Eloquent\Collection */ public function getVolumeFileAnnotations(VolumeFile $file, User $user) { @@ -113,7 +113,7 @@ public function getVolumeFileAnnotations(VolumeFile $file, User $user) * * This is **not** an Eloquent relation! * - * @return \Illuminate\Database\Eloquent\Builder + * @return \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Eloquent\Builder */ public function annotations() { diff --git a/app/Announcement.php b/app/Announcement.php index 78acd2607..1a4b276ca 100644 --- a/app/Announcement.php +++ b/app/Announcement.php @@ -22,14 +22,14 @@ class Announcement extends Model /** * The attributes that are mass assignable. * - * @var array + * @var array */ protected $fillable = ['title', 'show_until', 'body']; /** * The attributes that should be casted to native types. * - * @var array + * @var array */ protected $casts = [ 'show_until' => 'datetime', diff --git a/app/ApiToken.php b/app/ApiToken.php index 8f3aa4cbe..46bc6a133 100644 --- a/app/ApiToken.php +++ b/app/ApiToken.php @@ -12,7 +12,7 @@ class ApiToken extends Model /** * The attributes excluded from the model's JSON form. * - * @var array + * @var array */ protected $hidden = [ 'hash', @@ -21,7 +21,7 @@ class ApiToken extends Model /** * The user, this token belongs to. * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function owner() { diff --git a/app/Console/Commands/MigrateTiledImages.php b/app/Console/Commands/MigrateTiledImages.php index c1b014910..72ff13dac 100644 --- a/app/Console/Commands/MigrateTiledImages.php +++ b/app/Console/Commands/MigrateTiledImages.php @@ -38,7 +38,7 @@ public function handle() $query = Image::where('tiled', true); $bar = $this->output->createProgressBar($query->count()); - $query->eachById(function ($image) use ($dryRun, $bar, $disk) { + $query->eachById(function (Image $image) use ($dryRun, $bar, $disk) { if (!$dryRun) { Queue::push(new MigrateTiledImage($image, $disk)); } diff --git a/app/Console/Commands/UpdateImageMetadata.php b/app/Console/Commands/UpdateImageMetadata.php deleted file mode 100644 index 3e3097db1..000000000 --- a/app/Console/Commands/UpdateImageMetadata.php +++ /dev/null @@ -1,101 +0,0 @@ -dryRun = $this->option('dry-run'); - $this->stripTileProperties(); - $this->dispatchReprocessJobs(); - } - - /** - * Removes the tileProperties attribute from images. - */ - protected function stripTileProperties() - { - $images = Image::whereRaw("jsonb_exists(attrs::jsonb, 'tileProperties')") - ->select('id', 'attrs') - ->get(); - - $this->line('Removing obsolete image tileProperties.'); - - $total = $images->count(); - - if ($total > 0) { - $remaining = $total; - $bar = $this->output->createProgressBar($total); - - foreach ($images as $image) { - $attrs = $image->attrs; - unset($attrs['tileProperties']); - $image->attrs = $attrs; - if (!$this->dryRun) { - $image->save(); - } - - $remaining--; - - if ($remaining % 1000 === 0) { - $bar->setProgress($total - $remaining); - } - } - - $bar->setProgress($total); - $bar->finish(); - $this->line(''); - } - - $this->info('Done.'); - } - - /** - * Dispatches jobs to process all images anew. - */ - protected function dispatchReprocessJobs() - { - $volumes = Volume::select('id')->get(); - $this->line('Submitting jobs to reprocess images.'); - - foreach ($volumes as $volume) { - if (!$this->dryRun) { - $volume->handleNewImages(); - } - } - - $this->info('Done.'); - } -} diff --git a/app/Console/Commands/UpdateVideoMetadata.php b/app/Console/Commands/UpdateVideoMetadata.php index feeb1588d..400c62d78 100644 --- a/app/Console/Commands/UpdateVideoMetadata.php +++ b/app/Console/Commands/UpdateVideoMetadata.php @@ -38,7 +38,7 @@ class UpdateVideoMetadata extends Command /** * The FFProbe video instance. * - * @var FFProbe + * @var FFProbe|null */ protected $ffprobe; diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 225da6d57..028e9e1ac 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -35,7 +35,7 @@ public function register(): void * Prepare exception for rendering. * * @param Throwable $e - * @return \Exception + * @return Throwable */ protected function prepareException(Throwable $e) { diff --git a/app/Facades/VipsImage.php b/app/Facades/VipsImage.php deleted file mode 100644 index 0ce3a9428..000000000 --- a/app/Facades/VipsImage.php +++ /dev/null @@ -1,18 +0,0 @@ - */ protected $fillable = [ 'name', @@ -28,7 +28,7 @@ class FederatedSearchInstance extends Model implements AuthenticatableContract /** * The attributes excluded from the model's JSON form. * - * @var array + * @var array */ protected $hidden = [ 'local_token', @@ -38,7 +38,7 @@ class FederatedSearchInstance extends Model implements AuthenticatableContract /** * The attributes that should be casted to native types. * - * @var array + * @var array */ protected $casts = [ 'index_interval' => 'int', @@ -72,7 +72,7 @@ public function scopeWithRemoteToken($query) /** * The models that belong to this instance. * - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function models() { @@ -100,7 +100,7 @@ public function setRemoteTokenAttribute($value) */ public function getRemoteTokenAttribute() { - if (!isset($this->attributes['remote_token']) || is_null($this->attributes['remote_token'])) { + if (!isset($this->attributes['remote_token'])) { return null; } else { return decrypt($this->attributes['remote_token']); diff --git a/app/FederatedSearchModel.php b/app/FederatedSearchModel.php index 38e5095ef..d89f6bb29 100644 --- a/app/FederatedSearchModel.php +++ b/app/FederatedSearchModel.php @@ -13,7 +13,7 @@ class FederatedSearchModel extends Model /** * The attributes that should be casted to native types. * - * @var array + * @var array */ protected $casts = [ 'attrs' => 'array', @@ -58,7 +58,7 @@ public function scopeVolumes($query) /** * The instance, this model belongs to. * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function instance() { @@ -68,7 +68,7 @@ public function instance() /** * The users who can access this model. * - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function users() { diff --git a/app/Http/Controllers/Api/AnnotationSessionController.php b/app/Http/Controllers/Api/AnnotationSessionController.php index 424e94a7d..f64b72d39 100644 --- a/app/Http/Controllers/Api/AnnotationSessionController.php +++ b/app/Http/Controllers/Api/AnnotationSessionController.php @@ -42,11 +42,10 @@ class AnnotationSessionController extends Controller * } * * @param UpdateAnnotationSession $request - * @return \Illuminate\Http\Response */ public function update(UpdateAnnotationSession $request) { - $session = $request->session; + $session = $request->annotationSession; if ($request->filled('starts_at')) { $newStartsAt = Carbon::parse($request->input('starts_at')) @@ -157,14 +156,13 @@ public function update(UpdateAnnotationSession $request) * * @param DestroyAnnotationSession $request * @param int $id - * @return \Illuminate\Http\Response */ public function destroy(DestroyAnnotationSession $request, $id) { - if (!$request->input('force') && $request->session->annotations()->exists()) { + if (!$request->input('force') && $request->annotationSession->annotations()->exists()) { abort(400, 'There are annotations belonging to this annotation session. Use the force attribute to delete it anyway (the annotations will not be deleted).'); } - $request->session->delete(); + $request->annotationSession->delete(); } } diff --git a/app/Http/Controllers/Api/Annotations/Filters/AnnotationController.php b/app/Http/Controllers/Api/Annotations/Filters/AnnotationController.php index 37ed83410..7a7094073 100644 --- a/app/Http/Controllers/Api/Annotations/Filters/AnnotationController.php +++ b/app/Http/Controllers/Api/Annotations/Filters/AnnotationController.php @@ -26,7 +26,7 @@ class AnnotationController extends Controller * * @param Request $request * @param int $id - * @return \Illuminate\Http\Response + * @return \Illuminate\Support\Collection */ public function index(Request $request, $id) { diff --git a/app/Http/Controllers/Api/Annotations/Filters/AnnotationUserController.php b/app/Http/Controllers/Api/Annotations/Filters/AnnotationUserController.php index 19af1e5ff..e0768ada2 100644 --- a/app/Http/Controllers/Api/Annotations/Filters/AnnotationUserController.php +++ b/app/Http/Controllers/Api/Annotations/Filters/AnnotationUserController.php @@ -28,7 +28,7 @@ class AnnotationUserController extends Controller * @param Request $request * @param int $tid * @param int $uid - * @return \Illuminate\Http\Response + * @return \Illuminate\Support\Collection */ public function index(Request $request, $tid, $uid) { diff --git a/app/Http/Controllers/Api/Annotations/VolumeAnnotationLabelController.php b/app/Http/Controllers/Api/Annotations/VolumeAnnotationLabelController.php index 49219fb9e..ef550cd1d 100644 --- a/app/Http/Controllers/Api/Annotations/VolumeAnnotationLabelController.php +++ b/app/Http/Controllers/Api/Annotations/VolumeAnnotationLabelController.php @@ -39,7 +39,7 @@ class VolumeAnnotationLabelController extends Controller * ] * * @param int $id - * @return \Illuminate\Http\Response + * @return \Illuminate\Database\Eloquent\Collection */ public function index($id) { diff --git a/app/Http/Controllers/Api/Annotations/VolumeImageAreaController.php b/app/Http/Controllers/Api/Annotations/VolumeImageAreaController.php index 3a42452d9..e9c06fd0f 100644 --- a/app/Http/Controllers/Api/Annotations/VolumeImageAreaController.php +++ b/app/Http/Controllers/Api/Annotations/VolumeImageAreaController.php @@ -26,7 +26,7 @@ class VolumeImageAreaController extends Controller * } * * @param int $id - * @return \Illuminate\Http\Response + * @return \Illuminate\Support\Collection */ public function index($id) { diff --git a/app/Http/Controllers/Api/AnnouncementController.php b/app/Http/Controllers/Api/AnnouncementController.php index 2156e071a..3c6c548ea 100644 --- a/app/Http/Controllers/Api/AnnouncementController.php +++ b/app/Http/Controllers/Api/AnnouncementController.php @@ -21,7 +21,7 @@ class AnnouncementController extends Controller * @apiParam (Optional parameters) {Number} show_until Date and time until the announcement should be shown. Only one announcement can be shown at a time. If not specified, the announcement will be shown indefinitely. * * @param StoreAnnouncement $request - * @return Announcement + * @return Announcement|\Illuminate\Http\RedirectResponse */ public function store(StoreAnnouncement $request) { @@ -47,7 +47,7 @@ public function store(StoreAnnouncement $request) * @apiParam {Number} id The announcement ID. * * @param int $id - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse|null */ public function destroy($id) { diff --git a/app/Http/Controllers/Api/ApiTokenController.php b/app/Http/Controllers/Api/ApiTokenController.php index 83bf925dd..f0d0f3819 100644 --- a/app/Http/Controllers/Api/ApiTokenController.php +++ b/app/Http/Controllers/Api/ApiTokenController.php @@ -40,7 +40,7 @@ public function __construct() * ] * * @param Request $request - * @return \Illuminate\Http\Response + * @return \Illuminate\Database\Eloquent\Collection */ public function index(Request $request) { @@ -72,7 +72,7 @@ public function index(Request $request) * } * * @param Request $request - * @return \Illuminate\Http\Response + * @return ApiToken|\Illuminate\Http\RedirectResponse */ public function store(Request $request) { @@ -108,7 +108,7 @@ public function store(Request $request) * * @param Request $request * @param int $id - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse|null */ public function destroy(Request $request, $id) { diff --git a/app/Http/Controllers/Api/FederatedSearchInstanceController.php b/app/Http/Controllers/Api/FederatedSearchInstanceController.php index 24025486a..85de3fbb1 100644 --- a/app/Http/Controllers/Api/FederatedSearchInstanceController.php +++ b/app/Http/Controllers/Api/FederatedSearchInstanceController.php @@ -31,7 +31,7 @@ class FederatedSearchInstanceController extends Controller * } * * @param StoreFederatedSearchInstance $request - * @return \Illuminate\Http\Response + * @return FederatedSearchInstance|\Illuminate\Http\RedirectResponse */ public function store(StoreFederatedSearchInstance $request) { @@ -61,7 +61,7 @@ public function store(StoreFederatedSearchInstance $request) * @apiParam (Attributes that can be updated) {Boolean} local_token Set to `true` to (re-)set a new token that can be used by the remote instance to authenticate to the local instance. A new token is returned only once in plain text as `new_local_token`. Set this attribute to `false` to clear the local token and thus deny access by the remote instance. * * @param UpdateFederatedSearchInstance $request - * @return \Illuminate\Http\Response + * @return FederatedSearchInstance|\Illuminate\Http\RedirectResponse */ public function update(UpdateFederatedSearchInstance $request) { @@ -94,6 +94,7 @@ public function update(UpdateFederatedSearchInstance $request) if ($this->isAutomatedRequest()) { if (isset($token)) { + /** @phpstan-ignore-next-line */ $instance->new_local_token = $token; } @@ -120,7 +121,7 @@ public function update(UpdateFederatedSearchInstance $request) * @apiParam {Number} id ID of the instance to disconnect * * @param int $id - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse|null */ public function destroy($id) { diff --git a/app/Http/Controllers/Api/ImageAnnotationBulkController.php b/app/Http/Controllers/Api/ImageAnnotationBulkController.php index c22472999..0d5aa982e 100644 --- a/app/Http/Controllers/Api/ImageAnnotationBulkController.php +++ b/app/Http/Controllers/Api/ImageAnnotationBulkController.php @@ -90,18 +90,22 @@ public function store(StoreImageAnnotations $request) $annotation->points = $input['points']; $annotation->image_id = $input['image_id']; + /** @phpstan-ignore property.notFound */ $annotation->label_id = $input['label_id']; + /** @phpstan-ignore property.notFound */ $annotation->confidence = $input['confidence']; return $annotation; }); DB::transaction(function () use ($request, $annotations) { - $annotations->each(function ($annotation) use ($request) { + $annotations->each(function (ImageAnnotation $annotation) use ($request) { + /** @phpstan-ignore property.notFound */ $label = $request->labels[$annotation->label_id]; + /** @phpstan-ignore property.notFound */ $confidence = $annotation->confidence; unset($annotation->label_id, $annotation->confidence); - + $this->authorize('attach-label', [$annotation, $label]); $annotation->save(); diff --git a/app/Http/Controllers/Api/ImageAnnotationController.php b/app/Http/Controllers/Api/ImageAnnotationController.php index 02e118401..64461ec36 100644 --- a/app/Http/Controllers/Api/ImageAnnotationController.php +++ b/app/Http/Controllers/Api/ImageAnnotationController.php @@ -60,7 +60,7 @@ class ImageAnnotationController extends Controller * * @param Request $request * @param int $id image id - * @return \Illuminate\Http\Response + * @return \Illuminate\Database\Eloquent\Collection */ public function index(Request $request, $id) { @@ -266,7 +266,6 @@ public function store(StoreImageAnnotation $request) * * @param Request $request * @param int $id - * @return \Illuminate\Http\Response */ public function update(Request $request, $id) { diff --git a/app/Http/Controllers/Api/ImageAnnotationLabelController.php b/app/Http/Controllers/Api/ImageAnnotationLabelController.php index 357a19e7d..1e233f6d6 100644 --- a/app/Http/Controllers/Api/ImageAnnotationLabelController.php +++ b/app/Http/Controllers/Api/ImageAnnotationLabelController.php @@ -73,7 +73,7 @@ class ImageAnnotationLabelController extends Controller * ] * * @param int $id ImageAnnotation ID - * @return \Illuminate\Http\Response + * @return \Illuminate\Database\Eloquent\Collection */ public function index($id) { @@ -161,7 +161,7 @@ public function index($id) * } * * @param StoreImageAnnotationLabel $request - * @return \Illuminate\Http\Response + * @return ImageAnnotationLabel|null */ public function store(StoreImageAnnotationLabel $request) { @@ -187,7 +187,7 @@ public function store(StoreImageAnnotationLabel $request) AnnotationLabelAttached::dispatch($annotationLabel); - return response($annotationLabel, 201); + return $annotationLabel; } catch (QueryException $e) { // Although we check for existence above, this error happened some time. // I suspect some kind of race condition between PHP FPM workers. diff --git a/app/Http/Controllers/Api/ImageController.php b/app/Http/Controllers/Api/ImageController.php index 2d5e32e1e..fe9ccee14 100644 --- a/app/Http/Controllers/Api/ImageController.php +++ b/app/Http/Controllers/Api/ImageController.php @@ -54,7 +54,7 @@ public function show($id) * @apiParam {Number} id The image ID. * * @param int $id image id - * @return \Illuminate\Http\Response + * @return array|\Illuminate\Http\RedirectResponse|\Symfony\Component\HttpFoundation\StreamedResponse */ public function showFile($id) { diff --git a/app/Http/Controllers/Api/ImageLabelController.php b/app/Http/Controllers/Api/ImageLabelController.php index 8d5593385..a11bd207a 100644 --- a/app/Http/Controllers/Api/ImageLabelController.php +++ b/app/Http/Controllers/Api/ImageLabelController.php @@ -72,7 +72,7 @@ class ImageLabelController extends VolumeFileLabelController * } * * @param StoreImageLabel $request - * @return \Illuminate\Http\Response + * @return \Biigle\VolumeFileLabel */ public function store(StoreImageLabel $request) { diff --git a/app/Http/Controllers/Api/LabelController.php b/app/Http/Controllers/Api/LabelController.php index 37b5f696b..51a771fd8 100644 --- a/app/Http/Controllers/Api/LabelController.php +++ b/app/Http/Controllers/Api/LabelController.php @@ -22,7 +22,6 @@ class LabelController extends Controller * @apiParam (Attributes that can be updated) {Number} parent_id ID of the parent label for ordering in a tree-like structure. * * @param UpdateLabel $request - * @return \Illuminate\Http\Response */ public function update(UpdateLabel $request) { @@ -47,7 +46,6 @@ public function update(UpdateLabel $request) * @apiParam {Number} id The label ID * * @param DestroyLabel $request - * @return \Illuminate\Http\Response */ public function destroy(DestroyLabel $request) { diff --git a/app/Http/Controllers/Api/LabelSourceController.php b/app/Http/Controllers/Api/LabelSourceController.php index df9adcc00..f2f3ec432 100644 --- a/app/Http/Controllers/Api/LabelSourceController.php +++ b/app/Http/Controllers/Api/LabelSourceController.php @@ -20,7 +20,7 @@ class LabelSourceController extends Controller * * @param Request $request * @param int $id - * @return \Illuminate\Http\Response + * @return array */ public function find(Request $request, $id) { diff --git a/app/Http/Controllers/Api/LabelTreeAuthorizedProjectController.php b/app/Http/Controllers/Api/LabelTreeAuthorizedProjectController.php index 944ad83d6..8d3adaf3c 100644 --- a/app/Http/Controllers/Api/LabelTreeAuthorizedProjectController.php +++ b/app/Http/Controllers/Api/LabelTreeAuthorizedProjectController.php @@ -23,7 +23,7 @@ class LabelTreeAuthorizedProjectController extends Controller * @apiParam (Required attributes) {Number} id ID of the project to authorize * * @param StoreLabelTreeAuthorizedProject $request - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse|null */ public function store(StoreLabelTreeAuthorizedProject $request) { @@ -67,7 +67,7 @@ public function store(StoreLabelTreeAuthorizedProject $request) * @param Request $request * @param int $lid * @param int $pid - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse|null */ public function destroy(Request $request, $lid, $pid) { diff --git a/app/Http/Controllers/Api/LabelTreeController.php b/app/Http/Controllers/Api/LabelTreeController.php index 6624c82be..abf2c6b95 100644 --- a/app/Http/Controllers/Api/LabelTreeController.php +++ b/app/Http/Controllers/Api/LabelTreeController.php @@ -34,7 +34,7 @@ class LabelTreeController extends Controller * ] * * @param Request $request - * @return \Illuminate\Http\Response + * @return \Illuminate\Database\Eloquent\Collection */ public function index(Request $request) { @@ -94,14 +94,16 @@ public function index(Request $request) * } * * - * @return \Illuminate\Http\Response + * @return LabelTree */ public function show($id) { $tree = LabelTree::findOrFail($id); $this->authorize('access', $tree); - return $tree->load('labels', 'members', 'version', 'versions'); + $tree->load(['labels', 'members', 'version', 'versions']); + + return $tree; } /** @@ -132,7 +134,7 @@ public function show($id) * } * * @param StoreLabelTree $request - * @return \Illuminate\Http\Response + * @return LabelTree|\Illuminate\Http\RedirectResponse */ public function store(StoreLabelTree $request) { @@ -184,7 +186,7 @@ public function store(StoreLabelTree $request) * @apiParam (Attributes that can be updated) {Number} visibility_id ID of the new visibility of the label tree (public or private). * * @param UpdateLabelTree $request - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse|null */ public function update(UpdateLabelTree $request) { @@ -236,7 +238,7 @@ public function update(UpdateLabelTree $request) * @apiParam {Number} id The label tree ID. * * @param DestroyLabelTree $request - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse|null */ public function destroy(DestroyLabelTree $request) { diff --git a/app/Http/Controllers/Api/LabelTreeLabelController.php b/app/Http/Controllers/Api/LabelTreeLabelController.php index 2398f8b82..9fee3ff9d 100644 --- a/app/Http/Controllers/Api/LabelTreeLabelController.php +++ b/app/Http/Controllers/Api/LabelTreeLabelController.php @@ -41,7 +41,7 @@ class LabelTreeLabelController extends Controller * ] * * @param StoreLabel $request - * @return \Illuminate\Http\Response + * @return array|\Illuminate\Http\RedirectResponse */ public function store(StoreLabel $request) { diff --git a/app/Http/Controllers/Api/LabelTreeMergeController.php b/app/Http/Controllers/Api/LabelTreeMergeController.php index f92611947..d19ef8e4b 100644 --- a/app/Http/Controllers/Api/LabelTreeMergeController.php +++ b/app/Http/Controllers/Api/LabelTreeMergeController.php @@ -42,7 +42,7 @@ class LabelTreeMergeController extends Controller * } * * @param StoreLabelTreeMerge $request - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse|null */ public function store(StoreLabelTreeMerge $request) { diff --git a/app/Http/Controllers/Api/LabelTreeUserController.php b/app/Http/Controllers/Api/LabelTreeUserController.php index c5ffb098e..b26909049 100644 --- a/app/Http/Controllers/Api/LabelTreeUserController.php +++ b/app/Http/Controllers/Api/LabelTreeUserController.php @@ -22,7 +22,7 @@ class LabelTreeUserController extends Controller * @apiParam (Required attributes) {Number} role_id ID of the role of the new member (admin or editor). * * @param StoreLabelTreeUser $request - * @return \Illuminate\Http\Response + * @return \Biigle\LabelTree|\Illuminate\Http\RedirectResponse */ public function store(StoreLabelTreeUser $request) { @@ -50,7 +50,7 @@ public function store(StoreLabelTreeUser $request) * @apiParam (Attributes that can be updated) {Number} role_id New role of the member (admin or editor) * * @param UpdateLabelTreeUser $request - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse|null */ public function update(UpdateLabelTreeUser $request) { @@ -77,7 +77,7 @@ public function update(UpdateLabelTreeUser $request) * @apiParam {Number} uid User ID of the member. * * @param DestroyLabelTreeUser $request - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse|null */ public function destroy(DestroyLabelTreeUser $request) { diff --git a/app/Http/Controllers/Api/LabelTreeVersionController.php b/app/Http/Controllers/Api/LabelTreeVersionController.php index 43ec57a74..707d52fbb 100644 --- a/app/Http/Controllers/Api/LabelTreeVersionController.php +++ b/app/Http/Controllers/Api/LabelTreeVersionController.php @@ -36,7 +36,7 @@ class LabelTreeVersionController extends Controller * } * * @param StoreLabelTreeVersion $request - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse|null */ public function store(StoreLabelTreeVersion $request) { @@ -83,7 +83,6 @@ public function store(StoreLabelTreeVersion $request) * @apiParam (Required attributes) {String} doi DOI that should be associated with the new label tree version. * * @param UpdateLabelTreeVersion $request - * @return \Illuminate\Http\Response */ public function update(UpdateLabelTreeVersion $request) { @@ -103,7 +102,7 @@ public function update(UpdateLabelTreeVersion $request) * @apiParam {Number} id The label tree version ID. * * @param DestroyLabelTreeVersion $request - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse|null */ public function destroy(DestroyLabelTreeVersion $request) { diff --git a/app/Http/Controllers/Api/LinkVideoAnnotationController.php b/app/Http/Controllers/Api/LinkVideoAnnotationController.php index 08b885328..2cc3e235d 100644 --- a/app/Http/Controllers/Api/LinkVideoAnnotationController.php +++ b/app/Http/Controllers/Api/LinkVideoAnnotationController.php @@ -54,7 +54,7 @@ class LinkVideoAnnotationController extends Controller * } * * @param LinkVideoAnnotation $request - * @return \Illuminate\Http\Response + * @return VideoAnnotation */ public function store(LinkVideoAnnotation $request) { diff --git a/app/Http/Controllers/Api/MediaTypeController.php b/app/Http/Controllers/Api/MediaTypeController.php index 3f9cff19e..6e89bdda7 100644 --- a/app/Http/Controllers/Api/MediaTypeController.php +++ b/app/Http/Controllers/Api/MediaTypeController.php @@ -26,7 +26,7 @@ class MediaTypeController extends Controller * } * ] * - * @return \Illuminate\Http\Response + * @return \Illuminate\Database\Eloquent\Collection */ public function index() { diff --git a/app/Http/Controllers/Api/NotificationController.php b/app/Http/Controllers/Api/NotificationController.php index f2ec45f7a..aa47ef854 100644 --- a/app/Http/Controllers/Api/NotificationController.php +++ b/app/Http/Controllers/Api/NotificationController.php @@ -3,6 +3,7 @@ namespace Biigle\Http\Controllers\Api; use Illuminate\Http\Request; +use Illuminate\Http\Response; class NotificationController extends Controller { @@ -20,11 +21,13 @@ class NotificationController extends Controller * * @param Request $request * @param int $id Image ID - * @return \Illuminate\Http\Response */ public function update(Request $request, $id) { - $notification = $request->user()->unreadNotifications()->findOrFail($id); + $notification = $request->user()->unreadNotifications()->find($id); + if (is_null($notification)) { + abort(Response::HTTP_NOT_FOUND); + } $notification->markAsRead(); } @@ -57,11 +60,13 @@ public function updateAll(Request $request) * * @param Request $request * @param int $id - * @return \Illuminate\Http\Response */ public function destroy(Request $request, $id) { - $notification = $request->user()->notifications()->findOrFail($id); + $notification = $request->user()->notifications()->find($id); + if (is_null($notification)) { + abort(Response::HTTP_NOT_FOUND); + } $notification->delete(); } } diff --git a/app/Http/Controllers/Api/PendingVolumeController.php b/app/Http/Controllers/Api/PendingVolumeController.php new file mode 100644 index 000000000..c04a28809 --- /dev/null +++ b/app/Http/Controllers/Api/PendingVolumeController.php @@ -0,0 +1,203 @@ +project->pendingVolumes()->create([ + 'media_type_id' => $request->input('media_type_id'), + 'user_id' => $request->user()->id, + 'metadata_parser' => $request->input('metadata_parser', null), + ]); + + if ($request->has('metadata_file')) { + $pv->saveMetadata($request->file('metadata_file')); + } + + if ($this->isAutomatedRequest()) { + return $pv; + } + + return redirect()->route('pending-volume', $pv->id); + } + + /** + * Update a pending volume to create an actual volume + * + * @api {put} pending-volumes/:id Create a new volume (v2) + * @apiGroup Volumes + * @apiName UpdatePendingVolume + * @apiPermission projectAdminAndPendingVolumeOwner + * + * @apiDescription When this endpoint is called, the new volume is already created. Then there are two ways forward: 1) The user wants to import annotations and/or file labels. Then the pending volume is kept and used for the next steps (see the `import_*` attributes). Continue with (#Volumes:UpdatePendingVolumeAnnotationLabels) in this case. 2) Otherwise the pending volume will be deleted here. In both cases the endpoint returns the pending volume (even if it was deleted) which was updated with the new volume ID. + * + * @apiParam {Number} id The pending volume ID. + * + * @apiParam (Required attributes) {String} name The name of the new volume. + * @apiParam (Required attributes) {String} url The base URL of the image/video files. Can be a path to a storage disk like `local://volumes/1` or a remote path like `https://example.com/volumes/1`. + * @apiParam (Required attributes) {Array} files Array of file names of the images/videos that can be found at the base URL. Example: With the base URL `local://volumes/1` and the image `1.jpg`, the file `volumes/1/1.jpg` of the `local` storage disk will be used. This can also be a plain string of comma-separated filenames. + * + * @apiParam (Optional attributes) {String} handle Handle or DOI of the dataset that is represented by the new volume. + * @apiParam (Optional attributes) {Boolean} import_annotations Set to `true` to keep the pending volume for annotation import. Otherwise the pending volume will be deleted after this request. + * @apiParam (Optional attributes) {Boolean} import_file_labels Set to `true` to keep the pending volume for file label import. Otherwise the pending volume will be deleted after this request. + * + * @apiSuccessExample {json} Success response: + * { + * "id": 2, + * "created_at": "2015-02-19 16:10:17", + * "updated_at": "2015-02-19 16:10:17", + * "media_type_id": 1, + * "user_id": 2, + * "project_id": 3, + * "volume_id": 4, + * "import_annotations": true, + * "import_file_labels": false + * } + * + */ + public function update(UpdatePendingVolume $request) + { + $pv = $request->pendingVolume; + $volume = DB::transaction(function () use ($request, $pv) { + + $volume = Volume::create([ + 'name' => $request->input('name'), + 'url' => $request->input('url'), + 'media_type_id' => $pv->media_type_id, + 'handle' => $request->input('handle'), + 'creator_id' => $request->user()->id, + ]); + + $pv->project->volumes()->attach($volume); + + if ($pv->hasMetadata()) { + $volume->update([ + 'metadata_file_path' => $volume->id.'.'.pathinfo($pv->metadata_file_path, PATHINFO_EXTENSION), + 'metadata_parser' => $pv->metadata_parser, + ]); + $stream = Storage::disk(config('volumes.pending_metadata_storage_disk')) + ->readStream($pv->metadata_file_path); + Storage::disk(config('volumes.metadata_storage_disk')) + ->writeStream($volume->metadata_file_path, $stream); + } + + $files = $request->input('files'); + + // If too many files should be created, do this asynchronously in the + // background. Else the script will run in the 30s execution timeout. + $job = new CreateNewImagesOrVideos($volume, $files); + if (count($files) > self::CREATE_SYNC_LIMIT) { + Queue::pushOn('high', $job); + $volume->creating_async = true; + $volume->save(); + } else { + Queue::connection('sync')->push($job); + } + + return $volume; + }); + + if ($request->input('import_annotations') || $request->input('import_file_labels')) { + $pv->update([ + 'volume_id' => $volume->id, + 'import_annotations' => $request->input('import_annotations', false), + 'import_file_labels' => $request->input('import_file_labels', false), + ]); + } else { + $pv->volume_id = $volume->id; + $pv->delete(); + } + + if ($this->isAutomatedRequest()) { + return $pv; + } + + if ($pv->import_annotations) { + $redirect = redirect()->route('pending-volume-annotation-labels', $pv->id); + } elseif ($pv->import_file_labels) { + $redirect = redirect()->route('pending-volume-file-labels', $pv->id); + } else { + $redirect = redirect()->route('volume', $volume->id); + } + + return $redirect + ->with('message', 'Volume created.') + ->with('messageType', 'success'); + } + + /** + * Delete a pending volume + * + * @api {delete} pending-volumes/:id Discard a pending volume + * @apiGroup Volumes + * @apiName DestroyPendingVolume + * @apiPermission projectAdminAndPendingVolumeOwner + * + * @param Request $request] + */ + public function destroy(Request $request) + { + $pv = PendingVolume::findOrFail($request->route('id')); + $this->authorize('destroy', $pv); + + $pv->delete(); + + if (!$this->isAutomatedRequest()) { + return $this->fuzzyRedirect('create-volume', ['project' => $pv->project_id]); + } + } +} diff --git a/app/Http/Controllers/Api/PendingVolumeImportController.php b/app/Http/Controllers/Api/PendingVolumeImportController.php new file mode 100644 index 000000000..993e5a2d6 --- /dev/null +++ b/app/Http/Controllers/Api/PendingVolumeImportController.php @@ -0,0 +1,215 @@ +pendingVolume->update([ + 'only_annotation_labels' => $request->input('labels'), + ]); + + if ($this->isAutomatedRequest()) { + return $request->pendingVolume; + } + + if ($request->pendingVolume->import_file_labels) { + return redirect()->route('pending-volume-file-labels', $request->pendingVolume->id); + } + + return redirect()->route('pending-volume-label-map', $request->pendingVolume->id); + } + + /** + * Choose file labels for import. + * + * @api {put} pending-volumes/:id/file-labels Choose file labels for import + * @apiGroup Volumes + * @apiName UpdatePendingVolumeFileLabels + * @apiPermission projectAdminAndPendingVolumeOwner + * + * @apiDescription If this endpoint is not used to set a list of label IDs, all file labels will be imported by default. Continue with (#Volumes:UpdatePendingVolumeLabels). + * + * @apiParam {Number} id The pending volume ID. + * + * @apiParam (Required attributes) {array} labels The label IDs (from the metadata file) that should be used to filter the file label import. + * + * @apiSuccessExample {json} Success response: + * { + * "id": 2, + * "created_at": "2015-02-19 16:10:17", + * "updated_at": "2015-02-19 16:10:17", + * "media_type_id": 1, + * "user_id": 2, + * "project_id": 3, + * "volume_id": 4, + * "import_annotations": true, + * "import_file_labels": true, + * "only_annotation_labels": [123], + * "only_file_labels": [456] + * } + */ + public function updateFileLabels(UpdatePendingVolumeFileLabels $request) + { + $request->pendingVolume->update([ + 'only_file_labels' => $request->input('labels'), + ]); + + if ($this->isAutomatedRequest()) { + return $request->pendingVolume; + } + + return redirect()->route('pending-volume-label-map', $request->pendingVolume->id); + } + + /** + * Match metadata labels with database labels. + * + * @api {put} pending-volumes/:id/label-map Match metadata labels with database labels + * @apiGroup Volumes + * @apiName UpdatePendingVolumeLabels + * @apiPermission projectAdminAndPendingVolumeOwner + * + * @apiDescription If this endpoint is not used to set a map of metadata label IDs to database label IDs, the import will attempt to use the metadata label UUIDs to automatically find matches. Continue with (#Volumes:UpdatePendingVolumeUsers). + * + * @apiParam {Number} id The pending volume ID. + * + * @apiParam (Required attributes) {object} label_map Map of metadata label IDs as keys and database label IDs as values. + * + * @apiSuccessExample {json} Success response: + * { + * "id": 2, + * "created_at": "2015-02-19 16:10:17", + * "updated_at": "2015-02-19 16:10:17", + * "media_type_id": 1, + * "user_id": 2, + * "project_id": 3, + * "volume_id": 4, + * "import_annotations": true, + * "import_file_labels": true, + * "only_annotation_labels": [123], + * "only_file_labels": [456], + * "label_map": {"123": 987, "456": 654} + * } + */ + public function updateLabelMap(UpdatePendingVolumeLabelMap $request) + { + $map = array_map('intval', $request->input('label_map')); + $request->pendingVolume->update(['label_map' => $map]); + + if ($this->isAutomatedRequest()) { + return $request->pendingVolume; + } + + return redirect()->route('pending-volume-user-map', $request->pendingVolume->id); + } + + /** + * Match metadata users with database users. + * + * @api {put} pending-volumes/:id/user-map Match metadata users with database users + * @apiGroup Volumes + * @apiName UpdatePendingVolumeUsers + * @apiPermission projectAdminAndPendingVolumeOwner + * + * @apiDescription If this endpoint is not used to set a map of metadata user IDs to database user IDs, the import will attempt to use the metadata user UUIDs to automatically find matches. Continue with (#Volumes:UpdatePendingVolumeImport). + * + * @apiParam {Number} id The pending volume ID. + * + * @apiParam (Required attributes) {object} user_map Map of metadata user IDs as keys and database user IDs as values. + * + * @apiSuccessExample {json} Success response: + * { + * "id": 2, + * "created_at": "2015-02-19 16:10:17", + * "updated_at": "2015-02-19 16:10:17", + * "media_type_id": 1, + * "user_id": 2, + * "project_id": 3, + * "volume_id": 4, + * "import_annotations": true, + * "import_file_labels": true, + * "only_annotation_labels": [123], + * "only_file_labels": [456], + * "label_map": {"123": 987, "456": 654}, + * "user_map": {"135": 246, "975": 864} + * } + */ + public function updateUserMap(UpdatePendingVolumeUserMap $request) + { + $map = array_map('intval', $request->input('user_map')); + $request->pendingVolume->update(['user_map' => $map]); + + if ($this->isAutomatedRequest()) { + return $request->pendingVolume; + } + + return redirect()->route('pending-volume-finish', $request->pendingVolume->id); + } + + /** + * Perform the metadata annotation and/or file label import. + * + * @api {post} pending-volumes/:id/import Perform annotation/file label import + * @apiGroup Volumes + * @apiName UpdatePendingVolumeImport + * @apiPermission projectAdminAndPendingVolumeOwner + * + * @apiDescription This endpoint attempts to perform the annotation and/or file label import that can be started in (#Volumes:UpdatePendingVolume). If the import is successful, the pending volume will be deleted. + * + * @apiParam {Number} id The pending volume ID. + */ + public function storeImport(StorePendingVolumeImport $request) + { + DB::transaction(function () use ($request) { + $request->pendingVolume->update(['importing' => true]); + Queue::push(new ImportVolumeMetadata($request->pendingVolume)); + }); + + if (!$this->isAutomatedRequest()) { + return redirect() + ->route('volume', $request->pendingVolume->volume_id) + ->with('message', 'Metadata import in progress') + ->with('messageType', 'success'); + } + } +} diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index 635ab0dcb..6f06f288d 100644 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -32,7 +32,7 @@ class ProjectController extends Controller * ] * * @param Request $request - * @return \Illuminate\Http\Response + * @return \Illuminate\Database\Eloquent\Collection */ public function index(Request $request) { @@ -87,7 +87,7 @@ public function show($id) * @apiParam (Required attributes) {String} description Description of the new project. * * @param StoreProject $request - * @return Project + * @return Project|\Illuminate\Http\RedirectResponse */ public function store(StoreProject $request) { @@ -121,7 +121,7 @@ public function store(StoreProject $request) * @apiParam (Attributes that can be updated) {String} description Description of the project. * * @param UpdateProject $request - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse|null */ public function update(UpdateProject $request) { @@ -153,7 +153,7 @@ public function update(UpdateProject $request) * * @param Request $request * @param int $id - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse|null */ public function destroy(Request $request, $id) { diff --git a/app/Http/Controllers/Api/ProjectInvitationController.php b/app/Http/Controllers/Api/ProjectInvitationController.php index afa1e1f3b..bfa235ec3 100644 --- a/app/Http/Controllers/Api/ProjectInvitationController.php +++ b/app/Http/Controllers/Api/ProjectInvitationController.php @@ -9,7 +9,7 @@ use Biigle\Role; use DB; use Endroid\QrCode\Encoding\Encoding; -use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelLow; +use Endroid\QrCode\ErrorCorrectionLevel; use Endroid\QrCode\QrCode; use Endroid\QrCode\Writer\SvgWriter; use Ramsey\Uuid\Uuid; @@ -33,7 +33,7 @@ class ProjectInvitationController extends Controller * @apiParam (Optional attributes) {Boolean} add_to_sessions If set to `true`, all users joining the project will automatically be added to all annotation sessions of all volumes that belong to the project. * * @param StoreProjectInvitation $request - * @return \Illuminate\Http\Response + * @return ProjectInvitation */ public function store(StoreProjectInvitation $request) { @@ -62,7 +62,7 @@ public function store(StoreProjectInvitation $request) * @param JoinProjectInvitation $request * @param int $id * - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse|null */ public function join(JoinProjectInvitation $request, $id) { @@ -83,7 +83,7 @@ public function join(JoinProjectInvitation $request, $id) if ($invitation->add_to_sessions) { AnnotationSession::join('project_volume', 'annotation_sessions.volume_id', '=', 'project_volume.volume_id') ->where('project_volume.project_id', $project->id) - ->eachById(fn ($session) => $session->users()->syncWithoutDetaching([$userId])); + ->eachById(fn (AnnotationSession $session) => $session->users()->syncWithoutDetaching([$userId])); } } @@ -108,7 +108,6 @@ public function join(JoinProjectInvitation $request, $id) * @apiParam {Number} id The invitation ID. * * @param int $id - * @return \Illuminate\Http\Response */ public function destroy($id) { @@ -137,7 +136,7 @@ public function showQrCode($id) $qrCode = QrCode::create(route('project-invitation', $invitation->uuid)) ->setEncoding(new Encoding('UTF-8')) - ->setErrorCorrectionLevel(new ErrorCorrectionLevelLow()) + ->setErrorCorrectionLevel(ErrorCorrectionLevel::Low) ->setSize(300) ->setMargin(10); diff --git a/app/Http/Controllers/Api/ProjectLabelTreeController.php b/app/Http/Controllers/Api/ProjectLabelTreeController.php index 2db28f374..1a57fbc08 100644 --- a/app/Http/Controllers/Api/ProjectLabelTreeController.php +++ b/app/Http/Controllers/Api/ProjectLabelTreeController.php @@ -45,7 +45,7 @@ class ProjectLabelTreeController extends Controller * ] * * @param int $id Project ID - * @return \Illuminate\Http\Response + * @return \Illuminate\Database\Eloquent\Collection */ public function index($id) { @@ -86,7 +86,7 @@ public function index($id) * ] * * @param int $id Project ID - * @return \Illuminate\Http\Response + * @return array */ public function available($id) { @@ -122,7 +122,7 @@ public function available($id) * id: 3 * * @param StoreProjectLabelTree $request - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse|null */ public function store(StoreProjectLabelTree $request) { @@ -147,7 +147,7 @@ public function store(StoreProjectLabelTree $request) * @param Request $request * @param int $pid * @param int $lid - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse|null */ public function destroy(Request $request, $pid, $lid) { diff --git a/app/Http/Controllers/Api/ProjectUserController.php b/app/Http/Controllers/Api/ProjectUserController.php index 97077229e..76d956b83 100644 --- a/app/Http/Controllers/Api/ProjectUserController.php +++ b/app/Http/Controllers/Api/ProjectUserController.php @@ -36,7 +36,7 @@ class ProjectUserController extends Controller * ] * * @param int $id - * @return \Illuminate\Http\Response + * @return \Illuminate\Database\Eloquent\Collection */ public function index($id) { @@ -60,7 +60,6 @@ public function index($id) * @apiParam (Attributes that can be updated) {Number} project_role_id The project role of the member. Users with the global guest role cannot become project admins. * * @param UpdateProjectUser $request - * @return \Illuminate\Http\Response */ public function update(UpdateProjectUser $request) { @@ -84,7 +83,6 @@ public function update(UpdateProjectUser $request) * project_role_id: 3 * * @param AttachProjectUser $request - * @return \Illuminate\Http\Response */ public function attach(AttachProjectUser $request) { @@ -106,7 +104,6 @@ public function attach(AttachProjectUser $request) * @apiParam {Number} uid The user ID of the member. * * @param DestroyProjectUser $request - * @return \Illuminate\Http\Response */ public function destroy(DestroyProjectUser $request) { diff --git a/app/Http/Controllers/Api/ProjectVolumeController.php b/app/Http/Controllers/Api/ProjectVolumeController.php index 47e1e53e2..6d683b961 100644 --- a/app/Http/Controllers/Api/ProjectVolumeController.php +++ b/app/Http/Controllers/Api/ProjectVolumeController.php @@ -12,13 +12,6 @@ class ProjectVolumeController extends Controller { - /** - * Limit for the number of files above which volume files are created asynchronously. - * - * @var int - */ - const CREATE_SYNC_LIMIT = 10000; - /** * Shows a list of all volumes belonging to the specified project.. * @@ -43,7 +36,7 @@ class ProjectVolumeController extends Controller * ] * * @param int $id Project ID - * @return \Illuminate\Http\Response + * @return \Illuminate\Database\Eloquent\Collection */ public function index($id) { @@ -56,10 +49,11 @@ public function index($id) /** * Creates a new volume associated to the specified project. * - * @api {post} projects/:id/volumes Create a new volume + * @api {post} projects/:id/volumes Create a new volume (v1) * @apiGroup Volumes * @apiName StoreProjectVolumes * @apiPermission projectAdmin + * @apiDeprecated use now (#Volumes:StoreProjectPendingVolumes) and (#Volumes:UpdatePendingVolume). * * @apiParam {Number} id The project ID. * @@ -71,7 +65,6 @@ public function index($id) * @apiParam (Optional attributes) {String} handle Handle or DOI of the dataset that is represented by the new volume. * @apiParam (Optional attributes) {String} metadata_text CSV-like string with file metadata. See "metadata columns" for the possible columns. Each column may occur only once. There must be at least one column other than `filename`. For video metadata, multiple rows can contain metadata from different times of the same video. In this case, the `filename` of the rows must match and each row needs a (different) `taken_at` timestamp. * @apiParam (Optional attributes) {File} metadata_csv Alternative to `metadata_text`. This field allows the upload of an actual CSV file. See `metadata_text` for the further description. - * @apiParam (Optional attributes) {File} ifdo_file iFDO metadata file to upload and link with the volume. The metadata of this file is not used for the volume or volume files. Use `metadata_text` or `metadata_csv` for this. * * @apiParam (metadata columns) {String} filename The filename of the file the metadata belongs to. This column is required. * @apiParam (metadata columns) {String} taken_at The date and time where the file was taken. Example: `2016-12-19 12:49:00` @@ -103,7 +96,7 @@ public function index($id) * } * * @param StoreVolume $request - * @return Volume + * @return Volume|\Illuminate\Http\RedirectResponse */ public function store(StoreVolume $request) { @@ -113,18 +106,21 @@ public function store(StoreVolume $request) $volume->url = $request->input('url'); $volume->media_type_id = $request->input('media_type_id'); $volume->handle = $request->input('handle'); + $volume->metadata_parser = $request->metadataParser; $volume->creator()->associate($request->user()); $volume->save(); $request->project->volumes()->attach($volume); $files = $request->input('files'); - $metadata = $request->input('metadata', []); + if ($request->file('metadata_csv')) { + $volume->saveMetadata($request->file('metadata_csv')); + } // If too many files should be created, do this asynchronously in the // background. Else the script will run in the 30 s execution timeout. - $job = new CreateNewImagesOrVideos($volume, $files, $metadata); - if (count($files) > self::CREATE_SYNC_LIMIT) { + $job = new CreateNewImagesOrVideos($volume, $files); + if (count($files) > PendingVolumeController::CREATE_SYNC_LIMIT) { Queue::pushOn('high', $job); $volume->creating_async = true; $volume->save(); @@ -132,10 +128,6 @@ public function store(StoreVolume $request) Queue::connection('sync')->push($job); } - if ($request->hasFile('ifdo_file')) { - $volume->saveIfdo($request->file('ifdo_file')); - } - // media type shouldn't be returned unset($volume->media_type); @@ -167,7 +159,6 @@ public function store(StoreVolume $request) * @param Request $request * @param int $projectId * @param int $volumeId - * @return \Illuminate\Http\Response */ public function attach(Request $request, $projectId, $volumeId) { @@ -199,7 +190,6 @@ public function attach(Request $request, $projectId, $volumeId) * @param Request $request * @param int $projectId * @param int $volumeId - * @return \Illuminate\Http\Response */ public function destroy(Request $request, $projectId, $volumeId) { diff --git a/app/Http/Controllers/Api/ProjectsAttachableVolumesController.php b/app/Http/Controllers/Api/ProjectsAttachableVolumesController.php index 94bccbd0e..70232d8e8 100644 --- a/app/Http/Controllers/Api/ProjectsAttachableVolumesController.php +++ b/app/Http/Controllers/Api/ProjectsAttachableVolumesController.php @@ -35,7 +35,7 @@ class ProjectsAttachableVolumesController extends Controller * @param Request $request * @param int $id Project ID * - * @return \Illuminate\Http\Response + * @return \Illuminate\Database\Eloquent\Collection */ public function index(Request $request, $id) { diff --git a/app/Http/Controllers/Api/RoleController.php b/app/Http/Controllers/Api/RoleController.php index a19c3978c..b7e11a44e 100644 --- a/app/Http/Controllers/Api/RoleController.php +++ b/app/Http/Controllers/Api/RoleController.php @@ -30,7 +30,7 @@ class RoleController extends Controller * } * ] * - * @return \Illuminate\Http\Response + * @return \Illuminate\Database\Eloquent\Collection */ public function index() { diff --git a/app/Http/Controllers/Api/ShapeController.php b/app/Http/Controllers/Api/ShapeController.php index 9d0f1fec4..3c5d712bd 100644 --- a/app/Http/Controllers/Api/ShapeController.php +++ b/app/Http/Controllers/Api/ShapeController.php @@ -26,7 +26,7 @@ class ShapeController extends Controller * } * ] * - * @return \Illuminate\Http\Response + * @return \Illuminate\Database\Eloquent\Collection */ public function index() { diff --git a/app/Http/Controllers/Api/SplitVideoAnnotationController.php b/app/Http/Controllers/Api/SplitVideoAnnotationController.php index 10537f8b7..946aa4fa6 100644 --- a/app/Http/Controllers/Api/SplitVideoAnnotationController.php +++ b/app/Http/Controllers/Api/SplitVideoAnnotationController.php @@ -79,7 +79,7 @@ class SplitVideoAnnotationController extends Controller * ] * * @param SplitVideoAnnotation $request - * @return \Illuminate\Http\Response + * @return array */ public function store(SplitVideoAnnotation $request) { @@ -130,7 +130,7 @@ public function store(SplitVideoAnnotation $request) } } - $newAnnotation = VideoAnnotation::make([ + $newAnnotation = new VideoAnnotation([ 'video_id' => $oldAnnotation->video_id, 'shape_id' => $oldAnnotation->shape_id, 'points' => $newPoints, diff --git a/app/Http/Controllers/Api/UserController.php b/app/Http/Controllers/Api/UserController.php index 6bef665a8..1c7649f8e 100644 --- a/app/Http/Controllers/Api/UserController.php +++ b/app/Http/Controllers/Api/UserController.php @@ -57,7 +57,7 @@ public function __construct() * ] * * @param string $pattern - * @return \Illuminate\Http\Response + * @return \Illuminate\Database\Eloquent\Collection */ public function find($pattern) { @@ -98,7 +98,7 @@ public function find($pattern) * ] * * @param Request $request - * @return \Illuminate\Http\Response + * @return \Illuminate\Database\Eloquent\Collection */ public function index(Request $request) { @@ -204,7 +204,7 @@ public function showOwn(Request $request) * auth_password: 'password123' * * @param UpdateUser $request - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse|null */ public function update(UpdateUser $request) { @@ -265,7 +265,7 @@ public function update(UpdateUser $request) * super_user_mode: 0 * * @param UpdateOwnUser $request - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse|null */ public function updateOwn(UpdateOwnUser $request) { @@ -328,7 +328,7 @@ public function updateOwn(UpdateOwnUser $request) * } * * @param StoreUser $request - * @return User + * @return User|\Illuminate\Http\RedirectResponse */ public function store(StoreUser $request) { @@ -375,7 +375,7 @@ public function store(StoreUser $request) * @apiParam {Number} id The user ID. * * @param DestroyUser $request - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse|null */ public function destroy(DestroyUser $request) { @@ -404,7 +404,7 @@ public function destroy(DestroyUser $request) * @apiDescription This action is allowed only by session cookie authentication. If the user is the last admin of a project, they cannot be deleted. The admin role needs to be passed on to another member of the project first. * * @param DestroyOwnUser $request - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse|null */ public function destroyOwn(DestroyOwnUser $request) { diff --git a/app/Http/Controllers/Api/UserSettingsController.php b/app/Http/Controllers/Api/UserSettingsController.php index 2603a44bc..e472ec940 100644 --- a/app/Http/Controllers/Api/UserSettingsController.php +++ b/app/Http/Controllers/Api/UserSettingsController.php @@ -17,7 +17,7 @@ class UserSettingsController extends Controller * attributes which cannot be documented here. * * @param UpdateUserSettings $request - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse|null */ public function update(UpdateUserSettings $request) { diff --git a/app/Http/Controllers/Api/VideoAnnotationController.php b/app/Http/Controllers/Api/VideoAnnotationController.php index ff6a17446..c9d9a7334 100644 --- a/app/Http/Controllers/Api/VideoAnnotationController.php +++ b/app/Http/Controllers/Api/VideoAnnotationController.php @@ -209,7 +209,7 @@ public function store(StoreVideoAnnotation $request) $points = json_decode($points); } - $annotation = VideoAnnotation::make([ + $annotation = new VideoAnnotation([ 'video_id' => $request->video->id, 'shape_id' => $request->input('shape_id'), 'points' => $points, @@ -240,6 +240,7 @@ public function store(StoreVideoAnnotation $request) $queue = config('videos.track_object_queue'); Queue::pushOn($queue, new TrackObject($annotation, $request->user())); Cache::increment(TrackObject::getRateLimitCacheKey($request->user())); + /** @phpstan-ignore property.notFound */ $annotation->trackingJobLimitReached = $currentJobs === ($maxJobs - 1); } @@ -267,7 +268,6 @@ public function store(StoreVideoAnnotation $request) * } * * @param UpdateVideoAnnotation $request - * @return \Illuminate\Http\Response */ public function update(UpdateVideoAnnotation $request) { @@ -301,7 +301,6 @@ public function update(UpdateVideoAnnotation $request) * @apiParam {Number} id The annotation ID. * * @param int $id - * @return \Illuminate\Http\Response */ public function destroy($id) { diff --git a/app/Http/Controllers/Api/VideoFileController.php b/app/Http/Controllers/Api/VideoFileController.php index ab3b5ccf5..51df639bb 100644 --- a/app/Http/Controllers/Api/VideoFileController.php +++ b/app/Http/Controllers/Api/VideoFileController.php @@ -68,7 +68,7 @@ public function show(Request $request, $id) $offset = $range[0]; $length = $range[1] - $range[0] + 1; $total = $video->size; - $response->headers->set('Content-Length', $length); + $response->headers->set('Content-Length', "$length"); $response->headers->set('Content-Range', 'bytes '.implode('-', $range).'/'.$total); $response->setStatusCode(206); diff --git a/app/Http/Controllers/Api/VideoLabelController.php b/app/Http/Controllers/Api/VideoLabelController.php index c817ca970..2aa9e612a 100644 --- a/app/Http/Controllers/Api/VideoLabelController.php +++ b/app/Http/Controllers/Api/VideoLabelController.php @@ -72,7 +72,7 @@ class VideoLabelController extends VolumeFileLabelController * } * * @param StoreVideoLabel $request - * @return \Illuminate\Http\Response + * @return \Biigle\VolumeFileLabel */ public function store(StoreVideoLabel $request) { diff --git a/app/Http/Controllers/Api/VisibilityController.php b/app/Http/Controllers/Api/VisibilityController.php index 9becc64e1..c78a2169a 100644 --- a/app/Http/Controllers/Api/VisibilityController.php +++ b/app/Http/Controllers/Api/VisibilityController.php @@ -26,7 +26,7 @@ class VisibilityController extends Controller * } * ] * - * @return \Illuminate\Http\Response + * @return \Illuminate\Database\Eloquent\Collection */ public function index() { diff --git a/app/Http/Controllers/Api/VolumeAnnotationSessionController.php b/app/Http/Controllers/Api/VolumeAnnotationSessionController.php index 84494160a..1ac6b66a7 100644 --- a/app/Http/Controllers/Api/VolumeAnnotationSessionController.php +++ b/app/Http/Controllers/Api/VolumeAnnotationSessionController.php @@ -44,7 +44,7 @@ class VolumeAnnotationSessionController extends Controller * ] * * @param int $id volume id - * @return \Illuminate\Http\Response + * @return \Illuminate\Database\Eloquent\Collection */ public function index($id) { diff --git a/app/Http/Controllers/Api/VolumeController.php b/app/Http/Controllers/Api/VolumeController.php index 2ced33713..9abaf3b8d 100644 --- a/app/Http/Controllers/Api/VolumeController.php +++ b/app/Http/Controllers/Api/VolumeController.php @@ -19,7 +19,7 @@ class VolumeController extends Controller * Shows all volumes the user has access to. * * @param Request $request - * @return Response + * @return \Illuminate\Database\Eloquent\Collection * @api {get} volumes Get accessible volumes * @apiGroup Volumes * @apiName IndexVolumes @@ -112,7 +112,7 @@ public function show(Request $request, $id) * Updates the attributes of the specified volume. * * @param UpdateVolume $request - * @return Response + * @return \Illuminate\Http\RedirectResponse|null * @api {put} volumes/:id Update a volume * @apiGroup Volumes * @apiName UpdateVolumes @@ -153,7 +153,7 @@ public function update(UpdateVolume $request) * Clones volume to destination project. * * @param CloneVolume $request - * @return Response + * @return Volume|\Illuminate\Http\RedirectResponse * @api {post} volumes/:id/clone-to/:project_id Clones a volume * @apiGroup Volumes * @apiName CloneVolume @@ -192,6 +192,9 @@ public function clone(CloneVolume $request) $copy->name = $request->input('name', $volume->name); $copy->creating_async = true; $copy->save(); + if ($volume->hasMetadata()) { + $copy->update(['metadata_file_path' => $copy->id.'.'.pathinfo($volume->metadata_file_path, PATHINFO_EXTENSION)]); + } $project->addVolumeId($copy->id); $job = new CloneImagesOrVideos($request, $copy); @@ -209,4 +212,46 @@ public function clone(CloneVolume $request) ->with('messageType', 'success'); } + + /** + * Return file ids which are sorted by annotations.created_at + * + * @param int $id + * @return object + * @api {get} volumes{/id}/files/annotation-timestamps Get file ids sorted by recently annotated + * @apiGroup Volumes + * @apiName VolumeSortByAnnotated + * @apiPermission projectMember + * + * @apiParam {Number} id The volume ID. + * + * @apiSuccessExample {json} Success response: + * { + * 1: 1, + * 2: 2, + * 3: 3, + * } + * + */ + public function getFileIdsSortedByAnnotationTimestamps($id) + { + $volume = Volume::findOrFail($id); + $this->authorize('access', $volume); + + if ($volume->isImageVolume()) { + $ids = $volume->files() + ->leftJoin('image_annotations', 'images.id', "=", "image_annotations.image_id") + ->groupBy('images.id') + ->selectRaw('images.id, max(image_annotations.created_at) as created_at') + ->orderByRaw("created_at desc nulls last"); + } else { + $ids = $volume->files() + ->leftJoin('video_annotations', 'videos.id', "=", "video_annotations.video_id") + ->groupBy('videos.id') + ->selectRaw('videos.id, max(video_annotations.created_at) as created_at') + ->orderByRaw("created_at desc nulls last"); + } + + return $ids->pluck('id'); + } } diff --git a/app/Http/Controllers/Api/VolumeFileController.php b/app/Http/Controllers/Api/VolumeFileController.php index ef3af4d85..71305abf3 100644 --- a/app/Http/Controllers/Api/VolumeFileController.php +++ b/app/Http/Controllers/Api/VolumeFileController.php @@ -24,7 +24,7 @@ class VolumeFileController extends Controller * * @param int $id * - * @return \Illuminate\Http\Response + * @return \Illuminate\Support\Collection */ public function index($id) { @@ -70,7 +70,7 @@ public function index($id) * * * @param StoreVolumeFile $request - * @return \Illuminate\Http\Response + * @return \Illuminate\Database\Eloquent\Collection */ public function store(StoreVolumeFile $request) { diff --git a/app/Http/Controllers/Api/VolumeFileLabelController.php b/app/Http/Controllers/Api/VolumeFileLabelController.php index 9ae82f02d..cd893ab14 100644 --- a/app/Http/Controllers/Api/VolumeFileLabelController.php +++ b/app/Http/Controllers/Api/VolumeFileLabelController.php @@ -28,11 +28,12 @@ public function index($id) * Creates a new label for the specified file. * * @param StoreVolumeFileLabel $request - * @return \Illuminate\Http\Response + * @return \Biigle\VolumeFileLabel */ public function baseStore(StoreVolumeFileLabel $request) { $model = $this->getFileLabelModel(); + /** @var \Biigle\VolumeFileLabel */ $fileLabel = new $model; $fileLabel->user()->associate($request->user()); $fileLabel->label()->associate($request->label); @@ -58,7 +59,6 @@ public function baseStore(StoreVolumeFileLabel $request) * Deletes the specified file label. * * @param int $id - * @return \Illuminate\Http\Response */ public function destroy($id) { diff --git a/app/Http/Controllers/Api/Volumes/BrowserController.php b/app/Http/Controllers/Api/Volumes/BrowserController.php index 0eba09873..e619a2bd4 100644 --- a/app/Http/Controllers/Api/Volumes/BrowserController.php +++ b/app/Http/Controllers/Api/Volumes/BrowserController.php @@ -33,7 +33,7 @@ class BrowserController extends Controller * * @param Request $request * @param string $disk - * @return \Illuminate\Http\Response + * @return array */ public function indexDirectories(Request $request, $disk) { @@ -82,7 +82,7 @@ public function indexDirectories(Request $request, $disk) * * @param Request $request * @param string $disk - * @return \Illuminate\Http\Response + * @return array */ public function indexImages(Request $request, $disk) { @@ -114,7 +114,7 @@ public function indexImages(Request $request, $disk) * * @param Request $request * @param string $disk - * @return \Illuminate\Http\Response + * @return array */ public function indexVideos(Request $request, $disk) { @@ -132,7 +132,7 @@ public function indexVideos(Request $request, $disk) * @param string $disk * @param string $regex * - * @return \Illuminate\Http\Response + * @return array */ protected function indexFiles(Request $request, $disk, $regex) { diff --git a/app/Http/Controllers/Api/Volumes/FileLabelsController.php b/app/Http/Controllers/Api/Volumes/FileLabelsController.php index b6b8277c9..60d646ef8 100644 --- a/app/Http/Controllers/Api/Volumes/FileLabelsController.php +++ b/app/Http/Controllers/Api/Volumes/FileLabelsController.php @@ -44,7 +44,7 @@ class FileLabelsController extends Controller * } * * @param int $id - * @return \Illuminate\Http\Response + * @return \Illuminate\Support\Collection */ public function index($id) { diff --git a/app/Http/Controllers/Api/Volumes/FilenamesController.php b/app/Http/Controllers/Api/Volumes/FilenamesController.php index 5532c90ac..0145eebd8 100644 --- a/app/Http/Controllers/Api/Volumes/FilenamesController.php +++ b/app/Http/Controllers/Api/Volumes/FilenamesController.php @@ -25,7 +25,7 @@ class FilenamesController extends Controller * } * * @param int $id - * @return \Illuminate\Http\Response + * @return \Illuminate\Support\Collection */ public function index($id) { diff --git a/app/Http/Controllers/Api/Volumes/Filters/AnnotationLabelController.php b/app/Http/Controllers/Api/Volumes/Filters/AnnotationLabelController.php index 080258eda..c6e513571 100644 --- a/app/Http/Controllers/Api/Volumes/Filters/AnnotationLabelController.php +++ b/app/Http/Controllers/Api/Volumes/Filters/AnnotationLabelController.php @@ -28,7 +28,7 @@ class AnnotationLabelController extends Controller * @param Request $request * @param int $tid * @param int $lid - * @return \Illuminate\Http\Response + * @return \Illuminate\Support\Collection */ public function index(Request $request, $tid, $lid) { diff --git a/app/Http/Controllers/Api/Volumes/Filters/AnyFileLabelController.php b/app/Http/Controllers/Api/Volumes/Filters/AnyFileLabelController.php index 266373c65..49e672df7 100644 --- a/app/Http/Controllers/Api/Volumes/Filters/AnyFileLabelController.php +++ b/app/Http/Controllers/Api/Volumes/Filters/AnyFileLabelController.php @@ -21,7 +21,7 @@ class AnyFileLabelController extends Controller * [1, 5, 6] * * @param int $id - * @return \Illuminate\Http\Response + * @return \Illuminate\Support\Collection */ public function index($id) { diff --git a/app/Http/Controllers/Api/Volumes/Filters/FileLabelController.php b/app/Http/Controllers/Api/Volumes/Filters/FileLabelController.php index ca626589a..109f9d542 100644 --- a/app/Http/Controllers/Api/Volumes/Filters/FileLabelController.php +++ b/app/Http/Controllers/Api/Volumes/Filters/FileLabelController.php @@ -24,7 +24,7 @@ class FileLabelController extends Controller * * @param int $vid * @param int $lid - * @return \Illuminate\Http\Response + * @return \Illuminate\Support\Collection */ public function index($vid, $lid) { diff --git a/app/Http/Controllers/Api/Volumes/Filters/FileLabelUserController.php b/app/Http/Controllers/Api/Volumes/Filters/FileLabelUserController.php index 8ab127b5e..de1d3f116 100644 --- a/app/Http/Controllers/Api/Volumes/Filters/FileLabelUserController.php +++ b/app/Http/Controllers/Api/Volumes/Filters/FileLabelUserController.php @@ -24,7 +24,7 @@ class FileLabelUserController extends Controller * * @param int $vid * @param int $uid - * @return \Illuminate\Http\Response + * @return \Illuminate\Support\Collection */ public function index($vid, $uid) { diff --git a/app/Http/Controllers/Api/Volumes/Filters/FilenameController.php b/app/Http/Controllers/Api/Volumes/Filters/FilenameController.php index 1da167675..87b1f60da 100644 --- a/app/Http/Controllers/Api/Volumes/Filters/FilenameController.php +++ b/app/Http/Controllers/Api/Volumes/Filters/FilenameController.php @@ -23,7 +23,7 @@ class FilenameController extends Controller * * @param int $id * @param string $pattern - * @return \Illuminate\Http\Response + * @return \Illuminate\Support\Collection */ public function index($id, $pattern) { diff --git a/app/Http/Controllers/Api/Volumes/IfdoController.php b/app/Http/Controllers/Api/Volumes/IfdoController.php deleted file mode 100644 index 6e27cb797..000000000 --- a/app/Http/Controllers/Api/Volumes/IfdoController.php +++ /dev/null @@ -1,48 +0,0 @@ -authorize('access', $volume); - - return $volume->downloadIfdo(); - } - - /** - * Delete an iFDO file attached to a volume - * - * @api {delete} volumes/:id/ifdo Delete an iFDO file - * @apiGroup Volumes - * @apiName DestroyVolumeIfdo - * @apiPermission projectAdmin - ~ - * @param int $id - * - * @return \Illuminate\Http\Response - */ - public function destroy($id) - { - $volume = Volume::findOrFail($id); - $this->authorize('update', $volume); - $volume->deleteIfdo(); - } -} diff --git a/app/Http/Controllers/Api/Volumes/MetadataController.php b/app/Http/Controllers/Api/Volumes/MetadataController.php index 7ba285721..5cab2cc8a 100644 --- a/app/Http/Controllers/Api/Volumes/MetadataController.php +++ b/app/Http/Controllers/Api/Volumes/MetadataController.php @@ -4,18 +4,40 @@ use Biigle\Http\Controllers\Api\Controller; use Biigle\Http\Requests\StoreVolumeMetadata; -use Biigle\Rules\ImageMetadata; -use Biigle\Rules\VideoMetadata; -use Biigle\Traits\ChecksMetadataStrings; -use Biigle\Video; -use Carbon\Carbon; -use DB; -use Illuminate\Support\Collection; -use Illuminate\Validation\ValidationException; +use Biigle\Jobs\UpdateVolumeMetadata; +use Biigle\Volume; +use Illuminate\Http\Response; +use Queue; +use Storage; class MetadataController extends Controller { - use ChecksMetadataStrings; + /** + * Get a metadata file attached to a volume + * + * @api {get} volumes/:id/metadata Get a metadata file + * @apiGroup Volumes + * @apiName ShowVolumeMetadata + * @apiPermission projectMember + ~ + * @param int $id + * + * @return \Symfony\Component\HttpFoundation\StreamedResponse + */ + public function show($id) + { + $volume = Volume::findOrFail($id); + $this->authorize('access', $volume); + + if (!$volume->hasMetadata()) { + abort(Response::HTTP_NOT_FOUND); + } + + $disk = Storage::disk(config('volumes.metadata_storage_disk')); + $suffix = pathinfo($volume->metadata_file_path, PATHINFO_EXTENSION); + + return $disk->download($volume->metadata_file_path, "biigle-volume-{$volume->id}-metadata.{$suffix}"); + } /** * @api {post} volumes/:id/images/metadata Add image metadata @@ -32,13 +54,14 @@ class MetadataController extends Controller * @apiGroup Volumes * @apiName StoreVolumeMetadata * @apiPermission projectAdmin - * @apiDescription This endpoint allows adding or updating metadata such as geo coordinates for volume file. + * @apiDescription This endpoint allows adding or updating metadata such as geo + * coordinates for volume files. The uploaded metadata file replaces any previously + * uploaded file. * * @apiParam {Number} id The volume ID. * - * @apiParam (Attributes) {String} metadata_text CSV-like string with file metadata. See "metadata columns" for the possible columns. Each column may occur only once. There must be at least one column other than `filename`. For video metadata, multiple rows can contain metadata from different times of the same video. In this case, the `filename` of the rows must match and each row needs a (different) `taken_at` timestamp. - * @apiParam (Attributes) {File} metadata_csv Alternative to `metadata_text`. This field allows the upload of an actual CSV file. See `metadata_text` for the further description. - * @apiParam (Attributes) {File} ifdo_file iFDO metadata file to upload and link with the volume. The metadata of this file is not used for the volume or volume files. Use `metadata_text` or `metadata_csv` for this. + * @apiParam (attributes) {File} file A file with volume and image/video metadata. By default, this can be a CSV. See "metadata columns" for the possible columns. Each column may occur only once. There must be at least one column other than `filename`. For video metadata, multiple rows can contain metadata from different times of the same video. In this case, the `filename` of the rows must match and each row needs a (different) `taken_at` timestamp. Other file formats may be supported through modules. + * @apiParam (attributes) {String} parser The class namespace of the metadata parser to use. The default CSV parsers are: `Biigle\Services\MetadataParsing\ImageCsvParser` and `Biigle\Services\MetadataParsing\VideoCsvParser`. * * @apiParam (metadata columns) {String} filename The filename of the file the metadata belongs to. This column is required. * @apiParam (metadata columns) {String} taken_at The date and time where the file was taken. Example: `2016-12-19 12:49:00` @@ -48,210 +71,36 @@ class MetadataController extends Controller * @apiParam (metadata columns) {Number} distance_to_ground Distance to the sea floor in meters. Example: `30.25` * @apiParam (metadata columns) {Number} area Area shown by the file in m². Example `2.6`. * - * @apiParamExample {String} Request example: - * file: "filename,taken_at,lng,lat,gps_altitude,distance_to_ground,area - * image_1.png,2016-12-19 12:49:00,52.3211,28.775,-1500.5,30.25,2.6" - * * @param StoreVolumeMetadata $request * - * @return \Illuminate\Http\Response + * @return void */ public function store(StoreVolumeMetadata $request) { - if ($request->hasFile('ifdo_file')) { - $request->volume->saveIfdo($request->file('ifdo_file')); - } - - if ($request->input('metadata')) { - DB::transaction(function () use ($request) { - if ($request->volume->isImageVolume()) { - $this->updateImageMetadata($request); - } else { - $this->updateVideoMetadata($request); - } - }); - - $request->volume->flushGeoInfoCache(); - } - } - - /** - * Update volume metadata for each image. - * - * @param StoreVolumeMetadata $request - */ - protected function updateImageMetadata(StoreVolumeMetadata $request) - { - $metadata = $request->input('metadata'); - $images = $request->volume->images() - ->select('id', 'filename', 'attrs') - ->get() - ->keyBy('filename'); - - $columns = array_shift($metadata); - - foreach ($metadata as $row) { - $row = collect(array_combine($columns, $row)); - $image = $images->get($row['filename']); - // Remove empty cells. - $row = $row->filter(); - $fill = $row->only(ImageMetadata::ALLOWED_ATTRIBUTES); - if ($fill->has('taken_at')) { - $fill['taken_at'] = Carbon::parse($fill['taken_at']); - } - $image->fillable(ImageMetadata::ALLOWED_ATTRIBUTES); - $image->fill($fill->toArray()); - - $metadata = $row->only(ImageMetadata::ALLOWED_METADATA); - $image->metadata = array_merge($image->metadata, $metadata->toArray()); - $image->save(); - } + // Delete first because the metadata file may have a different extension, so it + // is not guaranteed that the file is overwritten. + $request->volume->deleteMetadata(); + $request->volume->saveMetadata($request->file('file')); + $request->volume->update(['metadata_parser' => $request->input('metadata_parser')]); + Queue::push(new UpdateVolumeMetadata($request->volume)); } /** - * Update volume metadata for each video. + * Delete a metadata file attached to a volume * - * @param StoreVolumeMetadata $request - */ - protected function updateVideoMetadata(StoreVolumeMetadata $request) - { - $metadata = $request->input('metadata'); - $videos = $request->volume->videos() - ->get() - ->keyBy('filename'); - - $columns = collect(array_shift($metadata)); - $rowsByFile = collect($metadata) - ->map(fn ($row) => $columns->combine($row)) - ->map(function ($row) { - if ($row->has('taken_at')) { - $row['taken_at'] = Carbon::parse($row['taken_at']); - } - - return $row; - }) - ->groupBy('filename'); - - foreach ($rowsByFile as $filename => $rows) { - $video = $videos->get($filename); - $merged = $this->mergeVideoMetadata($video, $rows); - $video->fillable(VideoMetadata::ALLOWED_ATTRIBUTES); - $video->fill($merged->only(VideoMetadata::ALLOWED_ATTRIBUTES)->toArray()); - // Fields for allowed metadata are filtered in mergeVideoMetadata(). We use - // except() with allowed attributes here so any metadata fields that were - // previously stored for the video but are not contained in ALLOWED_METADATA - // are not deleted. - $video->metadata = $merged->except(VideoMetadata::ALLOWED_ATTRIBUTES)->toArray(); - $video->save(); - } - } - - /** - * Merge existing video metadata with new metaddata based on timestamps. - * - * Timestamps of existing metadata are extended, even if no new values are provided - * for the fields. New values are extended with existing timestamps, even if these - * timestamps are not provided in the new metadata. - * - * @param Video $video - * @param Collection $rows - * - * @return Collection - */ - protected function mergeVideoMetadata(Video $video, Collection $rows) - { - $metadata = collect(); - // Everything will be indexed by the timestamps below. - $origTakenAt = collect($video->taken_at)->map(fn ($time) => $time->getTimestamp()); - $newTakenAt = $rows->pluck('taken_at')->filter()->map(fn ($time) => $time->getTimestamp()); - - if ($origTakenAt->isEmpty() && $this->hasMetadata($video)) { - if ($rows->count() > 1 || $newTakenAt->isNotEmpty()) { - throw ValidationException::withMessages( - [ - 'metadata' => ["Metadata of video '{$video->filename}' has no 'taken_at' timestamps and cannot be updated with new metadata that has timestamps."], - ] - ); - } - - return $rows->first(); - } elseif ($newTakenAt->isEmpty()) { - throw ValidationException::withMessages( - [ - 'metadata' => ["Metadata of video '{$video->filename}' has 'taken_at' timestamps and cannot be updated with new metadata that has no timestamps."], - ] - ); - } - - // These are used to fill missing values with null. - $origTakenAtNull = $origTakenAt->combine($origTakenAt->map(fn ($x) => null)); - $newTakenAtNull = $newTakenAt->combine($newTakenAt->map(fn ($x) => null)); - - $originalAttributes = collect(VideoMetadata::ALLOWED_ATTRIBUTES) - ->mapWithKeys(fn ($key) => [$key => $video->$key]); - - $originalMetadata = collect(VideoMetadata::ALLOWED_METADATA) - ->mapWithKeys(fn ($key) => [$key => null]) - ->merge($video->metadata); - - $originalData = $originalMetadata->merge($originalAttributes); - - foreach ($originalData as $key => $originalValues) { - $originalValues = collect($originalValues); - if ($originalValues->isNotEmpty()) { - $originalValues = $origTakenAt->combine($originalValues); - } - - // Pluck returns an array filled with null if the key doesn't exist. - $newValues = $newTakenAt - ->combine($rows->pluck($key)) - ->filter([$this, 'isFilledString']); - - // This merges old an new values, leaving null where no values are given - // (for an existing or new timestamp). The union order is essential. - $newValues = $newValues - ->union($originalValues) - ->union($origTakenAtNull) - ->union($newTakenAtNull); - - // Do not insert completely empty new values. - if ($newValues->filter([$this, 'isFilledString'])->isEmpty()) { - continue; - } - - // Sort everything by ascending timestamps. - $metadata[$key] = $newValues->sortKeys()->values(); - } - - // Convert numeric fields to numbers. - foreach (VideoMetadata::NUMERIC_FIELDS as $key => $value) { - if ($metadata->has($key)) { - $metadata[$key]->transform(function ($x) { - // This check is required since floatval would return 0 for - // an empty value. This could skew metadata. - return $this->isFilledString($x) ? floatval($x) : null; - }); - } - } - - return $metadata; - } - - /** - * Determine if a video has any metadata. - * - * @param Video $video - * - * @return boolean + * @api {delete} volumes/:id/metadata Delete a metadata file + * @apiGroup Volumes + * @apiName DestroyVolumeMetadata + * @apiPermission projectAdmin + * @apiDescription This does not delete the metadata that was already attached to the + * volume files. + ~ + * @param int $id */ - protected function hasMetadata(Video $video) + public function destroy($id) { - foreach (VideoMetadata::ALLOWED_ATTRIBUTES as $key) { - if (!is_null($video->$key)) { - return true; - } - } - - return !empty($video->metadata); + $volume = Volume::findOrFail($id); + $this->authorize('update', $volume); + $volume->deleteMetadata(); } } diff --git a/app/Http/Controllers/Api/Volumes/ParseIfdoController.php b/app/Http/Controllers/Api/Volumes/ParseIfdoController.php deleted file mode 100644 index 6a325a90a..000000000 --- a/app/Http/Controllers/Api/Volumes/ParseIfdoController.php +++ /dev/null @@ -1,29 +0,0 @@ -metadata; - } -} diff --git a/app/Http/Controllers/Api/Volumes/StatisticsController.php b/app/Http/Controllers/Api/Volumes/StatisticsController.php index af46ee3a8..b6e806688 100644 --- a/app/Http/Controllers/Api/Volumes/StatisticsController.php +++ b/app/Http/Controllers/Api/Volumes/StatisticsController.php @@ -21,7 +21,7 @@ class StatisticsController extends Controller * * @param int $id * - * @return \Illuminate\Http\Response + * @return \Illuminate\Support\Collection */ public function index($id) { @@ -44,8 +44,8 @@ public function index($id) $annotationTimeSeries = $baseQuery->clone() ->leftJoin('users', 'users.id', '=', "{$type}_annotation_labels.user_id") - ->selectRaw("{$type}_annotation_labels.user_id, concat(users.firstname, ' ', users.lastname) as fullname, count({$type}_annotation_labels.id), EXTRACT(YEAR from {$type}_annotations.created_at)::integer as year") - ->groupBy("{$type}_annotation_labels.user_id", 'fullname', 'year') + ->selectRaw("{$type}_annotation_labels.user_id, concat(users.firstname, ' ', users.lastname) as fullname, count({$type}_annotation_labels.id), to_char({$type}_annotations.created_at, 'YYYY-MM') as yearmonth") + ->groupBy("{$type}_annotation_labels.user_id", 'fullname', 'yearmonth') ->orderBy("{$type}_annotation_labels.user_id") ->get(); diff --git a/app/Http/Controllers/Api/Volumes/UsedFileLabelsController.php b/app/Http/Controllers/Api/Volumes/UsedFileLabelsController.php index ea1a88149..8a45783b2 100644 --- a/app/Http/Controllers/Api/Volumes/UsedFileLabelsController.php +++ b/app/Http/Controllers/Api/Volumes/UsedFileLabelsController.php @@ -37,7 +37,7 @@ class UsedFileLabelsController extends Controller * ] * * @param int $id - * @return \Illuminate\Http\Response + * @return \Illuminate\Database\Eloquent\Collection */ public function index($id) { diff --git a/app/Http/Controllers/Api/Volumes/UserController.php b/app/Http/Controllers/Api/Volumes/UserController.php index 1b3d404c3..48d5c6e0b 100644 --- a/app/Http/Controllers/Api/Volumes/UserController.php +++ b/app/Http/Controllers/Api/Volumes/UserController.php @@ -42,7 +42,7 @@ class UserController extends Controller * * @param int $id * - * @return \Illuminate\Http\Response + * @return \Illuminate\Database\Eloquent\Collection */ public function index($id) { diff --git a/app/Http/Controllers/Api/_apidoc.js b/app/Http/Controllers/Api/_apidoc.js index 686417f17..b5601b551 100644 --- a/app/Http/Controllers/Api/_apidoc.js +++ b/app/Http/Controllers/Api/_apidoc.js @@ -50,3 +50,8 @@ * The request must provide an authentication token of a remote instance configured for * federated search. */ + +/** + * @apiDefine projectAdminAndPendingVolumeOwner Project admin and pending volume owner + * The authenticated user must be admin of the project and creator of the pending volume. + */ diff --git a/app/Http/Controllers/Auth/RegisterController.php b/app/Http/Controllers/Auth/RegisterController.php index ae5bad4fc..ed6539699 100644 --- a/app/Http/Controllers/Auth/RegisterController.php +++ b/app/Http/Controllers/Auth/RegisterController.php @@ -117,7 +117,7 @@ protected function create(array $data) /** * Show the application registration form. * - * @return \Illuminate\Http\Response + * @return \Illuminate\View\View */ public function showRegistrationForm() { @@ -132,7 +132,7 @@ public function showRegistrationForm() * Handle a registration request for the application. * * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse */ public function register(Request $request) { diff --git a/app/Http/Controllers/Auth/ResetPasswordController.php b/app/Http/Controllers/Auth/ResetPasswordController.php index 02e30c16d..abb816744 100644 --- a/app/Http/Controllers/Auth/ResetPasswordController.php +++ b/app/Http/Controllers/Auth/ResetPasswordController.php @@ -31,7 +31,6 @@ class ResetPasswordController extends Controller public function __construct() { $this->middleware('guest'); - $this->redirectTo = route('home'); } /** diff --git a/app/Http/Controllers/Views/Admin/AnnouncementsController.php b/app/Http/Controllers/Views/Admin/AnnouncementsController.php index 3e3cfb0fe..980cb254f 100644 --- a/app/Http/Controllers/Views/Admin/AnnouncementsController.php +++ b/app/Http/Controllers/Views/Admin/AnnouncementsController.php @@ -9,8 +9,6 @@ class AnnouncementsController extends Controller { /** * Shows the admin announcements page. - * - * @return \Illuminate\Http\Response */ public function index() { @@ -24,8 +22,6 @@ public function index() /** * Shows the admin new announcement page. - * - * @return \Illuminate\Http\Response */ public function create() { diff --git a/app/Http/Controllers/Views/Admin/FederatedSearchController.php b/app/Http/Controllers/Views/Admin/FederatedSearchController.php index f623fdee9..36bc7afd6 100644 --- a/app/Http/Controllers/Views/Admin/FederatedSearchController.php +++ b/app/Http/Controllers/Views/Admin/FederatedSearchController.php @@ -12,7 +12,6 @@ class FederatedSearchController extends Controller * Show the federated search admin page. * * @param Request $request - * @return \Illuminate\Http\Response */ public function index(Request $request) { diff --git a/app/Http/Controllers/Views/Admin/IndexController.php b/app/Http/Controllers/Views/Admin/IndexController.php index c775d0a9b..3f5a1253e 100644 --- a/app/Http/Controllers/Views/Admin/IndexController.php +++ b/app/Http/Controllers/Views/Admin/IndexController.php @@ -15,7 +15,6 @@ class IndexController extends Controller * Shows the admin dashboard. * * @param Modules $modules - * @return \Illuminate\Http\Response */ public function get(Modules $modules) { diff --git a/app/Http/Controllers/Views/Admin/LabelTreesController.php b/app/Http/Controllers/Views/Admin/LabelTreesController.php index e6eff69e2..43b70a37f 100644 --- a/app/Http/Controllers/Views/Admin/LabelTreesController.php +++ b/app/Http/Controllers/Views/Admin/LabelTreesController.php @@ -9,8 +9,6 @@ class LabelTreesController extends Controller { /** * Show the label tree admin page. - * - * @return \Illuminate\Http\Response */ public function index() { diff --git a/app/Http/Controllers/Views/Admin/LogsController.php b/app/Http/Controllers/Views/Admin/LogsController.php index 59ee8efa0..7598de919 100644 --- a/app/Http/Controllers/Views/Admin/LogsController.php +++ b/app/Http/Controllers/Views/Admin/LogsController.php @@ -12,8 +12,6 @@ class LogsController extends Controller { /** * Shows the available logfiles. - * - * @return \Illuminate\Http\Response */ public function index(Request $request) { @@ -63,8 +61,6 @@ public function index(Request $request) /** * Shows a specific logfile. - * - * @return \Illuminate\Http\Response */ public function show($file) { diff --git a/app/Http/Controllers/Views/Admin/UsersController.php b/app/Http/Controllers/Views/Admin/UsersController.php index 8bd7a75cf..0a7076188 100644 --- a/app/Http/Controllers/Views/Admin/UsersController.php +++ b/app/Http/Controllers/Views/Admin/UsersController.php @@ -19,11 +19,10 @@ class UsersController extends Controller * Shows the admin users page. * * @param Request $request - * @return \Illuminate\Http\Response */ public function get(Request $request) { - $users = User::select('id', 'firstname', 'lastname', 'email', 'login_at', 'role_id', 'affiliation') + $users = User::select('id', 'firstname', 'lastname', 'email', 'login_at', 'created_at', 'role_id', 'affiliation') ->when($request->has('q'), function ($query) use ($request) { $q = $request->get('q'); $query->where(function ($query) use ($q) { @@ -32,6 +31,10 @@ public function get(Request $request) ->orWhere('email', 'ilike', "%$q%"); }); }) + ->when( + $request->get('recent'), + fn ($query) => $query->where('created_at', '>=', now()->subWeek()) + ) // Orders by login_at in descending order (most recent first) but puts // users with login_at=NULL at the end. ->orderByRaw('login_at IS NULL, login_at DESC') @@ -44,18 +47,20 @@ public function get(Request $request) Role::guestId() => 'Guest', ]; + $usersCount = User::whereDate('created_at', '>=', now()->subWeek()) + ->count(); + return view('admin.users', [ 'users' => $users, 'roleClass' => $this->roleClassMap(), 'roleNames' => $roleNames, 'query' => $request->get('q'), + 'usersCount' => $usersCount ]); } /** * Shows the admin new user page. - * - * @return \Illuminate\Http\Response */ public function newUser() { @@ -64,8 +69,6 @@ public function newUser() /** * Shows the admin edit user page. - * - * @return \Illuminate\Http\Response */ public function edit($id) { @@ -80,8 +83,6 @@ public function edit($id) /** * Shows the admin delete user page. - * - * @return \Illuminate\Http\Response */ public function delete($id) { @@ -94,7 +95,6 @@ public function delete($id) * * @param Modules $modules * @param int $id User ID - * @return \Illuminate\Http\Response */ public function show(Modules $modules, $id) { diff --git a/app/Http/Controllers/Views/Annotations/AnnotationToolController.php b/app/Http/Controllers/Views/Annotations/AnnotationToolController.php index 6dbdf9909..8221b1aa1 100644 --- a/app/Http/Controllers/Views/Annotations/AnnotationToolController.php +++ b/app/Http/Controllers/Views/Annotations/AnnotationToolController.php @@ -19,8 +19,6 @@ class AnnotationToolController extends Controller * * @param Request $request * @param int $id the image ID - * - * @return \Illuminate\Http\Response */ public function show(Request $request, $id) { diff --git a/app/Http/Controllers/Views/Annotations/ImageAnnotationController.php b/app/Http/Controllers/Views/Annotations/ImageAnnotationController.php index 766e4a2ae..c009f249b 100644 --- a/app/Http/Controllers/Views/Annotations/ImageAnnotationController.php +++ b/app/Http/Controllers/Views/Annotations/ImageAnnotationController.php @@ -11,7 +11,7 @@ class ImageAnnotationController extends Controller * Redirect to the annotator link that shows a specified annotation. * * @param int $id Image annotation ID - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse */ public function show($id) { diff --git a/app/Http/Controllers/Views/DashboardController.php b/app/Http/Controllers/Views/DashboardController.php index 00c209ea3..905f16cb5 100644 --- a/app/Http/Controllers/Views/DashboardController.php +++ b/app/Http/Controllers/Views/DashboardController.php @@ -26,7 +26,6 @@ public function __construct() * Show the application dashboard to the user. * * @param Guard $auth - * @return \Illuminate\Http\Response */ public function index(Guard $auth) { @@ -41,8 +40,6 @@ public function index(Guard $auth) * Show the dashboard for a logged in user. * * @param User $user - * - * @return \Illuminate\Http\Response */ protected function indexDashboard(User $user) { @@ -121,6 +118,7 @@ public function annotationsActivityItems(User $user, $limit = 3, $newerThan = nu ->map(function ($item) { return [ 'item' => $item, + /** @phpstan-ignore property.notFound */ 'created_at' => $item->annotation_labels_created_at, 'include' => 'annotations.dashboardActivityItem', ]; @@ -153,6 +151,7 @@ public function videosActivityItems(User $user, $limit = 3, $newerThan = null) ->map(function ($item) { return [ 'item' => $item, + /** @phpstan-ignore property.notFound */ 'created_at' => $item->video_annotation_labels_created_at, 'include' => 'videos.dashboardActivityItem', ]; @@ -162,8 +161,6 @@ public function videosActivityItems(User $user, $limit = 3, $newerThan = null) /** * Show the landing page if no user is authenticated. - * - * @return \Illuminate\Http\Response */ protected function indexLandingPage() { diff --git a/app/Http/Controllers/Views/ImprintController.php b/app/Http/Controllers/Views/ImprintController.php index 0d60cb2e3..9084350fb 100644 --- a/app/Http/Controllers/Views/ImprintController.php +++ b/app/Http/Controllers/Views/ImprintController.php @@ -9,8 +9,6 @@ class ImprintController extends Controller { /** * Show the the imprint view. - * - * @return \Illuminate\Http\Response */ public function show() { diff --git a/app/Http/Controllers/Views/LabelTrees/LabelTreeMembersController.php b/app/Http/Controllers/Views/LabelTrees/LabelTreeMembersController.php index eec802c05..d632f8296 100644 --- a/app/Http/Controllers/Views/LabelTrees/LabelTreeMembersController.php +++ b/app/Http/Controllers/Views/LabelTrees/LabelTreeMembersController.php @@ -16,7 +16,6 @@ class LabelTreeMembersController extends Controller * * @param Request $request * @param int $id project ID - * @return \Illuminate\Http\Response */ public function show(Request $request, $id) { diff --git a/app/Http/Controllers/Views/LabelTrees/LabelTreeMergeController.php b/app/Http/Controllers/Views/LabelTrees/LabelTreeMergeController.php index 5d7c855c0..41fb67da0 100644 --- a/app/Http/Controllers/Views/LabelTrees/LabelTreeMergeController.php +++ b/app/Http/Controllers/Views/LabelTrees/LabelTreeMergeController.php @@ -13,8 +13,6 @@ class LabelTreeMergeController extends Controller * * @param Request $request * @param int $id ID of the base label tree - * - * @return mixed */ public function index(Request $request, $id) { @@ -37,8 +35,6 @@ public function index(Request $request, $id) * * @param int $id1 ID of the base label tree * @param int $id2 ID of the label tree to merge into the base - * - * @return mixed */ public function show($id1, $id2) { diff --git a/app/Http/Controllers/Views/LabelTrees/LabelTreeProjectsController.php b/app/Http/Controllers/Views/LabelTrees/LabelTreeProjectsController.php index 08372cb6f..6d3975ddd 100644 --- a/app/Http/Controllers/Views/LabelTrees/LabelTreeProjectsController.php +++ b/app/Http/Controllers/Views/LabelTrees/LabelTreeProjectsController.php @@ -16,7 +16,6 @@ class LabelTreeProjectsController extends Controller * * @param Request $request * @param int $id project ID - * @return \Illuminate\Http\Response */ public function show(Request $request, $id) { @@ -37,8 +36,6 @@ public function show(Request $request, $id) * * @param LabelTree $tree * @param User $user - * - * @return \Illuminate\Http\Response */ protected function showMasterLabelTree(LabelTree $tree, User $user) { @@ -90,8 +87,6 @@ protected function showMasterLabelTree(LabelTree $tree, User $user) * * @param LabelTree $tree * @param User $user - * - * @return \Illuminate\Http\Response */ protected function showVersionedLabelTree(LabelTree $tree, User $user) { diff --git a/app/Http/Controllers/Views/LabelTrees/LabelTreeVersionsController.php b/app/Http/Controllers/Views/LabelTrees/LabelTreeVersionsController.php index dafbd5ce8..79416ce99 100644 --- a/app/Http/Controllers/Views/LabelTrees/LabelTreeVersionsController.php +++ b/app/Http/Controllers/Views/LabelTrees/LabelTreeVersionsController.php @@ -16,7 +16,7 @@ class LabelTreeVersionsController extends Controller * @param int $tid Label tree ID * @param int $vid Label tree version ID * - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse */ public function show(Request $request, $tid, $vid) { @@ -32,8 +32,6 @@ public function show(Request $request, $tid, $vid) * Show the create label tree version page. * * @param int $id Label tree ID - * - * @return \Illuminate\Http\Response */ public function create($id) { diff --git a/app/Http/Controllers/Views/LabelTrees/LabelTreesController.php b/app/Http/Controllers/Views/LabelTrees/LabelTreesController.php index 2e1385d3c..e2b97ccfb 100644 --- a/app/Http/Controllers/Views/LabelTrees/LabelTreesController.php +++ b/app/Http/Controllers/Views/LabelTrees/LabelTreesController.php @@ -17,8 +17,6 @@ class LabelTreesController extends Controller * * @param Request $request * @param int $id Label tree ID - * - * @return \Illuminate\Http\Response */ public function show(Request $request, $id) { @@ -38,7 +36,7 @@ public function show(Request $request, $id) * Show the label tree list. * * @deprecated This is a legacy route and got replaced by the global search. - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse */ public function index() { @@ -49,7 +47,6 @@ public function index() * Show the create label tree page. * * @param Request $request - * @return \Illuminate\Http\Response */ public function create(Request $request) { @@ -89,8 +86,6 @@ public function create(Request $request) * * @param LabelTree $tree * @param User $user - * - * @return \Illuminate\Http\Response */ protected function showMasterLabelTree(LabelTree $tree, User $user) { @@ -118,8 +113,6 @@ protected function showMasterLabelTree(LabelTree $tree, User $user) * * @param LabelTree $tree * @param User $user - * - * @return \Illuminate\Http\Response */ protected function showVersionedLabelTree(LabelTree $tree, User $user) { diff --git a/app/Http/Controllers/Views/ManualController.php b/app/Http/Controllers/Views/ManualController.php index db99e4b3b..ed7a1f316 100644 --- a/app/Http/Controllers/Views/ManualController.php +++ b/app/Http/Controllers/Views/ManualController.php @@ -9,8 +9,6 @@ class ManualController extends Controller { /** * Show the application manual to the user. - * - * @return \Illuminate\Http\Response */ public function index() { @@ -22,7 +20,6 @@ public function index() * * @param string $module Name of the module or name of the article * @param string $article Article name (only if the article belongs to a module) - * @return \Illuminate\Http\Response */ public function tutorialsArticle($module, $article = null) { @@ -44,7 +41,6 @@ public function tutorialsArticle($module, $article = null) * * @param string $module Name of the module or name of the article * @param string $article Article name (only if the article belongs to a module) - * @return \Illuminate\Http\Response */ public function documentationArticle($module, $article = null) { diff --git a/app/Http/Controllers/Views/Notifications/NotificationsController.php b/app/Http/Controllers/Views/Notifications/NotificationsController.php index fe9e9c541..cc982e5b2 100644 --- a/app/Http/Controllers/Views/Notifications/NotificationsController.php +++ b/app/Http/Controllers/Views/Notifications/NotificationsController.php @@ -3,7 +3,6 @@ namespace Biigle\Http\Controllers\Views\Notifications; use Biigle\Http\Controllers\Controller; -use Illuminate\Contracts\Auth\Guard; use Illuminate\Http\Request; class NotificationsController extends Controller @@ -12,14 +11,13 @@ class NotificationsController extends Controller * Shows the notification center. * * @param Request $request - * @param Guard $auth - * @return \Illuminate\Http\Response */ - public function index(Request $request, Guard $auth) + public function index(Request $request) { + $user = $request->user(); $all = (boolean) $request->input('all', false); - $user = $auth->user(); - $notifications = $all ? $user->notifications : $user->unreadNotifications; + $notifications = $all ? $user->notifications() : $user->unreadNotifications(); + $notifications = $notifications->get(); foreach ($notifications as $n) { $n->created_at_diff = $n->created_at->diffForHumans(); diff --git a/app/Http/Controllers/Views/PrivacyController.php b/app/Http/Controllers/Views/PrivacyController.php index 7e0d6196e..efa5a1dfa 100644 --- a/app/Http/Controllers/Views/PrivacyController.php +++ b/app/Http/Controllers/Views/PrivacyController.php @@ -9,8 +9,6 @@ class PrivacyController extends Controller { /** * Show the the privacy view. - * - * @return \Illuminate\Http\Response */ public function show() { diff --git a/app/Http/Controllers/Views/Projects/ProjectInvitationController.php b/app/Http/Controllers/Views/Projects/ProjectInvitationController.php index 150262729..fa05864e6 100644 --- a/app/Http/Controllers/Views/Projects/ProjectInvitationController.php +++ b/app/Http/Controllers/Views/Projects/ProjectInvitationController.php @@ -27,7 +27,7 @@ public function __construct() * * @param Request $request * @param string $uuid Invitation UUID - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse|\Illuminate\View\View */ public function show(Request $request, $uuid) { diff --git a/app/Http/Controllers/Views/Projects/ProjectLabelTreeController.php b/app/Http/Controllers/Views/Projects/ProjectLabelTreeController.php index cbff19415..e9e89276b 100644 --- a/app/Http/Controllers/Views/Projects/ProjectLabelTreeController.php +++ b/app/Http/Controllers/Views/Projects/ProjectLabelTreeController.php @@ -13,7 +13,6 @@ class ProjectLabelTreeController extends Controller * * @param Request $request * @param int $id project ID - * @return \Illuminate\Http\Response */ public function show(Request $request, $id) { @@ -27,7 +26,7 @@ public function show(Request $request, $id) $userProject = $request->user()->projects()->where('id', $id)->first(); $isMember = $userProject !== null; - $isPinned = $isMember && $userProject->pivot->pinned; + $isPinned = $isMember && $userProject->getRelationValue('pivot')->pinned; $canPin = $isMember && 3 > $request->user() ->projects() ->wherePivot('pinned', true) diff --git a/app/Http/Controllers/Views/Projects/ProjectStatisticsController.php b/app/Http/Controllers/Views/Projects/ProjectStatisticsController.php index 04e70c4a8..0655d2b30 100644 --- a/app/Http/Controllers/Views/Projects/ProjectStatisticsController.php +++ b/app/Http/Controllers/Views/Projects/ProjectStatisticsController.php @@ -17,7 +17,6 @@ class ProjectStatisticsController extends Controller * * @param Request $request * @param int $id project ID - * @return \Illuminate\Http\Response */ public function show(Request $request, $id) { @@ -26,7 +25,7 @@ public function show(Request $request, $id) $userProject = $request->user()->projects()->where('id', $id)->first(); $isMember = $userProject !== null; - $isPinned = $isMember && $userProject->pivot->pinned; + $isPinned = $isMember && $userProject->getRelationValue('pivot')->pinned; $canPin = $isMember && 3 > $request->user() ->projects() ->wherePivot('pinned', true) @@ -112,8 +111,8 @@ protected function getVolumeStatistics(Project $project, $type) $annotationTimeSeries = $baseQuery->clone() ->leftJoin('users', 'users.id', '=', "{$type}_annotation_labels.user_id") - ->selectRaw("{$type}_annotation_labels.user_id, concat(users.firstname, ' ', users.lastname) as fullname, count({$type}_annotation_labels.id), EXTRACT(YEAR from {$type}_annotations.created_at)::integer as year") - ->groupBy("{$type}_annotation_labels.user_id", 'fullname', 'year') + ->selectRaw("{$type}_annotation_labels.user_id, concat(users.firstname, ' ', users.lastname) as fullname, count({$type}_annotation_labels.id), to_char({$type}_annotations.created_at, 'YYYY-MM') as yearmonth") + ->groupBy("{$type}_annotation_labels.user_id", 'fullname', 'yearmonth') ->orderBy("{$type}_annotation_labels.user_id") ->get(); diff --git a/app/Http/Controllers/Views/Projects/ProjectUserController.php b/app/Http/Controllers/Views/Projects/ProjectUserController.php index a474218e3..d05475897 100644 --- a/app/Http/Controllers/Views/Projects/ProjectUserController.php +++ b/app/Http/Controllers/Views/Projects/ProjectUserController.php @@ -14,7 +14,6 @@ class ProjectUserController extends Controller * * @param Request $request * @param int $id project ID - * @return \Illuminate\Http\Response */ public function show(Request $request, $id) { @@ -43,7 +42,7 @@ public function show(Request $request, $id) $userProject = $request->user()->projects()->where('id', $id)->first(); $isMember = $userProject !== null; - $isPinned = $isMember && $userProject->pivot->pinned; + $isPinned = $isMember && $userProject->getRelationValue('pivot')->pinned; $canPin = $isMember && 3 > $request->user() ->projects() ->wherePivot('pinned', true) diff --git a/app/Http/Controllers/Views/Projects/ProjectsController.php b/app/Http/Controllers/Views/Projects/ProjectsController.php index e04d206ef..a294f00b6 100644 --- a/app/Http/Controllers/Views/Projects/ProjectsController.php +++ b/app/Http/Controllers/Views/Projects/ProjectsController.php @@ -10,8 +10,6 @@ class ProjectsController extends Controller { /** * Shows the create project page. - * - * @return \Illuminate\Http\Response */ public function create() { @@ -24,7 +22,7 @@ public function create() * Shows the project index page. * * @deprecated This is a legacy route and got replaced by the global search. - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse */ public function index() { @@ -36,7 +34,6 @@ public function index() * * @param Request $request * @param int $id project ID - * @return \Illuminate\Http\Response */ protected function show(Request $request, $id) { @@ -57,7 +54,7 @@ protected function show(Request $request, $id) $userProject = $request->user()->projects()->where('id', $id)->first(); $isMember = $userProject !== null; - $isPinned = $isMember && $userProject->pivot->pinned; + $isPinned = $isMember && $userProject->getRelationValue('pivot')->pinned; $canPin = $isMember && 3 > $request->user() ->projects() ->wherePivot('pinned', true) diff --git a/app/Http/Controllers/Views/SearchController.php b/app/Http/Controllers/Views/SearchController.php index 63f3b6e63..e5b182962 100644 --- a/app/Http/Controllers/Views/SearchController.php +++ b/app/Http/Controllers/Views/SearchController.php @@ -21,7 +21,6 @@ class SearchController extends Controller * @param Guard $auth * @param Request $request * @param Modules $modules - * @return \Illuminate\Http\Response */ public function index(Guard $auth, Request $request, Modules $modules) { @@ -76,6 +75,7 @@ protected function searchLabelTrees(User $user, $query, $type, $includeFederated }); if ($includeFederatedSearch) { + /** @var \Illuminate\Database\Query\Builder $queryBuilder2 */ $queryBuilder2 = $user->federatedSearchModels() ->labelTrees() ->selectRaw("id, name, description, updated_at, true as external") @@ -103,6 +103,7 @@ protected function searchLabelTrees(User $user, $query, $type, $includeFederated $external = FederatedSearchModel::whereIn('id', $collection->where('external', true)->pluck('id'))->get()->keyBy('id'); $results->setCollection($collection->map(function ($item) use ($internal, $external) { + /** @phpstan-ignore property.notFound */ if ($item->external) { return $external[$item->id]; } @@ -147,6 +148,7 @@ protected function searchProjects(User $user, $query, $type, $includeFederatedSe }); if ($includeFederatedSearch) { + /** @var \Illuminate\Database\Query\Builder $queryBuilder2 */ $queryBuilder2 = $user->federatedSearchModels() ->projects() ->selectRaw("id, name, description, updated_at, true as external") @@ -174,6 +176,7 @@ protected function searchProjects(User $user, $query, $type, $includeFederatedSe $external = FederatedSearchModel::whereIn('id', $collection->where('external', true)->pluck('id'))->get()->keyBy('id'); $results->setCollection($collection->map(function ($item) use ($internal, $external) { + /** @phpstan-ignore property.notFound */ if ($item->external) { return $external[$item->id]; } @@ -213,6 +216,7 @@ protected function searchVolumes(User $user, $query, $type, $includeFederatedSea }); if ($includeFederatedSearch) { + /** @var \Illuminate\Database\Query\Builder $queryBuilder2 */ $queryBuilder2 = $user->federatedSearchModels() ->volumes() ->selectRaw("id, name, updated_at, true as external") @@ -239,6 +243,7 @@ protected function searchVolumes(User $user, $query, $type, $includeFederatedSea $external = FederatedSearchModel::whereIn('id', $collection->where('external', true)->pluck('id'))->get()->keyBy('id'); $results->setCollection($collection->map(function ($item) use ($internal, $external) { + /** @phpstan-ignore property.notFound */ if ($item->external) { return $external[$item->id]; } diff --git a/app/Http/Controllers/Views/SettingsController.php b/app/Http/Controllers/Views/SettingsController.php index 3faf4272c..be12d6d3f 100644 --- a/app/Http/Controllers/Views/SettingsController.php +++ b/app/Http/Controllers/Views/SettingsController.php @@ -10,7 +10,7 @@ class SettingsController extends Controller /** * Redirects to the profile settings. * - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse */ public function index() { @@ -21,7 +21,6 @@ public function index() * Shows the profile settings. * * @param Guard $auth - * @return \Illuminate\Http\Response */ public function profile(Guard $auth) { @@ -34,7 +33,6 @@ public function profile(Guard $auth) * Shows the account settings. * * @param Guard $auth - * @return \Illuminate\Http\Response */ public function account(Guard $auth) { @@ -48,7 +46,6 @@ public function account(Guard $auth) * Shows the authentication settings. * * @param Guard $auth - * @return \Illuminate\Http\Response */ public function authentication(Guard $auth) { @@ -61,7 +58,6 @@ public function authentication(Guard $auth) * Shows the tokens settings. * * @param Guard $auth - * @return \Illuminate\Http\Response */ public function tokens(Guard $auth) { @@ -76,8 +72,6 @@ public function tokens(Guard $auth) /** * Shows the notification settings. - * - * @return \Illuminate\Http\Response */ public function notifications() { diff --git a/app/Http/Controllers/Views/TermsController.php b/app/Http/Controllers/Views/TermsController.php index b5b529a69..994f8abc3 100644 --- a/app/Http/Controllers/Views/TermsController.php +++ b/app/Http/Controllers/Views/TermsController.php @@ -9,8 +9,6 @@ class TermsController extends Controller { /** * Show the the terms view. - * - * @return \Illuminate\Http\Response */ public function show() { diff --git a/app/Http/Controllers/Views/Videos/VideoAnnotationController.php b/app/Http/Controllers/Views/Videos/VideoAnnotationController.php index d532f356d..bd82a7c01 100644 --- a/app/Http/Controllers/Views/Videos/VideoAnnotationController.php +++ b/app/Http/Controllers/Views/Videos/VideoAnnotationController.php @@ -11,7 +11,7 @@ class VideoAnnotationController extends Controller * Redirect to the annotator link that shows a specified annotation. * * @param int $id Video annotation ID - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\RedirectResponse */ public function show($id) { diff --git a/app/Http/Controllers/Views/Videos/VideoController.php b/app/Http/Controllers/Views/Videos/VideoController.php index 873fd9d74..97b58c87f 100644 --- a/app/Http/Controllers/Views/Videos/VideoController.php +++ b/app/Http/Controllers/Views/Videos/VideoController.php @@ -18,8 +18,6 @@ class VideoController extends Controller * * @param Request $request * @param int $id - * - * @return mixed */ public function show(Request $request, $id) { diff --git a/app/Http/Controllers/Views/Volumes/ImageController.php b/app/Http/Controllers/Views/Volumes/ImageController.php index 8b190719c..250d22563 100644 --- a/app/Http/Controllers/Views/Volumes/ImageController.php +++ b/app/Http/Controllers/Views/Volumes/ImageController.php @@ -12,7 +12,6 @@ class ImageController extends Controller * Shows the image index page. * * @param int $id volume ID - * @return \Illuminate\Http\Response */ public function index($id) { diff --git a/app/Http/Controllers/Views/Volumes/PendingVolumeController.php b/app/Http/Controllers/Views/Volumes/PendingVolumeController.php new file mode 100644 index 000000000..c6dae116f --- /dev/null +++ b/app/Http/Controllers/Views/Volumes/PendingVolumeController.php @@ -0,0 +1,347 @@ +findOrFail($request->route('id')); + $this->authorize('access', $pv); + + // If the volume was already created, we have to redirect to one of the subsequent + // steps. + if (!is_null($pv->volume_id)) { + if ($pv->import_annotations && empty($pv->only_annotation_labels) && empty($pv->only_file_labels) && empty($pv->label_map) && empty($pv->user_map)) { + $redirect = redirect()->route('pending-volume-annotation-labels', $pv->id); + } elseif ($pv->import_file_labels && empty($pv->only_file_labels) && empty($pv->label_map) && empty($pv->user_map)) { + $redirect = redirect()->route('pending-volume-file-labels', $pv->id); + } elseif (empty($pv->label_map) && empty($pv->user_map)) { + $redirect = redirect()->route('pending-volume-label-map', $pv->id); + } else { + $redirect = redirect()->route('pending-volume-user-map', $pv->id); + } + + return $redirect + ->with('message', 'This is a pending volume that you did not finish before.') + ->with('messageType', 'info'); + } + + $disks = collect([]); + $user = $request->user(); + + if ($user->can('sudo')) { + $disks = $disks->concat(config('volumes.admin_storage_disks')); + } elseif ($user->role_id === Role::editorId()) { + $disks = $disks->concat(config('volumes.editor_storage_disks')); + } + + // Limit to disks that actually exist. + $disks = $disks->intersect(array_keys(config('filesystems.disks')))->values(); + + // Use the disk keys as names, too. UserDisks can have different names + // (see below). + $disks = $disks->combine($disks)->map(fn ($name) => ucfirst($name)); + + if (class_exists(UserDisk::class)) { + $userDisks = UserDisk::where('user_id', $user->id) + ->pluck('name', 'id') + ->mapWithKeys(fn ($name, $id) => ["disk-{$id}" => $name]); + + $disks = $disks->merge($userDisks); + } + + $offlineMode = config('biigle.offline_mode'); + + if (class_exists(UserStorageServiceProvider::class)) { + $userDisk = "user-{$user->id}"; + } else { + $userDisk = null; + } + + $isImageMediaType = $pv->media_type_id === MediaType::imageId(); + $mediaType = $isImageMediaType ? 'image' : 'video'; + + $metadata = null; + $oldName = ''; + $oldUrl = ''; + $oldHandle = ''; + if ($pv->hasMetadata()) { + $metadata = $pv->getMetadata(); + $oldName = $metadata->name; + $oldUrl = $metadata->url; + $oldHandle = $metadata->handle; + } + + $oldName = old('name', $oldName); + $oldUrl = old('url', $oldUrl); + $oldHandle = old('handle', $oldHandle); + + $filenamesFromMeta = false; + if ($filenames = old('files')) { + $filenames = str_replace(["\r", "\n", '"', "'"], '', old('files')); + } elseif ($metadata) { + $filenames = $metadata->getFiles()->pluck('name')->join(','); + $filenamesFromMeta = !empty($filenames); + } + + $hasAnnotations = $metadata && $metadata->hasAnnotations(); + $hasFileLabels = $metadata && $metadata->hasFileLabels(); + + return view('volumes.create.step2', [ + 'pv' => $pv, + 'project' => $pv->project, + 'disks' => $disks, + 'hasDisks' => $disks->isNotEmpty(), + 'filenames' => $filenames, + 'offlineMode' => $offlineMode, + 'userDisk' => $userDisk, + 'mediaType' => $mediaType, + 'isImageMediaType' => $isImageMediaType, + 'oldName' => $oldName, + 'oldUrl' => $oldUrl, + 'oldHandle' => $oldHandle, + 'filenamesFromMeta' => $filenamesFromMeta, + 'hasAnnotations' => $hasAnnotations, + 'hasFileLabels' => $hasFileLabels, + ]); + } + + /** + * Show the form to select labels of metadata annotations to import. + * + * @param Request $request + */ + public function showAnnotationLabels(Request $request) + { + $pv = PendingVolume::findOrFail($request->route('id')); + $this->authorize('update', $pv); + + if (is_null($pv->volume_id)) { + return redirect()->route('pending-volume', $pv->id); + } + + if (!$pv->hasMetadata()) { + abort(Response::HTTP_NOT_FOUND); + } + + $metadata = $pv->getMetadata(); + + if (!$metadata->hasAnnotations()) { + abort(Response::HTTP_NOT_FOUND); + } + + // Use values() for a more compact JSON representation. + $labels = collect($metadata->getAnnotationLabels())->values(); + + return view('volumes.create.annotationLabels', [ + 'pv' => $pv, + 'labels' => $labels, + ]); + } + + /** + * Show the form to select labels of metadata file labels to import. + * + * @param Request $request + */ + public function showFileLabels(Request $request) + { + $pv = PendingVolume::findOrFail($request->route('id')); + $this->authorize('update', $pv); + + if (is_null($pv->volume_id)) { + return redirect()->route('pending-volume', $pv->id); + } + + if (!$pv->hasMetadata()) { + abort(Response::HTTP_NOT_FOUND); + } + + $metadata = $pv->getMetadata(); + + if (!$metadata->hasFileLabels()) { + abort(Response::HTTP_NOT_FOUND); + } + + // Use values() for a more compact JSON representation. + $labels = collect($metadata->getFileLabels())->values(); + + return view('volumes.create.fileLabels', [ + 'pv' => $pv, + 'labels' => $labels, + ]); + } + + /** + * Show the form to select the label map for the metadata import. + * + * @param Request $request + */ + public function showLabelMap(Request $request) + { + $pv = PendingVolume::findOrFail($request->route('id')); + $this->authorize('update', $pv); + + if (is_null($pv->volume_id)) { + return redirect()->route('pending-volume', $pv->id); + } + + if (!$pv->hasMetadata()) { + abort(Response::HTTP_NOT_FOUND); + } + + $metadata = $pv->getMetadata(); + + $onlyLabels = $pv->only_annotation_labels + $pv->only_file_labels; + $labelMap = collect($metadata->getMatchingLabels(onlyLabels: $onlyLabels)); + + if ($labelMap->isEmpty()) { + abort(Response::HTTP_NOT_FOUND); + } + + // Merge with previously selected map on error. + $oldMap = collect(old('label_map', []))->map(fn ($v) => intval($v)); + $labelMap = $oldMap->union($labelMap); + + $labels = []; + + if ($pv->import_file_labels) { + $labels += $metadata->getFileLabels($pv->only_file_labels); + } + + if ($pv->import_annotations) { + $labels += $metadata->getAnnotationLabels($pv->only_annotation_labels); + } + + $labels = collect($labels)->values(); + + $project = $pv->project; + + // These label trees are required to display the pre-mapped labels. + $labelTrees = + LabelTree::whereIn( + 'id', + fn ($query) => + $query->select('label_tree_id') + ->from('labels') + ->whereIn('id', $labelMap->values()->unique()->filter()) + )->get()->keyBy('id'); + + // These trees can also be used for manual mapping. + $labelTrees = $labelTrees->union($project->labelTrees->keyBy('id'))->values(); + + $labelTrees->load('labels'); + + // Hide attributes for a more compact JSON representation. + $labelTrees->each(function ($tree) { + $tree->makeHidden(['visibility_id', 'created_at', 'updated_at']); + $tree->labels->each(function ($label) { + $label->makeHidden(['source_id', 'label_source_id', 'label_tree_id', 'parent_id']); + }); + }); + + return view('volumes.create.labelMap', [ + 'pv' => $pv, + 'labelMap' => $labelMap, + 'labels' => $labels, + 'labelTrees' => $labelTrees, + ]); + } + + /** + * Show the form to select the user map for the metadata import. + * + * @param Request $request + */ + public function showUserMap(Request $request) + { + $pv = PendingVolume::findOrFail($request->route('id')); + $this->authorize('update', $pv); + + if (is_null($pv->volume_id)) { + return redirect()->route('pending-volume', $pv->id); + } + + if (!$pv->hasMetadata()) { + abort(Response::HTTP_NOT_FOUND); + } + + $metadata = $pv->getMetadata(); + + $onlyLabels = $pv->only_annotation_labels + $pv->only_file_labels; + $userMap = collect($metadata->getMatchingUsers(onlyLabels: $onlyLabels)); + + if ($userMap->isEmpty()) { + abort(Response::HTTP_NOT_FOUND); + } + + // Merge with previously selected map on error. + $oldMap = collect(old('user_map', []))->map(fn ($v) => intval($v)); + $userMap = $oldMap->union($userMap); + + $users = collect($metadata->getUsers($onlyLabels)) + ->values() + ->pluck('name', 'id'); + + return view('volumes.create.userMap', [ + 'pv' => $pv, + 'userMap' => $userMap, + 'users' => $users, + ]); + } + + /** + * Show the view to finish the metadata import. + * + * @param Request $request + */ + public function showFinish(Request $request) + { + $pv = PendingVolume::findOrFail($request->route('id')); + $this->authorize('update', $pv); + + if (is_null($pv->volume_id)) { + return redirect()->route('pending-volume', $pv->id); + } + + if (!$pv->hasMetadata()) { + abort(Response::HTTP_NOT_FOUND); + } + + $metadata = $pv->getMetadata(); + + if (empty($metadata->getUsers())) { + abort(Response::HTTP_NOT_FOUND); + } + + $onlyLabels = $pv->only_annotation_labels + $pv->only_file_labels; + + $labelMap = $metadata->getMatchingLabels($pv->label_map, $onlyLabels); + $labelMapOk = !empty($labelMap) && array_search(null, $labelMap) === false; + + $userMap = $metadata->getMatchingUsers($pv->user_map, $onlyLabels); + $userMapOk = !empty($userMap) && array_search(null, $userMap) === false; + + return view('volumes.create.finish', [ + 'pv' => $pv, + 'labelMapOk' => $labelMapOk, + 'userMapOk' => $userMapOk, + ]); + } +} diff --git a/app/Http/Controllers/Views/Volumes/VolumeCloneController.php b/app/Http/Controllers/Views/Volumes/VolumeCloneController.php index 5d7b5ebe7..de5c05fb7 100644 --- a/app/Http/Controllers/Views/Volumes/VolumeCloneController.php +++ b/app/Http/Controllers/Views/Volumes/VolumeCloneController.php @@ -2,7 +2,6 @@ namespace Biigle\Http\Controllers\Views\Volumes; -use \Illuminate\Contracts\View\View; use Biigle\Http\Controllers\Views\Controller; use Biigle\LabelTree; use Biigle\Project; @@ -16,8 +15,6 @@ class VolumeCloneController extends Controller * Shows the volume clone page. * @param Request $request * @param $id volume ID - * - * @return View **/ public function clone(Request $request, $id) { @@ -37,7 +34,7 @@ public function clone(Request $request, $id) } // Collection of projects where cloned volume can be copied to. - $destProjects = $destProjectQuery->select('name', 'id')->get(); + $destProjects = $destProjectQuery->select(['name', 'id'])->get(); $labelTrees = LabelTree::select('id', 'name', 'version_id') ->with('labels', 'version') diff --git a/app/Http/Controllers/Views/Volumes/VolumeController.php b/app/Http/Controllers/Views/Volumes/VolumeController.php index 17266ecea..d75651e19 100644 --- a/app/Http/Controllers/Views/Volumes/VolumeController.php +++ b/app/Http/Controllers/Views/Volumes/VolumeController.php @@ -5,10 +5,8 @@ use Biigle\Http\Controllers\Views\Controller; use Biigle\LabelTree; use Biigle\MediaType; -use Biigle\Modules\UserDisks\UserDisk; -use Biigle\Modules\UserStorage\UserStorageServiceProvider; use Biigle\Project; -use Biigle\Role; +use Biigle\Services\MetadataParsing\ParserFactory; use Biigle\User; use Biigle\Volume; use Carbon\Carbon; @@ -20,55 +18,37 @@ class VolumeController extends Controller * Shows the create volume page. * * @param Request $request - * @return \Illuminate\Http\Response */ public function create(Request $request) { $project = Project::findOrFail($request->input('project')); $this->authorize('update', $project); - $disks = collect([]); - $user = $request->user(); - - if ($user->can('sudo')) { - $disks = $disks->concat(config('volumes.admin_storage_disks')); - } elseif ($user->role_id === Role::editorId()) { - $disks = $disks->concat(config('volumes.editor_storage_disks')); - } - - // Limit to disks that actually exist. - $disks = $disks->intersect(array_keys(config('filesystems.disks')))->values(); - - // Use the disk keys as names, too. UserDisks can have different names - // (see below). - $disks = $disks->combine($disks)->map(fn ($name) => ucfirst($name)); - - if (class_exists(UserDisk::class)) { - $userDisks = UserDisk::where('user_id', $user->id) - ->pluck('name', 'id') - ->mapWithKeys(fn ($name, $id) => ["disk-{$id}" => $name]); - - $disks = $disks->merge($userDisks); + $pv = $project->pendingVolumes()->where('user_id', $request->user()->id)->first(); + if (!is_null($pv)) { + return redirect() + ->route('pending-volume', $pv->id) + ->with('message', 'This is a pending volume that you did not finish before.') + ->with('messageType', 'info'); } $mediaType = old('media_type', 'image'); - $filenames = str_replace(["\r", "\n", '"', "'"], '', old('files')); - $offlineMode = config('biigle.offline_mode'); - if (class_exists(UserStorageServiceProvider::class)) { - $userDisk = "user-{$user->id}"; - } else { - $userDisk = null; + $parsers = collect(ParserFactory::$parsers); + foreach ($parsers as $type => $p) { + $parsers[$type] = array_map(function ($class) { + return [ + 'parserClass' => $class, + 'name' => $class::getName(), + 'mimeTypes' => $class::getKnownMimeTypes(), + ]; + }, $p); } - return view('volumes.create', [ + return view('volumes.create.step1', [ 'project' => $project, - 'disks' => $disks, - 'hasDisks' => !empty($disks), 'mediaType' => $mediaType, - 'filenames' => $filenames, - 'offlineMode' => $offlineMode, - 'userDisk' => $userDisk, + 'parsers' => $parsers, ]); } @@ -77,8 +57,6 @@ public function create(Request $request) * * @param Request $request * @param int $id volume ID - * - * @return \Illuminate\Http\Response */ public function index(Request $request, $id) { @@ -122,8 +100,6 @@ public function index(Request $request, $id) * * @param Request $request * @param int $id volume ID - * - * @return \Illuminate\Http\Response */ public function edit(Request $request, $id) { @@ -133,6 +109,15 @@ public function edit(Request $request, $id) $projects = $this->getProjects($request->user(), $volume); $type = $volume->mediaType->name; + $parsers = collect(ParserFactory::$parsers[$type] ?? []) + ->map(function ($class) { + return [ + 'parserClass' => $class, + 'name' => $class::getName(), + 'mimeTypes' => $class::getKnownMimeTypes(), + ]; + }); + return view('volumes.edit', [ 'projects' => $projects, 'volume' => $volume, @@ -140,6 +125,7 @@ public function edit(Request $request, $id) 'annotationSessions' => $sessions, 'today' => Carbon::today(), 'type' => $type, + 'parsers' => $parsers, ]); } diff --git a/app/Http/Middleware/AuthenticateRegister.php b/app/Http/Middleware/AuthenticateRegister.php index 341165582..fed559787 100644 --- a/app/Http/Middleware/AuthenticateRegister.php +++ b/app/Http/Middleware/AuthenticateRegister.php @@ -17,5 +17,7 @@ protected function redirectTo($request) if (!$request->expectsJson()) { return route('register'); } + + return ''; } } diff --git a/app/Http/Requests/DestroyAnnotationSession.php b/app/Http/Requests/DestroyAnnotationSession.php index bf98d25e9..bd0d5f9cb 100644 --- a/app/Http/Requests/DestroyAnnotationSession.php +++ b/app/Http/Requests/DestroyAnnotationSession.php @@ -12,7 +12,7 @@ class DestroyAnnotationSession extends FormRequest * * @var AnnotationSession */ - public $session; + public $annotationSession; /** * Determine if the user is authorized to make this request. @@ -21,9 +21,9 @@ class DestroyAnnotationSession extends FormRequest */ public function authorize() { - $this->session = AnnotationSession::findOrFail($this->route('id')); + $this->annotationSession = AnnotationSession::findOrFail($this->route('id')); - return $this->user()->can('update', $this->session->volume); + return $this->user()->can('update', $this->annotationSession->volume); } /** diff --git a/app/Http/Requests/StoreImageAnnotations.php b/app/Http/Requests/StoreImageAnnotations.php index 221db9e4c..d6bffa5d6 100644 --- a/app/Http/Requests/StoreImageAnnotations.php +++ b/app/Http/Requests/StoreImageAnnotations.php @@ -18,21 +18,21 @@ class StoreImageAnnotations extends FormRequest /** * Unique image IDs of this request. * - * @var array + * @var \Illuminate\Support\Collection */ public $imageIds; /** * The images on which the annotations should be created. * - * @var array + * @var \Illuminate\Database\Eloquent\Collection<\Biigle\Image> */ public $images; /** * The labels that should be attached to the new annotations. * - * @var array + * @var \Illuminate\Database\Eloquent\Collection */ public $labels; diff --git a/app/Http/Requests/StoreLabelTree.php b/app/Http/Requests/StoreLabelTree.php index 38d0b75cd..284fad460 100644 --- a/app/Http/Requests/StoreLabelTree.php +++ b/app/Http/Requests/StoreLabelTree.php @@ -11,14 +11,14 @@ class StoreLabelTree extends FormRequest /** * The project to which the new label tree should be attached (if any). * - * @var Project + * @var Project|null */ public $project; /** * The upstream label tree that should be forked (if any) * - * @var LabelTree + * @var LabelTree|null */ public $upstreamLabelTree; diff --git a/app/Http/Requests/StoreParseIfdo.php b/app/Http/Requests/StoreParseIfdo.php deleted file mode 100644 index faea4af2e..000000000 --- a/app/Http/Requests/StoreParseIfdo.php +++ /dev/null @@ -1,61 +0,0 @@ - 'required|file|max:500000', - ]; - } - - /** - * Configure the validator instance. - * - * @param \Illuminate\Validation\Validator $validator - * @return void - */ - public function withValidator($validator) - { - $validator->after(function ($validator) { - if ($this->hasFile('file')) { - try { - $this->metadata = $this->parseIfdoFile($this->file('file')); - } catch (Exception $e) { - $validator->errors()->add('file', $e->getMessage()); - } - } - }); - } -} diff --git a/app/Http/Requests/StorePendingVolume.php b/app/Http/Requests/StorePendingVolume.php new file mode 100644 index 000000000..ee9ffda41 --- /dev/null +++ b/app/Http/Requests/StorePendingVolume.php @@ -0,0 +1,115 @@ +project = Project::findOrFail($this->route('id')); + + return $this->user()->can('update', $this->project); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + + $rules = [ + 'media_type' => ['required', Rule::in(array_keys(MediaType::INSTANCES))], + 'metadata_parser' => [ + 'required_with:metadata_file', + ], + // Allow a maximum of 500 MB. + 'metadata_file' => [ + 'required_with:metadata_parser', + 'file', + 'max:500000', + ], + ]; + + $parserClass = $this->input('metadata_parser', false); + if ($this->has('media_type') && $parserClass && ParserFactory::has($this->input('media_type'), $parserClass)) { + $rules['metadata_file'][] = 'mimetypes:'.implode(',', $parserClass::getKnownMimeTypes()); + } + + return $rules; + } + + /** + * Configure the validator instance. + * + * @param \Illuminate\Validation\Validator $validator + * @return void + */ + public function withValidator($validator) + { + $validator->after(function ($validator) { + if ($validator->errors()->isNotEmpty()) { + return; + } + + $exists = $this->project->pendingVolumes() + ->where('user_id', $this->user()->id) + ->exists(); + if ($exists) { + $validator->errors()->add('id', 'Only a single pending volume can be created at a time for each project and user.'); + return; + } + + if ($file = $this->file('metadata_file')) { + $type = $this->input('media_type'); + $parserClass = $this->input('metadata_parser'); + + if (!ParserFactory::has($type, $parserClass)) { + $validator->errors()->add('metadata_parser', 'Unknown metadata parser for this media type.'); + return; + } + + $parser = new $parserClass($file); + if (!$parser->recognizesFile()) { + $validator->errors()->add('metadata_file', 'Unknown metadata file format.'); + return; + } + + $rule = match ($type) { + 'video' => new VideoMetadata, + default => new ImageMetadata, + }; + + if (!$rule->passes('metadata_file', $parser->getMetadata())) { + $validator->errors()->add('metadata_file', $rule->message()); + } + } + }); + } + + /** + * Prepare the data for validation. + * + * @return void + */ + protected function prepareForValidation() + { + // Allow a string as media_type to be more conventient. + $type = $this->input('media_type'); + if (in_array($type, array_keys(MediaType::INSTANCES))) { + $this->merge(['media_type_id' => MediaType::$type()->id]); + } + } +} diff --git a/app/Http/Requests/StorePendingVolumeImport.php b/app/Http/Requests/StorePendingVolumeImport.php new file mode 100644 index 000000000..ca9410e53 --- /dev/null +++ b/app/Http/Requests/StorePendingVolumeImport.php @@ -0,0 +1,142 @@ +pendingVolume = PendingVolume::findOrFail($this->route('id')); + + return $this->user()->can('update', $this->pendingVolume); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + // + ]; + } + + /** + * Configure the validator instance. + * + * @param \Illuminate\Validation\Validator $validator + * @return void + */ + public function withValidator($validator) + { + $validator->after(function ($validator) { + if ($validator->errors()->isNotEmpty()) { + return; + } + $pv = $this->pendingVolume; + + if (is_null($pv->volume_id)) { + $validator->errors()->add('id', 'A volume must be created from the pending volume first.'); + return; + } + + if ($pv->importing) { + $validator->errors()->add('id', 'An import is already in progress.'); + return; + } + + if (!$pv->import_annotations && !$pv->import_file_labels) { + $validator->errors()->add('id', 'Neither annotations nor file labels were set to be imported.'); + return; + } + + $metadata = $pv->getMetadata(); + if (is_null($metadata)) { + $validator->errors()->add('id', 'No metadata file found.'); + return; + } + + // Check file labels first because the annotations have a more expensive + // validation. + if ($pv->import_file_labels) { + $labels = $metadata->getFileLabels($pv->only_file_labels); + + if (empty($labels)) { + if ($pv->only_file_labels) { + $validator->errors()->add('id', 'There are no file labels to import with the chosen labels.'); + } else { + $validator->errors()->add('id', 'There are no file labels to import.'); + } + + return; + } + + $matchingUsers = $metadata->getMatchingUsers($pv->user_map, $pv->only_file_labels); + foreach ($matchingUsers as $id => $value) { + if (is_null($value)) { + $validator->errors()->add('id', "No matching database user could be found for metadata user ID {$id}."); + return; + } + } + + $matchingLabels = $metadata->getMatchingLabels($pv->label_map, $pv->only_file_labels); + foreach ($matchingLabels as $id => $value) { + if (is_null($value)) { + $validator->errors()->add('id', "No matching database label could be found for metadata label ID {$id}."); + return; + } + } + } + + if ($pv->import_annotations) { + $labels = $metadata->getAnnotationLabels($pv->only_annotation_labels); + + if (empty($labels)) { + if ($pv->only_annotation_labels) { + $validator->errors()->add('id', 'There are no annotations to import with the chosen labels.'); + } else { + $validator->errors()->add('id', 'There are no annotations to import.'); + } + + return; + } + + $matchingUsers = $metadata->getMatchingUsers($pv->user_map, $pv->only_annotation_labels); + foreach ($matchingUsers as $id => $value) { + if (is_null($value)) { + $validator->errors()->add('id', "No matching database user could be found for metadata user ID {$id}."); + return; + } + } + + $matchingLabels = $metadata->getMatchingLabels($pv->label_map, $pv->only_annotation_labels); + foreach ($matchingLabels as $id => $value) { + if (is_null($value)) { + $validator->errors()->add('id', "No matching database label could be found for metadata label ID {$id}."); + return; + } + } + + foreach ($metadata->getFiles() as $file) { + foreach ($file->getAnnotations() as $annotation) { + try { + $annotation->validate(); + } catch (Exception $e) { + $validator->errors()->add('id', "Invalid annotation for file {$file->name}: ".$e->getMessage()); + return; + } + } + } + } + }); + } +} diff --git a/app/Http/Requests/StorePinnedProject.php b/app/Http/Requests/StorePinnedProject.php index 8b59787be..7f7b3f952 100644 --- a/app/Http/Requests/StorePinnedProject.php +++ b/app/Http/Requests/StorePinnedProject.php @@ -48,7 +48,7 @@ public function withValidator($validator) $validator->after(function ($validator) { $pinnedCount = $this->user()->projects()->where('pinned', true)->count(); - if ($pinnedCount === 3 && !$this->project->pivot->pinned) { + if ($pinnedCount === 3 && !$this->project->getRelationValue('pivot')->pinned) { $validator->errors()->add('id', 'You cannot pin more than three projects.'); } }); diff --git a/app/Http/Requests/StoreVolume.php b/app/Http/Requests/StoreVolume.php index e466faec4..b40e1358a 100644 --- a/app/Http/Requests/StoreVolume.php +++ b/app/Http/Requests/StoreVolume.php @@ -9,16 +9,16 @@ use Biigle\Rules\VideoMetadata; use Biigle\Rules\VolumeFiles; use Biigle\Rules\VolumeUrl; -use Biigle\Traits\ParsesMetadata; +use Biigle\Services\MetadataParsing\ImageCsvParser; +use Biigle\Services\MetadataParsing\VideoCsvParser; use Biigle\Volume; -use Exception; +use File; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Http\UploadedFile; use Illuminate\Validation\Rule; class StoreVolume extends FormRequest { - use ParsesMetadata; - /** * The project to attach the new volume to. * @@ -26,6 +26,30 @@ class StoreVolume extends FormRequest */ public $project; + /** + * Class name of the metadata parser that should be used. + * + * @var string|null + */ + public $metadataParser = null; + + /** + * Filled if an uploaded metadata text was stored in a file. + * + * @var string|null + */ + protected $metadataPath; + + /** + * Remove potential temporary files. + */ + public function __destruct() + { + if (isset($this->metadataPath)) { + unlink($this->metadataPath); + } + } + /** * Determine if the user is authorized to make this request. * @@ -54,9 +78,7 @@ public function rules() 'array', ], 'handle' => ['bail', 'nullable', 'string', 'max:256', new Handle], - 'metadata_csv' => 'file|mimetypes:text/plain,text/csv,application/csv', - 'ifdo_file' => 'file', - 'metadata' => 'filled', + 'metadata_csv' => 'file|mimetypes:text/plain,text/csv,application/csv|max:500000', // Do not validate the maximum filename length with a 'files.*' rule because // this leads to a request timeout when the rule is expanded for a huge // number of files. This is checked in the VolumeFiles rule below. @@ -71,40 +93,41 @@ public function rules() */ public function withValidator($validator) { - if ($validator->fails()) { - return; - } - - // Only validate sample volume files after all other fields have been validated. $validator->after(function ($validator) { + // Only validate sample volume files after all other fields have been + // validated. + if ($validator->errors()->isNotEmpty()) { + return; + } + $files = $this->input('files'); $rule = new VolumeFiles($this->input('url'), $this->input('media_type_id')); if (!$rule->passes('files', $files)) { $validator->errors()->add('files', $rule->message()); } - if ($this->has('metadata')) { - if ($this->input('media_type_id') === MediaType::imageId()) { - $rule = new ImageMetadata($files); - } else { - $rule = new VideoMetadata($files); - } + if ($file = $this->file('metadata_csv')) { + $type = $this->input('media_type'); - if (!$rule->passes('metadata', $this->input('metadata'))) { - $validator->errors()->add('metadata', $rule->message()); + $parser = match ($type) { + 'video' => new VideoCsvParser($file), + default => new ImageCsvParser($file), + }; + + $this->metadataParser = get_class($parser); + + if (!$parser->recognizesFile()) { + $validator->errors()->add('metadata_file', 'Unknown metadata file format.'); + return; } - } - if ($this->hasFile('ifdo_file')) { - try { - // This throws an error if the iFDO is invalid. - $data = $this->parseIfdoFile($this->file('ifdo_file')); + $rule = match ($type) { + 'video' => new VideoMetadata, + default => new ImageMetadata, + }; - if ($data['media_type'] !== $this->input('media_type')) { - $validator->errors()->add('ifdo_file', 'The iFDO image-acquisition type does not match the media type of the volume.'); - } - } catch (Exception $e) { - $validator->errors()->add('ifdo_file', $e->getMessage()); + if (!$rule->passes('metadata', $parser->getMetadata())) { + $validator->errors()->add('metadata', $rule->message()); } } }); @@ -135,10 +158,13 @@ protected function prepareForValidation() $this->merge(['files' => Volume::parseFilesQueryString($files)]); } - if ($this->input('metadata_text')) { - $this->merge(['metadata' => $this->parseMetadata($this->input('metadata_text'))]); - } elseif ($this->hasFile('metadata_csv')) { - $this->merge(['metadata' => $this->parseMetadataFile($this->file('metadata_csv'))]); + if ($this->input('metadata_text') && !$this->file('metadata_csv')) { + $this->metadataPath = tempnam(sys_get_temp_dir(), 'volume_metadata'); + File::put($this->metadataPath, $this->input('metadata_text')); + $file = new UploadedFile($this->metadataPath, 'metadata.csv', 'text/csv', test: true); + // Reset this so the new file will be picked up. + unset($this->convertedFiles); + $this->files->add(['metadata_csv' => $file]); } // Backwards compatibility. diff --git a/app/Http/Requests/StoreVolumeMetadata.php b/app/Http/Requests/StoreVolumeMetadata.php index 3cc7fe2bc..df0753b2d 100644 --- a/app/Http/Requests/StoreVolumeMetadata.php +++ b/app/Http/Requests/StoreVolumeMetadata.php @@ -3,17 +3,13 @@ namespace Biigle\Http\Requests; use Biigle\Rules\ImageMetadata; -use Biigle\Rules\Utf8; use Biigle\Rules\VideoMetadata; -use Biigle\Traits\ParsesMetadata; +use Biigle\Services\MetadataParsing\ParserFactory; use Biigle\Volume; -use Exception; use Illuminate\Foundation\Http\FormRequest; class StoreVolumeMetadata extends FormRequest { - use ParsesMetadata; - /** * The volume to store the new metadata to. * @@ -28,6 +24,8 @@ class StoreVolumeMetadata extends FormRequest */ public function authorize() { + $this->volume = Volume::findOrFail($this->route('id')); + return $this->user()->can('update', $this->volume); } @@ -38,41 +36,24 @@ public function authorize() */ public function rules() { + $type = $this->volume->isImageVolume() ? 'image' : 'video'; + $parserClass = $this->input('parser', false); + $mimeTypes = []; + if ($parserClass && ParserFactory::has($type, $parserClass)) { + $mimeTypes = $parserClass::getKnownMimeTypes(); + } + return [ - 'metadata_csv' => [ - 'bail', - 'required_without_all:metadata_text,ifdo_file', + 'parser' => 'required', + 'file' => [ + 'required', 'file', - 'mimetypes:text/plain,text/csv,application/csv', - new Utf8, + 'max:500000', + 'mimetypes:'.implode(',', $mimeTypes), ], - 'metadata_text' => 'required_without_all:metadata_csv,ifdo_file', - 'ifdo_file' => 'required_without_all:metadata_csv,metadata_text|file', - 'metadata' => 'filled', ]; } - /** - * Prepare the data for validation. - * - * @return void - */ - protected function prepareForValidation() - { - $this->volume = Volume::findOrFail($this->route('id')); - - // Backwards compatibility. - if ($this->hasFile('file') && !$this->hasFile('metadata_csv')) { - $this->convertedFiles['metadata_csv'] = $this->file('file'); - } - - if ($this->hasFile('metadata_csv')) { - $this->merge(['metadata' => $this->parseMetadataFile($this->file('metadata_csv'))]); - } elseif ($this->input('metadata_text')) { - $this->merge(['metadata' => $this->parseMetadata($this->input('metadata_text'))]); - } - } - /** * Configure the validator instance. * @@ -81,36 +62,32 @@ protected function prepareForValidation() */ public function withValidator($validator) { - if ($validator->fails()) { - return; - } - $validator->after(function ($validator) { - if ($this->has('metadata')) { - $files = $this->volume->files()->pluck('filename')->toArray(); + if ($validator->errors()->isNotEmpty()) { + return; + } - if ($this->volume->isImageVolume()) { - $rule = new ImageMetadata($files); - } else { - $rule = new VideoMetadata($files); - } + $type = $this->volume->isImageVolume() ? 'image' : 'video'; + $parserClass = $this->input('parser'); + + if (!ParserFactory::has($type, $parserClass)) { + $validator->errors()->add('parser', 'Unknown metadata parser for this media type.'); + return; + } - if (!$rule->passes('metadata', $this->input('metadata'))) { - $validator->errors()->add('metadata', $rule->message()); - } + $parser = new $parserClass($this->file('file')); + if (!$parser->recognizesFile()) { + $validator->errors()->add('file', 'Unknown metadata file format.'); + return; } - if ($this->hasFile('ifdo_file')) { - try { - // This throws an error if the iFDO is invalid. - $data = $this->parseIfdoFile($this->file('ifdo_file')); + $rule = match ($type) { + 'video' => new VideoMetadata, + default => new ImageMetadata, + }; - if ($data['media_type'] !== $this->volume->mediaType->name) { - $validator->errors()->add('ifdo_file', 'The iFDO image-acquisition type does not match the media type of the volume.'); - } - } catch (Exception $e) { - $validator->errors()->add('ifdo_file', $e->getMessage()); - } + if (!$rule->passes('file', $parser->getMetadata())) { + $validator->errors()->add('file', $rule->message()); } }); } diff --git a/app/Http/Requests/UpdateAnnotationSession.php b/app/Http/Requests/UpdateAnnotationSession.php index 646bd21b5..7b2769969 100644 --- a/app/Http/Requests/UpdateAnnotationSession.php +++ b/app/Http/Requests/UpdateAnnotationSession.php @@ -12,7 +12,7 @@ class UpdateAnnotationSession extends FormRequest * * @var AnnotationSession */ - public $session; + public $annotationSession; /** * Determine if the user is authorized to make this request. @@ -21,9 +21,9 @@ class UpdateAnnotationSession extends FormRequest */ public function authorize() { - $this->session = AnnotationSession::findOrFail($this->route('id')); + $this->annotationSession = AnnotationSession::findOrFail($this->route('id')); - return $this->user()->can('update', $this->session->volume); + return $this->user()->can('update', $this->annotationSession->volume); } /** diff --git a/app/Http/Requests/UpdatePendingVolume.php b/app/Http/Requests/UpdatePendingVolume.php new file mode 100644 index 000000000..1736e0aa1 --- /dev/null +++ b/app/Http/Requests/UpdatePendingVolume.php @@ -0,0 +1,81 @@ +pendingVolume = PendingVolume::findOrFail($this->route('id')); + + return $this->user()->can('update', $this->pendingVolume); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'name' => 'required|max:512', + 'url' => ['required', 'string', 'max:256', new VolumeUrl], + 'files' => ['required', 'array', 'min:1'], + 'handle' => ['nullable', 'max:256', new Handle], + 'import_annotations' => 'bool', + 'import_file_labels' => 'bool', + // Do not validate the maximum filename length with a 'files.*' rule because + // this leads to a request timeout when the rule is expanded for a huge + // number of files. This is checked in the VolumeFiles rule below. + ]; + } + + /** + * Configure the validator instance. + * + * @param \Illuminate\Validation\Validator $validator + * @return void + */ + public function withValidator($validator) + { + $validator->after(function ($validator) { + // Only validate sample volume files after all other fields have been + // validated. + if ($validator->errors()->isNotEmpty()) { + return; + } + + $files = $this->input('files'); + $rule = new VolumeFiles($this->input('url'), $this->pendingVolume->media_type_id); + if (!$rule->passes('files', $files)) { + $validator->errors()->add('files', $rule->message()); + } + }); + } + + /** + * Prepare the data for validation. + * + * @return void + */ + protected function prepareForValidation() + { + $files = $this->input('files'); + if (!is_array($files)) { + $files = explode(',', $files); + } + + $files = array_map(fn ($f) => trim($f, " \n\r\t\v\x00'\""), $files); + $this->merge(['files' => array_filter($files)]); + } +} diff --git a/app/Http/Requests/UpdatePendingVolumeAnnotationLabels.php b/app/Http/Requests/UpdatePendingVolumeAnnotationLabels.php new file mode 100644 index 000000000..644544e21 --- /dev/null +++ b/app/Http/Requests/UpdatePendingVolumeAnnotationLabels.php @@ -0,0 +1,66 @@ +pendingVolume = PendingVolume::findOrFail($this->route('id')); + + return $this->user()->can('update', $this->pendingVolume); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'labels' => 'required|array|min:1', + ]; + } + + /** + * Configure the validator instance. + * + * @param \Illuminate\Validation\Validator $validator + * @return void + */ + public function withValidator($validator) + { + $validator->after(function ($validator) { + if ($validator->errors()->isNotEmpty()) { + return; + } + + if (is_null($this->pendingVolume->volume_id)) { + $validator->errors()->add('labels', 'A volume must be created from the pending volume first.'); + return; + } + + $labels = $this->input('labels'); + $metadata = $this->pendingVolume->getMetadata(); + if (is_null($metadata)) { + $validator->errors()->add('labels', 'No metadata file found.'); + return; + } + + $metaLabels = $metadata->getAnnotationLabels(); + foreach ($labels as $id) { + if (!array_key_exists($id, $metaLabels)) { + $validator->errors()->add('labels', "Label ID {$id} does not exist in the metadata file."); + return; + } + } + }); + } +} diff --git a/app/Http/Requests/UpdatePendingVolumeFileLabels.php b/app/Http/Requests/UpdatePendingVolumeFileLabels.php new file mode 100644 index 000000000..df89040db --- /dev/null +++ b/app/Http/Requests/UpdatePendingVolumeFileLabels.php @@ -0,0 +1,66 @@ +pendingVolume = PendingVolume::findOrFail($this->route('id')); + + return $this->user()->can('update', $this->pendingVolume); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'labels' => 'required|array|min:1', + ]; + } + + /** + * Configure the validator instance. + * + * @param \Illuminate\Validation\Validator $validator + * @return void + */ + public function withValidator($validator) + { + $validator->after(function ($validator) { + if ($validator->errors()->isNotEmpty()) { + return; + } + + if (is_null($this->pendingVolume->volume_id)) { + $validator->errors()->add('labels', 'A volume must be created from the pending volume first.'); + return; + } + + $labels = $this->input('labels'); + $metadata = $this->pendingVolume->getMetadata(); + if (is_null($metadata)) { + $validator->errors()->add('labels', 'No metadata file found.'); + return; + } + + $metaLabels = $metadata->getFileLabels(); + foreach ($labels as $id) { + if (!array_key_exists($id, $metaLabels)) { + $validator->errors()->add('labels', "Label ID {$id} does not exist in the metadata file."); + return; + } + } + }); + } +} diff --git a/app/Http/Requests/UpdatePendingVolumeLabelMap.php b/app/Http/Requests/UpdatePendingVolumeLabelMap.php new file mode 100644 index 000000000..d276fe0c3 --- /dev/null +++ b/app/Http/Requests/UpdatePendingVolumeLabelMap.php @@ -0,0 +1,102 @@ +pendingVolume = PendingVolume::findOrFail($this->route('id')); + + return $this->user()->can('update', $this->pendingVolume); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'label_map' => 'required|array|min:1', + 'label_map.*' => 'int', + ]; + } + + /** + * Configure the validator instance. + * + * @param \Illuminate\Validation\Validator $validator + * @return void + */ + public function withValidator($validator) + { + $validator->after(function ($validator) { + if ($validator->errors()->isNotEmpty()) { + return; + } + + if (is_null($this->pendingVolume->volume_id)) { + $validator->errors()->add('label_map', 'A volume must be created from the pending volume first.'); + return; + } + + $metadata = $this->pendingVolume->getMetadata(); + if (is_null($metadata)) { + $validator->errors()->add('label_map', 'No metadata file found.'); + return; + } + + $map = $this->input('label_map'); + $metaLabels = $metadata->getFileLabels() + $metadata->getAnnotationLabels(); + foreach ($map as $id => $dbId) { + if (!array_key_exists($id, $metaLabels)) { + $validator->errors()->add('label_map', "Label ID {$id} does not exist in the metadata file."); + return; + } + } + + $onlyLabels = $this->pendingVolume->only_annotation_labels + $this->pendingVolume->only_file_labels; + if (!empty($onlyLabels)) { + $diff = array_diff(array_keys($map), $onlyLabels); + if (!empty($diff)) { + $validator->errors()->add('label_map', 'Some chosen metadata labels were excluded by a previously defined subset of annotation and/or file labels to import.'); + } + } + + $uniqueIds = array_values(array_unique($map)); + $count = Label::whereIn('id', $uniqueIds)->count(); + if (count($uniqueIds) !== $count) { + $validator->errors()->add('label_map', 'Some label IDs do not exist in the database.'); + } + + $count = Label::whereIn('id', $uniqueIds) + ->whereIn('label_tree_id', function ($query) { + // All public and all accessible private label trees. + $query->select('id') + ->from('label_trees') + ->where('visibility_id', Visibility::publicId()) + ->union( + DB::table('label_tree_user') + ->select('label_tree_id as id') + ->where('user_id', $this->user()->id) + ); + }) + ->count(); + + if (count($uniqueIds) !== $count) { + $validator->errors()->add('label_map', 'You do not have access to some label IDs in the database.'); + } + }); + } +} diff --git a/app/Http/Requests/UpdatePendingVolumeUserMap.php b/app/Http/Requests/UpdatePendingVolumeUserMap.php new file mode 100644 index 000000000..28451f48e --- /dev/null +++ b/app/Http/Requests/UpdatePendingVolumeUserMap.php @@ -0,0 +1,74 @@ +pendingVolume = PendingVolume::findOrFail($this->route('id')); + + return $this->user()->can('update', $this->pendingVolume); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'user_map' => 'required|array|min:1', + 'user_map.*' => 'int', + ]; + } + + /** + * Configure the validator instance. + * + * @param \Illuminate\Validation\Validator $validator + * @return void + */ + public function withValidator($validator) + { + $validator->after(function ($validator) { + if ($validator->errors()->isNotEmpty()) { + return; + } + + if (is_null($this->pendingVolume->volume_id)) { + $validator->errors()->add('user_map', 'A volume must be created from the pending volume first.'); + return; + } + + $metadata = $this->pendingVolume->getMetadata(); + if (is_null($metadata)) { + $validator->errors()->add('user_map', 'No metadata file found.'); + return; + } + + $map = $this->input('user_map'); + $metaUsers = $metadata->getUsers(); + foreach ($map as $id => $dbId) { + if (!array_key_exists($id, $metaUsers)) { + $validator->errors()->add('user_map', "User ID {$id} does not exist in the metadata file."); + return; + } + } + + $uniqueIds = array_values(array_unique($map)); + $count = User::whereIn('id', $uniqueIds)->count(); + if (count($uniqueIds) !== $count) { + $validator->errors()->add('user_map', 'Some user IDs do not exist in the database.'); + } + }); + } +} diff --git a/app/Image.php b/app/Image.php index c6eab803b..926aa6aaa 100644 --- a/app/Image.php +++ b/app/Image.php @@ -16,7 +16,7 @@ class Image extends VolumeFile /** * Allowed image MIME types. * - * @var array + * @var array */ const MIMES = [ 'image/jpeg', @@ -25,10 +25,26 @@ class Image extends VolumeFile 'image/webp', ]; + /** + * The attributes that are mass assignable. + * + * @var array + */ + protected $fillable = [ + 'filename', + 'volume_id', + 'uuid', + 'taken_at', + 'lng', + 'lat', + 'attrs', + 'tiled', + ]; + /** * The attributes hidden in the model's JSON form. * - * @var array + * @var array */ protected $hidden = [ 'labels', @@ -37,7 +53,7 @@ class Image extends VolumeFile /** * The attributes that should be casted to native types. * - * @var array + * @var array */ protected $casts = [ 'attrs' => 'array', @@ -50,7 +66,7 @@ class Image extends VolumeFile /** * The annotations on this image. * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function annotations() { @@ -60,7 +76,7 @@ public function annotations() /** * The labels, this image got attached by the users. * - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function labels() { @@ -70,7 +86,7 @@ public function labels() /** * Get the original image as download response. * - * @return Response + * @return array|\Illuminate\Http\RedirectResponse|\Symfony\Component\HttpFoundation\StreamedResponse */ public function getFile() { diff --git a/app/ImageAnnotation.php b/app/ImageAnnotation.php index b25e16443..080c9f6c6 100644 --- a/app/ImageAnnotation.php +++ b/app/ImageAnnotation.php @@ -11,7 +11,7 @@ class ImageAnnotation extends Annotation /** * The attributes that should be casted to native types. * - * @var array + * @var array */ protected $casts = [ 'image_id' => 'int', @@ -22,7 +22,7 @@ class ImageAnnotation extends Annotation /** * The image, this annotation belongs to. * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function image() { @@ -30,9 +30,7 @@ public function image() } /** - * The file, this annotation belongs to. - * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * {@inheritdoc} */ public function file() { @@ -50,9 +48,7 @@ public function getFileIdAttribute() } /** - * The labels, this annotation got assigned by the users. - * - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * {@inheritdoc} */ public function labels() { diff --git a/app/ImageAnnotationLabel.php b/app/ImageAnnotationLabel.php index 3e899fdd6..1554d3c3e 100644 --- a/app/ImageAnnotationLabel.php +++ b/app/ImageAnnotationLabel.php @@ -10,7 +10,7 @@ class ImageAnnotationLabel extends AnnotationLabel /** * The attributes excluded from the model's JSON form. * - * @var array + * @var array */ protected $hidden = [ 'created_at', @@ -20,7 +20,7 @@ class ImageAnnotationLabel extends AnnotationLabel /** * The attributes that should be casted to native types. * - * @var array + * @var array */ protected $casts = [ 'user_id' => 'int', @@ -31,7 +31,7 @@ class ImageAnnotationLabel extends AnnotationLabel /** * The annotation, this annotation label belongs to. * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function annotation() { diff --git a/app/ImageLabel.php b/app/ImageLabel.php index 15e1fe209..f54cdffd3 100644 --- a/app/ImageLabel.php +++ b/app/ImageLabel.php @@ -5,9 +5,7 @@ class ImageLabel extends VolumeFileLabel { /** - * The file, this volume file label belongs to. - * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * {@inheritdoc} */ public function file() { @@ -17,7 +15,7 @@ public function file() /** * The image, this image label belongs to. * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function image() { diff --git a/app/Jobs/CloneImagesOrVideos.php b/app/Jobs/CloneImagesOrVideos.php index 776213afe..aa60e2b51 100644 --- a/app/Jobs/CloneImagesOrVideos.php +++ b/app/Jobs/CloneImagesOrVideos.php @@ -8,10 +8,7 @@ use Biigle\ImageAnnotation; use Biigle\ImageAnnotationLabel; use Biigle\ImageLabel; -use Biigle\Modules\Largo\Jobs\ProcessAnnotatedImage; -use Biigle\Modules\Largo\Jobs\ProcessAnnotatedVideo; use Biigle\Project; -use Biigle\Traits\ChecksMetadataStrings; use Biigle\Video; use Biigle\VideoAnnotation; use Biigle\VideoAnnotationLabel; @@ -26,7 +23,7 @@ class CloneImagesOrVideos extends Job implements ShouldQueue { - use InteractsWithQueue, SerializesModels, ChecksMetadataStrings; + use InteractsWithQueue, SerializesModels; /** @@ -137,54 +134,21 @@ public function handle() } } if ($copy->files()->exists()) { - $this->postProcessCloning($copy); + ProcessNewVolumeFiles::dispatch($copy); } - //save ifdo-file if exist - if ($volume->hasIfdo()) { - $this->copyIfdoFile($volume->id, $copy->id); + if ($volume->hasMetadata()) { + $this->copyMetadataFile($volume, $copy); } $copy->creating_async = false; $copy->save(); - event('volume.cloned', [$copy->id]); + event('volume.cloned', $copy); }); } - /** - * Initiate file thumbnail creation - * @param Volume $volume for which thumbnail creation should be started - * @return void - **/ - public function postProcessCloning($volume) - { - ProcessNewVolumeFiles::dispatch($volume); - - // Give the ProcessNewVolumeFiles job a head start so the file thumbnails are - // generated (mostly) before the annotation thumbnails. - $delay = now()->addSeconds(30); - - if (class_exists(ProcessAnnotatedImage::class)) { - $volume->images()->whereHas('annotations') - ->eachById(function ($image) use ($delay) { - ProcessAnnotatedImage::dispatch($image) - ->delay($delay) - ->onQueue(config('largo.generate_annotation_patch_queue')); - }); - } - - if (class_exists(ProcessAnnotatedVideo::class)) { - $volume->videos() - ->whereHas('annotations')->eachById(function ($video) use ($delay) { - ProcessAnnotatedVideo::dispatch($video) - ->delay($delay) - ->onQueue(config('largo.generate_annotation_patch_queue')); - }); - } - } - /** * Copies (selected) images from given volume to volume copy. * @@ -247,6 +211,7 @@ private function copyImageAnnotation($volume, $copy, $selectedFileIds, $selected $chunkNewImageIds = []; // Consider all previous image chunks when calculating the start of the index. $baseImageIndex = ($page - 1) * $chunkSize; + /** @var Image $image */ foreach ($chunk as $index => $image) { $newImageId = $newImageIds[$baseImageIndex + $index]; // Collect relevant image IDs for the annotation query below. @@ -271,6 +236,7 @@ private function copyImageAnnotation($volume, $copy, $selectedFileIds, $selected ->orderBy('id') ->pluck('id'); $insertData = []; + /** @var Image $image */ foreach ($chunk as $image) { foreach ($image->annotations as $annotation) { if ($annotation->labels->isEmpty()) { @@ -390,6 +356,7 @@ private function copyVideoAnnotation($volume, $copy, $selectedFileIds, $selected $chunkNewVideoIds = []; // Consider all previous video chunks when calculating the start of the index. $baseVideoIndex = ($page - 1) * $chunkSize; + /** @var Video $video */ foreach ($chunk as $index => $video) { $newVideoId = $newVideoIds[$baseVideoIndex + $index]; // Collect relevant video IDs for the annotation query below. @@ -409,12 +376,12 @@ private function copyVideoAnnotation($volume, $copy, $selectedFileIds, $selected } collect($insertData)->chunk($parameterLimit)->each(fn ($chunk) => VideoAnnotation::insert($chunk->toArray())); - // Get the IDs of all newly inserted annotations. Ordering is essential. $newAnnotationIds = VideoAnnotation::whereIn('video_id', $chunkNewVideoIds) ->orderBy('id') ->pluck('id'); $insertData = []; + /** @var Video $video */ foreach ($chunk as $video) { foreach ($video->annotations as $annotation) { if ($annotation->labels->isEmpty()) { @@ -473,16 +440,10 @@ private function copyVideoLabels($volume, $copy, $selectedFileIds, $selectedLabe }); } - /** Copies ifDo-Files from given volume to volume copy. - * - * @param int $volumeId - * @param int $copyId - **/ - private function copyIfdoFile($volumeId, $copyId) + private function copyMetadataFile(Volume $source, Volume $target): void { - $disk = Storage::disk(config('volumes.ifdo_storage_disk')); - $iFdoFilename = $volumeId.".yaml"; - $copyIFdoFilename = $copyId.".yaml"; - $disk->copy($iFdoFilename, $copyIFdoFilename); + $disk = Storage::disk(config('volumes.metadata_storage_disk')); + // The target metadata file path was updated in the controller method. + $disk->copy($source->metadata_file_path, $target->metadata_file_path); } } diff --git a/app/Jobs/CreateNewImagesOrVideos.php b/app/Jobs/CreateNewImagesOrVideos.php index c266698b2..4f43c9216 100644 --- a/app/Jobs/CreateNewImagesOrVideos.php +++ b/app/Jobs/CreateNewImagesOrVideos.php @@ -3,11 +3,9 @@ namespace Biigle\Jobs; use Biigle\Image; -use Biigle\Rules\ImageMetadata; -use Biigle\Traits\ChecksMetadataStrings; +use Biigle\Services\MetadataParsing\VolumeMetadata; use Biigle\Video; use Biigle\Volume; -use Carbon\Carbon; use DB; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\InteractsWithQueue; @@ -16,7 +14,7 @@ class CreateNewImagesOrVideos extends Job implements ShouldQueue { - use InteractsWithQueue, SerializesModels, ChecksMetadataStrings; + use InteractsWithQueue, SerializesModels; /** * The volume to create the files for. @@ -44,15 +42,13 @@ class CreateNewImagesOrVideos extends Job implements ShouldQueue * * @param Volume $volume The volume to create the files for. * @param array $filenames The filenames of the files to create. - * @param array $metadata File metadata (one row per file plus column headers). * * @return void */ - public function __construct(Volume $volume, array $filenames, $metadata = []) + public function __construct(Volume $volume, array $filenames) { $this->volume = $volume; $this->filenames = $filenames; - $this->metadata = $metadata; } /** @@ -67,17 +63,16 @@ public function handle() DB::transaction(function () { $chunks = collect($this->filenames)->chunk(1000); + $metadata = $this->volume->getMetadata(); if ($this->volume->isImageVolume()) { - $metadataMap = $this->generateImageMetadataMap(); - $chunks->each(function ($chunk) use ($metadataMap) { - Image::insert($this->createFiles($chunk->toArray(), $metadataMap)); - }); + $chunks->each( + fn ($chunk) => Image::insert($this->createFiles($chunk->toArray(), $metadata)) + ); } else { - $metadataMap = $this->generateVideoMetadataMap(); - $chunks->each(function ($chunk) use ($metadataMap) { - Video::insert($this->createFiles($chunk->toArray(), $metadataMap)); - }); + $chunks->each( + fn ($chunk) => Video::insert($this->createFiles($chunk->toArray(), $metadata)) + ); } }); @@ -107,142 +102,45 @@ public function handle() /** * Create an array to be inserted as new image or video models. - * - * @param array $filenames New image/video filenames. - * @param \Illuminate\Support\Collection $metadataMap - * - * @return array */ - protected function createFiles($filenames, $metadataMap) + protected function createFiles(array $filenames, ?VolumeMetadata $metadata): array { - return array_map(function ($filename) use ($metadataMap) { - // This makes sure that the inserts have the same number of columns even if - // some images have additional metadata and others not. - $insert = array_fill_keys( - array_merge(['attrs'], ImageMetadata::ALLOWED_ATTRIBUTES), - null - ); + $metaKeys = []; + $insertData = []; - $insert = array_merge($insert, [ - 'filename' => $filename, - 'volume_id' => $this->volume->id, - 'uuid' => (string) Uuid::uuid4(), - ]); + foreach ($filenames as $filename) { + $insert = []; - $metadata = collect($metadataMap->get($filename)); - if ($metadata) { - // Remove empty cells. - $metadata = $metadata->filter(); - $insert = array_merge( - $insert, - $metadata->only(ImageMetadata::ALLOWED_ATTRIBUTES)->toArray() - ); + if ($metadata && ($fileMeta = $metadata->getFile($filename))) { + $insert = array_map(function ($item) { + if (is_array($item)) { + return json_encode($item); + } - $more = $metadata->only(ImageMetadata::ALLOWED_METADATA); - if ($more->isNotEmpty()) { - $insert['attrs'] = collect(['metadata' => $more])->toJson(); - } + return $item; + }, $fileMeta->getInsertData()); } - return $insert; - }, $filenames); - } - /** - * Generate a map for image metadata that is indexed by filename. - * - * @return \Illuminate\Support\Collection - */ - protected function generateImageMetadataMap() - { - if (empty($this->metadata)) { - return collect([]); - } - - $columns = $this->metadata[0]; - - $map = collect(array_slice($this->metadata, 1)) - ->map(fn ($row) => array_combine($columns, $row)) - ->map(function ($row) { - if (array_key_exists('taken_at', $row)) { - $row['taken_at'] = Carbon::parse($row['taken_at']); - } + $metaKeys = array_merge($metaKeys, array_keys($insert)); - return $row; - }) - ->keyBy('filename'); - - $map->forget('filename'); - - return $map; - } + $insert = array_merge($insert, [ + 'filename' => $filename, + 'volume_id' => $this->volume->id, + 'uuid' => (string) Uuid::uuid4(), + ]); - /** - * Generate a map for video metadata that is indexed by filename. - * - * @return \Illuminate\Support\Collection - */ - protected function generateVideoMetadataMap() - { - if (empty($this->metadata)) { - return collect([]); + $insertData[] = $insert; } - $columns = $this->metadata[0]; - - $map = collect(array_slice($this->metadata, 1)) - ->map(fn ($row) => array_combine($columns, $row)) - ->map(function ($row) { - if (array_key_exists('taken_at', $row)) { - $row['taken_at'] = Carbon::parse($row['taken_at']); - } else { - $row['taken_at'] = null; - } - - return $row; - }) - ->sortBy('taken_at') - ->groupBy('filename') - ->map(fn ($entries) => $this->processVideoColumns($entries, $columns)); + $metaKeys = array_unique($metaKeys); - return $map; - } - - /** - * Generate the metadata map entry for a single video file. - * - * @param \Illuminate\Support\Collection $entries - * @param \Illuminate\Support\Collection $columns - * - * @return \Illuminate\Support\Collection - */ - protected function processVideoColumns($entries, $columns) - { - $return = collect([]); - foreach ($columns as $column) { - $values = $entries->pluck($column); - if ($values->filter([$this, 'isFilledString'])->isEmpty()) { - // Ignore completely empty columns. - continue; - } - - $return[$column] = $values; - - if (in_array($column, array_keys(ImageMetadata::NUMERIC_FIELDS))) { - $return[$column] = $return[$column]->map(function ($x) { - // This check is required since floatval would return 0 for - // an empty value. This could skew metadata. - return $this->isFilledString($x) ? floatval($x) : null; - }); - } - - if (in_array($column, ImageMetadata::ALLOWED_ATTRIBUTES)) { - $return[$column] = $return[$column]->toJson(); - } + // Ensure that each item has the same keys even if some are missing metadata. + if (!empty($metaKeys)) { + $fill = array_fill_keys($metaKeys, null); + $insertData = array_map(fn ($i) => array_merge($fill, $i), $insertData); } - $return->forget('filename'); - - return $return; + return $insertData; } } diff --git a/app/Jobs/GenerateFederatedSearchIndex.php b/app/Jobs/GenerateFederatedSearchIndex.php index 34a73980d..2f91b7484 100644 --- a/app/Jobs/GenerateFederatedSearchIndex.php +++ b/app/Jobs/GenerateFederatedSearchIndex.php @@ -36,7 +36,7 @@ public function handle() $index['users'] = []; User::whereIn('id', array_unique($userIds)) ->select('id', 'uuid') - ->eachById(function ($user) use (&$index) { + ->eachById(function (User $user) use (&$index) { $index['users'][] = [ 'id' => $user->id, 'uuid' => $user->uuid, @@ -59,7 +59,7 @@ protected function generateLabelTreeIndex() // Versions and global label trees should not be indexed. LabelTree::withoutVersions() ->whereHas('members') - ->eachById(function ($tree) use (&$trees) { + ->eachById(function (LabelTree $tree) use (&$trees) { $trees[] = [ 'id' => $tree->id, 'name' => $tree->name, @@ -82,7 +82,7 @@ protected function generateLabelTreeIndex() protected function generateProjectIndex() { $projects = []; - Project::eachById(function ($project) use (&$projects) { + Project::eachById(function (Project $project) use (&$projects) { $projects[] = [ 'id' => $project->id, 'name' => $project->name, @@ -115,7 +115,7 @@ protected function generateProjectIndex() protected function generateVolumeIndex() { $volumes = []; - Volume::eachById(function ($volume) use (&$volumes) { + Volume::eachById(function (Volume $volume) use (&$volumes) { $volumes[] = [ 'id' => $volume->id, 'name' => $volume->name, @@ -123,7 +123,7 @@ protected function generateVolumeIndex() 'updated_at' => strval($volume->updated_at), 'url' => route('volume', $volume->id, false), 'thumbnail_url' => $volume->thumbnailUrl, - 'thumbnail_urls' => $volume->thumbnailUrls, + 'thumbnail_urls' => $volume->thumbnailsUrl, ]; }); diff --git a/app/Jobs/ImportVolumeMetadata.php b/app/Jobs/ImportVolumeMetadata.php new file mode 100644 index 000000000..4506c5bf5 --- /dev/null +++ b/app/Jobs/ImportVolumeMetadata.php @@ -0,0 +1,215 @@ +pv->volume->creating_async) { + // Wait 10 minutes so the volume has a chance to finish creating the files. + // Do this for a maximum of 120 min (12 tries). + $this->release(600); + + return; + } + + DB::transaction(function () { + $metadata = $this->pv->getMetadata(); + + $annotationUserMap = $metadata->getMatchingUsers($this->pv->user_map, $this->pv->only_annotation_labels); + $annotationLabelMap = $metadata->getMatchingLabels($this->pv->label_map, $this->pv->only_annotation_labels); + + $fileLabelUserMap = $metadata->getMatchingUsers($this->pv->user_map, $this->pv->only_file_labels); + $fileLabelLabelMap = $metadata->getMatchingLabels($this->pv->label_map, $this->pv->only_file_labels); + + foreach ($this->pv->volume->files()->lazyById() as $file) { + $metaFile = $metadata->getFile($file->filename); + if (!$metaFile) { + continue; + } + + if ($this->pv->import_annotations && $metaFile->hasAnnotations()) { + $this->insertAnnotations($metaFile, $file, $annotationUserMap, $annotationLabelMap); + } + + if ($this->pv->import_file_labels && $metaFile->hasFileLabels()) { + $this->insertFileLabels($metaFile, $file, $fileLabelUserMap, $fileLabelLabelMap); + } + } + }); + + $this->pv->delete(); + } + + /** + * Insert metadata annotations of a file into the database. + */ + protected function insertAnnotations( + FileMetadata $meta, + VolumeFile $file, + array $userMap, + array $labelMap + ): void { + $insertAnnotations = []; + $insertAnnotationLabels = []; + $now = now()->toDateTimeString(); + + foreach ($meta->getAnnotations() as $index => $annotation) { + // This will remove labels that should be ignored based on $onlyLabels and + // that have no match in the database. + $annotationLabels = array_filter( + $annotation->labels, + fn ($lau) => !is_null($labelMap[$lau->label->id] ?? null) + ); + + if (empty($annotationLabels)) { + continue; + } + + $insertAnnotations[] = array_merge($annotation->getInsertData($file->id), [ + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $insertAnnotationLabels[] = array_map(fn ($lau) => [ + 'label_id' => $labelMap[$lau->label->id], + 'user_id' => $userMap[$lau->user->id], + 'created_at' => $now, + 'updated_at' => $now, + ], $annotationLabels); + + // Insert in chunks because a single file can have tens of thousands of + // annotations (e.g. a video or mosaic). + if (($index % static::$insertChunkSize) === 0) { + $this->insertAnnotationChunk($file, $insertAnnotations, $insertAnnotationLabels); + $insertAnnotations = []; + $insertAnnotationLabels = []; + } + } + + if (!empty($insertAnnotations)) { + $this->insertAnnotationChunk($file, $insertAnnotations, $insertAnnotationLabels); + } + } + + protected function insertAnnotationChunk( + VolumeFile $file, + array $annotations, + array $annotationLabels + ): void { + $file->annotations()->insert($annotations); + + $ids = $file->annotations() + ->orderBy('id', 'desc') + ->take(count($annotations)) + ->pluck('id') + ->reverse() + ->toArray(); + + foreach ($ids as $index => $id) { + foreach ($annotationLabels[$index] as &$i) { + $i['annotation_id'] = $id; + } + } + + // Flatten. Use array_values to prevent accidental array unpacking with string + // keys (which makes the linter complain). + $annotationLabels = array_merge(...array_values($annotationLabels)); + + if ($file instanceof Image) { + foreach ($annotationLabels as &$i) { + $i['confidence'] = 1.0; + } + + ImageAnnotationLabel::insert($annotationLabels); + } else { + VideoAnnotationLabel::insert($annotationLabels); + } + } + + /** + * Insert metadata file labels of a file into the database. + */ + protected function insertFileLabels( + FileMetadata $meta, + VolumeFile $file, + array $userMap, + array $labelMap + ): void { + // This will remove labels that should be ignored based on $onlyLabels and + // that have no match in the database. + $fileLabels = array_filter( + $meta->getFileLabels(), + fn ($lau) => !is_null($labelMap[$lau->label->id] ?? null) + ); + + if (empty($fileLabels)) { + return; + } + + $insertFileLabels = array_map(fn ($lau) => [ + 'label_id' => $labelMap[$lau->label->id], + 'user_id' => $userMap[$lau->user->id], + ], $fileLabels); + + if ($file instanceof Image) { + foreach ($insertFileLabels as &$i) { + $i['image_id'] = $file->id; + } + + ImageLabel::insert($insertFileLabels); + } else { + foreach ($insertFileLabels as &$i) { + $i['video_id'] = $file->id; + } + + VideoLabel::insert($insertFileLabels); + } + } +} diff --git a/app/Jobs/ProcessNewImage.php b/app/Jobs/ProcessNewImage.php index 10491f3d9..7b141e1f9 100644 --- a/app/Jobs/ProcessNewImage.php +++ b/app/Jobs/ProcessNewImage.php @@ -13,8 +13,8 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Storage; +use Jcupitt\Vips\Image as VipsImage; use Log; -use VipsImage; class ProcessNewImage extends Job implements ShouldQueue { @@ -153,7 +153,7 @@ protected function collectMetadata(Image $image, $path) $image->mimetype = File::mimeType($path); try { - $i = VipsImage::newFromFile($path); + $i = $this->getVipsImage($path); $image->width = $i->width; $image->height = $i->height; } catch (Exception $e) { @@ -206,6 +206,18 @@ protected function collectMetadata(Image $image, $path) 'gps_altitude' => $ref * $this->fracToFloat($exif['GPSAltitude']), ]); } + + if ($this->hasGpsImgDirInfo($exif)) { + $image->metadata = array_merge($image->metadata, [ + 'yaw' => $this->fracToFloat($exif['GPSImgDirection']), + ]); + } + + if ($this->hasSubjectAreaInfo($exif)) { + $image->metadata = array_merge($image->metadata, [ + 'area' => $this->fracToFloat($exif['SubjectArea']), + ]); + } } $image->save(); @@ -248,6 +260,28 @@ protected function hasExtendedGpsInfo(array $exif) array_key_exists('GPSAltitudeRef', $exif); } + /** + * Check if an exif array contains GPSImgDirection information. + * + * @param array $exif + * @return bool + */ + protected function hasGpsImgDirInfo(array $exif) + { + return array_key_exists('GPSImgDirection', $exif); + } + + /** + * Check if an exif array contains SubjectArea information. + * + * @param array $exif + * @return bool + */ + protected function hasSubjectAreaInfo(array $exif) + { + return array_key_exists('SubjectArea', $exif); + } + /** * Get the exif information of an image if possible. * @@ -296,18 +330,14 @@ protected function fracToFloat($frac) $parts = explode('/', $frac); $count = count($parts); - if ($count === 0) { - return 0; - } elseif ($count === 1) { - return $parts[0]; - } - - // Don't use === to catch all incorrect values. - if ($parts[1] == 0) { - return 0; + // Don't use !== 0 to catch all incorrect values. + if ($count === 2 && $parts[1] != 0) { + return floatval($parts[0]) / floatval($parts[1]); + } elseif (is_numeric($parts[0])) { + return floatval($parts[0]); } - return floatval($parts[0]) / floatval($parts[1]); + return 0; } /** @@ -346,4 +376,16 @@ protected function submitTileJob(Image $image) $image->save(); TileSingleImage::dispatch($image); } + + /** + * Get a Vips image instance for the file. + * + * @param string $path + * + * @return VipsImage + */ + protected function getVipsImage(string $path) + { + return VipsImage::newFromFile($path); + } } diff --git a/app/Jobs/ProcessNewVideo.php b/app/Jobs/ProcessNewVideo.php index 0c830c4da..09309608d 100644 --- a/app/Jobs/ProcessNewVideo.php +++ b/app/Jobs/ProcessNewVideo.php @@ -16,9 +16,9 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +use Jcupitt\Vips\Image as VipsImage; use Log; use Throwable; -use VipsImage; class ProcessNewVideo extends Job implements ShouldQueue { @@ -40,11 +40,16 @@ class ProcessNewVideo extends Job implements ShouldQueue /** * The FFMpeg video instance. - * - * @var \FFMpeg\Media\Video */ protected $ffmpegVideo; + /** + * The FFProbe instance. + * + * @var FFProbe|null + */ + protected $ffprobe; + /** * Ignore this job if the video does not exist any more. * @@ -232,8 +237,8 @@ protected function generateVideoThumbnail($path, $time, $width, $height, $format $this->ffmpegVideo = FFMpeg::create()->open($path); } - $buffer = $this->ffmpegVideo->frame(TimeCode::fromSeconds($time)) - ->save(null, false, true); + $buffer = (string) $this->ffmpegVideo->frame(TimeCode::fromSeconds($time)) + ->save('', false, true); return VipsImage::thumbnail_buffer($buffer, $width, ['height' => $height]) ->writeToBuffer(".{$format}", [ diff --git a/app/Jobs/ProcessNewVolumeFiles.php b/app/Jobs/ProcessNewVolumeFiles.php index 4e1b87af1..a79f4ae1c 100644 --- a/app/Jobs/ProcessNewVolumeFiles.php +++ b/app/Jobs/ProcessNewVolumeFiles.php @@ -2,6 +2,7 @@ namespace Biigle\Jobs; +use Biigle\Video; use Biigle\Volume; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\InteractsWithQueue; @@ -62,7 +63,7 @@ public function handle() $query->eachById([ProcessNewImage::class, 'dispatch']); } else { $queue = config('videos.process_new_video_queue'); - $query->eachById(fn ($v) => ProcessNewVideo::dispatch($v)->onQueue($queue)); + $query->eachById(fn (Video $v) => ProcessNewVideo::dispatch($v)->onQueue($queue)); } } } diff --git a/app/Jobs/TileSingleImage.php b/app/Jobs/TileSingleImage.php index f0f262379..70b16070e 100644 --- a/app/Jobs/TileSingleImage.php +++ b/app/Jobs/TileSingleImage.php @@ -11,9 +11,9 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Storage; +use Jcupitt\Vips\Image as VipsImage; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; -use VipsImage; class TileSingleImage extends Job implements ShouldQueue { diff --git a/app/Jobs/UpdateVolumeMetadata.php b/app/Jobs/UpdateVolumeMetadata.php new file mode 100644 index 000000000..c884375ad --- /dev/null +++ b/app/Jobs/UpdateVolumeMetadata.php @@ -0,0 +1,68 @@ +volume->getMetadata(); + + if (!$metadata) { + return; + } + + foreach ($this->volume->files()->lazyById() as $file) { + $fileMeta = $metadata->getFile($file->filename); + if (!$fileMeta) { + continue; + } + + $insert = $fileMeta->getInsertData(); + + // If a video is updated with timestamped metadata, the old metadata must + // be replaced entirely. + if (($file instanceof Video) && array_key_exists('taken_at', $insert)) { + $file->taken_at = null; + $file->lat = null; + $file->lng = null; + $file->metadata = null; + } + + $attrs = $insert['attrs'] ?? null; + unset($insert['attrs']); + $file->fill($insert); + if ($attrs) { + $file->metadata = array_merge($file->metadata ?: [], $attrs['metadata']); + } + + if ($file->isDirty()) { + $file->save(); + } + } + + $this->volume->flushGeoInfoCache(); + } +} diff --git a/app/Label.php b/app/Label.php index 2090550fe..610c399a9 100644 --- a/app/Label.php +++ b/app/Label.php @@ -13,6 +13,9 @@ * with `rock`. * * Labels can be ordered in a tree-like structure. + * + * @property int $id + * @property string $uuid */ class Label extends Model { @@ -21,7 +24,7 @@ class Label extends Model /** * The attributes hidden from the model's JSON form. * - * @var array + * @var array */ protected $hidden = [ 'uuid', @@ -30,7 +33,7 @@ class Label extends Model /** * The attributes that should be casted to native types. * - * @var array + * @var array */ protected $casts = [ 'parent_id' => 'int', @@ -82,7 +85,7 @@ public function scopeUsed($query) /** * The parent label if the labels are ordered in a tree-like structure. * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function parent() { @@ -92,7 +95,7 @@ public function parent() /** * The label tree this label belongs to. * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function tree() { @@ -103,7 +106,7 @@ public function tree() * The child labels of this label if they are ordered in a tree-like * structue. * - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @return \Illuminate\Database\Eloquent\Relations\HasMany