From a8600dcf759f7ad8939f505b8286fccc1de16787 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Wed, 29 Nov 2023 20:49:43 +0100 Subject: [PATCH 001/209] Create PendingVolume model --- app/PendingVolume.php | 38 +++++++++++++ config/filesystems.php | 5 ++ config/volumes.php | 6 +++ database/factories/PendingVolumeFactory.php | 38 +++++++++++++ ...29_201953_create_pending_volumes_table.php | 45 ++++++++++++++++ tests/php/PendingVolumeTest.php | 54 +++++++++++++++++++ 6 files changed, 186 insertions(+) create mode 100644 app/PendingVolume.php create mode 100644 database/factories/PendingVolumeFactory.php create mode 100644 database/migrations/2023_11_29_201953_create_pending_volumes_table.php create mode 100644 tests/php/PendingVolumeTest.php diff --git a/app/PendingVolume.php b/app/PendingVolume.php new file mode 100644 index 000000000..afc05d2ed --- /dev/null +++ b/app/PendingVolume.php @@ -0,0 +1,38 @@ +metadata_file_path); + } + + public function saveMetadata(UploadedFile $file): void + { + $disk = config('volumes.pending_metadata_storage_disk'); + $extension = $file->getExtension(); + $this->metadata_file_path = "{$this->id}.{$extension}"; + $file->storeAs('', $this->metadata_file_path, $disk); + $this->save(); + } +} diff --git a/config/filesystems.php b/config/filesystems.php index 68fd13004..138a5a919 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -64,6 +64,11 @@ 'driver' => 'local', 'root' => storage_path('ifdos'), ], + + 'pending-metadata' => [ + 'driver' => 'local', + 'root' => storage_path('pending-metadata'), + ], ], /* diff --git a/config/volumes.php b/config/volumes.php index e413a61ee..9c0138bf2 100644 --- a/config/volumes.php +++ b/config/volumes.php @@ -20,4 +20,10 @@ | Storage disk for iFDO metadata files linked with volumes. */ 'ifdo_storage_disk' => env('VOLUME_IFDO_STORAGE_DISK', 'ifdos'), + + + /* + | Storage disk for metadata files of pending volumes. + */ + 'pending_metadata_storage_disk' => env('VOLUME_PENDING_METADATA_STORAGE_DISK', 'pending-metadata'), ]; diff --git a/database/factories/PendingVolumeFactory.php b/database/factories/PendingVolumeFactory.php new file mode 100644 index 000000000..320c82565 --- /dev/null +++ b/database/factories/PendingVolumeFactory.php @@ -0,0 +1,38 @@ + + */ +class PendingVolumeFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string + */ + protected $model = PendingVolume::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'media_type_id' => fn () => MediaType::imageId(), + 'user_id' => User::factory(), + 'project_id' => Project::factory(), + ]; + } +} diff --git a/database/migrations/2023_11_29_201953_create_pending_volumes_table.php b/database/migrations/2023_11_29_201953_create_pending_volumes_table.php new file mode 100644 index 000000000..2f52476d5 --- /dev/null +++ b/database/migrations/2023_11_29_201953_create_pending_volumes_table.php @@ -0,0 +1,45 @@ +id(); + $table->timestamps(); + + $table->foreignId('user_id') + ->constrained() + ->onDelete('cascade'); + + $table->foreignId('media_type_id') + ->constrained() + ->onDelete('restrict'); + + $table->foreignId('project_id') + ->constrained() + ->onDelete('cascade'); + + // Path of the file in the pending_metadata_storage_disk. + $table->string('metadata_file_path', 256)->nullable(); + + // A user is only allowed to create one pending volume at a time for a + // project. + $table->unique(['user_id', 'project_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('pending_volumes'); + } +}; diff --git a/tests/php/PendingVolumeTest.php b/tests/php/PendingVolumeTest.php new file mode 100644 index 000000000..934bcf998 --- /dev/null +++ b/tests/php/PendingVolumeTest.php @@ -0,0 +1,54 @@ +assertNotNull($this->model->media_type_id); + $this->assertNotNull($this->model->user_id); + $this->assertNotNull($this->model->project_id); + $this->assertNotNull($this->model->created_at); + $this->assertNotNull($this->model->updated_at); + $this->assertNull($this->model->metadata_file_path); + } + + public function testCreateOnlyOneForProject() + { + $this->expectException(UniqueConstraintViolationException::class); + PendingVolume::factory()->create([ + 'user_id' => $this->model->user_id, + 'project_id' => $this->model->project_id, + ]); + } + + public function testStoreMetadataFile() + { + $disk = Storage::fake('pending-metadata'); + $csv = __DIR__."/../files/image-metadata.csv"; + $file = new UploadedFile($csv, 'metadata.csv', 'text/csv', null, true); + + $this->assertFalse($this->model->hasMetadata()); + $this->model->saveMetadata($file); + + $disk->assertExists($this->model->id.'.csv'); + $this->assertTrue($this->model->hasMetadata()); + $this->assertEquals($this->model->id.'.csv', $this->model->metadata_file_path); + } +} From 25389f09404a58d1e082f81676a5cf187a8599e4 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Thu, 30 Nov 2023 16:35:28 +0100 Subject: [PATCH 002/209] Start to implement pending volume API endpoint --- .../Api/ProjectPendingVolumeController.php | 31 ++++++++ app/Http/Requests/StorePendingVolume.php | 70 +++++++++++++++++++ routes/api.php | 5 ++ .../ProjectPendingVolumeControllerTest.php | 58 +++++++++++++++ tests/php/PendingVolumeTest.php | 4 -- 5 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 app/Http/Controllers/Api/ProjectPendingVolumeController.php create mode 100644 app/Http/Requests/StorePendingVolume.php create mode 100644 tests/php/Http/Controllers/Api/ProjectPendingVolumeControllerTest.php diff --git a/app/Http/Controllers/Api/ProjectPendingVolumeController.php b/app/Http/Controllers/Api/ProjectPendingVolumeController.php new file mode 100644 index 000000000..6dd0cb43e --- /dev/null +++ b/app/Http/Controllers/Api/ProjectPendingVolumeController.php @@ -0,0 +1,31 @@ + $request->input('media_type_id'), + 'project_id' => $request->project->id, + 'user_id' => $request->user()->id, + ]); + } +} diff --git a/app/Http/Requests/StorePendingVolume.php b/app/Http/Requests/StorePendingVolume.php new file mode 100644 index 000000000..65514e0c7 --- /dev/null +++ b/app/Http/Requests/StorePendingVolume.php @@ -0,0 +1,70 @@ +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 + { + return [ + 'media_type' => ['required', Rule::in(array_keys(MediaType::INSTANCES))], + ]; + } + + /** + * Configure the validator instance. + * + * @param \Illuminate\Validation\Validator $validator + * @return void + */ + public function withValidator($validator) + { + if ($validator->fails()) { + return; + } + + $validator->after(function ($validator) { + $exists = PendingVolume::where('project_id', $this->project->id) + ->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.'); + } + }); + } + + /** + * 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/routes/api.php b/routes/api.php index c24cb026c..a254b75ff 100644 --- a/routes/api.php +++ b/routes/api.php @@ -168,6 +168,11 @@ 'parameters' => ['projects' => 'id', 'label-trees' => 'id2'], ]); +$router->resource('projects.pending-volumes', 'ProjectPendingVolumeController', [ + 'only' => ['store'], + 'parameters' => ['projects' => 'id'], +]); + $router->get( 'projects/pinned', 'UserPinnedProjectController@index' diff --git a/tests/php/Http/Controllers/Api/ProjectPendingVolumeControllerTest.php b/tests/php/Http/Controllers/Api/ProjectPendingVolumeControllerTest.php new file mode 100644 index 000000000..8539a1b85 --- /dev/null +++ b/tests/php/Http/Controllers/Api/ProjectPendingVolumeControllerTest.php @@ -0,0 +1,58 @@ +project()->id; + $this->doTestApiRoute('POST', "/api/v1/projects/{$id}/pending-volumes"); + + $this->beEditor(); + $this->post("/api/v1/projects/{$id}/pending-volumes")->assertStatus(403); + + $this->beAdmin(); + // Missing arguments. + $this->json('POST', "/api/v1/projects/{$id}/pending-volumes")->assertStatus(422); + + // Incorrect media type. + $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ + 'media_type' => 'whatever', + ])->assertStatus(422); + + $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ + 'media_type' => 'image', + ])->assertStatus(201); + + $pv = PendingVolume::where('project_id', $id)->first(); + $this->assertEquals(MediaType::imageId(), $pv->media_type_id); + $this->assertEquals($this->admin()->id, $pv->user_id); + } + + public function testStoreTwice() + { + $this->beAdmin(); + $id = $this->project()->id; + $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ + 'media_type' => 'image', + ])->assertStatus(201); + + $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ + 'media_type' => 'image', + ])->assertStatus(422); + } + + public function testStoreVideo() + { + $this->beAdmin(); + $id = $this->project()->id; + $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ + 'media_type' => 'video', + ])->assertStatus(201); + } +} diff --git a/tests/php/PendingVolumeTest.php b/tests/php/PendingVolumeTest.php index 934bcf998..a508198b4 100644 --- a/tests/php/PendingVolumeTest.php +++ b/tests/php/PendingVolumeTest.php @@ -2,15 +2,11 @@ namespace Biigle\Tests; -use Biigle\MediaType; use Biigle\PendingVolume; -use Biigle\Role; use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Http\UploadedFile; use ModelTestCase; use Storage; -use Symfony\Component\HttpFoundation\StreamedResponse; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class PendingVolumeTest extends ModelTestCase { From 0550180d31f02ff1cced56eab5b627edf697e453 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Thu, 30 Nov 2023 21:02:04 +0100 Subject: [PATCH 003/209] Implement Project::pendingVolumes --- .../Controllers/Api/ProjectPendingVolumeController.php | 5 +---- app/Http/Requests/StorePendingVolume.php | 3 +-- app/Project.php | 10 ++++++++++ tests/php/ProjectTest.php | 8 ++++++++ 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/Api/ProjectPendingVolumeController.php b/app/Http/Controllers/Api/ProjectPendingVolumeController.php index 6dd0cb43e..3a3328bd5 100644 --- a/app/Http/Controllers/Api/ProjectPendingVolumeController.php +++ b/app/Http/Controllers/Api/ProjectPendingVolumeController.php @@ -3,11 +3,9 @@ namespace Biigle\Http\Controllers\Api; use Biigle\Http\Requests\StorePendingVolume; -use Biigle\PendingVolume; class ProjectPendingVolumeController extends Controller { - /** * Creates a new pending volume associated to the specified project. * @@ -22,9 +20,8 @@ class ProjectPendingVolumeController extends Controller */ public function store(StorePendingVolume $request) { - return PendingVolume::create([ + return $request->project->pendingVolumes()->create([ 'media_type_id' => $request->input('media_type_id'), - 'project_id' => $request->project->id, 'user_id' => $request->user()->id, ]); } diff --git a/app/Http/Requests/StorePendingVolume.php b/app/Http/Requests/StorePendingVolume.php index 65514e0c7..21a471786 100644 --- a/app/Http/Requests/StorePendingVolume.php +++ b/app/Http/Requests/StorePendingVolume.php @@ -3,7 +3,6 @@ namespace Biigle\Http\Requests; use Biigle\MediaType; -use Biigle\PendingVolume; use Biigle\Project; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -45,7 +44,7 @@ public function withValidator($validator) } $validator->after(function ($validator) { - $exists = PendingVolume::where('project_id', $this->project->id) + $exists = $this->project->pendingVolumes() ->where('user_id', $this->user()->id) ->exists(); if ($exists) { diff --git a/app/Project.php b/app/Project.php index 4098402ff..494f9b1e7 100644 --- a/app/Project.php +++ b/app/Project.php @@ -209,6 +209,16 @@ public function videoVolumes() return $this->volumes()->where('media_type_id', MediaType::videoId()); } + /** + * The pending volumes of this project. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + */ + public function pendingVolumes() + { + return $this->hasMany(PendingVolume::class); + } + /** * Adds a volume to this project if it wasn't already. * diff --git a/tests/php/ProjectTest.php b/tests/php/ProjectTest.php index 3261393ad..bfbd4d8d9 100644 --- a/tests/php/ProjectTest.php +++ b/tests/php/ProjectTest.php @@ -4,6 +4,7 @@ use Biigle\Jobs\DeleteVolume; use Biigle\MediaType; +use Biigle\PendingVolume; use Biigle\Project; use Biigle\ProjectInvitation; use Biigle\Role; @@ -339,4 +340,11 @@ public function testInvitations() ProjectInvitation::factory(['project_id' => $this->model->id])->create(); $this->assertTrue($this->model->invitations()->exists()); } + + public function testPendingVolumes() + { + $this->assertFalse($this->model->pendingVolumes()->exists()); + PendingVolume::factory(['project_id' => $this->model->id])->create(); + $this->assertTrue($this->model->pendingVolumes()->exists()); + } } From 87cd37a580edd4a6941b71517f28d6187690673c Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Thu, 30 Nov 2023 21:02:31 +0100 Subject: [PATCH 004/209] Start implementing the CSV metadata parser --- app/Services/MetadataParsers/CsvParser.php | 45 +++++++++++++++++++ .../MetadataParsers/MetadataParser.php | 35 +++++++++++++++ .../MetadataParsers/ParserFactory.php | 13 ++++++ .../MetadataParsers/ValidationException.php | 8 ++++ .../MetadataParsers/CsvParserTest.php | 22 +++++++++ 5 files changed, 123 insertions(+) create mode 100644 app/Services/MetadataParsers/CsvParser.php create mode 100644 app/Services/MetadataParsers/MetadataParser.php create mode 100644 app/Services/MetadataParsers/ParserFactory.php create mode 100644 app/Services/MetadataParsers/ValidationException.php create mode 100644 tests/php/Services/MetadataParsers/CsvParserTest.php diff --git a/app/Services/MetadataParsers/CsvParser.php b/app/Services/MetadataParsers/CsvParser.php new file mode 100644 index 000000000..17f2f8b8b --- /dev/null +++ b/app/Services/MetadataParsers/CsvParser.php @@ -0,0 +1,45 @@ +getFileObject(); + $line = $file->current(); + if (!is_array($line)) { + return false; + } + + $line = array_map('strtolower', $line); + + if (!in_array('filename', $line, true) && !in_array('file', $line, true)) { + return false; + } + + return true; + } + + /** + * {@inheritdoc} + */ + public function validateFile(): void + { + // + } + + protected function getFileObject(): SplFileObject + { + $file = parent::getFileObject(); + $file->setFlags(SplFileObject::READ_CSV); + + return $file; + } +} diff --git a/app/Services/MetadataParsers/MetadataParser.php b/app/Services/MetadataParsers/MetadataParser.php new file mode 100644 index 000000000..ab76d4568 --- /dev/null +++ b/app/Services/MetadataParsers/MetadataParser.php @@ -0,0 +1,35 @@ +fileObject)) { + $this->fileObject = $this->file->openFile(); + } + + return $this->fileObject; + } +} diff --git a/app/Services/MetadataParsers/ParserFactory.php b/app/Services/MetadataParsers/ParserFactory.php new file mode 100644 index 000000000..a3a54a488 --- /dev/null +++ b/app/Services/MetadataParsers/ParserFactory.php @@ -0,0 +1,13 @@ +assertTrue($parser->recognizesFile()); + } + + public function testValidateFile() + { + // + } +} From 23734e2a059d8d14e2833a329895328a68e0c49e Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Wed, 6 Dec 2023 20:46:11 +0100 Subject: [PATCH 005/209] WIP Implement ImageCsvParser --- .../MetadataParsers/ValidationException.php | 8 ---- app/Services/MetadataParsing/FileMetadata.php | 11 +++++ .../ImageCsvParser.php} | 10 +++-- .../MetadataParsing/ImageMetadata.php | 20 +++++++++ .../MetadataParser.php | 4 +- .../ParserFactory.php | 0 .../MetadataParsing/VolumeMetadata.php | 30 ++++++++++++++ .../MetadataParsers/CsvParserTest.php | 22 ---------- .../MetadataParsing/ImageCsvParserTest.php | 41 +++++++++++++++++++ .../MetadataParsing/VolumeMetadataTest.php | 31 ++++++++++++++ 10 files changed, 142 insertions(+), 35 deletions(-) delete mode 100644 app/Services/MetadataParsers/ValidationException.php create mode 100644 app/Services/MetadataParsing/FileMetadata.php rename app/Services/{MetadataParsers/CsvParser.php => MetadataParsing/ImageCsvParser.php} (78%) create mode 100644 app/Services/MetadataParsing/ImageMetadata.php rename app/Services/{MetadataParsers => MetadataParsing}/MetadataParser.php (84%) rename app/Services/{MetadataParsers => MetadataParsing}/ParserFactory.php (100%) create mode 100644 app/Services/MetadataParsing/VolumeMetadata.php delete mode 100644 tests/php/Services/MetadataParsers/CsvParserTest.php create mode 100644 tests/php/Services/MetadataParsing/ImageCsvParserTest.php create mode 100644 tests/php/Services/MetadataParsing/VolumeMetadataTest.php diff --git a/app/Services/MetadataParsers/ValidationException.php b/app/Services/MetadataParsers/ValidationException.php deleted file mode 100644 index a04984ef4..000000000 --- a/app/Services/MetadataParsers/ValidationException.php +++ /dev/null @@ -1,8 +0,0 @@ -files = []; + } + + public function addFile(FileMetadata $file) + { + $this->files[$file->name] = $file; + } + + public function getFiles(): array + { + return array_values($this->files); + } +} diff --git a/tests/php/Services/MetadataParsers/CsvParserTest.php b/tests/php/Services/MetadataParsers/CsvParserTest.php deleted file mode 100644 index 70d965ec4..000000000 --- a/tests/php/Services/MetadataParsers/CsvParserTest.php +++ /dev/null @@ -1,22 +0,0 @@ -assertTrue($parser->recognizesFile()); - } - - public function testValidateFile() - { - // - } -} diff --git a/tests/php/Services/MetadataParsing/ImageCsvParserTest.php b/tests/php/Services/MetadataParsing/ImageCsvParserTest.php new file mode 100644 index 000000000..493633cc5 --- /dev/null +++ b/tests/php/Services/MetadataParsing/ImageCsvParserTest.php @@ -0,0 +1,41 @@ +assertTrue($parser->recognizesFile()); + + $file = new File(__DIR__."/../../../files/image-metadata-with-bom.csv"); + $parser = new CsvParser($file); + $this->assertTrue($parser->recognizesFile()); + + $file = new File(__DIR__."/../../../files/test.mp4"); + $parser = new CsvParser($file); + $this->assertFalse($parser->recognizesFile()); + } + + public function testGetMetadata() + { + // + } + + public function testGetMetadataIgnoreMissingFilename() + { + // + } + + public function testGetMetadataCantReadFile() + { + // + } +} diff --git a/tests/php/Services/MetadataParsing/VolumeMetadataTest.php b/tests/php/Services/MetadataParsing/VolumeMetadataTest.php new file mode 100644 index 000000000..154d5fad7 --- /dev/null +++ b/tests/php/Services/MetadataParsing/VolumeMetadataTest.php @@ -0,0 +1,31 @@ +assertEquals(MediaType::imageId(), $metadata->type->id); + $this->assertEquals('volumename', $metadata->name); + $this->assertEquals('volumeurl', $metadata->url); + $this->assertEquals('volumehandle', $metadata->handle); + } + + public function testAddGetFiles() + { + $metadata = new VolumeMetadata(); + $file = new FileMetadata('filename'); + $metadata->addFile($file); + $this->assertEquals($file, $metadata->getFiles()[0]); + $metadata->addFile($file); + $this->assertCount(1, $metadata->getFiles()); + } +} From be8b6d03e82aa8e53e405e9d4cb5949effada2b7 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Thu, 7 Dec 2023 20:38:11 +0100 Subject: [PATCH 006/209] Continue implementing ImageCsvParser --- .../MetadataParsing/ImageCsvParser.php | 92 ++++++++++- .../MetadataParsing/MetadataParser.php | 2 +- .../MetadataParsing/ParserFactory.php | 2 +- .../MetadataParsing/VolumeMetadata.php | 9 +- .../MetadataParsing/ImageCsvParserTest.php | 152 +++++++++++++++++- 5 files changed, 236 insertions(+), 21 deletions(-) diff --git a/app/Services/MetadataParsing/ImageCsvParser.php b/app/Services/MetadataParsing/ImageCsvParser.php index 0c14ab24f..07acc6758 100644 --- a/app/Services/MetadataParsing/ImageCsvParser.php +++ b/app/Services/MetadataParsing/ImageCsvParser.php @@ -1,28 +1,46 @@ 'filename', + 'lon' => 'lng', + 'longitude' => 'lng', + 'latitude' => 'lat', + 'heading' => 'yaw', + 'sub_datetime' => 'taken_at', + 'sub_longitude' => 'lng', + 'sub_latitude' => 'lat', + 'sub_heading' => 'yaw', + 'sub_distance' => 'distance_to_ground', + 'sub_altitude' => 'gps_altitude', + ]; + /** * {@inheritdoc} */ public function recognizesFile(): bool { - $file = $this->getFileObject(); + $file = $this->getCsvIterator(); $line = $file->current(); if (!is_array($line)) { return false; } - $line = array_map('strtolower', $line); - if (!empty($line[0])) { - $line[0] = Util::removeBom($line[0]); - } + $line = $this->processFirstLine($line); if (!in_array('filename', $line, true) && !in_array('file', $line, true)) { return false; @@ -34,16 +52,74 @@ public function recognizesFile(): bool /** * {@inheritdoc} */ - public function getMetadata(): VolumeMetadata; + public function getMetadata(): VolumeMetadata { + $data = new VolumeMetadata(MediaType::image()); + + $file = $this->getCsvIterator(); + $keys = $file->current(); + if (!is_array($keys)) { + return $data; + } + + $keys = $this->processFirstLine($keys); + $keys = array_map(function ($column) { + if (array_key_exists($column, self::COLUMN_SYNONYMS)) { + return self::COLUMN_SYNONYMS[$column]; + } + + return $column; + }, $keys); + + $keyMap = array_flip($keys); + $getValue = fn ($row, $key) => $row[$keyMap[$key] ?? null] ?? null; + $maybeCast = fn ($value) => (is_null($value) || $value === '') ? null : floatval($value); + + $file->next(); + while ($file->valid()) { + $row = $file->current(); + $file->next(); + if (empty($row)) { + continue; + } + $name = $getValue($row, 'filename'); + if (empty($name)) { + continue; + } + + $fileData = new ImageMetadata( + name: $getValue($row, 'filename'), + lat: $maybeCast($getValue($row, 'lat')), + lng: $maybeCast($getValue($row, 'lng')), + takenAt: $getValue($row, 'taken_at') ?: null, // Use null instead of ''. + area: $maybeCast($getValue($row, 'area')), + distanceToGround: $maybeCast($getValue($row, 'distance_to_ground')), + gpsAltitude: $maybeCast($getValue($row, 'gps_altitude')), + yaw: $maybeCast($getValue($row, 'yaw')), + ); + + $data->addFile($fileData); + } + + return $data; } - protected function getFileObject(): SplFileObject + protected function getCsvIterator(): SeekableIterator { $file = parent::getFileObject(); $file->setFlags(SplFileObject::READ_CSV); return $file; } + + protected function processFirstLine(array $line): array + { + $line = array_map('strtolower', $line); + if (!empty($line[0])) { + $line[0] = Util::removeBom($line[0]); + } + + return $line; + } } diff --git a/app/Services/MetadataParsing/MetadataParser.php b/app/Services/MetadataParsing/MetadataParser.php index 376a577cb..22668d2ae 100644 --- a/app/Services/MetadataParsing/MetadataParser.php +++ b/app/Services/MetadataParsing/MetadataParser.php @@ -1,6 +1,6 @@ files = []; + $this->files = collect([]); } public function addFile(FileMetadata $file) @@ -23,8 +24,8 @@ public function addFile(FileMetadata $file) $this->files[$file->name] = $file; } - public function getFiles(): array + public function getFiles(): Collection { - return array_values($this->files); + return $this->files->values(); } } diff --git a/tests/php/Services/MetadataParsing/ImageCsvParserTest.php b/tests/php/Services/MetadataParsing/ImageCsvParserTest.php index 493633cc5..1100aa2b3 100644 --- a/tests/php/Services/MetadataParsing/ImageCsvParserTest.php +++ b/tests/php/Services/MetadataParsing/ImageCsvParserTest.php @@ -2,7 +2,8 @@ namespace Biigle\Tests\Services\MetadataParsing; -use Biigle\Services\MetadataParsing\CsvParser; +use Biigle\Services\MetadataParsing\ImageCsvParser; +use Biigle\MediaType; use Biigle\Volume; use Symfony\Component\HttpFoundation\File\File; use TestCase; @@ -12,30 +13,167 @@ class ImageCsvParserTest extends TestCase public function testRecognizesFile() { $file = new File(__DIR__."/../../../files/image-metadata.csv"); - $parser = new CsvParser($file); + $parser = new ImageCsvParser($file); $this->assertTrue($parser->recognizesFile()); $file = new File(__DIR__."/../../../files/image-metadata-with-bom.csv"); - $parser = new CsvParser($file); + $parser = new ImageCsvParser($file); $this->assertTrue($parser->recognizesFile()); $file = new File(__DIR__."/../../../files/test.mp4"); - $parser = new CsvParser($file); + $parser = new ImageCsvParser($file); $this->assertFalse($parser->recognizesFile()); } public function testGetMetadata() { - // + $file = new File(__DIR__."/../../../files/image-metadata.csv"); + $parser = new ImageCsvParser($file); + $data = $parser->getMetadata(); + $this->assertEquals(MediaType::imageId(), $data->type->id); + $this->assertNull($data->name); + $this->assertNull($data->url); + $this->assertNull($data->handle); + $this->assertCount(1, $data->getFiles()); + $file = $data->getFiles()->first(); + $this->assertEquals('abc.jpg', $file->name); + $this->assertEquals('2016-12-19 12:27:00', $file->takenAt); + $this->assertEquals(52.220, $file->lng); + $this->assertEquals(28.123, $file->lat); + $this->assertEquals(-1500, $file->gpsAltitude); + $this->assertEquals(10, $file->distanceToGround); + $this->assertEquals(2.6, $file->area); + $this->assertEquals(180, $file->yaw); } public function testGetMetadataIgnoreMissingFilename() { - // + $file = new File(__DIR__."/../../../files/image-metadata.csv"); + $parser = new ImageCsvParserStub($file); + $parser->content = [ + ['filename', 'taken_at', 'lng', 'lat', 'gps_altitude', 'distance_to_ground', 'area', 'yaw'], + ['', '2016-12-19 12:27:00', '52.220', '28.123', '-1500', '10', '2.6', '180'], + ]; + + $data = $parser->getMetadata(); + $this->assertCount(0, $data->getFiles()); } public function testGetMetadataCantReadFile() { - // + $file = new File(__DIR__."/../../../files/test.mp4"); + $parser = new ImageCsvParser($file); + $data = $parser->getMetadata(); + $this->assertEquals(MediaType::imageId(), $data->type->id); + $this->assertCount(0, $data->getFiles()); + } + + public function testGetMetadataCaseInsensitive() + { + $file = new File(__DIR__."/../../../files/image-metadata.csv"); + $parser = new ImageCsvParserStub($file); + $parser->content = [ + ['Filename', 'tAken_at', 'lnG', 'Lat', 'gPs_altitude', 'diStance_to_ground', 'areA'], + ['abc.jpg', '2016-12-19 12:27:00', '52.220', '28.123', '-1500', '10', '2.6'], + ]; + + $data = $parser->getMetadata(); + $this->assertCount(1, $data->getFiles()); + $file = $data->getFiles()->first(); + $this->assertEquals('abc.jpg', $file->name); + $this->assertEquals('2016-12-19 12:27:00', $file->takenAt); + $this->assertEquals(52.220, $file->lng); + $this->assertEquals(28.123, $file->lat); + $this->assertEquals(-1500, $file->gpsAltitude); + $this->assertEquals(10, $file->distanceToGround); + $this->assertEquals(2.6, $file->area); + } + + public function testGetMetadataColumnSynonyms1() + { + $file = new File(__DIR__."/../../../files/image-metadata.csv"); + $parser = new ImageCsvParserStub($file); + $parser->content = [ + ['file', 'lon', 'lat', 'heading'], + ['abc.jpg', '52.220', '28.123', '180'], + ]; + + $data = $parser->getMetadata(); + $this->assertCount(1, $data->getFiles()); + $file = $data->getFiles()->first(); + $this->assertEquals('abc.jpg', $file->name); + $this->assertEquals(52.220, $file->lng); + $this->assertEquals(28.123, $file->lat); + $this->assertEquals(180, $file->yaw); + } + + public function testGetMetadataColumnSynonyms2() + { + $file = new File(__DIR__."/../../../files/image-metadata.csv"); + $parser = new ImageCsvParserStub($file); + $parser->content = [ + ['file', 'longitude', 'latitude'], + ['abc.jpg', '52.220', '28.123'], + ]; + + $data = $parser->getMetadata(); + $this->assertCount(1, $data->getFiles()); + $file = $data->getFiles()->first(); + $this->assertEquals('abc.jpg', $file->name); + $this->assertEquals(52.220, $file->lng); + $this->assertEquals(28.123, $file->lat); + } + + public function testGetMetadataColumnSynonyms3() + { + $file = new File(__DIR__."/../../../files/image-metadata.csv"); + $parser = new ImageCsvParserStub($file); + $parser->content = [ + ['file', 'SUB_datetime', 'SUB_longitude', 'SUB_latitude', 'SUB_altitude', 'SUB_distance', 'SUB_heading'], + ['abc.jpg', '2016-12-19 12:27:00', '52.220', '28.123', '-1500', '10', '180'], + ]; + + $data = $parser->getMetadata(); + $this->assertCount(1, $data->getFiles()); + $file = $data->getFiles()->first(); + $this->assertEquals('abc.jpg', $file->name); + $this->assertEquals('2016-12-19 12:27:00', $file->takenAt); + $this->assertEquals(52.220, $file->lng); + $this->assertEquals(28.123, $file->lat); + $this->assertEquals(-1500, $file->gpsAltitude); + $this->assertEquals(10, $file->distanceToGround); + $this->assertEquals(180, $file->yaw); + } + + public function testGetMetadataEmptyCells() + { + $file = new File(__DIR__."/../../../files/image-metadata.csv"); + $parser = new ImageCsvParserStub($file); + $parser->content = [ + ['filename', 'taken_at', 'lng', 'lat', 'gps_altitude', 'distance_to_ground', 'area', 'yaw'], + ['abc.jpg', '', '52.220', '28.123', '', '', '', ''], + ]; + + $data = $parser->getMetadata(); + $this->assertCount(1, $data->getFiles()); + $file = $data->getFiles()->first(); + $this->assertEquals('abc.jpg', $file->name); + $this->assertNull($file->takenAt); + $this->assertEquals(52.220, $file->lng); + $this->assertEquals(28.123, $file->lat); + $this->assertNull($file->gpsAltitude); + $this->assertNull($file->distanceToGround); + $this->assertNull($file->area); + $this->assertNull($file->yaw); + } +} + +class ImageCsvParserStub extends ImageCsvParser +{ + public array $content = []; + + protected function getCsvIterator(): \SeekableIterator + { + return new \ArrayIterator($this->content); } } From c2b18720cfd083dc742c1b02281ba146f65431aa Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Mon, 11 Dec 2023 20:25:46 +0100 Subject: [PATCH 007/209] Implement ParserFactory --- .../MetadataParsing/ParserFactory.php | 13 +++++++++- .../MetadataParsing/ParserFactoryTest.php | 25 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 tests/php/Services/MetadataParsing/ParserFactoryTest.php diff --git a/app/Services/MetadataParsing/ParserFactory.php b/app/Services/MetadataParsing/ParserFactory.php index 5538bc95a..b385a476f 100644 --- a/app/Services/MetadataParsing/ParserFactory.php +++ b/app/Services/MetadataParsing/ParserFactory.php @@ -6,8 +6,19 @@ class ParserFactory { + public static array $parsers = [ + ImageCsvParser::class, + ]; + public static function getParserForFile(File $file): ?MetadataParser { - // + foreach (self::$parsers as $parserClass) { + $parser = new $parserClass($file); + if ($parser->recognizesFile()) { + return $parser; + } + } + + return null; } } diff --git a/tests/php/Services/MetadataParsing/ParserFactoryTest.php b/tests/php/Services/MetadataParsing/ParserFactoryTest.php new file mode 100644 index 000000000..af525eece --- /dev/null +++ b/tests/php/Services/MetadataParsing/ParserFactoryTest.php @@ -0,0 +1,25 @@ +assertInstanceOf(ImageCsvParser::class, $parser); + } + + public function testGetParserForFileUnknown() + { + $file = new File(__DIR__."/../../../files/test.mp4"); + $parser = ParserFactory::getParserForFile($file); + $this->assertNull($parser); + } +} From f1c8122907555a47cc2ced39d394a20a0c58a965 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Mon, 11 Dec 2023 21:00:17 +0100 Subject: [PATCH 008/209] Update the ImageMetadata validation rule --- app/Rules/ImageMetadata.php | 162 ++++------------- app/Services/MetadataParsing/FileMetadata.php | 5 + .../MetadataParsing/ImageCsvParser.php | 2 + .../MetadataParsing/ImageMetadata.php | 14 ++ .../MetadataParsing/VolumeMetadata.php | 14 ++ tests/php/Rules/ImageMetadataTest.php | 165 +++++++----------- .../MetadataParsing/VolumeMetadataTest.php | 13 ++ 7 files changed, 140 insertions(+), 235 deletions(-) diff --git a/app/Rules/ImageMetadata.php b/app/Rules/ImageMetadata.php index 6d012c006..972c56c93 100644 --- a/app/Rules/ImageMetadata.php +++ b/app/Rules/ImageMetadata.php @@ -3,32 +3,10 @@ namespace Biigle\Rules; use Illuminate\Contracts\Validation\Rule; +use Biigle\Services\MetadataParsing\VolumeMetadata; class ImageMetadata implements Rule { - /** - * Allowed columns for the metadata information to change image attributes. - * - * @var array - */ - const ALLOWED_ATTRIBUTES = [ - 'lat', - 'lng', - 'taken_at', - ]; - - /** - * Allowed columns for the metadata information to change image metadata. - * - * @var array - */ - const ALLOWED_METADATA = [ - 'area', - 'distance_to_ground', - 'gps_altitude', - 'yaw', - ]; - /** * All numeric metadata fields (keys) with description (values). * @@ -36,20 +14,13 @@ class ImageMetadata implements Rule */ const NUMERIC_FIELDS = [ 'area' => 'area', - 'distance_to_ground' => 'distance to ground', - 'gps_altitude' => 'GPS altitude', + 'distanceToGround' => 'distance to ground', + 'gpsAltitude' => 'GPS altitude', 'lat' => 'latitude', 'lng' => 'longitude', 'yaw' => 'yaw', ]; - /** - * Array of volume file names. - * - * @var array - */ - protected $files; - /** * The validation error message. * @@ -62,157 +33,84 @@ class ImageMetadata implements Rule * * @param array $files */ - public function __construct($files) + public function __construct(public array $files) { - $this->files = $files; $this->message = "The :attribute is invalid."; } /** * Determine if the validation rule passes. - * - * @param string $attribute - * @param array $value - * @return bool */ - public function passes($attribute, $value) + public function passes($attribute, $value): bool { - if (!is_array($value)) { - return false; + if (!($value instanceof VolumeMetadata)) { + throw new \Exception('No value of type '.VolumeMetadata::class.' given.'); } + $fileMetadata = $value->getFiles(); // This checks if any information is given at all. - if (empty($value)) { - $this->message = 'The metadata information is empty.'; - - return false; - } - - $columns = array_shift($value); - - // This checks if any information is given beside the column description. - if (empty($value)) { + if ($fileMetadata->isEmpty()) { $this->message = 'The metadata information is empty.'; return false; } - if (!in_array('filename', $columns)) { - $this->message = 'The filename column is required.'; - - return false; - } - - $colCount = count($columns); + foreach ($fileMetadata as $file) { - if ($colCount === 1) { - $this->message = 'No metadata columns given.'; - - return false; - } - - if ($colCount !== count(array_unique($columns))) { - $this->message = 'Each column may occur only once.'; - - return false; - } - - $allowedColumns = array_merge(['filename'], self::ALLOWED_ATTRIBUTES, self::ALLOWED_METADATA); - $diff = array_diff($columns, $allowedColumns); - - if (count($diff) > 0) { - $this->message = 'The columns array may contain only values of: '.implode(', ', $allowedColumns).'.'; - - return false; - } - - $lng = in_array('lng', $columns); - $lat = in_array('lat', $columns); - if ($lng && !$lat || !$lng && $lat) { - $this->message = "If the 'lng' column is present, the 'lat' column must be present, too (and vice versa)."; - - return false; - } - - foreach ($value as $index => $row) { - // +1 since index starts at 0. - // +1 since column description row was removed above. - $line = $index + 2; - - if (count($row) !== $colCount) { - $this->message = "Invalid column count in line {$line}."; - - return false; - } - - $combined = array_combine($columns, $row); - $combined = array_filter($combined); - if (!array_key_exists('filename', $combined)) { - $this->message = "Filename missing in line {$line}."; + if (!in_array($file->name, $this->files)) { + $this->message = "There is no file with filename {$file->name}."; return false; } - $filename = $combined['filename']; - - if (!in_array($filename, $this->files)) { - $this->message = "There is no file with filename {$filename}."; - - return false; - } - - if (array_key_exists('lng', $combined)) { - $lng = $combined['lng']; - if (!is_numeric($lng) || abs($lng) > 180) { - $this->message = "'{$lng}' is no valid longitude for file {$filename}."; + if (!is_null($file->lng)) { + if (!is_numeric($file->lng) || abs($file->lng) > 180) { + $this->message = "'{$file->lng}' is no valid longitude for file {$file->name}."; return false; } - - if (!array_key_exists('lat', $combined)) { - $this->message = "Missing latitude for file {$filename}."; + if (is_null($file->lat)) { + $this->message = "Missing latitude for file {$file->name}."; return false; } } - if (array_key_exists('lat', $combined)) { - $lat = $combined['lat']; - if (!is_numeric($lat) || abs($lat) > 90) { - $this->message = "'{$lat}' is no valid latitude for file {$filename}."; + if (!is_null($file->lat)) { + if (!is_numeric($file->lat) || abs($file->lat) > 90) { + $this->message = "'{$file->lat}' is no valid latitude for file {$file->name}."; return false; } - if (!array_key_exists('lng', $combined)) { - $this->message = "Missing longitude for file {$filename}."; + if (is_null($file->lng)) { + $this->message = "Missing longitude for file {$file->name}."; return false; } } // Catch both a malformed date (false) and the zero date (negative integer). - if (array_key_exists('taken_at', $combined)) { - $date = $combined['taken_at']; - if (!(strtotime($date) > 0)) { - $this->message = "'{$date}' is no valid date for file {$filename}."; + if (!is_null($file->takenAt)) { + if (!(strtotime($file->takenAt) > 0)) { + $this->message = "'{$file->takenAt}' is no valid date for file {$file->name}."; return false; } } foreach (self::NUMERIC_FIELDS as $key => $text) { - if (array_key_exists($key, $combined) && !is_numeric($combined[$key])) { - $this->message = "'{$combined[$key]}' is no valid {$text} for file {$filename}."; + if (!is_null($file->$key) && !is_numeric($file->$key)) { + $this->message = "'{$file->$key}' is no valid {$text} for file {$file->name}."; return false; } } - if (array_key_exists('yaw', $combined)) { - if ($combined['yaw'] < 0 || $combined['yaw'] > 360) { - $this->message = "'{$combined['yaw']}' is no valid yaw for file {$filename}."; + if (!is_null($file->yaw)) { + if ($file->yaw < 0 || $file->yaw > 360) { + $this->message = "'{$file->yaw}' is no valid yaw for file {$file->name}."; return false; } diff --git a/app/Services/MetadataParsing/FileMetadata.php b/app/Services/MetadataParsing/FileMetadata.php index 080b57b88..9ccdf5d40 100644 --- a/app/Services/MetadataParsing/FileMetadata.php +++ b/app/Services/MetadataParsing/FileMetadata.php @@ -8,4 +8,9 @@ public function __construct(public string $name) { // } + + public function isEmpty(): bool + { + return true; + } } diff --git a/app/Services/MetadataParsing/ImageCsvParser.php b/app/Services/MetadataParsing/ImageCsvParser.php index 07acc6758..cd9dc49d3 100644 --- a/app/Services/MetadataParsing/ImageCsvParser.php +++ b/app/Services/MetadataParsing/ImageCsvParser.php @@ -71,7 +71,9 @@ public function getMetadata(): VolumeMetadata return $column; }, $keys); + // This will remove duplicate columns and retain the "last" one. $keyMap = array_flip($keys); + $getValue = fn ($row, $key) => $row[$keyMap[$key] ?? null] ?? null; $maybeCast = fn ($value) => (is_null($value) || $value === '') ? null : floatval($value); diff --git a/app/Services/MetadataParsing/ImageMetadata.php b/app/Services/MetadataParsing/ImageMetadata.php index ad766e718..36920aef8 100644 --- a/app/Services/MetadataParsing/ImageMetadata.php +++ b/app/Services/MetadataParsing/ImageMetadata.php @@ -17,4 +17,18 @@ public function __construct( { parent::__construct($name); } + + /** + * Determines if any metadata field other than the name is filled. + */ + public function isEmpty(): bool + { + return is_null($this->lat) + && is_null($this->lng) + && is_null($this->takenAt) + && is_null($this->area) + && is_null($this->distanceToGround) + && is_null($this->gpsAltitude) + && is_null($this->yaw); + } } diff --git a/app/Services/MetadataParsing/VolumeMetadata.php b/app/Services/MetadataParsing/VolumeMetadata.php index a480df64e..b6defcd43 100644 --- a/app/Services/MetadataParsing/VolumeMetadata.php +++ b/app/Services/MetadataParsing/VolumeMetadata.php @@ -28,4 +28,18 @@ public function getFiles(): Collection { return $this->files->values(); } + + /** + * Determine if there is any file metadata. + */ + public function isEmpty(): bool + { + foreach ($this->files as $file) { + if (!$file->isEmpty()) { + return false; + } + } + + return true; + } } diff --git a/tests/php/Rules/ImageMetadataTest.php b/tests/php/Rules/ImageMetadataTest.php index d2c935a35..8bb5eac07 100644 --- a/tests/php/Rules/ImageMetadataTest.php +++ b/tests/php/Rules/ImageMetadataTest.php @@ -2,150 +2,109 @@ namespace Biigle\Tests\Rules; -use Biigle\Rules\ImageMetadata; +use Biigle\Rules\ImageMetadata as ImageMetadataRule; +use Biigle\Services\MetadataParsing\VolumeMetadata; +use Biigle\Services\MetadataParsing\ImageMetadata; use TestCase; class ImageMetadataTest extends TestCase { - protected static $ruleClass = ImageMetadata::class; - public function testMetadataOk() { - $validator = new static::$ruleClass(['abc.jpg']); - $metadata = [ - ['filename', 'taken_at', 'lng', 'lat', 'gps_altitude', 'distance_to_ground', 'area', 'yaw'], - ['abc.jpg', '2016-12-19 12:27:00', '52.220', '28.123', '-1500', '10', '2.6', '180'], - ]; + $validator = new ImageMetadataRule(['abc.jpg']); + + $metadata = new VolumeMetadata; + $metadata->addFile(new ImageMetadata( + name: 'abc.jpg', + takenAt: '2016-12-19 12:27:00', + lng: 52.220, + lat: 28.123, + gpsAltitude: -1500, + distanceToGround: 10, + area: 2.6, + yaw: 180 + )); $this->assertTrue($validator->passes(null, $metadata)); } public function testMetadataWrongFile() { - $validator = new static::$ruleClass(['abc.jpg']); - $metadata = [ - ['filename', 'taken_at'], - ['cba.jpg', '2016-12-19 12:27:00'], - ]; - $this->assertFalse($validator->passes(null, $metadata)); - } - - public function testMetadataNoCols() - { - $validator = new static::$ruleClass(['abc.jpg']); - $metadata = [ - ['abc.jpg', '2016-12-19 12:27:00'], - ]; - $this->assertFalse($validator->passes(null, $metadata)); - } - - public function testMetadataWrongCols() - { - $validator = new static::$ruleClass(['abc.jpg']); - $metadata = [ - ['filename', 'abc'], - ['abc.jpg', '2016-12-19 12:27:00'], - ]; - $this->assertFalse($validator->passes(null, $metadata)); - } - - public function testMetadataColCount() - { - $validator = new static::$ruleClass(['abc.jpg']); - $metadata = [ - ['filename', 'taken_at'], - ['abc.jpg', '2016-12-19 12:27:00', '52.220', '28.123'], - ]; + $validator = new ImageMetadataRule(['abc.jpg']); + $metadata = new VolumeMetadata; + $metadata->addFile(new ImageMetadata( + name: 'cba.jpg', + takenAt: '2016-12-19 12:27:00' + )); $this->assertFalse($validator->passes(null, $metadata)); } public function testMetadataNoLat() { - $validator = new static::$ruleClass(['abc.jpg']); - $metadata = [ - ['filename', 'lng'], - ['abc.jpg', '52.220'], - ]; + $validator = new ImageMetadataRule(['abc.jpg']); + $metadata = new VolumeMetadata; + $metadata->addFile(new ImageMetadata( + name: 'abc.jpg', + lng: 52.220 + )); $this->assertFalse($validator->passes(null, $metadata)); } public function testMetadataNoLng() { - $validator = new static::$ruleClass(['abc.jpg']); - $metadata = [ - ['filename', 'lat'], - ['abc.jpg', '28.123'], - ]; - $this->assertFalse($validator->passes(null, $metadata)); - } - - public function testMetadataColOrdering() - { - $validator = new static::$ruleClass(['abc.jpg']); - $metadata = [ - ['filename', 'lng', 'lat', 'taken_at'], - ['abc.jpg', '2016-12-19 12:27:00', '52.220', '28.123'], - ]; + $validator = new ImageMetadataRule(['abc.jpg']); + $metadata = new VolumeMetadata; + $metadata->addFile(new ImageMetadata( + name: 'abc.jpg', + lat: 28.123 + )); $this->assertFalse($validator->passes(null, $metadata)); } public function testMetadataInvalidLat() { - $validator = new static::$ruleClass(['abc.jpg']); - $metadata = [ - ['filename', 'lng', 'lat'], - ['abc.jpg', '50', '91'], - ]; + $validator = new ImageMetadataRule(['abc.jpg']); + $metadata = new VolumeMetadata; + $metadata->addFile(new ImageMetadata( + name: 'abc.jpg', + lng: 50, + lat: 91 + )); $this->assertFalse($validator->passes(null, $metadata)); } public function testMetadataInvalidLng() { - $validator = new static::$ruleClass(['abc.jpg']); - $metadata = [ - ['filename', 'lng', 'lat'], - ['abc.jpg', '181', '50'], - ]; + $validator = new ImageMetadataRule(['abc.jpg']); + $metadata = new VolumeMetadata; + $metadata->addFile(new ImageMetadata( + name: 'abc.jpg', + lng: 181, + lat: 50 + )); $this->assertFalse($validator->passes(null, $metadata)); } public function testMetadataInvalidYaw() { - $validator = new static::$ruleClass(['abc.jpg']); - $metadata = [ - ['filename', 'yaw'], - ['abc.jpg', '361'], - ]; - $this->assertFalse($validator->passes(null, $metadata)); - } - - public function testMetadataOnlyValidateFilled() - { - $validator = new static::$ruleClass(['abc.jpg']); - $metadata = [ - ['filename', 'taken_at'], - ['abc.jpg', ''], - ]; - $this->assertTrue($validator->passes(null, $metadata)); - } - - public function testMetadataLatFilledLonNotFilled() - { - $validator = new static::$ruleClass(['abc.jpg']); - $metadata = [ - ['filename', 'lat', 'lon'], - ['abc.jpg', '28.123', ''], - ]; + $validator = new ImageMetadataRule(['abc.jpg']); + $metadata = new VolumeMetadata; + $metadata->addFile(new ImageMetadata( + name: 'abc.jpg', + yaw: 361 + )); $this->assertFalse($validator->passes(null, $metadata)); } public function testEmptyFilename() { - $validator = new static::$ruleClass(['abc.jpg']); - $metadata = [ - ['filename', 'taken_at'], - ['abc.jpg', ''], - ['', ''], - ]; + $validator = new ImageMetadataRule(['abc.jpg']); + $metadata = new VolumeMetadata; + $metadata->addFile(new ImageMetadata( + name: 'abc.jpg' + )); + $metadata->addFile(new ImageMetadata( + name: '' + )); $this->assertFalse($validator->passes(null, $metadata)); } } diff --git a/tests/php/Services/MetadataParsing/VolumeMetadataTest.php b/tests/php/Services/MetadataParsing/VolumeMetadataTest.php index 154d5fad7..44524cd92 100644 --- a/tests/php/Services/MetadataParsing/VolumeMetadataTest.php +++ b/tests/php/Services/MetadataParsing/VolumeMetadataTest.php @@ -5,6 +5,7 @@ use Biigle\MediaType; use Biigle\Services\MetadataParsing\VolumeMetadata; use Biigle\Services\MetadataParsing\FileMetadata; +use Biigle\Services\MetadataParsing\ImageMetadata; use TestCase; class VolumeMetadataTest extends TestCase @@ -28,4 +29,16 @@ public function testAddGetFiles() $metadata->addFile($file); $this->assertCount(1, $metadata->getFiles()); } + + public function testIsEmpty() + { + $metadata = new VolumeMetadata(); + $this->assertTrue($metadata->isEmpty()); + $file = new ImageMetadata('filename'); + $metadata->addFile($file); + $this->assertTrue($metadata->isEmpty()); + $file = new ImageMetadata('filename', area: 100); + $metadata->addFile($file); + $this->assertFalse($metadata->isEmpty()); + } } From 993044c75c38e120d4ff905780bf2cee3cc90de8 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Tue, 12 Dec 2023 21:00:02 +0100 Subject: [PATCH 009/209] Implement VideoCsvParser --- app/Rules/VideoMetadata.php | 2 +- app/Services/MetadataParsing/CsvParser.php | 96 ++++++++ .../MetadataParsing/ImageCsvParser.php | 92 +------- .../MetadataParsing/VideoCsvParser.php | 72 ++++++ .../MetadataParsing/VideoMetadata.php | 81 +++++++ .../MetadataParsing/VolumeMetadata.php | 5 + ...sv => video-metadata-strange-encoding.csv} | 0 .../MetadataParsing/ImageMetadataTest.php | 18 ++ .../MetadataParsing/VideoCsvParserTest.php | 211 ++++++++++++++++++ .../MetadataParsing/VideoMetadataTest.php | 42 ++++ .../MetadataParsing/VolumeMetadataTest.php | 9 + 11 files changed, 545 insertions(+), 83 deletions(-) create mode 100644 app/Services/MetadataParsing/CsvParser.php create mode 100644 app/Services/MetadataParsing/VideoCsvParser.php create mode 100644 app/Services/MetadataParsing/VideoMetadata.php rename tests/files/{video-metadata-incorrect-encoding.csv => video-metadata-strange-encoding.csv} (100%) create mode 100644 tests/php/Services/MetadataParsing/ImageMetadataTest.php create mode 100644 tests/php/Services/MetadataParsing/VideoCsvParserTest.php create mode 100644 tests/php/Services/MetadataParsing/VideoMetadataTest.php diff --git a/app/Rules/VideoMetadata.php b/app/Rules/VideoMetadata.php index a23c42ee9..c86bf4935 100644 --- a/app/Rules/VideoMetadata.php +++ b/app/Rules/VideoMetadata.php @@ -7,7 +7,7 @@ class VideoMetadata extends ImageMetadata /** * {@inheritdoc} */ - public function passes($attribute, $value) + public function passes($attribute, $value): bool { $passes = parent::passes($attribute, $value); diff --git a/app/Services/MetadataParsing/CsvParser.php b/app/Services/MetadataParsing/CsvParser.php new file mode 100644 index 000000000..94ea8ee94 --- /dev/null +++ b/app/Services/MetadataParsing/CsvParser.php @@ -0,0 +1,96 @@ + 'filename', + 'lon' => 'lng', + 'longitude' => 'lng', + 'latitude' => 'lat', + 'heading' => 'yaw', + 'sub_datetime' => 'taken_at', + 'sub_longitude' => 'lng', + 'sub_latitude' => 'lat', + 'sub_heading' => 'yaw', + 'sub_distance' => 'distance_to_ground', + 'sub_altitude' => 'gps_altitude', + ]; + + /** + * {@inheritdoc} + */ + public function recognizesFile(): bool + { + $file = $this->getCsvIterator(); + $line = $file->current(); + if (!is_array($line)) { + return false; + } + + $line = $this->processFirstLine($line); + + if (!in_array('filename', $line, true) && !in_array('file', $line, true)) { + return false; + } + + return true; + } + + /** + * {@inheritdoc} + */ + abstract public function getMetadata(): VolumeMetadata; + + protected function getCsvIterator(): SeekableIterator + { + $file = parent::getFileObject(); + $file->setFlags(SplFileObject::READ_CSV); + + return $file; + } + + protected function processFirstLine(array $line): array + { + $line = array_map('strtolower', $line); + if (!empty($line[0])) { + $line[0] = Util::removeBom($line[0]); + } + + return $line; + } + + protected function getKeyMap(array $line): array + { + $line = $this->processFirstLine($line); + + $keys = array_map(function ($column) { + if (array_key_exists($column, self::COLUMN_SYNONYMS)) { + return self::COLUMN_SYNONYMS[$column]; + } + + return $column; + }, $line); + + // This will remove duplicate columns and retain the "last" one. + return array_flip($keys); + } + + /** + * Cast the value to float if it is not null or an empty string. + */ + protected function maybeCastToFloat(?string $value): ?float + { + return (is_null($value) || $value === '') ? null : floatval($value); + } +} diff --git a/app/Services/MetadataParsing/ImageCsvParser.php b/app/Services/MetadataParsing/ImageCsvParser.php index cd9dc49d3..9859aa783 100644 --- a/app/Services/MetadataParsing/ImageCsvParser.php +++ b/app/Services/MetadataParsing/ImageCsvParser.php @@ -3,52 +3,9 @@ namespace Biigle\Services\MetadataParsing; use Biigle\MediaType; -use duncan3dc\Bom\Util; -use SeekableIterator; -use SplFileObject; -use Symfony\Component\HttpFoundation\File\File; -class ImageCsvParser extends MetadataParser +class ImageCsvParser extends CsvParser { - /** - * Column name synonyms. - * - * @var array - */ - const COLUMN_SYNONYMS = [ - 'file' => 'filename', - 'lon' => 'lng', - 'longitude' => 'lng', - 'latitude' => 'lat', - 'heading' => 'yaw', - 'sub_datetime' => 'taken_at', - 'sub_longitude' => 'lng', - 'sub_latitude' => 'lat', - 'sub_heading' => 'yaw', - 'sub_distance' => 'distance_to_ground', - 'sub_altitude' => 'gps_altitude', - ]; - - /** - * {@inheritdoc} - */ - public function recognizesFile(): bool - { - $file = $this->getCsvIterator(); - $line = $file->current(); - if (!is_array($line)) { - return false; - } - - $line = $this->processFirstLine($line); - - if (!in_array('filename', $line, true) && !in_array('file', $line, true)) { - return false; - } - - return true; - } - /** * {@inheritdoc} */ @@ -57,25 +14,14 @@ public function getMetadata(): VolumeMetadata $data = new VolumeMetadata(MediaType::image()); $file = $this->getCsvIterator(); - $keys = $file->current(); - if (!is_array($keys)) { + $line = $file->current(); + if (!is_array($line)) { return $data; } - $keys = $this->processFirstLine($keys); - $keys = array_map(function ($column) { - if (array_key_exists($column, self::COLUMN_SYNONYMS)) { - return self::COLUMN_SYNONYMS[$column]; - } - - return $column; - }, $keys); - - // This will remove duplicate columns and retain the "last" one. - $keyMap = array_flip($keys); + $keyMap = $this->getKeyMap($line); $getValue = fn ($row, $key) => $row[$keyMap[$key] ?? null] ?? null; - $maybeCast = fn ($value) => (is_null($value) || $value === '') ? null : floatval($value); $file->next(); while ($file->valid()) { @@ -92,13 +38,13 @@ public function getMetadata(): VolumeMetadata $fileData = new ImageMetadata( name: $getValue($row, 'filename'), - lat: $maybeCast($getValue($row, 'lat')), - lng: $maybeCast($getValue($row, 'lng')), + lat: $this->maybeCastToFloat($getValue($row, 'lat')), + lng: $this->maybeCastToFloat($getValue($row, 'lng')), takenAt: $getValue($row, 'taken_at') ?: null, // Use null instead of ''. - area: $maybeCast($getValue($row, 'area')), - distanceToGround: $maybeCast($getValue($row, 'distance_to_ground')), - gpsAltitude: $maybeCast($getValue($row, 'gps_altitude')), - yaw: $maybeCast($getValue($row, 'yaw')), + area: $this->maybeCastToFloat($getValue($row, 'area')), + distanceToGround: $this->maybeCastToFloat($getValue($row, 'distance_to_ground')), + gpsAltitude: $this->maybeCastToFloat($getValue($row, 'gps_altitude')), + yaw: $this->maybeCastToFloat($getValue($row, 'yaw')), ); $data->addFile($fileData); @@ -106,22 +52,4 @@ public function getMetadata(): VolumeMetadata return $data; } - - protected function getCsvIterator(): SeekableIterator - { - $file = parent::getFileObject(); - $file->setFlags(SplFileObject::READ_CSV); - - return $file; - } - - protected function processFirstLine(array $line): array - { - $line = array_map('strtolower', $line); - if (!empty($line[0])) { - $line[0] = Util::removeBom($line[0]); - } - - return $line; - } } diff --git a/app/Services/MetadataParsing/VideoCsvParser.php b/app/Services/MetadataParsing/VideoCsvParser.php new file mode 100644 index 000000000..616c5ca72 --- /dev/null +++ b/app/Services/MetadataParsing/VideoCsvParser.php @@ -0,0 +1,72 @@ +getCsvIterator(); + $line = $file->current(); + if (!is_array($line)) { + return $data; + } + + $keyMap = $this->getKeyMap($line); + + $getValue = fn ($row, $key) => $row[$keyMap[$key] ?? null] ?? null; + + $file->next(); + while ($file->valid()) { + $row = $file->current(); + $file->next(); + if (empty($row)) { + continue; + } + + $name = $getValue($row, 'filename'); + if (empty($name)) { + continue; + } + + // Use null instead of ''. + $takenAt = $getValue($row, 'taken_at') ?: null; + + // If the file already exists but takenAt is null, replace the file by newly + // adding it. + if (!is_null($fileData = $data->getFile($name)) && !is_null($takenAt)) { + $fileData->addFrame( + takenAt: $takenAt, + lat: $this->maybeCastToFloat($getValue($row, 'lat')), + lng: $this->maybeCastToFloat($getValue($row, 'lng')), + area: $this->maybeCastToFloat($getValue($row, 'area')), + distanceToGround: $this->maybeCastToFloat($getValue($row, 'distance_to_ground')), + gpsAltitude: $this->maybeCastToFloat($getValue($row, 'gps_altitude')), + yaw: $this->maybeCastToFloat($getValue($row, 'yaw')), + ); + } else { + $fileData = new VideoMetadata( + name: $getValue($row, 'filename'), + lat: $this->maybeCastToFloat($getValue($row, 'lat')), + lng: $this->maybeCastToFloat($getValue($row, 'lng')), + takenAt: $takenAt, + area: $this->maybeCastToFloat($getValue($row, 'area')), + distanceToGround: $this->maybeCastToFloat($getValue($row, 'distance_to_ground')), + gpsAltitude: $this->maybeCastToFloat($getValue($row, 'gps_altitude')), + yaw: $this->maybeCastToFloat($getValue($row, 'yaw')), + ); + + $data->addFile($fileData); + } + } + + return $data; + } +} diff --git a/app/Services/MetadataParsing/VideoMetadata.php b/app/Services/MetadataParsing/VideoMetadata.php new file mode 100644 index 000000000..eba7df169 --- /dev/null +++ b/app/Services/MetadataParsing/VideoMetadata.php @@ -0,0 +1,81 @@ +frames = collect([]); + + if (!is_null($takenAt)) { + $this->addFrame( + takenAt: $takenAt, + lat: $lat, + lng: $lng, + area: $area, + distanceToGround: $distanceToGround, + gpsAltitude: $gpsAltitude, + yaw: $yaw + ); + } + } + + public function getFrames(): Collection + { + return $this->frames; + } + + public function addFrame( + string $takenAt, + ?float $lat = null, + ?float $lng = null, + ?float $area = null, + ?float $distanceToGround = null, + ?float $gpsAltitude = null, + ?float $yaw = null + ): void + { + $frame = new ImageMetadata( + name: $this->name, + takenAt: $takenAt, + lat: $lat, + lng: $lng, + area: $area, + distanceToGround: $distanceToGround, + gpsAltitude: $gpsAltitude, + yaw: $yaw + ); + $this->frames->push($frame); + } + + /** + * Determines if any metadata field other than the name is filled. + */ + public function isEmpty(): bool + { + return $this->frames->isEmpty() + && is_null($this->lat) + && is_null($this->lng) + && is_null($this->takenAt) + && is_null($this->area) + && is_null($this->distanceToGround) + && is_null($this->gpsAltitude) + && is_null($this->yaw); + } +} diff --git a/app/Services/MetadataParsing/VolumeMetadata.php b/app/Services/MetadataParsing/VolumeMetadata.php index b6defcd43..ce5885e29 100644 --- a/app/Services/MetadataParsing/VolumeMetadata.php +++ b/app/Services/MetadataParsing/VolumeMetadata.php @@ -29,6 +29,11 @@ public function getFiles(): Collection return $this->files->values(); } + public function getFile(string $name): ?FileMetadata + { + return $this->files->get($name); + } + /** * Determine if there is any file metadata. */ diff --git a/tests/files/video-metadata-incorrect-encoding.csv b/tests/files/video-metadata-strange-encoding.csv similarity index 100% rename from tests/files/video-metadata-incorrect-encoding.csv rename to tests/files/video-metadata-strange-encoding.csv diff --git a/tests/php/Services/MetadataParsing/ImageMetadataTest.php b/tests/php/Services/MetadataParsing/ImageMetadataTest.php new file mode 100644 index 000000000..aaa39f6a5 --- /dev/null +++ b/tests/php/Services/MetadataParsing/ImageMetadataTest.php @@ -0,0 +1,18 @@ +assertTrue($data->isEmpty()); + + $data = new ImageMetadata('filename', area: 10); + $this->assertFalse($data->isEmpty()); + } +} diff --git a/tests/php/Services/MetadataParsing/VideoCsvParserTest.php b/tests/php/Services/MetadataParsing/VideoCsvParserTest.php new file mode 100644 index 000000000..4bc75ec46 --- /dev/null +++ b/tests/php/Services/MetadataParsing/VideoCsvParserTest.php @@ -0,0 +1,211 @@ +assertTrue($parser->recognizesFile()); + + $file = new File(__DIR__."/../../../files/test.mp4"); + $parser = new VideoCsvParser($file); + $this->assertFalse($parser->recognizesFile()); + } + + public function testGetMetadata() + { + $file = new File(__DIR__."/../../../files/video-metadata.csv"); + $parser = new VideoCsvParser($file); + $data = $parser->getMetadata(); + $this->assertEquals(MediaType::videoId(), $data->type->id); + $this->assertNull($data->name); + $this->assertNull($data->url); + $this->assertNull($data->handle); + $this->assertCount(1, $data->getFiles()); + $file = $data->getFiles()->first(); + $this->assertEquals('abc.mp4', $file->name); + $this->assertEquals('2016-12-19 12:27:00', $file->takenAt); + $this->assertEquals(52.220, $file->lng); + $this->assertEquals(28.123, $file->lat); + $this->assertEquals(-1500, $file->gpsAltitude); + $this->assertEquals(10, $file->distanceToGround); + $this->assertEquals(2.6, $file->area); + $this->assertEquals(180, $file->yaw); + + $frames = $file->getFrames(); + $this->assertCount(2, $frames); + $frame = $frames[0]; + $this->assertEquals('abc.mp4', $frame->name); + $this->assertEquals('2016-12-19 12:27:00', $frame->takenAt); + $this->assertEquals(52.220, $frame->lng); + $this->assertEquals(28.123, $frame->lat); + $this->assertEquals(-1500, $frame->gpsAltitude); + $this->assertEquals(10, $frame->distanceToGround); + $this->assertEquals(2.6, $frame->area); + $this->assertEquals(180, $frame->yaw); + + $frame = $frames[1]; + $this->assertEquals('abc.mp4', $frame->name); + $this->assertEquals('2016-12-19 12:28:00', $frame->takenAt); + $this->assertEquals(52.230, $frame->lng); + $this->assertEquals(28.133, $frame->lat); + $this->assertEquals(-1505, $frame->gpsAltitude); + $this->assertEquals(5, $frame->distanceToGround); + $this->assertEquals(1.6, $frame->area); + $this->assertEquals(181, $frame->yaw); + } + + public function testGetMetadataIgnoreMissingFilename() + { + $file = new File(__DIR__."/../../../files/video-metadata.csv"); + $parser = new VideoCsvParserStub($file); + $parser->content = [ + ['filename', 'taken_at', 'lng', 'lat', 'gps_altitude', 'distance_to_ground', 'area', 'yaw'], + ['', '2016-12-19 12:27:00', '52.220', '28.123', '-1500', '10', '2.6', '180'], + ]; + + $data = $parser->getMetadata(); + $this->assertCount(0, $data->getFiles()); + } + + public function testGetMetadataCantReadFile() + { + $file = new File(__DIR__."/../../../files/test.mp4"); + $parser = new VideoCsvParser($file); + $data = $parser->getMetadata(); + $this->assertEquals(MediaType::videoId(), $data->type->id); + $this->assertCount(0, $data->getFiles()); + } + + public function testGetMetadataCaseInsensitive() + { + $file = new File(__DIR__."/../../../files/video-metadata.csv"); + $parser = new VideoCsvParserStub($file); + $parser->content = [ + ['Filename', 'tAken_at', 'lnG', 'Lat', 'gPs_altitude', 'diStance_to_ground', 'areA'], + ['abc.mp4', '2016-12-19 12:27:00', '52.220', '28.123', '-1500', '10', '2.6'], + ]; + + $data = $parser->getMetadata(); + $this->assertCount(1, $data->getFiles()); + $file = $data->getFiles()->first(); + $this->assertEquals('abc.mp4', $file->name); + $frame = $file->getFrames()->first(); + $this->assertEquals('2016-12-19 12:27:00', $frame->takenAt); + $this->assertEquals(52.220, $frame->lng); + $this->assertEquals(28.123, $frame->lat); + $this->assertEquals(-1500, $frame->gpsAltitude); + $this->assertEquals(10, $frame->distanceToGround); + $this->assertEquals(2.6, $frame->area); + } + + public function testGetMetadataColumnSynonyms1() + { + $file = new File(__DIR__."/../../../files/video-metadata.csv"); + $parser = new VideoCsvParserStub($file); + $parser->content = [ + ['file', 'lon', 'lat', 'heading'], + ['abc.mp4', '52.220', '28.123', '180'], + ]; + + $data = $parser->getMetadata(); + $this->assertCount(1, $data->getFiles()); + $file = $data->getFiles()->first(); + $this->assertEquals('abc.mp4', $file->name); + $this->assertEquals(52.220, $file->lng); + $this->assertEquals(28.123, $file->lat); + $this->assertEquals(180, $file->yaw); + $this->assertTrue($file->getFrames()->isEmpty()); + } + + public function testGetMetadataColumnSynonyms2() + { + $file = new File(__DIR__."/../../../files/video-metadata.csv"); + $parser = new VideoCsvParserStub($file); + $parser->content = [ + ['file', 'longitude', 'latitude'], + ['abc.mp4', '52.220', '28.123'], + ]; + + $data = $parser->getMetadata(); + $this->assertCount(1, $data->getFiles()); + $file = $data->getFiles()->first(); + $this->assertEquals('abc.mp4', $file->name); + $this->assertEquals(52.220, $file->lng); + $this->assertEquals(28.123, $file->lat); + $this->assertTrue($file->getFrames()->isEmpty()); + } + + public function testGetMetadataColumnSynonyms3() + { + $file = new File(__DIR__."/../../../files/video-metadata.csv"); + $parser = new VideoCsvParserStub($file); + $parser->content = [ + ['file', 'SUB_datetime', 'SUB_longitude', 'SUB_latitude', 'SUB_altitude', 'SUB_distance', 'SUB_heading'], + ['abc.mp4', '2016-12-19 12:27:00', '52.220', '28.123', '-1500', '10', '180'], + ]; + + $data = $parser->getMetadata(); + $this->assertCount(1, $data->getFiles()); + $file = $data->getFiles()->first(); + $this->assertEquals('abc.mp4', $file->name); + $frame = $file->getFrames()->first(); + $this->assertEquals('2016-12-19 12:27:00', $frame->takenAt); + $this->assertEquals(52.220, $frame->lng); + $this->assertEquals(28.123, $frame->lat); + $this->assertEquals(-1500, $frame->gpsAltitude); + $this->assertEquals(10, $frame->distanceToGround); + $this->assertEquals(180, $frame->yaw); + } + + public function testGetMetadataEmptyCells() + { + $file = new File(__DIR__."/../../../files/video-metadata.csv"); + $parser = new VideoCsvParserStub($file); + $parser->content = [ + ['filename', 'taken_at', 'lng', 'lat', 'gps_altitude', 'distance_to_ground', 'area', 'yaw'], + ['abc.mp4', '', '52.220', '28.123', '', '', '', ''], + ]; + + $data = $parser->getMetadata(); + $this->assertCount(1, $data->getFiles()); + $file = $data->getFiles()->first(); + $this->assertEquals('abc.mp4', $file->name); + $this->assertNull($file->takenAt); + $this->assertEquals(52.220, $file->lng); + $this->assertEquals(28.123, $file->lat); + $this->assertNull($file->gpsAltitude); + $this->assertNull($file->distanceToGround); + $this->assertNull($file->area); + $this->assertNull($file->yaw); + $this->assertTrue($file->getFrames()->isEmpty()); + } + + public function testGetMetadataStrangeEncoding() + { + $file = new File(__DIR__."/../../../files/video-metadata-strange-encoding.csv"); + $parser = new VideoCsvParser($file); + $data = $parser->getMetadata(); + $this->assertCount(1, $data->getFiles()); + $this->assertCount(1, $data->getFiles()->first()->getFrames()); + } +} + +class VideoCsvParserStub extends VideoCsvParser +{ + public array $content = []; + + protected function getCsvIterator(): \SeekableIterator + { + return new \ArrayIterator($this->content); + } +} diff --git a/tests/php/Services/MetadataParsing/VideoMetadataTest.php b/tests/php/Services/MetadataParsing/VideoMetadataTest.php new file mode 100644 index 000000000..7cc771962 --- /dev/null +++ b/tests/php/Services/MetadataParsing/VideoMetadataTest.php @@ -0,0 +1,42 @@ +assertTrue($data->isEmpty()); + $data->addFrame('2023-12-12 20:26:00'); + $this->assertFalse($data->isEmpty()); + + $data = new VideoMetadata('filename', area: 10); + $this->assertFalse($data->isEmpty()); + } + + public function testGetFrames() + { + $data = new VideoMetadata('filename'); + $this->assertTrue($data->getFrames()->isEmpty()); + + $data = new VideoMetadata('filename', takenAt: '2023-12-12 20:26:00'); + $frame = $data->getFrames()->first(); + $this->assertEquals('filename', $frame->name); + $this->assertEquals('2023-12-12 20:26:00', $frame->takenAt); + } + + public function testAddFrame() + { + $data = new VideoMetadata('filename'); + $this->assertTrue($data->getFrames()->isEmpty()); + $data->addFrame('2023-12-12 20:26:00'); + $frame = $data->getFrames()->first(); + $this->assertEquals('filename', $frame->name); + $this->assertEquals('2023-12-12 20:26:00', $frame->takenAt); + } +} diff --git a/tests/php/Services/MetadataParsing/VolumeMetadataTest.php b/tests/php/Services/MetadataParsing/VolumeMetadataTest.php index 44524cd92..b2c75cf13 100644 --- a/tests/php/Services/MetadataParsing/VolumeMetadataTest.php +++ b/tests/php/Services/MetadataParsing/VolumeMetadataTest.php @@ -30,6 +30,15 @@ public function testAddGetFiles() $this->assertCount(1, $metadata->getFiles()); } + public function testGetFile() + { + $metadata = new VolumeMetadata(); + $this->assertNull($metadata->getFile('filename')); + $file = new FileMetadata('filename'); + $metadata->addFile($file); + $this->assertEquals($file, $metadata->getFile('filename')); + } + public function testIsEmpty() { $metadata = new VolumeMetadata(); From 2437e9869bd9c0278d1238691271284ba474699a Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Mon, 18 Dec 2023 20:33:48 +0100 Subject: [PATCH 010/209] Fix VideoMetadata validation rule for new metadata objects --- app/Rules/ImageMetadata.php | 110 ++++++++------- app/Rules/VideoMetadata.php | 22 +-- tests/php/Rules/ImageMetadataTest.php | 15 +- tests/php/Rules/VideoMetadataTest.php | 192 +++++++++++++++++++++----- 4 files changed, 234 insertions(+), 105 deletions(-) diff --git a/app/Rules/ImageMetadata.php b/app/Rules/ImageMetadata.php index 972c56c93..3fe4fe028 100644 --- a/app/Rules/ImageMetadata.php +++ b/app/Rules/ImageMetadata.php @@ -4,6 +4,7 @@ use Illuminate\Contracts\Validation\Rule; use Biigle\Services\MetadataParsing\VolumeMetadata; +use Biigle\Services\MetadataParsing\FileMetadata; class ImageMetadata implements Rule { @@ -47,86 +48,95 @@ public function passes($attribute, $value): bool throw new \Exception('No value of type '.VolumeMetadata::class.' given.'); } - $fileMetadata = $value->getFiles(); // This checks if any information is given at all. - if ($fileMetadata->isEmpty()) { + if ($value->isEmpty()) { $this->message = 'The metadata information is empty.'; return false; } - foreach ($fileMetadata as $file) { - - if (!in_array($file->name, $this->files)) { - $this->message = "There is no file with filename {$file->name}."; + $fileMetadata = $value->getFiles(); + foreach ($fileMetadata as $file) { + if (!$this->fileMetadataPasses($file)) { return false; } + } + + return true; + } + + /** + * Get the validation error message. + * + * @return string + */ + public function message() + { + return $this->message; + } - if (!is_null($file->lng)) { - if (!is_numeric($file->lng) || abs($file->lng) > 180) { - $this->message = "'{$file->lng}' is no valid longitude for file {$file->name}."; + protected function fileMetadataPasses(FileMetadata $file) + { + if (!in_array($file->name, $this->files)) { + $this->message = "There is no file with filename {$file->name}."; - return false; - } + return false; + } - if (is_null($file->lat)) { - $this->message = "Missing latitude for file {$file->name}."; + if (!is_null($file->lng)) { + if (!is_numeric($file->lng) || abs($file->lng) > 180) { + $this->message = "'{$file->lng}' is no valid longitude for file {$file->name}."; - return false; - } + return false; } - if (!is_null($file->lat)) { - if (!is_numeric($file->lat) || abs($file->lat) > 90) { - $this->message = "'{$file->lat}' is no valid latitude for file {$file->name}."; + if (is_null($file->lat)) { + $this->message = "Missing latitude for file {$file->name}."; - return false; - } + return false; + } + } - if (is_null($file->lng)) { - $this->message = "Missing longitude for file {$file->name}."; + if (!is_null($file->lat)) { + if (!is_numeric($file->lat) || abs($file->lat) > 90) { + $this->message = "'{$file->lat}' is no valid latitude for file {$file->name}."; - return false; - } + return false; } - // Catch both a malformed date (false) and the zero date (negative integer). - if (!is_null($file->takenAt)) { - if (!(strtotime($file->takenAt) > 0)) { - $this->message = "'{$file->takenAt}' is no valid date for file {$file->name}."; + if (is_null($file->lng)) { + $this->message = "Missing longitude for file {$file->name}."; - return false; - } + return false; } + } - foreach (self::NUMERIC_FIELDS as $key => $text) { - if (!is_null($file->$key) && !is_numeric($file->$key)) { - $this->message = "'{$file->$key}' is no valid {$text} for file {$file->name}."; + // Catch both a malformed date (false) and the zero date (negative integer). + if (!is_null($file->takenAt)) { + if (!(strtotime($file->takenAt) > 0)) { + $this->message = "'{$file->takenAt}' is no valid date for file {$file->name}."; - return false; - } + return false; } + } - if (!is_null($file->yaw)) { - if ($file->yaw < 0 || $file->yaw > 360) { - $this->message = "'{$file->yaw}' is no valid yaw for file {$file->name}."; + foreach (self::NUMERIC_FIELDS as $key => $text) { + if (!is_null($file->$key) && !is_numeric($file->$key)) { + $this->message = "'{$file->$key}' is no valid {$text} for file {$file->name}."; - return false; - } + return false; } } - return true; - } + if (!is_null($file->yaw)) { + if ($file->yaw < 0 || $file->yaw > 360) { + $this->message = "'{$file->yaw}' is no valid yaw for file {$file->name}."; - /** - * Get the validation error message. - * - * @return string - */ - public function message() - { - return $this->message; + return false; + } + } + + return true; } } diff --git a/app/Rules/VideoMetadata.php b/app/Rules/VideoMetadata.php index c86bf4935..83ad3110b 100644 --- a/app/Rules/VideoMetadata.php +++ b/app/Rules/VideoMetadata.php @@ -15,27 +15,13 @@ public function passes($attribute, $value): bool return false; } - $columns = array_shift($value); - - $filenames = []; - foreach ($value as $index => $row) { - $combined = array_combine($columns, $row); - $combined = array_filter($combined); - $filename = $combined['filename']; - if (array_key_exists($filename, $filenames)) { - // If this exists, it was already checked if it is a valid date by the - // parent method. - if (!array_key_exists('taken_at', $combined)) { - // +1 since index starts at 0. - // +1 since column description row was removed above. - $line = $index + 2; - - $this->message = "File {$filename} has multiple entries but no 'taken_at' at line {$line}."; + $fileMetadata = $value->getFiles(); + foreach ($fileMetadata as $file) { + foreach ($file->getFrames() as $frame) { + if (!$this->fileMetadataPasses($frame)) { return false; } - } else { - $filenames[$filename] = true; } } diff --git a/tests/php/Rules/ImageMetadataTest.php b/tests/php/Rules/ImageMetadataTest.php index 8bb5eac07..913399452 100644 --- a/tests/php/Rules/ImageMetadataTest.php +++ b/tests/php/Rules/ImageMetadataTest.php @@ -99,12 +99,15 @@ public function testEmptyFilename() { $validator = new ImageMetadataRule(['abc.jpg']); $metadata = new VolumeMetadata; - $metadata->addFile(new ImageMetadata( - name: 'abc.jpg' - )); - $metadata->addFile(new ImageMetadata( - name: '' - )); + $metadata->addFile(new ImageMetadata(name: '')); + $this->assertFalse($validator->passes(null, $metadata)); + } + + public function testEmpty() + { + $validator = new ImageMetadataRule(['abc.jpg']); + $metadata = new VolumeMetadata; + $metadata->addFile(new ImageMetadata(name: 'abc.jpg')); $this->assertFalse($validator->passes(null, $metadata)); } } diff --git a/tests/php/Rules/VideoMetadataTest.php b/tests/php/Rules/VideoMetadataTest.php index f44c4eb40..df8d26f49 100644 --- a/tests/php/Rules/VideoMetadataTest.php +++ b/tests/php/Rules/VideoMetadataTest.php @@ -2,52 +2,182 @@ namespace Biigle\Tests\Rules; -use Biigle\Rules\VideoMetadata; +use Biigle\Rules\VideoMetadata as VideoMetadataRule; +use Biigle\Services\MetadataParsing\VolumeMetadata; +use Biigle\Services\MetadataParsing\VideoMetadata; +use TestCase; -class VideoMetadataTest extends ImageMetadataTest +class VideoMetadataTest extends TestCase { - protected static $ruleClass = VideoMetadata::class; - - public function testMultipleRowsWithTakenAtCol() + public function testMetadataOk() { - $validator = new static::$ruleClass(['abc.mp4']); - $metadata = [ - ['filename', 'taken_at', 'lng', 'lat'], - ['abc.mp4', '2016-12-19 12:27:00', '52.220', '28.123'], - ['abc.mp4', '2016-12-19 12:28:00', '52.230', '28.133'], - ]; + $validator = new VideoMetadataRule(['abc.mp4']); + + $metadata = new VolumeMetadata; + $metadata->addFile(new VideoMetadata( + name: 'abc.mp4', + takenAt: '2016-12-19 12:27:00', + lng: 52.220, + lat: 28.123, + gpsAltitude: -1500, + distanceToGround: 10, + area: 2.6, + yaw: 180 + )); $this->assertTrue($validator->passes(null, $metadata)); } - public function testMultipleRowsWithoutTakenAtCol() + public function testMetadataWrongFile() + { + $validator = new VideoMetadataRule(['abc.mp4']); + $metadata = new VolumeMetadata; + $metadata->addFile(new VideoMetadata( + name: 'cba.jpg', + takenAt: '2016-12-19 12:27:00' + )); + $this->assertFalse($validator->passes(null, $metadata)); + } + + public function testMetadataNoLat() + { + $validator = new VideoMetadataRule(['abc.mp4']); + $metadata = new VolumeMetadata; + $metadata->addFile(new VideoMetadata( + name: 'abc.mp4', + lng: 52.220 + )); + $this->assertFalse($validator->passes(null, $metadata)); + } + + public function testMetadataNoLatFrame() + { + $validator = new VideoMetadataRule(['abc.mp4']); + $metadata = new VolumeMetadata; + $fileMeta = new VideoMetadata(name: 'abc.mp4'); + $fileMeta->addFrame('2016-12-19 12:27:00', lng: 52.220); + $metadata->addFile($fileMeta); + $this->assertFalse($validator->passes(null, $metadata)); + } + + public function testMetadataNoLng() + { + $validator = new VideoMetadataRule(['abc.mp4']); + $metadata = new VolumeMetadata; + $metadata->addFile(new VideoMetadata( + name: 'abc.mp4', + lat: 28.123 + )); + $this->assertFalse($validator->passes(null, $metadata)); + } + + public function testMetadataNoLngFrame() { - $validator = new static::$ruleClass(['abc.mp4']); - $metadata = [ - ['filename', 'lng', 'lat'], - ['abc.mp4', '52.220', '28.123'], - ['abc.mp4', '52.230', '28.133'], - ]; + $validator = new VideoMetadataRule(['abc.mp4']); + $metadata = new VolumeMetadata; + $fileMeta = new VideoMetadata(name: 'abc.mp4'); + $fileMeta->addFrame('2016-12-19 12:27:00', lat: 28.123); + $metadata->addFile($fileMeta); $this->assertFalse($validator->passes(null, $metadata)); } - public function testMultipleRowsWithEmptyTakenAtCol() + public function testMetadataInvalidLat() { - $validator = new static::$ruleClass(['abc.mp4']); - $metadata = [ - ['filename', 'taken_at', 'lng', 'lat'], - ['abc.mp4', '2016-12-19 12:27:00', '52.220', '28.123'], - ['abc.mp4', '', '52.230', '28.133'], - ]; + $validator = new VideoMetadataRule(['abc.mp4']); + $metadata = new VolumeMetadata; + $metadata->addFile(new VideoMetadata( + name: 'abc.mp4', + lng: 50, + lat: 91 + )); $this->assertFalse($validator->passes(null, $metadata)); } - public function testOneRowWithoutTakenAtCol() + public function testMetadataInvalidLatFrame() { - $validator = new static::$ruleClass(['abc.mp4']); - $metadata = [ - ['filename', 'lng', 'lat'], - ['abc.mp4', '52.220', '28.123'], - ]; + $validator = new VideoMetadataRule(['abc.mp4']); + $metadata = new VolumeMetadata; + $fileMeta = new VideoMetadata(name: 'abc.mp4'); + $fileMeta->addFrame('2016-12-19 12:27:00', lng: 50, lat: 91); + $metadata->addFile($fileMeta); + $this->assertFalse($validator->passes(null, $metadata)); + } + + public function testMetadataInvalidLng() + { + $validator = new VideoMetadataRule(['abc.mp4']); + $metadata = new VolumeMetadata; + $metadata->addFile(new VideoMetadata( + name: 'abc.mp4', + lng: 181, + lat: 50 + )); + $this->assertFalse($validator->passes(null, $metadata)); + } + + public function testMetadataInvalidLngFrame() + { + $validator = new VideoMetadataRule(['abc.mp4']); + $metadata = new VolumeMetadata; + $fileMeta = new VideoMetadata(name: 'abc.mp4'); + $fileMeta->addFrame('2016-12-19 12:27:00', lng: 181, lat: 50); + $metadata->addFile($fileMeta); + $this->assertFalse($validator->passes(null, $metadata)); + } + + public function testMetadataInvalidYaw() + { + $validator = new VideoMetadataRule(['abc.mp4']); + $metadata = new VolumeMetadata; + $metadata->addFile(new VideoMetadata( + name: 'abc.mp4', + yaw: 361 + )); + $this->assertFalse($validator->passes(null, $metadata)); + } + + public function testMetadataInvalidYawFrame() + { + $validator = new VideoMetadataRule(['abc.mp4']); + $metadata = new VolumeMetadata; + $fileMeta = new VideoMetadata(name: 'abc.mp4'); + $fileMeta->addFrame('2016-12-19 12:27:00', yaw: 361); + $metadata->addFile($fileMeta); + $this->assertFalse($validator->passes(null, $metadata)); + } + + public function testEmptyFilename() + { + $validator = new VideoMetadataRule(['abc.mp4']); + $metadata = new VolumeMetadata; + $metadata->addFile(new VideoMetadata(name: '')); + $this->assertFalse($validator->passes(null, $metadata)); + } + + public function testEmpty() + { + $validator = new VideoMetadataRule(['abc.jpg']); + $metadata = new VolumeMetadata; + $metadata->addFile(new VideoMetadata(name: 'abc.jpg')); + $this->assertFalse($validator->passes(null, $metadata)); + } + + public function testMultipleFrames() + { + $validator = new VideoMetadataRule(['abc.mp4']); + + $metadata = new VolumeMetadata; + $fileMeta = new VideoMetadata( + name: 'abc.mp4', + takenAt: '2016-12-19 12:27:00', + lng: 52.220, + lat: 28.123 + ); + $fileMeta->addFrame( + takenAt: '2016-12-19 12:28:00', + lng: 52.230, + lat: 28.133 + ); + $metadata->addFile($fileMeta); $this->assertTrue($validator->passes(null, $metadata)); } } From 08f73ef59b6509de79e0cda5eb1ac7f2dfd28ef3 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Wed, 6 Mar 2024 14:55:54 +0100 Subject: [PATCH 011/209] Add media type to metadata file detection --- .../MetadataParsing/ParserFactory.php | 12 +++++++--- .../MetadataParsing/ParserFactoryTest.php | 23 +++++++++++++++---- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/app/Services/MetadataParsing/ParserFactory.php b/app/Services/MetadataParsing/ParserFactory.php index b385a476f..b0774b40f 100644 --- a/app/Services/MetadataParsing/ParserFactory.php +++ b/app/Services/MetadataParsing/ParserFactory.php @@ -7,12 +7,18 @@ class ParserFactory { public static array $parsers = [ - ImageCsvParser::class, + 'image' => [ + ImageCsvParser::class, + ], + 'video' => [ + VideoCsvParser::class, + ], ]; - public static function getParserForFile(File $file): ?MetadataParser + public static function getParserForFile(File $file, string $type): ?MetadataParser { - foreach (self::$parsers as $parserClass) { + $parsers = self::$parsers[$type] ?? []; + foreach ($parsers as $parserClass) { $parser = new $parserClass($file); if ($parser->recognizesFile()) { return $parser; diff --git a/tests/php/Services/MetadataParsing/ParserFactoryTest.php b/tests/php/Services/MetadataParsing/ParserFactoryTest.php index af525eece..4a3c9a513 100644 --- a/tests/php/Services/MetadataParsing/ParserFactoryTest.php +++ b/tests/php/Services/MetadataParsing/ParserFactoryTest.php @@ -4,22 +4,37 @@ use Biigle\Services\MetadataParsing\ImageCsvParser; use Biigle\Services\MetadataParsing\ParserFactory; +use Biigle\Services\MetadataParsing\VideoCsvParser; use Symfony\Component\HttpFoundation\File\File; use TestCase; class ParserFactoryTest extends TestCase { - public function testGetParserForFile() + public function testGetParserForFileImage() { $file = new File(__DIR__."/../../../files/image-metadata.csv"); - $parser = ParserFactory::getParserForFile($file); + $parser = ParserFactory::getParserForFile($file, 'image'); $this->assertInstanceOf(ImageCsvParser::class, $parser); } - public function testGetParserForFileUnknown() + public function testGetParserForFileVideo() + { + $file = new File(__DIR__."/../../../files/video-metadata.csv"); + $parser = ParserFactory::getParserForFile($file, 'video'); + $this->assertInstanceOf(VideoCsvParser::class, $parser); + } + + public function testGetParserForFileUnknownFile() { $file = new File(__DIR__."/../../../files/test.mp4"); - $parser = ParserFactory::getParserForFile($file); + $parser = ParserFactory::getParserForFile($file, 'video'); + $this->assertNull($parser); + } + + public function testGetParserForFileUnknownType() + { + $file = new File(__DIR__."/../../../files/image-metadata.csv"); + $parser = ParserFactory::getParserForFile($file, 'test'); $this->assertNull($parser); } } From 2310e9904829b65ba45a1dd7e43afac9d95a9b80 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Wed, 6 Mar 2024 15:47:36 +0100 Subject: [PATCH 012/209] Do not match files in metadata validation When the metadata file is uploaded it is not clear which files the volume will have with the create volume v2 flow. Instead, metadata of files not in the volume will be ignored. Also, files without metadata will simply be not populated. --- app/Rules/ImageMetadata.php | 10 +------- tests/php/Rules/ImageMetadataTest.php | 27 ++++++------------- tests/php/Rules/VideoMetadataTest.php | 37 ++++++++++----------------- 3 files changed, 22 insertions(+), 52 deletions(-) diff --git a/app/Rules/ImageMetadata.php b/app/Rules/ImageMetadata.php index 3fe4fe028..c743d5286 100644 --- a/app/Rules/ImageMetadata.php +++ b/app/Rules/ImageMetadata.php @@ -31,10 +31,8 @@ class ImageMetadata implements Rule /** * Create a new instance. - * - * @param array $files */ - public function __construct(public array $files) + public function __construct() { $this->message = "The :attribute is invalid."; } @@ -78,12 +76,6 @@ public function message() protected function fileMetadataPasses(FileMetadata $file) { - if (!in_array($file->name, $this->files)) { - $this->message = "There is no file with filename {$file->name}."; - - return false; - } - if (!is_null($file->lng)) { if (!is_numeric($file->lng) || abs($file->lng) > 180) { $this->message = "'{$file->lng}' is no valid longitude for file {$file->name}."; diff --git a/tests/php/Rules/ImageMetadataTest.php b/tests/php/Rules/ImageMetadataTest.php index 913399452..910297830 100644 --- a/tests/php/Rules/ImageMetadataTest.php +++ b/tests/php/Rules/ImageMetadataTest.php @@ -11,7 +11,7 @@ class ImageMetadataTest extends TestCase { public function testMetadataOk() { - $validator = new ImageMetadataRule(['abc.jpg']); + $validator = new ImageMetadataRule(); $metadata = new VolumeMetadata; $metadata->addFile(new ImageMetadata( @@ -27,20 +27,9 @@ public function testMetadataOk() $this->assertTrue($validator->passes(null, $metadata)); } - public function testMetadataWrongFile() - { - $validator = new ImageMetadataRule(['abc.jpg']); - $metadata = new VolumeMetadata; - $metadata->addFile(new ImageMetadata( - name: 'cba.jpg', - takenAt: '2016-12-19 12:27:00' - )); - $this->assertFalse($validator->passes(null, $metadata)); - } - public function testMetadataNoLat() { - $validator = new ImageMetadataRule(['abc.jpg']); + $validator = new ImageMetadataRule(); $metadata = new VolumeMetadata; $metadata->addFile(new ImageMetadata( name: 'abc.jpg', @@ -51,7 +40,7 @@ public function testMetadataNoLat() public function testMetadataNoLng() { - $validator = new ImageMetadataRule(['abc.jpg']); + $validator = new ImageMetadataRule(); $metadata = new VolumeMetadata; $metadata->addFile(new ImageMetadata( name: 'abc.jpg', @@ -62,7 +51,7 @@ public function testMetadataNoLng() public function testMetadataInvalidLat() { - $validator = new ImageMetadataRule(['abc.jpg']); + $validator = new ImageMetadataRule(); $metadata = new VolumeMetadata; $metadata->addFile(new ImageMetadata( name: 'abc.jpg', @@ -74,7 +63,7 @@ public function testMetadataInvalidLat() public function testMetadataInvalidLng() { - $validator = new ImageMetadataRule(['abc.jpg']); + $validator = new ImageMetadataRule(); $metadata = new VolumeMetadata; $metadata->addFile(new ImageMetadata( name: 'abc.jpg', @@ -86,7 +75,7 @@ public function testMetadataInvalidLng() public function testMetadataInvalidYaw() { - $validator = new ImageMetadataRule(['abc.jpg']); + $validator = new ImageMetadataRule(); $metadata = new VolumeMetadata; $metadata->addFile(new ImageMetadata( name: 'abc.jpg', @@ -97,7 +86,7 @@ public function testMetadataInvalidYaw() public function testEmptyFilename() { - $validator = new ImageMetadataRule(['abc.jpg']); + $validator = new ImageMetadataRule(); $metadata = new VolumeMetadata; $metadata->addFile(new ImageMetadata(name: '')); $this->assertFalse($validator->passes(null, $metadata)); @@ -105,7 +94,7 @@ public function testEmptyFilename() public function testEmpty() { - $validator = new ImageMetadataRule(['abc.jpg']); + $validator = new ImageMetadataRule(); $metadata = new VolumeMetadata; $metadata->addFile(new ImageMetadata(name: 'abc.jpg')); $this->assertFalse($validator->passes(null, $metadata)); diff --git a/tests/php/Rules/VideoMetadataTest.php b/tests/php/Rules/VideoMetadataTest.php index df8d26f49..530acc302 100644 --- a/tests/php/Rules/VideoMetadataTest.php +++ b/tests/php/Rules/VideoMetadataTest.php @@ -11,7 +11,7 @@ class VideoMetadataTest extends TestCase { public function testMetadataOk() { - $validator = new VideoMetadataRule(['abc.mp4']); + $validator = new VideoMetadataRule(); $metadata = new VolumeMetadata; $metadata->addFile(new VideoMetadata( @@ -27,20 +27,9 @@ public function testMetadataOk() $this->assertTrue($validator->passes(null, $metadata)); } - public function testMetadataWrongFile() - { - $validator = new VideoMetadataRule(['abc.mp4']); - $metadata = new VolumeMetadata; - $metadata->addFile(new VideoMetadata( - name: 'cba.jpg', - takenAt: '2016-12-19 12:27:00' - )); - $this->assertFalse($validator->passes(null, $metadata)); - } - public function testMetadataNoLat() { - $validator = new VideoMetadataRule(['abc.mp4']); + $validator = new VideoMetadataRule(); $metadata = new VolumeMetadata; $metadata->addFile(new VideoMetadata( name: 'abc.mp4', @@ -51,7 +40,7 @@ public function testMetadataNoLat() public function testMetadataNoLatFrame() { - $validator = new VideoMetadataRule(['abc.mp4']); + $validator = new VideoMetadataRule(); $metadata = new VolumeMetadata; $fileMeta = new VideoMetadata(name: 'abc.mp4'); $fileMeta->addFrame('2016-12-19 12:27:00', lng: 52.220); @@ -61,7 +50,7 @@ public function testMetadataNoLatFrame() public function testMetadataNoLng() { - $validator = new VideoMetadataRule(['abc.mp4']); + $validator = new VideoMetadataRule(); $metadata = new VolumeMetadata; $metadata->addFile(new VideoMetadata( name: 'abc.mp4', @@ -72,7 +61,7 @@ public function testMetadataNoLng() public function testMetadataNoLngFrame() { - $validator = new VideoMetadataRule(['abc.mp4']); + $validator = new VideoMetadataRule(); $metadata = new VolumeMetadata; $fileMeta = new VideoMetadata(name: 'abc.mp4'); $fileMeta->addFrame('2016-12-19 12:27:00', lat: 28.123); @@ -82,7 +71,7 @@ public function testMetadataNoLngFrame() public function testMetadataInvalidLat() { - $validator = new VideoMetadataRule(['abc.mp4']); + $validator = new VideoMetadataRule(); $metadata = new VolumeMetadata; $metadata->addFile(new VideoMetadata( name: 'abc.mp4', @@ -94,7 +83,7 @@ public function testMetadataInvalidLat() public function testMetadataInvalidLatFrame() { - $validator = new VideoMetadataRule(['abc.mp4']); + $validator = new VideoMetadataRule(); $metadata = new VolumeMetadata; $fileMeta = new VideoMetadata(name: 'abc.mp4'); $fileMeta->addFrame('2016-12-19 12:27:00', lng: 50, lat: 91); @@ -104,7 +93,7 @@ public function testMetadataInvalidLatFrame() public function testMetadataInvalidLng() { - $validator = new VideoMetadataRule(['abc.mp4']); + $validator = new VideoMetadataRule(); $metadata = new VolumeMetadata; $metadata->addFile(new VideoMetadata( name: 'abc.mp4', @@ -116,7 +105,7 @@ public function testMetadataInvalidLng() public function testMetadataInvalidLngFrame() { - $validator = new VideoMetadataRule(['abc.mp4']); + $validator = new VideoMetadataRule(); $metadata = new VolumeMetadata; $fileMeta = new VideoMetadata(name: 'abc.mp4'); $fileMeta->addFrame('2016-12-19 12:27:00', lng: 181, lat: 50); @@ -126,7 +115,7 @@ public function testMetadataInvalidLngFrame() public function testMetadataInvalidYaw() { - $validator = new VideoMetadataRule(['abc.mp4']); + $validator = new VideoMetadataRule(); $metadata = new VolumeMetadata; $metadata->addFile(new VideoMetadata( name: 'abc.mp4', @@ -137,7 +126,7 @@ public function testMetadataInvalidYaw() public function testMetadataInvalidYawFrame() { - $validator = new VideoMetadataRule(['abc.mp4']); + $validator = new VideoMetadataRule(); $metadata = new VolumeMetadata; $fileMeta = new VideoMetadata(name: 'abc.mp4'); $fileMeta->addFrame('2016-12-19 12:27:00', yaw: 361); @@ -147,7 +136,7 @@ public function testMetadataInvalidYawFrame() public function testEmptyFilename() { - $validator = new VideoMetadataRule(['abc.mp4']); + $validator = new VideoMetadataRule(); $metadata = new VolumeMetadata; $metadata->addFile(new VideoMetadata(name: '')); $this->assertFalse($validator->passes(null, $metadata)); @@ -163,7 +152,7 @@ public function testEmpty() public function testMultipleFrames() { - $validator = new VideoMetadataRule(['abc.mp4']); + $validator = new VideoMetadataRule(); $metadata = new VolumeMetadata; $fileMeta = new VideoMetadata( From b535d7f1de27d07974e26c0c6a46031006f71696 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Wed, 6 Mar 2024 16:22:44 +0100 Subject: [PATCH 013/209] Implement metadata file upload for new pending volumes --- .../Api/ProjectPendingVolumeController.php | 22 ++++- app/Http/Requests/StorePendingVolume.php | 31 +++++- tests/files/image-metadata-invalid.csv | 2 + tests/files/video-metadata-invalid.csv | 3 + .../ProjectPendingVolumeControllerTest.php | 96 ++++++++++++++++++- 5 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 tests/files/image-metadata-invalid.csv create mode 100644 tests/files/video-metadata-invalid.csv diff --git a/app/Http/Controllers/Api/ProjectPendingVolumeController.php b/app/Http/Controllers/Api/ProjectPendingVolumeController.php index 3a3328bd5..8bc62a0f6 100644 --- a/app/Http/Controllers/Api/ProjectPendingVolumeController.php +++ b/app/Http/Controllers/Api/ProjectPendingVolumeController.php @@ -17,12 +17,32 @@ class ProjectPendingVolumeController extends Controller * @apiParam {Number} id The project ID. * * @apiParam (Required attributes) {String} media_type The media type of the new volume (`image` or `video`). + * + * @apiParam (Optional attributes) {File} metadata_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 formats may be supported through modules. + * + * @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` + * @apiParam (metadata columns) {Number} lng Longitude where the file was taken in decimal form. If this column is present, `lat` must be present, too. Example: `52.3211` + * @apiParam (metadata columns) {Number} lat Latitude where the file was taken in decimal form. If this column is present, `lng` must be present, too. Example: `28.775` + * @apiParam (metadata columns) {Number} gps_altitude GPS Altitude where the file was taken in meters. Negative for below sea level. Example: `-1500.5` + * @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`. */ public function store(StorePendingVolume $request) { - return $request->project->pendingVolumes()->create([ + $pv = $request->project->pendingVolumes()->create([ 'media_type_id' => $request->input('media_type_id'), 'user_id' => $request->user()->id, ]); + + if ($request->has('metadata_file')) { + $pv->saveMetadata($request->file('metadata_file')); + } + + return $pv; } + + // TODO implement update() endpoint to create the volume and optionally continue to + // import annotations/file labels. + // See: https://github.com/biigle/core/issues/701#issue-2000484824 } diff --git a/app/Http/Requests/StorePendingVolume.php b/app/Http/Requests/StorePendingVolume.php index 21a471786..1c6749e8a 100644 --- a/app/Http/Requests/StorePendingVolume.php +++ b/app/Http/Requests/StorePendingVolume.php @@ -4,6 +4,9 @@ use Biigle\MediaType; use Biigle\Project; +use Biigle\Rules\ImageMetadata; +use Biigle\Rules\VideoMetadata; +use Biigle\Services\MetadataParsing\ParserFactory; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -28,6 +31,7 @@ public function rules(): array { return [ 'media_type' => ['required', Rule::in(array_keys(MediaType::INSTANCES))], + 'metadata_file' => ['file'], ]; } @@ -39,16 +43,35 @@ public function rules(): array */ public function withValidator($validator) { - if ($validator->fails()) { - return; - } - $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'); + $parser = ParserFactory::getParserForFile($file, $type); + if (is_null($parser)) { + $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()); + } } }); } diff --git a/tests/files/image-metadata-invalid.csv b/tests/files/image-metadata-invalid.csv new file mode 100644 index 000000000..0c0937c59 --- /dev/null +++ b/tests/files/image-metadata-invalid.csv @@ -0,0 +1,2 @@ +filename,taken_at,lng,lat,gps_altitude,distance_to_ground,area,yaw +abc.jpg,2016-12-19 12:27:00,52.220,28.123,-1500,10,2.6,999 diff --git a/tests/files/video-metadata-invalid.csv b/tests/files/video-metadata-invalid.csv new file mode 100644 index 000000000..0320a04f1 --- /dev/null +++ b/tests/files/video-metadata-invalid.csv @@ -0,0 +1,3 @@ +filename,taken_at,lng,lat,gps_altitude,distance_to_ground,area,yaw +abc.mp4,2016-12-19 12:27:00,52.220,28.123,-1500,10,2.6,999 +abc.mp4,2016-12-19 12:28:00,52.230,28.133,-1505,5,1.6,181 diff --git a/tests/php/Http/Controllers/Api/ProjectPendingVolumeControllerTest.php b/tests/php/Http/Controllers/Api/ProjectPendingVolumeControllerTest.php index 8539a1b85..b86ea9eb4 100644 --- a/tests/php/Http/Controllers/Api/ProjectPendingVolumeControllerTest.php +++ b/tests/php/Http/Controllers/Api/ProjectPendingVolumeControllerTest.php @@ -5,10 +5,12 @@ use ApiTestCase; use Biigle\MediaType; use Biigle\PendingVolume; +use Illuminate\Http\UploadedFile; +use Storage; class ProjectPendingVolumeControllerTest extends ApiTestCase { - public function testStoreImages() + public function testStoreImage() { $id = $this->project()->id; $this->doTestApiRoute('POST', "/api/v1/projects/{$id}/pending-volumes"); @@ -55,4 +57,96 @@ public function testStoreVideo() 'media_type' => 'video', ])->assertStatus(201); } + + public function testStoreImageWithFile() + { + $disk = Storage::fake('pending-metadata'); + $csv = __DIR__."/../../../../files/image-metadata.csv"; + $file = new UploadedFile($csv, 'metadata.csv', 'text/csv', null, true); + + $id = $this->project()->id; + $this->beAdmin(); + $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ + 'media_type' => 'image', + 'metadata_file' => $file, + ])->assertStatus(201); + + $pv = PendingVolume::where('project_id', $id)->first(); + $this->assertNotNull($pv->metadata_file_path); + $disk->assertExists($pv->metadata_file_path); + } + + public function testStoreImageWithFileUnknown() + { + $disk = Storage::fake('pending-metadata'); + $csv = __DIR__."/../../../../files/test.mp4"; + $file = new UploadedFile($csv, 'metadata.csv', 'text/csv', null, true); + + $id = $this->project()->id; + $this->beAdmin(); + $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ + 'media_type' => 'image', + 'metadata_file' => $file, + ])->assertStatus(422); + } + + public function testStoreImageWithFileInvalid() + { + $disk = Storage::fake('pending-metadata'); + $csv = __DIR__."/../../../../files/image-metadata-invalid.csv"; + $file = new UploadedFile($csv, 'metadata.csv', 'text/csv', null, true); + + $id = $this->project()->id; + $this->beAdmin(); + $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ + 'media_type' => 'image', + 'metadata_file' => $file, + ])->assertStatus(422); + } + + public function testStoreVideoWithFile() + { + $disk = Storage::fake('pending-metadata'); + $csv = __DIR__."/../../../../files/video-metadata.csv"; + $file = new UploadedFile($csv, 'metadata.csv', 'text/csv', null, true); + + $id = $this->project()->id; + $this->beAdmin(); + $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ + 'media_type' => 'video', + 'metadata_file' => $file, + ])->assertStatus(201); + + $pv = PendingVolume::where('project_id', $id)->first(); + $this->assertNotNull($pv->metadata_file_path); + $disk->assertExists($pv->metadata_file_path); + } + + public function testStoreVideoWithFileUnknown() + { + $disk = Storage::fake('pending-metadata'); + $csv = __DIR__."/../../../../files/test.mp4"; + $file = new UploadedFile($csv, 'metadata.csv', 'text/csv', null, true); + + $id = $this->project()->id; + $this->beAdmin(); + $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ + 'media_type' => 'video', + 'metadata_file' => $file, + ])->assertStatus(422); + } + + public function testStoreVideoWithFileInvalid() + { + $disk = Storage::fake('pending-metadata'); + $csv = __DIR__."/../../../../files/video-metadata-invalid.csv"; + $file = new UploadedFile($csv, 'metadata.csv', 'text/csv', null, true); + + $id = $this->project()->id; + $this->beAdmin(); + $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ + 'media_type' => 'video', + 'metadata_file' => $file, + ])->assertStatus(422); + } } From 4d962ba9fd7a87d35cbd2046d8b1772514b19953 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Thu, 7 Mar 2024 15:37:17 +0100 Subject: [PATCH 014/209] Implement pending volume update API endpoint The annotation/file label import is still missing. --- .../Api/PendingVolumeController.php | 135 ++++ .../Api/ProjectPendingVolumeController.php | 48 -- .../Api/ProjectVolumeController.php | 11 +- app/Http/Controllers/Api/_apidoc.js | 5 + app/Http/Requests/StoreVolume.php | 11 +- app/Http/Requests/UpdatePendingVolume.php | 77 +++ app/PendingVolume.php | 16 + app/Policies/PendingVolumePolicy.php | 43 ++ app/Volume.php | 13 + ...6_143800_create_pending_volumes_table.php} | 8 + routes/api.php | 7 +- .../Api/PendingVolumeControllerTest.php | 577 ++++++++++++++++++ .../ProjectPendingVolumeControllerTest.php | 152 ----- tests/php/PendingVolumeTest.php | 1 + .../php/Policies/PendingVolumePolicyTest.php | 46 ++ 15 files changed, 935 insertions(+), 215 deletions(-) create mode 100644 app/Http/Controllers/Api/PendingVolumeController.php delete mode 100644 app/Http/Controllers/Api/ProjectPendingVolumeController.php create mode 100644 app/Http/Requests/UpdatePendingVolume.php create mode 100644 app/Policies/PendingVolumePolicy.php rename database/migrations/{2023_11_29_201953_create_pending_volumes_table.php => 2024_03_06_143800_create_pending_volumes_table.php} (75%) create mode 100644 tests/php/Http/Controllers/Api/PendingVolumeControllerTest.php delete mode 100644 tests/php/Http/Controllers/Api/ProjectPendingVolumeControllerTest.php create mode 100644 tests/php/Policies/PendingVolumePolicyTest.php diff --git a/app/Http/Controllers/Api/PendingVolumeController.php b/app/Http/Controllers/Api/PendingVolumeController.php new file mode 100644 index 000000000..dff453881 --- /dev/null +++ b/app/Http/Controllers/Api/PendingVolumeController.php @@ -0,0 +1,135 @@ +project->pendingVolumes()->create([ + 'media_type_id' => $request->input('media_type_id'), + 'user_id' => $request->user()->id, + ]); + + if ($request->has('metadata_file')) { + $pv->saveMetadata($request->file('metadata_file')); + } + + return $pv; + } + + /** + * 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. 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. + * + * @apiParam (Optional attributes) {String} handle Handle or DOI of the dataset that is represented by the new volume. + * + * @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 + * } + * + */ + public function update(UpdatePendingVolume $request) + { + $volume = DB::transaction(function () use ($request) { + $volume = Volume::create([ + 'name' => $request->input('name'), + 'url' => $request->input('url'), + 'media_type_id' => $request->pendingVolume->media_type_id, + 'handle' => $request->input('handle'), + 'creator_id' => $request->user()->id, + ]); + + $request->pendingVolume->project->volumes()->attach($volume); + + $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; + }); + + // TODO: Implement annotation/file label import where this must be saved in the + // DB. + $request->pendingVolume->volume_id = $volume->id; + + $request->pendingVolume->delete(); + + return $request->pendingVolume; + } +} diff --git a/app/Http/Controllers/Api/ProjectPendingVolumeController.php b/app/Http/Controllers/Api/ProjectPendingVolumeController.php deleted file mode 100644 index 8bc62a0f6..000000000 --- a/app/Http/Controllers/Api/ProjectPendingVolumeController.php +++ /dev/null @@ -1,48 +0,0 @@ -project->pendingVolumes()->create([ - 'media_type_id' => $request->input('media_type_id'), - 'user_id' => $request->user()->id, - ]); - - if ($request->has('metadata_file')) { - $pv->saveMetadata($request->file('metadata_file')); - } - - return $pv; - } - - // TODO implement update() endpoint to create the volume and optionally continue to - // import annotations/file labels. - // See: https://github.com/biigle/core/issues/701#issue-2000484824 -} diff --git a/app/Http/Controllers/Api/ProjectVolumeController.php b/app/Http/Controllers/Api/ProjectVolumeController.php index 47e1e53e2..8df5636b6 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.. * @@ -56,7 +49,7 @@ 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 @@ -124,7 +117,7 @@ public function store(StoreVolume $request) // 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) { + if (count($files) > PendingVolumeController::CREATE_SYNC_LIMIT) { Queue::pushOn('high', $job); $volume->creating_async = true; $volume->save(); 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/Requests/StoreVolume.php b/app/Http/Requests/StoreVolume.php index ae34feb48..5e91d72b7 100644 --- a/app/Http/Requests/StoreVolume.php +++ b/app/Http/Requests/StoreVolume.php @@ -71,12 +71,13 @@ 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)) { diff --git a/app/Http/Requests/UpdatePendingVolume.php b/app/Http/Requests/UpdatePendingVolume.php new file mode 100644 index 000000000..b8163b60e --- /dev/null +++ b/app/Http/Requests/UpdatePendingVolume.php @@ -0,0 +1,77 @@ +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'], + 'handle' => ['nullable', 'max:256', new Handle], + // 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 = array_map(fn ($f) => trim($f, " \n\r\t\v\x00'\""), $files); + $this->merge(['files' => array_filter($files)]); + } + } +} diff --git a/app/PendingVolume.php b/app/PendingVolume.php index afc05d2ed..8323df2fe 100644 --- a/app/PendingVolume.php +++ b/app/PendingVolume.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Http\UploadedFile; class PendingVolume extends Model @@ -20,6 +21,16 @@ class PendingVolume extends Model 'user_id', 'project_id', 'metadata_file_path', + 'volume_id', + ]; + + /** + * The attributes that should be hidden for arrays. + * + * @var array + */ + protected $hidden = [ + 'metadata_file_path', ]; public function hasMetadata(): bool @@ -35,4 +46,9 @@ public function saveMetadata(UploadedFile $file): void $file->storeAs('', $this->metadata_file_path, $disk); $this->save(); } + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } } diff --git a/app/Policies/PendingVolumePolicy.php b/app/Policies/PendingVolumePolicy.php new file mode 100644 index 000000000..f35306b8c --- /dev/null +++ b/app/Policies/PendingVolumePolicy.php @@ -0,0 +1,43 @@ +can('sudo')) { + return true; + } + } + + /** + * Determine if the given volume can be updated by the user. + */ + public function update(User $user, PendingVolume $pv): bool + { + return $user->id === $pv->user_id && + $this->remember("pending-volume-can-update-{$user->id}-{$pv->id}", fn () => + DB::table('project_user') + ->where('project_id', $pv->project_id) + ->where('user_id', $user->id) + ->where('project_role_id', Role::adminId()) + ->exists() + ); + } +} diff --git a/app/Volume.php b/app/Volume.php index c99b9d686..ca468650c 100644 --- a/app/Volume.php +++ b/app/Volume.php @@ -41,6 +41,19 @@ class Volume extends Model */ const VIDEO_FILE_REGEX = '/\.(mpe?g|mp4|webm)(\?.+)?$/i'; + /** + * The attributes that are mass assignable. + * + * @var array + */ + protected $fillable = [ + 'name', + 'url', + 'media_type_id', + 'handle', + 'creator_id', + ]; + /** * The attributes hidden from the model's JSON form. * diff --git a/database/migrations/2023_11_29_201953_create_pending_volumes_table.php b/database/migrations/2024_03_06_143800_create_pending_volumes_table.php similarity index 75% rename from database/migrations/2023_11_29_201953_create_pending_volumes_table.php rename to database/migrations/2024_03_06_143800_create_pending_volumes_table.php index 2f52476d5..6376d0aca 100644 --- a/database/migrations/2023_11_29_201953_create_pending_volumes_table.php +++ b/database/migrations/2024_03_06_143800_create_pending_volumes_table.php @@ -26,6 +26,14 @@ public function up(): void ->constrained() ->onDelete('cascade'); + // This is used if annotations or file labels should be imported. The volume + // will be created first but the pending volume is still required to store + // additional information required for the import. + $table->foreignId('volume_id') + ->nullable() + ->constrained() + ->onDelete('cascade'); + // Path of the file in the pending_metadata_storage_disk. $table->string('metadata_file_path', 256)->nullable(); diff --git a/routes/api.php b/routes/api.php index a254b75ff..7e625c4b0 100644 --- a/routes/api.php +++ b/routes/api.php @@ -147,6 +147,11 @@ 'only' => ['update', 'destroy'], ]); +$router->resource('pending-volumes', 'PendingVolumeController', [ + 'only' => ['update'], + 'parameters' => ['pending-volumes' => 'id'], +]); + $router->resource('projects', 'ProjectController', [ 'only' => ['index', 'show', 'update', 'store', 'destroy'], 'parameters' => ['projects' => 'id'], @@ -168,7 +173,7 @@ 'parameters' => ['projects' => 'id', 'label-trees' => 'id2'], ]); -$router->resource('projects.pending-volumes', 'ProjectPendingVolumeController', [ +$router->resource('projects.pending-volumes', 'PendingVolumeController', [ 'only' => ['store'], 'parameters' => ['projects' => 'id'], ]); diff --git a/tests/php/Http/Controllers/Api/PendingVolumeControllerTest.php b/tests/php/Http/Controllers/Api/PendingVolumeControllerTest.php new file mode 100644 index 000000000..3879fcfcd --- /dev/null +++ b/tests/php/Http/Controllers/Api/PendingVolumeControllerTest.php @@ -0,0 +1,577 @@ +project()->id; + $this->doTestApiRoute('POST', "/api/v1/projects/{$id}/pending-volumes"); + + $this->beEditor(); + $this->post("/api/v1/projects/{$id}/pending-volumes")->assertStatus(403); + + $this->beAdmin(); + // Missing arguments. + $this->json('POST', "/api/v1/projects/{$id}/pending-volumes")->assertStatus(422); + + // Incorrect media type. + $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ + 'media_type' => 'whatever', + ])->assertStatus(422); + + $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ + 'media_type' => 'image', + ])->assertStatus(201); + + $pv = PendingVolume::where('project_id', $id)->first(); + $this->assertEquals(MediaType::imageId(), $pv->media_type_id); + $this->assertEquals($this->admin()->id, $pv->user_id); + } + + public function testStoreTwice() + { + $this->beAdmin(); + $id = $this->project()->id; + $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ + 'media_type' => 'image', + ])->assertStatus(201); + + $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ + 'media_type' => 'image', + ])->assertStatus(422); + } + + public function testStoreVideo() + { + $this->beAdmin(); + $id = $this->project()->id; + $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ + 'media_type' => 'video', + ])->assertStatus(201); + } + + public function testStoreImageWithFile() + { + $disk = Storage::fake('pending-metadata'); + $csv = __DIR__."/../../../../files/image-metadata.csv"; + $file = new UploadedFile($csv, 'metadata.csv', 'text/csv', null, true); + + $id = $this->project()->id; + $this->beAdmin(); + $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ + 'media_type' => 'image', + 'metadata_file' => $file, + ])->assertStatus(201); + + $pv = PendingVolume::where('project_id', $id)->first(); + $this->assertNotNull($pv->metadata_file_path); + $disk->assertExists($pv->metadata_file_path); + } + + public function testStoreImageWithFileUnknown() + { + $disk = Storage::fake('pending-metadata'); + $csv = __DIR__."/../../../../files/test.mp4"; + $file = new UploadedFile($csv, 'metadata.csv', 'text/csv', null, true); + + $id = $this->project()->id; + $this->beAdmin(); + $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ + 'media_type' => 'image', + 'metadata_file' => $file, + ])->assertStatus(422); + } + + public function testStoreImageWithFileInvalid() + { + $disk = Storage::fake('pending-metadata'); + $csv = __DIR__."/../../../../files/image-metadata-invalid.csv"; + $file = new UploadedFile($csv, 'metadata.csv', 'text/csv', null, true); + + $id = $this->project()->id; + $this->beAdmin(); + $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ + 'media_type' => 'image', + 'metadata_file' => $file, + ])->assertStatus(422); + } + + public function testStoreVideoWithFile() + { + $disk = Storage::fake('pending-metadata'); + $csv = __DIR__."/../../../../files/video-metadata.csv"; + $file = new UploadedFile($csv, 'metadata.csv', 'text/csv', null, true); + + $id = $this->project()->id; + $this->beAdmin(); + $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ + 'media_type' => 'video', + 'metadata_file' => $file, + ])->assertStatus(201); + + $pv = PendingVolume::where('project_id', $id)->first(); + $this->assertNotNull($pv->metadata_file_path); + $disk->assertExists($pv->metadata_file_path); + } + + public function testStoreVideoWithFileUnknown() + { + $disk = Storage::fake('pending-metadata'); + $csv = __DIR__."/../../../../files/test.mp4"; + $file = new UploadedFile($csv, 'metadata.csv', 'text/csv', null, true); + + $id = $this->project()->id; + $this->beAdmin(); + $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ + 'media_type' => 'video', + 'metadata_file' => $file, + ])->assertStatus(422); + } + + public function testStoreVideoWithFileInvalid() + { + $disk = Storage::fake('pending-metadata'); + $csv = __DIR__."/../../../../files/video-metadata-invalid.csv"; + $file = new UploadedFile($csv, 'metadata.csv', 'text/csv', null, true); + + $id = $this->project()->id; + $this->beAdmin(); + $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ + 'media_type' => 'video', + 'metadata_file' => $file, + ])->assertStatus(422); + } + + public function testUpdateImages() + { + config(['volumes.editor_storage_disks' => ['test']]); + $disk = Storage::fake('test'); + $pv = PendingVolume::factory()->create([ + 'project_id' => $this->project()->id, + 'media_type_id' => MediaType::imageId(), + 'user_id' => $this->editor()->id, + ]); + $id = $pv->id; + $this->doTestApiRoute('PUT', "/api/v1/pending-volumes/{$id}"); + + $this->beEditor(); + $this->putJson("/api/v1/pending-volumes/{$id}")->assertStatus(403); + + $this->beAdmin(); + // Does not own the pending volume. + $this->putJson("/api/v1/pending-volumes/{$id}")->assertStatus(403); + + $pv->update(['user_id' => $this->admin()->id]); + // mssing arguments + $this->putJson("/api/v1/pending-volumes/{$id}")->assertStatus(422); + + // invalid url format + $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'my volume no. 1', + 'url' => 'test', + 'media_type' => 'image', + 'files' => ['1.jpg', '2.jpg'], + ])->assertStatus(422); + + // unknown storage disk + $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'my volume no. 1', + 'url' => 'random', + 'media_type' => 'image', + 'files' => ['1.jpg', '2.jpg'], + ])->assertStatus(422); + + // images directory dows not exist in storage disk + $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'my volume no. 1', + 'url' => 'test://images', + 'media_type' => 'image', + 'files' => ['1.jpg', '2.jpg'], + ])->assertStatus(422); + + $disk->makeDirectory('images'); + $disk->put('images/1.jpg', 'abc'); + $disk->put('images/2.jpg', 'abc'); + $disk->put('images/1.bmp', 'abc'); + + // images array is empty + $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'my volume no. 1', + 'url' => 'test://images', + 'media_type' => 'image', + 'files' => [], + ])->assertStatus(422); + + // error because of duplicate image + $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'my volume no. 1', + 'url' => 'test://images', + 'media_type' => 'image', + 'files' => ['1.jpg', '1.jpg'], + ])->assertStatus(422); + + // error because of unsupported image format + $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'my volume no. 1', + 'url' => 'test://images', + 'media_type' => 'image', + 'files' => ['1.bmp'], + ])->assertStatus(422); + + // Image filename too long. + $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'my volume no. 1', + 'url' => 'test://images', + 'media_type' => 'image', + 'files' => ['aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.jpg'], + ])->assertStatus(422); + + $response = $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'my volume no. 1', + 'url' => 'test://images', + 'media_type' => 'image', + // Elements should be sanitized and empty elements should be discarded + 'files' => ['" 1.jpg"', '', '\'2.jpg\' ', '', ''], + ])->assertSuccessful(); + $content = $response->getContent(); + $this->assertEquals(1, $this->project()->volumes()->count()); + $this->assertStringStartsWith('{', $content); + $this->assertStringEndsWith('}', $content); + + $id = json_decode($content)->volume_id; + Queue::assertPushed(CreateNewImagesOrVideos::class, function ($job) use ($id) { + $this->assertEquals($id, $job->volume->id); + $this->assertContains('1.jpg', $job->filenames); + $this->assertContains('2.jpg', $job->filenames); + + return true; + }); + + $this->assertNull($pv->fresh()); + } + + public function testUpdateImagesWithMetadata() + { + $this->markTestIncomplete(); + } + + public function testUpdateImagesWithAnnotationImport() + { + $this->markTestIncomplete(); + } + + public function testUpdateImagesWithImageLabelImport() + { + $this->markTestIncomplete(); + } + + public function testUpdateHandle() + { + config(['volumes.editor_storage_disks' => ['test']]); + $disk = Storage::fake('test'); + $disk->makeDirectory('images'); + $disk->put('images/1.jpg', 'abc'); + + $id = PendingVolume::factory()->create([ + 'project_id' => $this->project()->id, + 'media_type_id' => MediaType::imageId(), + 'user_id' => $this->admin()->id, + ])->id; + + $this->beAdmin(); + // Invalid handle format. + $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'my volume no. 1', + 'url' => 'test://images', + 'files' => ['1.jpg'], + 'handle' => 'https://doi.org/10.3389/fmars.2017.00083', + ])->assertStatus(422); + + $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'my volume no. 1', + 'url' => 'test://images', + 'files' => ['1.jpg'], + 'handle' => '10.3389/fmars.2017.00083', + ])->assertStatus(200); + + $volume = Volume::orderBy('id', 'desc')->first(); + $this->assertEquals('10.3389/fmars.2017.00083', $volume->handle); + + $id = PendingVolume::factory()->create([ + 'project_id' => $this->project()->id, + 'media_type_id' => MediaType::imageId(), + 'user_id' => $this->admin()->id, + ])->id; + + // Some DOIs can contain multiple slashes. + $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'my volume no. 1', + 'url' => 'test://images', + 'files' => ['1.jpg'], + 'handle' => '10.3389/fmars.2017/00083', + ])->assertStatus(200); + + $id = PendingVolume::factory()->create([ + 'project_id' => $this->project()->id, + 'media_type_id' => MediaType::imageId(), + 'user_id' => $this->admin()->id, + ])->id; + + $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'my volume no. 1', + 'url' => 'test://images', + 'files' => ['1.jpg'], + 'handle' => '', + ])->assertStatus(200); + + $volume = Volume::orderBy('id', 'desc')->first(); + $this->assertNull($volume->handle); + } + + public function testUpdateFilesExist() + { + config(['volumes.editor_storage_disks' => ['test']]); + $disk = Storage::fake('test'); + $disk->makeDirectory('images'); + $disk->put('images/1.jpg', 'abc'); + + $id = PendingVolume::factory()->create([ + 'project_id' => $this->project()->id, + 'media_type_id' => MediaType::imageId(), + 'user_id' => $this->admin()->id, + ])->id; + + $this->beAdmin(); + + $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'my volume no. 1', + 'url' => 'test://images', + 'files' => ['1.jpg', '2.jpg'], + ])->assertStatus(422); + + $disk->put('images/2.jpg', 'abc'); + + $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'my volume no. 1', + 'url' => 'test://images', + 'files' => ['1.jpg', '2.jpg'], + ])->assertSuccessful(); + } + + public function testUpdateUnableToParseUri() + { + config(['volumes.editor_storage_disks' => ['test']]); + $disk = Storage::fake('test'); + $disk->makeDirectory('images'); + $disk->put('images/1.jpg', 'abc'); + $disk->put('images/2.jpg', 'abc'); + + $id = PendingVolume::factory()->create([ + 'project_id' => $this->project()->id, + 'media_type_id' => MediaType::imageId(), + 'user_id' => $this->admin()->id, + ])->id; + $this->beAdmin(); + $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'my volume no. 1', + 'url' => 'https:///my/images', + 'files' => ['1.jpg', '2.jpg'], + ])->assertStatus(422); + } + + public function testUpdateFilesExistException() + { + config(['volumes.editor_storage_disks' => ['test']]); + $disk = Storage::fake('test'); + $disk->makeDirectory('images'); + $disk->put('images/1.jpg', 'abc'); + + $id = PendingVolume::factory()->create([ + 'project_id' => $this->project()->id, + 'media_type_id' => MediaType::imageId(), + 'user_id' => $this->admin()->id, + ])->id; + $this->beAdmin(); + FileCache::shouldReceive('exists')->andThrow(new Exception('Invalid MIME type.')); + + $response = $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'my volume no. 1', + 'url' => 'test://images', + 'files' => ['1.jpg'], + ])->assertStatus(422); + + $this->assertStringContainsString('Some files could not be accessed. Invalid MIME type.', $response->getContent()); + } + + public function testUpdateVideos() + { + config(['volumes.editor_storage_disks' => ['test']]); + $disk = Storage::fake('test'); + $pv = PendingVolume::factory()->create([ + 'project_id' => $this->project()->id, + 'media_type_id' => MediaType::videoId(), + 'user_id' => $this->admin()->id, + ]); + $id = $pv->id; + + $this->beAdmin(); + // invalid url format + $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'my volume no. 1', + 'url' => 'test', + 'files' => ['1.mp4', '2.mp4'], + ])->assertStatus(422); + + // unknown storage disk + $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'my volume no. 1', + 'url' => 'random', + 'files' => ['1.mp4', '2.mp4'], + ])->assertStatus(422); + + // videos directory dows not exist in storage disk + $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'my volume no. 1', + 'url' => 'test://videos', + 'files' => ['1.mp4', '2.mp4'], + ])->assertStatus(422); + + $disk->makeDirectory('videos'); + $disk->put('videos/1.mp4', 'abc'); + $disk->put('videos/2.mp4', 'abc'); + + // error because of duplicate video + $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'my volume no. 1', + 'url' => 'test://videos', + 'files' => ['1.mp4', '1.mp4'], + ])->assertStatus(422); + + // error because of unsupported video format + $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'my volume no. 1', + 'url' => 'test://videos', + 'files' => ['1.avi'], + ])->assertStatus(422); + + // Video filename too long. + $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'my volume no. 1', + 'url' => 'test://videos', + 'files' => ['aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.mp4'], + ])->assertStatus(422); + + $response = $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'my volume no. 1', + 'url' => 'test://videos', + 'media_type' => 'video', + // Elements should be sanitized and empty elements should be discarded + 'files' => ['" 1.mp4"', '', '\'2.mp4\' ', '', ''], + ])->assertSuccessful(); + $this->assertEquals(1, $this->project()->volumes()->count()); + + $id = json_decode($response->getContent())->volume_id; + Queue::assertPushed(CreateNewImagesOrVideos::class, function ($job) use ($id) { + $this->assertEquals($id, $job->volume->id); + $this->assertContains('1.mp4', $job->filenames); + $this->assertContains('2.mp4', $job->filenames); + + return true; + }); + + $this->assertNull($pv->fresh()); + } + + public function testUpdateVideosWithMetadata() + { + $this->markTestIncomplete(); + } + + public function testUpdateVideosWithAnnotationImport() + { + $this->markTestIncomplete(); + } + + public function testUpdateVideosWithImageLabelImport() + { + $this->markTestIncomplete(); + } + + public function testUpdateProviderDenylist() + { + $id = PendingVolume::factory()->create([ + 'project_id' => $this->project()->id, + 'media_type_id' => MediaType::imageId(), + 'user_id' => $this->admin()->id, + ])->id; + $this->beAdmin(); + $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'my volume no. 1', + 'url' => 'https://dropbox.com', + 'files' => ['1.jpg', '2.jpg'], + ])->assertStatus(422); + } + + public function testUpdateAuthorizeDisk() + { + config(['volumes.admin_storage_disks' => ['admin-test']]); + config(['volumes.editor_storage_disks' => ['editor-test']]); + + $adminDisk = Storage::fake('admin-test'); + $adminDisk->put('images/1.jpg', 'abc'); + + $editorDisk = Storage::fake('editor-test'); + $editorDisk->put('images/2.jpg', 'abc'); + + $id = PendingVolume::factory()->create([ + 'project_id' => $this->project()->id, + 'media_type_id' => MediaType::imageId(), + 'user_id' => $this->admin()->id, + ])->id; + $this->beAdmin(); + $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'name', + 'url' => 'admin-test://images', + 'files' => ['1.jpg'], + ])->assertStatus(422); + + $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'name', + 'url' => 'editor-test://images', + 'files' => ['2.jpg'], + ])->assertSuccessful(); + + $id = PendingVolume::factory()->create([ + 'project_id' => $this->project()->id, + 'media_type_id' => MediaType::imageId(), + 'user_id' => $this->admin()->id, + ])->id; + + $this->beGlobalAdmin(); + + $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'name', + 'url' => 'editor-test://images', + 'files' => ['2.jpg'], + ])->assertStatus(422); + + $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'name', + 'url' => 'admin-test://images', + 'files' => ['1.jpg'], + ])->assertSuccessful(); + } +} diff --git a/tests/php/Http/Controllers/Api/ProjectPendingVolumeControllerTest.php b/tests/php/Http/Controllers/Api/ProjectPendingVolumeControllerTest.php deleted file mode 100644 index b86ea9eb4..000000000 --- a/tests/php/Http/Controllers/Api/ProjectPendingVolumeControllerTest.php +++ /dev/null @@ -1,152 +0,0 @@ -project()->id; - $this->doTestApiRoute('POST', "/api/v1/projects/{$id}/pending-volumes"); - - $this->beEditor(); - $this->post("/api/v1/projects/{$id}/pending-volumes")->assertStatus(403); - - $this->beAdmin(); - // Missing arguments. - $this->json('POST', "/api/v1/projects/{$id}/pending-volumes")->assertStatus(422); - - // Incorrect media type. - $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ - 'media_type' => 'whatever', - ])->assertStatus(422); - - $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ - 'media_type' => 'image', - ])->assertStatus(201); - - $pv = PendingVolume::where('project_id', $id)->first(); - $this->assertEquals(MediaType::imageId(), $pv->media_type_id); - $this->assertEquals($this->admin()->id, $pv->user_id); - } - - public function testStoreTwice() - { - $this->beAdmin(); - $id = $this->project()->id; - $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ - 'media_type' => 'image', - ])->assertStatus(201); - - $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ - 'media_type' => 'image', - ])->assertStatus(422); - } - - public function testStoreVideo() - { - $this->beAdmin(); - $id = $this->project()->id; - $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ - 'media_type' => 'video', - ])->assertStatus(201); - } - - public function testStoreImageWithFile() - { - $disk = Storage::fake('pending-metadata'); - $csv = __DIR__."/../../../../files/image-metadata.csv"; - $file = new UploadedFile($csv, 'metadata.csv', 'text/csv', null, true); - - $id = $this->project()->id; - $this->beAdmin(); - $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ - 'media_type' => 'image', - 'metadata_file' => $file, - ])->assertStatus(201); - - $pv = PendingVolume::where('project_id', $id)->first(); - $this->assertNotNull($pv->metadata_file_path); - $disk->assertExists($pv->metadata_file_path); - } - - public function testStoreImageWithFileUnknown() - { - $disk = Storage::fake('pending-metadata'); - $csv = __DIR__."/../../../../files/test.mp4"; - $file = new UploadedFile($csv, 'metadata.csv', 'text/csv', null, true); - - $id = $this->project()->id; - $this->beAdmin(); - $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ - 'media_type' => 'image', - 'metadata_file' => $file, - ])->assertStatus(422); - } - - public function testStoreImageWithFileInvalid() - { - $disk = Storage::fake('pending-metadata'); - $csv = __DIR__."/../../../../files/image-metadata-invalid.csv"; - $file = new UploadedFile($csv, 'metadata.csv', 'text/csv', null, true); - - $id = $this->project()->id; - $this->beAdmin(); - $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ - 'media_type' => 'image', - 'metadata_file' => $file, - ])->assertStatus(422); - } - - public function testStoreVideoWithFile() - { - $disk = Storage::fake('pending-metadata'); - $csv = __DIR__."/../../../../files/video-metadata.csv"; - $file = new UploadedFile($csv, 'metadata.csv', 'text/csv', null, true); - - $id = $this->project()->id; - $this->beAdmin(); - $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ - 'media_type' => 'video', - 'metadata_file' => $file, - ])->assertStatus(201); - - $pv = PendingVolume::where('project_id', $id)->first(); - $this->assertNotNull($pv->metadata_file_path); - $disk->assertExists($pv->metadata_file_path); - } - - public function testStoreVideoWithFileUnknown() - { - $disk = Storage::fake('pending-metadata'); - $csv = __DIR__."/../../../../files/test.mp4"; - $file = new UploadedFile($csv, 'metadata.csv', 'text/csv', null, true); - - $id = $this->project()->id; - $this->beAdmin(); - $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ - 'media_type' => 'video', - 'metadata_file' => $file, - ])->assertStatus(422); - } - - public function testStoreVideoWithFileInvalid() - { - $disk = Storage::fake('pending-metadata'); - $csv = __DIR__."/../../../../files/video-metadata-invalid.csv"; - $file = new UploadedFile($csv, 'metadata.csv', 'text/csv', null, true); - - $id = $this->project()->id; - $this->beAdmin(); - $this->json('POST', "/api/v1/projects/{$id}/pending-volumes", [ - 'media_type' => 'video', - 'metadata_file' => $file, - ])->assertStatus(422); - } -} diff --git a/tests/php/PendingVolumeTest.php b/tests/php/PendingVolumeTest.php index a508198b4..853347a4b 100644 --- a/tests/php/PendingVolumeTest.php +++ b/tests/php/PendingVolumeTest.php @@ -23,6 +23,7 @@ public function testAttributes() $this->assertNotNull($this->model->created_at); $this->assertNotNull($this->model->updated_at); $this->assertNull($this->model->metadata_file_path); + $this->assertNull($this->model->volume_id); } public function testCreateOnlyOneForProject() diff --git a/tests/php/Policies/PendingVolumePolicyTest.php b/tests/php/Policies/PendingVolumePolicyTest.php new file mode 100644 index 000000000..866eacbfd --- /dev/null +++ b/tests/php/Policies/PendingVolumePolicyTest.php @@ -0,0 +1,46 @@ +create(); + $this->user = User::factory()->create(); + $this->guest = User::factory()->create(); + $this->editor = User::factory()->create(); + $this->expert = User::factory()->create(); + $this->admin = User::factory()->create(); + $this->owner = User::factory()->create(); + $this->globalAdmin = User::factory()->create(['role_id' => Role::adminId()]); + $this->pv = PendingVolume::factory()->create([ + 'project_id' => $project->id, + 'user_id' => $this->owner->id, + ]); + + $project->addUserId($this->guest->id, Role::guestId()); + $project->addUserId($this->editor->id, Role::editorId()); + $project->addUserId($this->expert->id, Role::expertId()); + $project->addUserId($this->admin->id, Role::adminId()); + $project->addUserId($this->owner->id, Role::adminId()); + } + + public function testUpdate() + { + $this->assertFalse($this->user->can('update', $this->pv)); + $this->assertFalse($this->guest->can('update', $this->pv)); + $this->assertFalse($this->editor->can('update', $this->pv)); + $this->assertFalse($this->expert->can('update', $this->pv)); + $this->assertFalse($this->admin->can('update', $this->pv)); + $this->assertTrue($this->owner->can('update', $this->pv)); + $this->assertTrue($this->globalAdmin->can('update', $this->pv)); + } +} From a432727ce31b53237dd7f457f758fd375170bf2b Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Thu, 7 Mar 2024 17:04:48 +0100 Subject: [PATCH 015/209] WIP Implement metadata support in the v1 create volume controller --- .../Api/ProjectVolumeController.php | 6 +- app/Http/Requests/StorePendingVolume.php | 2 +- app/Http/Requests/StoreVolume.php | 65 ++++++----- app/Volume.php | 88 ++------------ config/filesystems.php | 4 +- config/volumes.php | 4 +- ...06_143800_create_pending_volumes_table.php | 8 ++ .../Api/ProjectVolumeControllerTest.php | 107 ++---------------- tests/php/PendingVolumeTest.php | 5 + tests/php/VolumeTest.php | 52 +-------- 10 files changed, 86 insertions(+), 255 deletions(-) diff --git a/app/Http/Controllers/Api/ProjectVolumeController.php b/app/Http/Controllers/Api/ProjectVolumeController.php index 8df5636b6..9e7dfc49b 100644 --- a/app/Http/Controllers/Api/ProjectVolumeController.php +++ b/app/Http/Controllers/Api/ProjectVolumeController.php @@ -112,11 +112,13 @@ public function store(StoreVolume $request) $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); + $job = new CreateNewImagesOrVideos($volume, $files); if (count($files) > PendingVolumeController::CREATE_SYNC_LIMIT) { Queue::pushOn('high', $job); $volume->creating_async = true; diff --git a/app/Http/Requests/StorePendingVolume.php b/app/Http/Requests/StorePendingVolume.php index 1c6749e8a..a267f6d40 100644 --- a/app/Http/Requests/StorePendingVolume.php +++ b/app/Http/Requests/StorePendingVolume.php @@ -60,7 +60,7 @@ public function withValidator($validator) $type = $this->input('media_type'); $parser = ParserFactory::getParserForFile($file, $type); if (is_null($parser)) { - $validator->errors()->add('metadata_file', 'Unknown metadata file format.'); + $validator->errors()->add('metadata_file', 'Unknown metadata file format for this media type.'); return; } diff --git a/app/Http/Requests/StoreVolume.php b/app/Http/Requests/StoreVolume.php index 5e91d72b7..f8b1ef3cd 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\ParserFactory; 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,22 @@ class StoreVolume extends FormRequest */ public $project; + /** + * Filled if an uploaded metadata text was stored in a file. + * + * @var string + */ + protected $metadataPath; + + /** + * Remove potential temporary files. + */ + function __destruct() { + if (isset($this->metadataPath)) { + File::delete($this->metadataPath); + } + } + /** * Determine if the user is authorized to make this request. * @@ -56,7 +72,6 @@ public function rules() 'handle' => ['nullable', 'max:256', new Handle], 'metadata_csv' => 'file|mimetypes:text/plain,text/csv,application/csv', 'ifdo_file' => 'file', - 'metadata' => 'filled', // 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. @@ -84,28 +99,21 @@ public function withValidator($validator) $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'); + $parser = ParserFactory::getParserForFile($file, $type); + if (is_null($parser)) { + $validator->errors()->add('metadata', 'Unknown metadata file format for this media type.'); + return; } - if (!$rule->passes('metadata', $this->input('metadata'))) { - $validator->errors()->add('metadata', $rule->message()); - } - } - - 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()); } } }); @@ -136,10 +144,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/Volume.php b/app/Volume.php index ca468650c..cedee7fad 100644 --- a/app/Volume.php +++ b/app/Volume.php @@ -454,83 +454,13 @@ public function isVideoVolume() return $this->media_type_id === MediaType::videoId(); } - /** - * Save an iFDO metadata file and link it with this volume. - * - * @param UploadedFile $file iFDO YAML file. - * - */ - public function saveIfdo(UploadedFile $file) - { - $disk = config('volumes.ifdo_storage_disk'); - $file->storeAs('', $this->getIfdoFilename(), $disk); - Cache::forget($this->getIfdoCacheKey()); - } - - /** - * Check if an iFDO metadata file is available for this volume. - * - * @param bool $ignoreErrors Set to `true` to ignore exceptions and return `false` if iFDO existence could not be determined. - * @return boolean - */ - public function hasIfdo($ignoreErrors = false) - { - try { - return Cache::remember($this->getIfdoCacheKey(), 3600, fn () => Storage::disk(config('volumes.ifdo_storage_disk'))->exists($this->getIfdoFilename())); - } catch (Exception $e) { - if (!$ignoreErrors) { - throw $e; - } - - return false; - } - } - - /** - * Delete the iFDO metadata file linked with this volume. - */ - public function deleteIfdo() - { - Storage::disk(config('volumes.ifdo_storage_disk'))->delete($this->getIfdoFilename()); - Cache::forget($this->getIfdoCacheKey()); - } - - /** - * Download the iFDO that is attached to this volume. - * - * @return Response - */ - public function downloadIfdo() - { - $disk = Storage::disk(config('volumes.ifdo_storage_disk')); - - if (!$disk->exists($this->getIfdoFilename())) { - abort(Response::HTTP_NOT_FOUND); - } - - return $disk->download($this->getIfdoFilename(), "biigle-volume-{$this->id}-ifdo.yaml"); - } - - /** - * Get the content of the iFDO file associated with this volume. - * - * @return array - */ - public function getIfdo() - { - $content = Storage::disk(config('volumes.ifdo_storage_disk'))->get($this->getIfdoFilename()); - - return yaml_parse($content); - } - - /** - * Get the filename of the volume iFDO in storage. - * - * @return string - */ - protected function getIfdoFilename() + public function saveMetadata(UploadedFile $file): void { - return $this->id.'.yaml'; + $disk = config('volumes.metadata_storage_disk'); + $extension = $file->getExtension(); + $this->metadata_file_path = "{$this->id}.{$extension}"; + $file->storeAs('', $this->metadata_file_path, $disk); + $this->save(); } /** @@ -554,12 +484,12 @@ protected function getGeoInfoCacheKey() } /** - * Get the cache key for volume iFDO info. + * Get the cache key for volume metadata info. * * @return string */ - protected function getIfdoCacheKey() + protected function getMetadataCacheKey() { - return "volume-{$this->id}-has-ifdo"; + return "volume-{$this->id}-has-metadata"; } } diff --git a/config/filesystems.php b/config/filesystems.php index 138a5a919..e95473eea 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -60,9 +60,9 @@ 'visibility' => 'public', ], - 'ifdos' => [ + 'metadata' => [ 'driver' => 'local', - 'root' => storage_path('ifdos'), + 'root' => storage_path('metadata'), ], 'pending-metadata' => [ diff --git a/config/volumes.php b/config/volumes.php index 9c0138bf2..98723f697 100644 --- a/config/volumes.php +++ b/config/volumes.php @@ -17,9 +17,9 @@ 'editor_storage_disks' => array_filter(explode(',', env('VOLUME_EDITOR_STORAGE_DISKS', ''))), /* - | Storage disk for iFDO metadata files linked with volumes. + | Storage disk for metadata files linked with volumes. */ - 'ifdo_storage_disk' => env('VOLUME_IFDO_STORAGE_DISK', 'ifdos'), + 'metadata_storage_disk' => env('VOLUME_METADATA_STORAGE_DISK', 'metadata'), /* diff --git a/database/migrations/2024_03_06_143800_create_pending_volumes_table.php b/database/migrations/2024_03_06_143800_create_pending_volumes_table.php index 6376d0aca..47fdbd90b 100644 --- a/database/migrations/2024_03_06_143800_create_pending_volumes_table.php +++ b/database/migrations/2024_03_06_143800_create_pending_volumes_table.php @@ -41,6 +41,10 @@ public function up(): void // project. $table->unique(['user_id', 'project_id']); }); + + Schema::table('volumes', function (Blueprint $table) { + $table->string('metadata_file_path', 256)->nullable(); + }); } /** @@ -48,6 +52,10 @@ public function up(): void */ public function down(): void { + Schema::table('volumes', function (Blueprint $table) { + $table->dropColumn('metadata_file_path'); + }); + Schema::dropIfExists('pending_volumes'); } }; diff --git a/tests/php/Http/Controllers/Api/ProjectVolumeControllerTest.php b/tests/php/Http/Controllers/Api/ProjectVolumeControllerTest.php index 3881dcfd3..7085db332 100644 --- a/tests/php/Http/Controllers/Api/ProjectVolumeControllerTest.php +++ b/tests/php/Http/Controllers/Api/ProjectVolumeControllerTest.php @@ -466,11 +466,14 @@ public function testStoreImageMetadataText() ]) ->assertSuccessful(); - Queue::assertPushed(CreateNewImagesOrVideos::class, fn ($job) => $job->metadata[1][0] === '1.jpg' && $job->metadata[1][1] === '2.5'); + $volume = Volume::orderBy('id', 'desc')->first(); + $this->assertNotNull($volume->metadata_file_path); } public function testStoreImageMetadataCsv() { + Storage::disk('metadata'); + $id = $this->project()->id; $this->beAdmin(); $csv = __DIR__."/../../../../files/image-metadata.csv"; @@ -478,8 +481,7 @@ public function testStoreImageMetadataCsv() Storage::disk('test')->makeDirectory('images'); Storage::disk('test')->put('images/abc.jpg', 'abc'); - $this - ->postJson("/api/v1/projects/{$id}/volumes", [ + $this->postJson("/api/v1/projects/{$id}/volumes", [ 'name' => 'my volume no. 1', 'url' => 'test://images', 'media_type' => 'image', @@ -488,7 +490,8 @@ public function testStoreImageMetadataCsv() ]) ->assertSuccessful(); - Queue::assertPushed(CreateNewImagesOrVideos::class, fn ($job) => $job->metadata[1][0] === 'abc.jpg' && $job->metadata[1][6] === '2.6'); + $volume = Volume::orderBy('id', 'desc')->first(); + $this->assertNotNull($volume->metadata_file_path); } public function testStoreImageMetadataInvalid() @@ -546,7 +549,8 @@ public function testStoreVideoMetadataText() ]) ->assertSuccessful(); - Queue::assertPushed(CreateNewImagesOrVideos::class, fn ($job) => $job->metadata[1][0] === '1.mp4' && $job->metadata[1][1] === '2.5'); + $volume = Volume::orderBy('id', 'desc')->first(); + $this->assertNotNull($volume->metadata_file_path); } public function testStoreVideoMetadataCsv() @@ -568,7 +572,8 @@ public function testStoreVideoMetadataCsv() ]) ->assertSuccessful(); - Queue::assertPushed(CreateNewImagesOrVideos::class, fn ($job) => $job->metadata[1][0] === 'abc.mp4' && $job->metadata[1][6] === '2.6' && $job->metadata[2][6] === '1.6'); + $volume = Volume::orderBy('id', 'desc')->first(); + $this->assertNotNull($volume->metadata_file_path); } public function testStoreVideoMetadataInvalid() @@ -589,96 +594,6 @@ public function testStoreVideoMetadataInvalid() ->assertStatus(422); } - public function testStoreImageIfdoFile() - { - $id = $this->project()->id; - $this->beAdmin(); - $csv = __DIR__."/../../../../files/image-ifdo.yaml"; - $file = new UploadedFile($csv, 'ifdo.yaml', 'application/yaml', null, true); - Storage::disk('test')->makeDirectory('images'); - Storage::disk('test')->put('images/abc.jpg', 'abc'); - - Storage::fake('ifdos'); - - $this - ->postJson("/api/v1/projects/{$id}/volumes", [ - 'name' => 'my volume no. 1', - 'url' => 'test://images', - 'media_type' => 'image', - 'files' => 'abc.jpg', - 'ifdo_file' => $file, - ]) - ->assertSuccessful(); - - $volume = Volume::orderBy('id', 'desc')->first(); - $this->assertTrue($volume->hasIfdo()); - } - - public function testStoreVideoIfdoFile() - { - $id = $this->project()->id; - $this->beAdmin(); - $csv = __DIR__."/../../../../files/video-ifdo.yaml"; - $file = new UploadedFile($csv, 'ifdo.yaml', 'application/yaml', null, true); - Storage::disk('test')->makeDirectory('videos'); - Storage::disk('test')->put('videos/abc.mp4', 'abc'); - - Storage::fake('ifdos'); - - $this - ->postJson("/api/v1/projects/{$id}/volumes", [ - 'name' => 'my volume no. 1', - 'url' => 'test://videos', - 'media_type' => 'video', - 'files' => 'abc.mp4', - 'ifdo_file' => $file, - ]) - ->assertSuccessful(); - - $volume = Volume::orderBy('id', 'desc')->first(); - $this->assertTrue($volume->hasIfdo()); - } - - public function testStoreVideoVolumeWithImageIfdoFile() - { - $id = $this->project()->id; - $this->beAdmin(); - $csv = __DIR__."/../../../../files/image-ifdo.yaml"; - $file = new UploadedFile($csv, 'ifdo.yaml', 'application/yaml', null, true); - Storage::disk('test')->makeDirectory('videos'); - Storage::disk('test')->put('videos/abc.mp4', 'abc'); - - $this - ->postJson("/api/v1/projects/{$id}/volumes", [ - 'name' => 'my volume no. 1', - 'url' => 'test://videos', - 'media_type' => 'video', - 'files' => 'abc.mp4', - 'ifdo_file' => $file, - ]) - ->assertStatus(422); - } - - public function testStoreImageVolumeWithVideoIfdoFile() - { - $id = $this->project()->id; - $this->beAdmin(); - $csv = __DIR__."/../../../../files/video-ifdo.yaml"; - $file = new UploadedFile($csv, 'ifdo.yaml', 'application/yaml', null, true); - Storage::disk('test')->makeDirectory('images'); - Storage::disk('test')->put('images/abc.jpg', 'abc'); - - $this - ->postJson("/api/v1/projects/{$id}/volumes", [ - 'name' => 'my volume no. 1', - 'url' => 'test://images', - 'media_type' => 'image', - 'files' => 'abc.jpg', - 'ifdo_file' => $file, - ]) - ->assertStatus(422); - } - public function testStoreProviderDenylist() { $id = $this->project()->id; diff --git a/tests/php/PendingVolumeTest.php b/tests/php/PendingVolumeTest.php index 853347a4b..9c0c67711 100644 --- a/tests/php/PendingVolumeTest.php +++ b/tests/php/PendingVolumeTest.php @@ -48,4 +48,9 @@ public function testStoreMetadataFile() $this->assertTrue($this->model->hasMetadata()); $this->assertEquals($this->model->id.'.csv', $this->model->metadata_file_path); } + + public function testDeleteMetadataOnDelete() + { + $this->markTestIncomplete(); + } } diff --git a/tests/php/VolumeTest.php b/tests/php/VolumeTest.php index 504a9ea2c..73eca084b 100644 --- a/tests/php/VolumeTest.php +++ b/tests/php/VolumeTest.php @@ -553,8 +553,9 @@ public function testCreatingAsyncAttr() $this->assertFalse($this->model->fresh()->creating_async); } - public function testSaveIfdo() + public function testSaveMetadata() { + $this->markTestIncomplete(); $disk = Storage::fake('ifdos'); $csv = __DIR__."/../files/image-ifdo.yaml"; $file = new UploadedFile($csv, 'ifdo.yaml', 'application/yaml', null, true); @@ -566,59 +567,18 @@ public function testSaveIfdo() $this->assertTrue($this->model->hasIfdo()); } - public function testHasIfdo() - { - $disk = Storage::fake('ifdos'); - $this->assertFalse($this->model->hasIfdo()); - $disk->put($this->model->id.'.yaml', 'abc'); - $this->assertFalse($this->model->hasIfdo()); - Cache::flush(); - $this->assertTrue($this->model->hasIfdo()); - } - - public function testHasIfdoError() - { - Storage::shouldReceive('disk')->andThrow(Exception::class); - $this->assertFalse($this->model->hasIfdo(true)); - - $this->expectException(Exception::class); - $this->model->hasIfdo(); - } - - public function testDeleteIfdo() - { - $disk = Storage::fake('ifdos'); - $disk->put($this->model->id.'.yaml', 'abc'); - $this->assertTrue($this->model->hasIfdo()); - $this->model->deleteIfdo(); - $disk->assertMissing($this->model->id.'.yaml'); - $this->assertFalse($this->model->hasIfdo()); - } - - public function testDeleteIfdoOnDelete() + public function testDeleteMetadataOnDelete() { + $this->markTestIncomplete(); $disk = Storage::fake('ifdos'); $disk->put($this->model->id.'.yaml', 'abc'); $this->model->delete(); $disk->assertMissing($this->model->id.'.yaml'); } - public function testDownloadIfdoNotFound() - { - $this->expectException(NotFoundHttpException::class); - $this->model->downloadIfdo(); - } - - public function testDownloadIfdo() - { - $disk = Storage::fake('ifdos'); - $disk->put($this->model->id.'.yaml', 'abc'); - $response = $this->model->downloadIfdo(); - $this->assertInstanceOf(StreamedResponse::class, $response); - } - - public function testGetIfdo() + public function testGetMetadata() { + $this->markTestIncomplete(); $disk = Storage::fake('ifdos'); $this->assertNull($this->model->getIfdo()); $disk->put($this->model->id.'.yaml', 'abc: def'); From 46ad3000864cbe4a02b6a6278970e585e7a2ab2b Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Mon, 11 Mar 2024 14:43:35 +0100 Subject: [PATCH 016/209] Fix test for v1 create volume controller --- .../php/Http/Controllers/Api/ProjectVolumeControllerTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/php/Http/Controllers/Api/ProjectVolumeControllerTest.php b/tests/php/Http/Controllers/Api/ProjectVolumeControllerTest.php index 7085db332..723f6b2f6 100644 --- a/tests/php/Http/Controllers/Api/ProjectVolumeControllerTest.php +++ b/tests/php/Http/Controllers/Api/ProjectVolumeControllerTest.php @@ -507,7 +507,7 @@ public function testStoreImageMetadataInvalid() 'url' => 'test://images', 'media_type' => 'image', 'files' => '1.jpg', - 'metadata_text' => "filename,area\nabc.jpg,2.5", + 'metadata_text' => "filename,yaw\nabc.jpg,400", ]) ->assertStatus(422); } @@ -589,7 +589,7 @@ public function testStoreVideoMetadataInvalid() 'url' => 'test://videos', 'media_type' => 'video', 'files' => '1.mp4', - 'metadata_text' => "filename,area\nabc.mp4,2.5", + 'metadata_text' => "filename,yaw\nabc.mp4,400", ]) ->assertStatus(422); } From 684246cc0cffd5acd3d793680820d7dbab1e6a43 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Mon, 11 Mar 2024 15:32:56 +0100 Subject: [PATCH 017/209] Implement methods to save, get and delete volume metadata --- app/Observers/VolumeObserver.php | 2 +- app/PendingVolume.php | 11 ++++ .../MetadataParsing/MetadataParser.php | 4 +- .../MetadataParsing/ParserFactory.php | 4 +- app/Volume.php | 51 +++++++++++++++---- tests/files/image-ifdo.yaml | 37 -------------- tests/files/video-ifdo.yaml | 19 ------- tests/php/PendingVolumeTest.php | 9 +++- tests/php/VolumeTest.php | 38 +++++++------- 9 files changed, 84 insertions(+), 91 deletions(-) delete mode 100644 tests/files/image-ifdo.yaml delete mode 100644 tests/files/video-ifdo.yaml diff --git a/app/Observers/VolumeObserver.php b/app/Observers/VolumeObserver.php index 397798cb1..20f6926ad 100644 --- a/app/Observers/VolumeObserver.php +++ b/app/Observers/VolumeObserver.php @@ -48,7 +48,7 @@ public function deleting(Volume $volume) event(new VideosDeleted($uuids)); } - $volume->deleteIfdo(); + $volume->deleteMetadata(); return true; } diff --git a/app/PendingVolume.php b/app/PendingVolume.php index 8323df2fe..e997e6cc9 100644 --- a/app/PendingVolume.php +++ b/app/PendingVolume.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Http\UploadedFile; +use Storage; class PendingVolume extends Model { @@ -33,6 +34,16 @@ class PendingVolume extends Model 'metadata_file_path', ]; + protected static function booted(): void + { + static::deleting(function (PendingVolume $pv) { + if ($pv->hasMetadata()) { + $disk = config('volumes.pending_metadata_storage_disk'); + Storage::disk($disk)->delete($pv->metadata_file_path); + } + }); + } + public function hasMetadata(): bool { return !is_null($this->metadata_file_path); diff --git a/app/Services/MetadataParsing/MetadataParser.php b/app/Services/MetadataParsing/MetadataParser.php index 22668d2ae..164611284 100644 --- a/app/Services/MetadataParsing/MetadataParser.php +++ b/app/Services/MetadataParsing/MetadataParser.php @@ -2,14 +2,14 @@ namespace Biigle\Services\MetadataParsing; +use SplFileInfo; use SplFileObject; -use Symfony\Component\HttpFoundation\File\File; abstract class MetadataParser { public SplFileObject $fileObject; - public function __construct(public File $file) + public function __construct(public SplFileInfo $file) { // } diff --git a/app/Services/MetadataParsing/ParserFactory.php b/app/Services/MetadataParsing/ParserFactory.php index b0774b40f..2df5356a1 100644 --- a/app/Services/MetadataParsing/ParserFactory.php +++ b/app/Services/MetadataParsing/ParserFactory.php @@ -2,7 +2,7 @@ namespace Biigle\Services\MetadataParsing; -use Symfony\Component\HttpFoundation\File\File; +use SplFileInfo; class ParserFactory { @@ -15,7 +15,7 @@ class ParserFactory ], ]; - public static function getParserForFile(File $file, string $type): ?MetadataParser + public static function getParserForFile(SplFileInfo $file, string $type): ?MetadataParser { $parsers = self::$parsers[$type] ?? []; foreach ($parsers as $parserClass) { diff --git a/app/Volume.php b/app/Volume.php index cedee7fad..37f344d4e 100644 --- a/app/Volume.php +++ b/app/Volume.php @@ -2,6 +2,8 @@ namespace Biigle; +use Biigle\Services\MetadataParsing\ParserFactory; +use Biigle\Services\MetadataParsing\VolumeMetadata; use Biigle\Traits\HasJsonAttributes; use Cache; use Carbon\Carbon; @@ -11,7 +13,9 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Response; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Storage; +use SplFileInfo; /** * A volume is a collection of images. Volumes belong to one or many @@ -454,6 +458,11 @@ public function isVideoVolume() return $this->media_type_id === MediaType::videoId(); } + public function hasMetadata(): bool + { + return !is_null($this->metadata_file_path); + } + public function saveMetadata(UploadedFile $file): void { $disk = config('volumes.metadata_storage_disk'); @@ -463,6 +472,38 @@ public function saveMetadata(UploadedFile $file): void $this->save(); } + public function getMetadata(): ?VolumeMetadata + { + if (!$this->hasMetadata()) { + return null; + } + + $tmpPath = tempnam(sys_get_temp_dir(), 'volume-metadata'); + try { + $from = Storage::disk(config('volumes.metadata_storage_disk')) + ->readStream($this->metadata_file_path); + $to = fopen($tmpPath, 'w'); + stream_copy_to_stream($from, $to); + $type = $this->isImageVolume() ? 'image' : 'video'; + $parser = ParserFactory::getParserForFile(new SplFileInfo($tmpPath), $type); + if (is_null($parser)) { + return null; + } + + return $parser->getMetadata(); + } finally { + fclose($to); + File::delete($tmpPath); + } + } + + public function deleteMetadata(): void + { + if ($this->hasMetadata()) { + Storage::disk(config('volumes.metadata_storage_disk'))->delete($this->metadata_file_path); + } + } + /** * Get the cache key for volume thumbnails. * @@ -482,14 +523,4 @@ protected function getGeoInfoCacheKey() { return "volume-{$this->id}-has-geo-info"; } - - /** - * Get the cache key for volume metadata info. - * - * @return string - */ - protected function getMetadataCacheKey() - { - return "volume-{$this->id}-has-metadata"; - } } diff --git a/tests/files/image-ifdo.yaml b/tests/files/image-ifdo.yaml deleted file mode 100644 index 5d14b386b..000000000 --- a/tests/files/image-ifdo.yaml +++ /dev/null @@ -1,37 +0,0 @@ -image-set-header: - image-acquisition: photo - image-area-square-meter: 5.0 - image-capture-mode: mixed - image-coordinate-reference-system: EPSG:4326 - image-deployment: survey - image-event: SO268-2_100-1_OFOS - image-illumination: artificial - image-latitude: 11.8581802 - image-license: CC-BY - image-longitude: -117.0214864 - image-marine-zone: seafloor - image-meters-above-ground: 2 - image-navigation: beacon - image-project: SO268 - image-quality: raw - image-resolution: mm - image-scale-reference: laser marker - image-set-data-handle: 20.500.12085/d7546c4b-307f-4d42-8554-33236c577450@data - image-set-handle: 20.500.12085/d7546c4b-307f-4d42-8554-33236c577450 - image-set-metadata-handle: 20.500.12085/d7546c4b-307f-4d42-8554-33236c577450@metadata - image-set-name: SO268 SO268-2_100-1_OFOS SO_CAM-1_Photo_OFOS - image-set-uuid: d7546c4b-307f-4d42-8554-33236c577450 - image-spectral-resolution: rgb -image-set-items: - SO268-2_100-1_OFOS_SO_CAM-1_20190406_042927.JPG: - image-datetime: '2019-04-06 04:29:27.000000' - image-depth: 2248.0 - image-camera-yaw-degrees: 20 - SO268-2_100-1_OFOS_SO_CAM-1_20190406_052726.JPG: - - image-datetime: '2019-04-06 05:27:26.000000' - image-altitude: -4129.6 - image-latitude: 11.8582192 - image-longitude: -117.0214286 - image-area-square-meter: 5.1 - image-meters-above-ground: 2.1 - image-camera-yaw-degrees: 21 diff --git a/tests/files/video-ifdo.yaml b/tests/files/video-ifdo.yaml deleted file mode 100644 index 6cc8bfd09..000000000 --- a/tests/files/video-ifdo.yaml +++ /dev/null @@ -1,19 +0,0 @@ -image-set-header: - image-acquisition: video - image-area-square-meter: 5.0 - image-latitude: 11.8581802 - image-longitude: -117.0214864 - image-meters-above-ground: 2 - image-set-data-handle: 20.500.12085/d7546c4b-307f-4d42-8554-33236c577450@data - image-set-handle: 20.500.12085/d7546c4b-307f-4d42-8554-33236c577450 - image-set-metadata-handle: 20.500.12085/d7546c4b-307f-4d42-8554-33236c577450@metadata - image-set-name: SO268 SO268-2_100-1_OFOS SO_CAM-1_Photo_OFOS - image-set-uuid: d7546c4b-307f-4d42-8554-33236c577450 -image-set-items: - video1.mp4: - - image-datetime: '2019-04-06 04:29:27.000000' - image-depth: 2248.0 - image-camera-yaw-degrees: 20 - - image-datetime: '2019-04-06 04:30:27.000000' - image-depth: 2250.0 - image-camera-yaw-degrees: 21 diff --git a/tests/php/PendingVolumeTest.php b/tests/php/PendingVolumeTest.php index 9c0c67711..e26d3d597 100644 --- a/tests/php/PendingVolumeTest.php +++ b/tests/php/PendingVolumeTest.php @@ -35,7 +35,7 @@ public function testCreateOnlyOneForProject() ]); } - public function testStoreMetadataFile() + public function testSaveMetadata() { $disk = Storage::fake('pending-metadata'); $csv = __DIR__."/../files/image-metadata.csv"; @@ -51,6 +51,11 @@ public function testStoreMetadataFile() public function testDeleteMetadataOnDelete() { - $this->markTestIncomplete(); + $disk = Storage::fake('pending-metadata'); + $disk->put($this->model->id.'.csv', 'abc'); + $this->model->metadata_file_path = $this->model->id.'.csv'; + $this->model->save(); + $this->model->delete(); + $disk->assertMissing($this->model->id.'.csv'); } } diff --git a/tests/php/VolumeTest.php b/tests/php/VolumeTest.php index 73eca084b..c4e9b1aa2 100644 --- a/tests/php/VolumeTest.php +++ b/tests/php/VolumeTest.php @@ -555,34 +555,36 @@ public function testCreatingAsyncAttr() public function testSaveMetadata() { - $this->markTestIncomplete(); - $disk = Storage::fake('ifdos'); - $csv = __DIR__."/../files/image-ifdo.yaml"; - $file = new UploadedFile($csv, 'ifdo.yaml', 'application/yaml', null, true); + $disk = Storage::fake('metadata'); + $csv = __DIR__."/../files/image-metadata.csv"; + $file = new UploadedFile($csv, 'metadata.csv', 'text/csv', null, true); - $this->assertFalse($this->model->hasIfdo()); - $this->model->saveIfdo($file); + $this->assertFalse($this->model->hasMetadata()); + $this->model->saveMetadata($file); - $disk->assertExists($this->model->id.'.yaml'); - $this->assertTrue($this->model->hasIfdo()); + $disk->assertExists($this->model->id.'.csv'); + $this->assertTrue($this->model->hasMetadata()); + $this->assertEquals($this->model->id.'.csv', $this->model->metadata_file_path); } public function testDeleteMetadataOnDelete() { - $this->markTestIncomplete(); - $disk = Storage::fake('ifdos'); - $disk->put($this->model->id.'.yaml', 'abc'); + $disk = Storage::fake('metadata'); + $disk->put($this->model->id.'.csv', 'abc'); + $this->model->metadata_file_path = $this->model->id.'.csv'; + $this->model->save(); $this->model->delete(); - $disk->assertMissing($this->model->id.'.yaml'); + $disk->assertMissing($this->model->id.'.csv'); } public function testGetMetadata() { - $this->markTestIncomplete(); - $disk = Storage::fake('ifdos'); - $this->assertNull($this->model->getIfdo()); - $disk->put($this->model->id.'.yaml', 'abc: def'); - $ifdo = $this->model->getIfdo(); - $this->assertEquals(['abc' => 'def'], $ifdo); + $this->assertNull($this->model->getMetadata()); + $disk = Storage::fake('metadata'); + $this->model->metadata_file_path = $this->model->id.'.csv'; + $disk->put($this->model->metadata_file_path, "filename,area\n1.jpg,2.5"); + $metadata = $this->model->getMetadata(); + $fileMeta = $metadata->getFile('1.jpg'); + $this->assertEquals(2.5, $fileMeta->area); } } From 70967416fb491f4a0a9e068e3d211d920a5ea2ee Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Mon, 11 Mar 2024 16:13:36 +0100 Subject: [PATCH 018/209] Implement download and deletion of generic volume metadata files --- .../Api/Volumes/IfdoController.php | 48 ----------------- .../Api/Volumes/MetadataController.php | 50 +++++++++++++++++ app/Observers/VolumeObserver.php | 2 +- app/Volume.php | 9 +++- public/assets/images/ifdo_logo_grey.svg | 13 ----- resources/assets/sass/volumes/main.scss | 11 ---- .../partials/handleIndicator.blade.php | 2 +- .../volumes/partials/ifdoIndicator.blade.php | 5 -- .../partials/metadataIndicator.blade.php | 5 ++ resources/views/volumes/show.blade.php | 2 +- routes/api.php | 8 +-- .../Api/Volumes/IfdoControllerTest.php | 54 ------------------- .../Api/Volumes/MetadataControllerTest.php | 51 ++++++++++++++++++ tests/php/VolumeTest.php | 11 ++++ 14 files changed, 132 insertions(+), 139 deletions(-) delete mode 100644 app/Http/Controllers/Api/Volumes/IfdoController.php delete mode 100644 public/assets/images/ifdo_logo_grey.svg delete mode 100644 resources/views/volumes/partials/ifdoIndicator.blade.php create mode 100644 resources/views/volumes/partials/metadataIndicator.blade.php delete mode 100644 tests/php/Http/Controllers/Api/Volumes/IfdoControllerTest.php 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..18f2853da 100644 --- a/app/Http/Controllers/Api/Volumes/MetadataController.php +++ b/app/Http/Controllers/Api/Volumes/MetadataController.php @@ -8,15 +8,45 @@ use Biigle\Rules\VideoMetadata; use Biigle\Traits\ChecksMetadataStrings; use Biigle\Video; +use Biigle\Volume; use Carbon\Carbon; use DB; +use Illuminate\Http\Response; use Illuminate\Support\Collection; use Illuminate\Validation\ValidationException; +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 \Illuminate\Http\Response + */ + 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 * @apiDeprecated use now (#Volumes:StoreVolumeMetadata). @@ -75,6 +105,26 @@ public function store(StoreVolumeMetadata $request) } } + /** + * Delete a metadata file attached to a volume + * + * @api {delete} volumes/:id/metadata Delete a metadata file + * @apiGroup Volumes + * @apiName DestroyVolumeMetadata + * @apiPermission projectAdmin + * @apiDescription This does not delete the metadata that was attached to the volume files. + ~ + * @param int $id + * + * @return \Illuminate\Http\Response + */ + public function destroy($id) + { + $volume = Volume::findOrFail($id); + $this->authorize('update', $volume); + $volume->deleteMetadata(); + } + /** * Update volume metadata for each image. * diff --git a/app/Observers/VolumeObserver.php b/app/Observers/VolumeObserver.php index 20f6926ad..94e8deb70 100644 --- a/app/Observers/VolumeObserver.php +++ b/app/Observers/VolumeObserver.php @@ -48,7 +48,7 @@ public function deleting(Volume $volume) event(new VideosDeleted($uuids)); } - $volume->deleteMetadata(); + $volume->deleteMetadata(true); return true; } diff --git a/app/Volume.php b/app/Volume.php index 37f344d4e..1d97f5508 100644 --- a/app/Volume.php +++ b/app/Volume.php @@ -56,6 +56,7 @@ class Volume extends Model 'media_type_id', 'handle', 'creator_id', + 'metadata_file_path', ]; /** @@ -497,10 +498,16 @@ public function getMetadata(): ?VolumeMetadata } } - public function deleteMetadata(): void + /** + * @param boolean $noUpdate Do not set metadata_file_path to null. + */ + public function deleteMetadata($noUpdate=false): void { if ($this->hasMetadata()) { Storage::disk(config('volumes.metadata_storage_disk'))->delete($this->metadata_file_path); + if (!$noUpdate) { + $this->update(['metadata_file_path' => null]); + } } } diff --git a/public/assets/images/ifdo_logo_grey.svg b/public/assets/images/ifdo_logo_grey.svg deleted file mode 100644 index c0413811b..000000000 --- a/public/assets/images/ifdo_logo_grey.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - diff --git a/resources/assets/sass/volumes/main.scss b/resources/assets/sass/volumes/main.scss index 0a674fc1d..9cd3c1485 100644 --- a/resources/assets/sass/volumes/main.scss +++ b/resources/assets/sass/volumes/main.scss @@ -25,17 +25,6 @@ $volume-image-padding: 0.5em; background-color: $gray-lighter; } -.ifdo-icon { - width: 12px; - height: 12px; - display: inline-block; - margin-top: -3px; - - &.ifdo-icon--btn { - margin-top: -2px; - } -} - .volume-storage-disk-btn { overflow-x: hidden; text-overflow: ellipsis; diff --git a/resources/views/volumes/partials/handleIndicator.blade.php b/resources/views/volumes/partials/handleIndicator.blade.php index 1ccb77958..801371f0b 100644 --- a/resources/views/volumes/partials/handleIndicator.blade.php +++ b/resources/views/volumes/partials/handleIndicator.blade.php @@ -1,3 +1,3 @@ @if ($volume->handle) - + @endif diff --git a/resources/views/volumes/partials/ifdoIndicator.blade.php b/resources/views/volumes/partials/ifdoIndicator.blade.php deleted file mode 100644 index f51d344d1..000000000 --- a/resources/views/volumes/partials/ifdoIndicator.blade.php +++ /dev/null @@ -1,5 +0,0 @@ -@if ($volume->hasIfdo(true)) - id}/ifdo")}}" class="btn btn-default btn-xs" title="Download the iFDO attached to this volume"> - iFDO - -@endif diff --git a/resources/views/volumes/partials/metadataIndicator.blade.php b/resources/views/volumes/partials/metadataIndicator.blade.php new file mode 100644 index 000000000..69f9d613f --- /dev/null +++ b/resources/views/volumes/partials/metadataIndicator.blade.php @@ -0,0 +1,5 @@ +@if ($volume->hasMetadata()) + id}/metadata")}}" class="btn btn-default btn-xs" title="Download the metadata file attached to this volume"> + + +@endif diff --git a/resources/views/volumes/show.blade.php b/resources/views/volumes/show.blade.php index 467b0612d..e28c73cea 100644 --- a/resources/views/volumes/show.blade.php +++ b/resources/views/volumes/show.blade.php @@ -36,7 +36,7 @@ @section('navbar') @endsection diff --git a/routes/api.php b/routes/api.php index 7e625c4b0..4cafd5ab0 100644 --- a/routes/api.php +++ b/routes/api.php @@ -360,12 +360,12 @@ 'uses' => 'MetadataController@store', ]); - $router->get('{id}/ifdo', [ - 'uses' => 'IfdoController@show', + $router->get('{id}/metadata', [ + 'uses' => 'MetadataController@show', ]); - $router->delete('{id}/ifdo', [ - 'uses' => 'IfdoController@destroy', + $router->delete('{id}/metadata', [ + 'uses' => 'MetadataController@destroy', ]); $router->get('{id}/users', [ diff --git a/tests/php/Http/Controllers/Api/Volumes/IfdoControllerTest.php b/tests/php/Http/Controllers/Api/Volumes/IfdoControllerTest.php deleted file mode 100644 index 861ffbe5c..000000000 --- a/tests/php/Http/Controllers/Api/Volumes/IfdoControllerTest.php +++ /dev/null @@ -1,54 +0,0 @@ -volume()->id; - - $this->doTestApiRoute('GET', "/api/v1/volumes/{$id}/ifdo"); - - $this->beUser(); - $this->getJson("/api/v1/volumes/{$id}/ifdo") - ->assertStatus(403); - - $this->beGuest(); - $this->getJson("/api/v1/volumes/{$id}/ifdo") - ->assertStatus(404); - - $disk = Storage::fake('ifdos'); - $disk->put($id.'.yaml', 'abc'); - - $this->getJson("/api/v1/volumes/-1/ifdo") - ->assertStatus(404); - - $response = $this->getJson("/api/v1/volumes/{$id}/ifdo"); - $response->assertStatus(200); - $this->assertEquals("attachment; filename=biigle-volume-{$id}-ifdo.yaml", $response->headers->get('content-disposition')); - } - - public function testDestroy() - { - $id = $this->volume()->id; - - $this->doTestApiRoute('DELETE', "/api/v1/volumes/{$id}/ifdo"); - - $disk = Storage::fake('ifdos'); - $disk->put($id.'.yaml', 'abc'); - - $this->beExpert(); - $this->deleteJson("/api/v1/volumes/{$id}/ifdo") - ->assertStatus(403); - - $this->beAdmin(); - $this->deleteJson("/api/v1/volumes/{$id}/ifdo") - ->assertSuccessful(); - - $this->assertFalse($this->volume()->hasIfdo()); - } -} diff --git a/tests/php/Http/Controllers/Api/Volumes/MetadataControllerTest.php b/tests/php/Http/Controllers/Api/Volumes/MetadataControllerTest.php index 9237a93bf..a9f6fb612 100644 --- a/tests/php/Http/Controllers/Api/Volumes/MetadataControllerTest.php +++ b/tests/php/Http/Controllers/Api/Volumes/MetadataControllerTest.php @@ -11,6 +11,34 @@ class MetadataControllerTest extends ApiTestCase { + public function testGet() + { + $volume = $this->volume(); + $id = $volume->id; + + $this->doTestApiRoute('GET', "/api/v1/volumes/{$id}/metadata"); + + $this->beUser(); + $this->getJson("/api/v1/volumes/{$id}/metadata") + ->assertStatus(403); + + $this->beGuest(); + $this->getJson("/api/v1/volumes/{$id}/metadata") + ->assertStatus(404); + + $disk = Storage::fake('metadata'); + $disk->put($id.'.csv', 'abc'); + $volume->metadata_file_path = $id.'.csv'; + $volume->save(); + + $this->getJson("/api/v1/volumes/-1/metadata") + ->assertStatus(404); + + $response = $this->getJson("/api/v1/volumes/{$id}/metadata"); + $response->assertStatus(200); + $this->assertEquals("attachment; filename=biigle-volume-{$id}-metadata.csv", $response->headers->get('content-disposition')); + } + public function testStoreDeprecated() { $id = $this->volume()->id; @@ -447,4 +475,27 @@ public function testStoreImageIfdoFileForVideoVolume() $this->postJson("/api/v1/volumes/{$id}/metadata", ['ifdo_file' => $file]) ->assertStatus(422); } + + public function testDestroy() + { + $volume = $this->volume(); + $id = $volume->id; + + $this->doTestApiRoute('DELETE', "/api/v1/volumes/{$id}/metadata"); + + $disk = Storage::fake('metadata'); + $disk->put($id.'.csv', 'abc'); + $volume->metadata_file_path = $id.'.csv'; + $volume->save(); + + $this->beExpert(); + $this->deleteJson("/api/v1/volumes/{$id}/metadata") + ->assertStatus(403); + + $this->beAdmin(); + $this->deleteJson("/api/v1/volumes/{$id}/metadata") + ->assertSuccessful(); + + $this->assertFalse($volume->fresh()->hasMetadata()); + } } diff --git a/tests/php/VolumeTest.php b/tests/php/VolumeTest.php index c4e9b1aa2..057990121 100644 --- a/tests/php/VolumeTest.php +++ b/tests/php/VolumeTest.php @@ -577,6 +577,17 @@ public function testDeleteMetadataOnDelete() $disk->assertMissing($this->model->id.'.csv'); } + public function testDeleteMetadata() + { + $disk = Storage::fake('metadata'); + $disk->put($this->model->id.'.csv', 'abc'); + $this->model->metadata_file_path = $this->model->id.'.csv'; + $this->model->save(); + $this->model->deleteMetadata(); + $disk->assertMissing($this->model->id.'.csv'); + $this->assertNull($this->model->fresh()->metadata_file_path); + } + public function testGetMetadata() { $this->assertNull($this->model->getMetadata()); From 696a36c43b99a00333e0b99ece336c0a37d9fa36 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Mon, 11 Mar 2024 16:19:54 +0100 Subject: [PATCH 019/209] Remove ParsesMetadata trait --- app/Traits/ParsesMetadata.php | 294 -------------- tests/php/Traits/ParsesMetadataTest.php | 493 ------------------------ 2 files changed, 787 deletions(-) delete mode 100644 app/Traits/ParsesMetadata.php delete mode 100644 tests/php/Traits/ParsesMetadataTest.php diff --git a/app/Traits/ParsesMetadata.php b/app/Traits/ParsesMetadata.php deleted file mode 100644 index 7289a912c..000000000 --- a/app/Traits/ParsesMetadata.php +++ /dev/null @@ -1,294 +0,0 @@ - 'filename', - 'lon' => 'lng', - 'longitude' => 'lng', - 'latitude' => 'lat', - 'heading' => 'yaw', - 'sub_datetime' => 'taken_at', - 'sub_longitude' => 'lng', - 'sub_latitude' => 'lat', - 'sub_heading' => 'yaw', - 'sub_distance' => 'distance_to_ground', - 'sub_altitude' => 'gps_altitude', - - ]; - - /** - * Maps iFDO field names to BIIGLE metadata CSV fields. - * - * @var array - */ - protected $ifdoFieldMap = [ - 'image-area-square-meter' => 'area', - 'image-meters-above-ground' => 'distance_to_ground', - 'image-altitude' => 'gps_altitude', - 'image-latitude' => 'lat', - 'image-longitude' => 'lng', - 'image-datetime' => 'taken_at', - 'image-camera-yaw-degrees' => 'yaw', - ]; - - /** - * Parse a metadata CSV string to an array. - * - * @param string $content - * - * @return array - */ - public function parseMetadata($content) - { - // Split string by rows but respect possible escaped linebreaks. - $rows = str_getcsv($content, "\n"); - // Now parse individual rows. - $rows = array_map('str_getcsv', $rows); - - - if (!empty($rows) && is_array($rows[0])) { - if (!empty($rows[0])) { - $rows[0][0] = Util::removeBom($rows[0][0]); - } - - $rows[0] = array_map('strtolower', $rows[0]); - - $rows[0] = array_map(function ($column) { - if (array_key_exists($column, $this->columnSynonyms)) { - return $this->columnSynonyms[$column]; - } - - return $column; - }, $rows[0]); - } - - return $rows; - } - - /** - * Parse metadata from a CSV file to an array. - * - * @param UploadedFile $file - * - * @return array - */ - public function parseMetadataFile(UploadedFile $file) - { - return $this->parseMetadata($file->get()); - } - - /** - * Parse a volume metadata iFDO YAML string to an array. - * - * See: https://marine-imaging.com/fair/ifdos/iFDO-overview/ - * - * @param string $content - * - * @return array - */ - public function parseIfdo($content) - { - try { - $yaml = yaml_parse($content); - } catch (Exception $e) { - throw new Exception("The YAML file could not be parsed."); - } - - if (!is_array($yaml)) { - throw new Exception("The file does not seem to be a valid iFDO."); - } - - if (!array_key_exists('image-set-header', $yaml)) { - throw new Exception("The 'image-set-header' key must be present."); - } - - $header = $yaml['image-set-header']; - - if (!array_key_exists('image-set-name', $header)) { - throw new Exception("The 'image-set-name' key must be present."); - } - - if (!array_key_exists('image-set-handle', $header)) { - throw new Exception("The 'image-set-handle' key must be present."); - } - - if (!$this->isValidHandle($header['image-set-handle'])) { - throw new Exception("The 'image-set-handle' key must be a valid handle."); - } - - if (!array_key_exists('image-set-uuid', $header)) { - throw new Exception("The 'image-set-uuid' key must be present."); - } - - $url = ''; - if (array_key_exists('image-set-data-handle', $header)) { - if (!$this->isValidHandle($header['image-set-data-handle'])) { - throw new Exception("The 'image-set-data-handle' key must be a valid handle."); - } - - $url = 'https://hdl.handle.net/'.$header['image-set-data-handle']; - } - - $mediaType = 'image'; - - if (array_key_exists('image-acquisition', $header) && $header['image-acquisition'] === 'video') { - $mediaType = 'video'; - } - - $files = []; - if (array_key_exists('image-set-items', $yaml)) { - $files = $this->parseIfdoItems($header, $yaml['image-set-items']); - } - - return [ - 'name' => $header['image-set-name'], - 'handle' => $header['image-set-handle'], - 'uuid' => $header['image-set-uuid'], - 'url' => $url, - 'media_type' => $mediaType, - 'files' => $files, - ]; - } - - /** - * Parse a volume metadata iFDO YAML file to an array. - * - * @param UploadedFile $file - * - * @return array - */ - public function parseIfdoFile(UploadedFile $file) - { - return $this->parseIfdo($file->get()); - } - - /** - * Parse iFDO image-set-items to a CSV-like metadata array that can be parsed by - * `parseMetadata` if converted to a string. - * - * @param array $header iFDO image-set-header - * @param array $items iFDO image-set-items. Passed by reference so potentially huge arrays are not copied. - * - * @return array - */ - protected function parseIfdoItems($header, &$items) - { - $fields = []; - $rows = []; - $reverseFieldMap = array_flip($this->ifdoFieldMap); - - if (array_key_exists('image-depth', $header)) { - $header['image-altitude'] = -1 * $header['image-depth']; - } - - $leftToCheck = $this->ifdoFieldMap; - - // Add all metadata fields present in header. - foreach ($leftToCheck as $ifdoField => $csvField) { - if (array_key_exists($ifdoField, $header)) { - $fields[] = $csvField; - unset($leftToCheck[$ifdoField]); - } - } - - // Normalize image-set-items entries. An entry can be either a list (e.g. for a - // video) or an object (e.g. for an image). But an image could be a list with a - // single entry, too. - foreach ($items as &$item) { - if (!is_array($item)) { - $item = [null]; - } elseif (!array_key_exists(0, $item)) { - $item = [$item]; - } - } - - // Convert item depth to altitude. - // Also add all metadata fields present in items (stop early). - foreach ($items as &$subItems) { - foreach ($subItems as &$subItem) { - if (empty($subItem)) { - continue; - } - - if (array_key_exists('image-depth', $subItem)) { - $subItem['image-altitude'] = -1 * $subItem['image-depth']; - // Save some memory for potentially huge arrays. - unset($subItem['image-depth']); - } - - foreach ($leftToCheck as $ifdoField => $csvField) { - if (array_key_exists($ifdoField, $subItem)) { - $fields[] = $csvField; - unset($leftToCheck[$ifdoField]); - if (empty($leftToCheck)) { - break; - } - } - } - } - unset($subItem); // Important to destroy by-reference variable after the loop! - } - unset($subItems); // Important to destroy by-reference variable after the loop! - - sort($fields); - - foreach ($items as $filename => $subItems) { - $defaults = []; - foreach ($subItems as $index => $subItem) { - if ($index === 0 && is_array($subItem)) { - $defaults = $subItem; - } - - $row = [$filename]; - foreach ($fields as $field) { - $ifdoField = $reverseFieldMap[$field]; - if (is_array($subItem) && array_key_exists($ifdoField, $subItem)) { - // Take field value of subItem if it is given. - $row[] = $subItem[$ifdoField]; - } elseif (array_key_exists($ifdoField, $defaults)) { - // Otherwise fall back to the defaults of the first subItem. - $row[] = $defaults[$ifdoField]; - } elseif (array_key_exists($ifdoField, $header)) { - // Otherwise fall back to the defaults of the header. - $row[] = $header[$ifdoField]; - } else { - $row[] = ''; - } - } - - $rows[] = $row; - } - } - - // Add this only not because it should not be included in sort earlier and it is - // should be skipped in the loop above. - array_unshift($fields, 'filename'); - array_unshift($rows, $fields); - - return $rows; - } - - /** - * Determine if a value is a valid handle. - * - * @param string $value - * - * @return boolean - */ - protected function isValidHandle($value) - { - return preg_match('/[^\/]+\/[^\/]/', $value); - } -} diff --git a/tests/php/Traits/ParsesMetadataTest.php b/tests/php/Traits/ParsesMetadataTest.php deleted file mode 100644 index ea3d08d20..000000000 --- a/tests/php/Traits/ParsesMetadataTest.php +++ /dev/null @@ -1,493 +0,0 @@ -assertEquals($expect, $stub->parseMetadata($input)); - } - - public function testParseMetadataCaseInsensitive() - { - $stub = new ParsesMetadataStub; - $input = "Filename,tAken_at,lnG,Lat,gPs_altitude,diStance_to_ground,areA\nabc.jpg,2016-12-19 12:27:00,52.220,28.123,-1500,10,2.6"; - $expect = [ - ['filename', 'taken_at', 'lng', 'lat', 'gps_altitude', 'distance_to_ground', 'area'], - ['abc.jpg', '2016-12-19 12:27:00', '52.220', '28.123', '-1500', '10', '2.6'], - ]; - $this->assertEquals($expect, $stub->parseMetadata($input)); - } - - public function testParseMetadataSynonyms1() - { - $stub = new ParsesMetadataStub; - $input = "file,lon,lat,heading\nabc.jpg,52.220,28.123,180"; - $expect = [ - ['filename', 'lng', 'lat', 'yaw'], - ['abc.jpg', '52.220', '28.123', '180'], - ]; - $this->assertEquals($expect, $stub->parseMetadata($input)); - } - - public function testParseMetadataSynonyms2() - { - $stub = new ParsesMetadataStub; - $input = "file,longitude,latitude\nabc.jpg,52.220,28.123"; - $expect = [ - ['filename', 'lng', 'lat'], - ['abc.jpg', '52.220', '28.123'], - ]; - $this->assertEquals($expect, $stub->parseMetadata($input)); - } - - public function testParseMetadataSynonyms3() - { - $stub = new ParsesMetadataStub; - $input = "filename,SUB_datetime,SUB_longitude,SUB_latitude,SUB_altitude,SUB_distance,area,SUB_heading\nabc.jpg,2016-12-19 12:27:00,52.220,28.123,-1500,10,2.6,180"; - $expect = [ - ['filename', 'taken_at', 'lng', 'lat', 'gps_altitude', 'distance_to_ground', 'area', 'yaw'], - ['abc.jpg', '2016-12-19 12:27:00', '52.220', '28.123', '-1500', '10', '2.6', '180'], - ]; - $this->assertEquals($expect, $stub->parseMetadata($input)); - } - - public function testParseMetadataEmptyCells() - { - $stub = new ParsesMetadataStub; - $input = "filename,taken_at,lng,lat,gps_altitude,distance_to_ground,area,yaw\nabc.jpg,,52.220,28.123,,,,"; - $expect = [ - ['filename', 'taken_at', 'lng', 'lat', 'gps_altitude', 'distance_to_ground', 'area', 'yaw'], - ['abc.jpg', '', '52.220', '28.123', '', '', '', ''], - ]; - $this->assertEquals($expect, $stub->parseMetadata($input)); - } - - public function testParseMetadataFile() - { - $stub = new ParsesMetadataStub; - $csv = __DIR__."/../../files/image-metadata.csv"; - $file = new UploadedFile($csv, 'metadata.csv', 'text/csv', null, true); - $expect = [ - ['filename', 'taken_at', 'lng', 'lat', 'gps_altitude', 'distance_to_ground', 'area', 'yaw'], - ['abc.jpg', '2016-12-19 12:27:00', '52.220', '28.123', '-1500', '10', '2.6', '180'], - ]; - $this->assertEquals($expect, $stub->parseMetadataFile($file)); - } - - public function testParseMetadataFileBOM() - { - $stub = new ParsesMetadataStub; - $csv = __DIR__."/../../files/image-metadata-with-bom.csv"; - $file = new UploadedFile($csv, 'metadata.csv', 'text/csv', null, true); - $expect = [ - ['filename', 'taken_at', 'lng', 'lat', 'gps_altitude', 'distance_to_ground', 'area', 'yaw'], - ['abc.jpg', '2016-12-19 12:27:00', '52.220', '28.123', '-1500', '10', '2.6', '180'], - ]; - $this->assertEquals($expect, $stub->parseMetadataFile($file)); - } - - public function testParseIfdo() - { - $stub = new ParsesMetadataStub; - $input = << 'myvolume', - 'url' => '', - 'handle' => '20.500.12085/d7546c4b-307f-4d42-8554-33236c577450', - 'media_type' => 'image', - 'uuid' => 'd7546c4b-307f-4d42-8554-33236c577450', - 'files' => [ - ['filename'], - ['myimage.jpg'], - ], - ]; - $this->assertEquals($expect, $stub->parseIfdo($input)); - } - - public function testParseIfdoHeader() - { - $stub = new ParsesMetadataStub; - $input = << 'myvolume', - 'url' => 'https://hdl.handle.net/20.500.12085/d7546c4b-307f-4d42-8554-33236c577450@data', - 'handle' => '20.500.12085/d7546c4b-307f-4d42-8554-33236c577450', - 'media_type' => 'image', - 'uuid' => 'd7546c4b-307f-4d42-8554-33236c577450', - 'files' => [ - ['filename', 'area', 'distance_to_ground', 'gps_altitude', 'lat', 'lng', 'taken_at', 'yaw'], - ['myimage.jpg', '5.0', '2', '-2248.0', '11.8581802', '-117.0214864', '2019-04-06 04:29:27.000000', '20'], - ], - ]; - $this->assertEquals($expect, $stub->parseIfdo($input)); - } - - public function testParseIfdoVideoType() - { - $stub = new ParsesMetadataStub; - $input = << 'myvolume', - 'url' => '', - 'handle' => '20.500.12085/d7546c4b-307f-4d42-8554-33236c577450', - 'media_type' => 'video', - 'uuid' => 'd7546c4b-307f-4d42-8554-33236c577450', - 'files' => [ - ['filename'], - ['myvideo.mp4'], - ], - ]; - $this->assertEquals($expect, $stub->parseIfdo($input)); - } - - public function testParseIfdoSlideIsImageType() - { - $stub = new ParsesMetadataStub; - $input = << 'myvolume', - 'url' => '', - 'handle' => '20.500.12085/d7546c4b-307f-4d42-8554-33236c577450', - 'media_type' => 'image', - 'uuid' => 'd7546c4b-307f-4d42-8554-33236c577450', - 'files' => [ - ['filename'], - ['myimage.jpg'], - ], - ]; - $this->assertEquals($expect, $stub->parseIfdo($input)); - } - - public function testParseIfdoItems() - { - $stub = new ParsesMetadataStub; - $input = << 'myvolume', - 'url' => '', - 'handle' => '20.500.12085/d7546c4b-307f-4d42-8554-33236c577450', - 'media_type' => 'image', - 'uuid' => 'd7546c4b-307f-4d42-8554-33236c577450', - 'files' => [ - ['filename', 'area', 'distance_to_ground', 'gps_altitude', 'lat', 'lng', 'taken_at', 'yaw'], - ['myimage.jpg', '5.0', '2', '-2248.0', '11.8581802', '-117.0214864', '2019-04-06 04:29:27.000000', '20'], - ], - ]; - $this->assertEquals($expect, $stub->parseIfdo($input)); - } - - public function testParseIfdoImageArrayItems() - { - $stub = new ParsesMetadataStub; - $input = << 'myvolume', - 'url' => '', - 'handle' => '20.500.12085/d7546c4b-307f-4d42-8554-33236c577450', - 'media_type' => 'image', - 'uuid' => 'd7546c4b-307f-4d42-8554-33236c577450', - 'files' => [ - ['filename', 'area', 'distance_to_ground', 'gps_altitude', 'lat', 'lng', 'taken_at', 'yaw'], - ['myimage.jpg', '5.0', '2', '-2248.0', '11.8581802', '-117.0214864', '2019-04-06 04:29:27.000000', '20'], - ], - ]; - $this->assertEquals($expect, $stub->parseIfdo($input)); - } - - public function testParseIfdoItemsOverrideHeader() - { - $stub = new ParsesMetadataStub; - $input = << 'myvolume', - 'url' => '', - 'handle' => '20.500.12085/d7546c4b-307f-4d42-8554-33236c577450', - 'media_type' => 'image', - 'uuid' => 'd7546c4b-307f-4d42-8554-33236c577450', - 'files' => [ - ['filename', 'area', 'distance_to_ground', 'gps_altitude', 'lat', 'lng', 'taken_at', 'yaw'], - ['myimage.jpg', '5.1', '3', '-2248.0', '11.8581802', '-117.0214864', '2019-04-06 05:29:27.000000', '20'], - ], - ]; - $this->assertEquals($expect, $stub->parseIfdo($input)); - } - - public function testParseIfdoSubItemsOverrideDefaultsAndHeader() - { - $stub = new ParsesMetadataStub; - $input = << 'myvolume', - 'url' => '', - 'handle' => '20.500.12085/d7546c4b-307f-4d42-8554-33236c577450', - 'media_type' => 'video', - 'uuid' => 'd7546c4b-307f-4d42-8554-33236c577450', - 'files' => [ - ['filename', 'area', 'distance_to_ground', 'gps_altitude', 'lat', 'lng', 'taken_at', 'yaw'], - ['myvideo.mp4', '5.1', '3', '-2248.0', '11.8581802', '-117.0214864', '2019-04-06 05:29:27.000000', '20'], - ['myvideo.mp4', '5.1', '4', '-2248.0', '11.8581802', '-117.0214864', '2019-04-06 05:30:27.000000', '20'], - ], - ]; - $this->assertEquals($expect, $stub->parseIfdo($input)); - } - - public function testParseIfdoFile() - { - $stub = new ParsesMetadataStub; - $path = __DIR__."/../../files/image-ifdo.yaml"; - $file = new UploadedFile($path, 'ifdo.yaml', 'application/yaml', null, true); - $expect = [ - 'name' => 'SO268 SO268-2_100-1_OFOS SO_CAM-1_Photo_OFOS', - 'url' => 'https://hdl.handle.net/20.500.12085/d7546c4b-307f-4d42-8554-33236c577450@data', - 'handle' => '20.500.12085/d7546c4b-307f-4d42-8554-33236c577450', - 'media_type' => 'image', - 'uuid' => 'd7546c4b-307f-4d42-8554-33236c577450', - 'files' => [ - ['filename', 'area', 'distance_to_ground', 'gps_altitude', 'lat', 'lng', 'taken_at', 'yaw'], - ['SO268-2_100-1_OFOS_SO_CAM-1_20190406_042927.JPG', '5.0', '2', '-2248.0', '11.8581802', '-117.0214864', '2019-04-06 04:29:27.000000', '20'], - ['SO268-2_100-1_OFOS_SO_CAM-1_20190406_052726.JPG', '5.1', '2.1', '-4129.6', '11.8582192', '-117.0214286', '2019-04-06 05:27:26.000000', '21'], - ], - ]; - $this->assertEquals($expect, $stub->parseIfdoFile($file)); - } - - public function testParseIfdoNoHeader() - { - $stub = new ParsesMetadataStub; - $input = <<expectException(Exception::class); - $stub->parseIfdo($input); - } - - public function testParseIfdoNoItems() - { - $stub = new ParsesMetadataStub; - $input = << 'myvolume', - 'url' => '', - 'handle' => '20.500.12085/d7546c4b-307f-4d42-8554-33236c577450', - 'media_type' => 'image', - 'uuid' => 'd7546c4b-307f-4d42-8554-33236c577450', - 'files' => [], - ]; - $this->assertEquals($expect, $stub->parseIfdo($input)); - } - - public function testParseIfdoNoName() - { - $stub = new ParsesMetadataStub; - $input = <<expectException(Exception::class); - $stub->parseIfdo($input); - } - - public function testParseIfdoNoHandle() - { - $stub = new ParsesMetadataStub; - $input = <<expectException(Exception::class); - $stub->parseIfdo($input); - } - - public function testParseIfdoNoUuid() - { - $stub = new ParsesMetadataStub; - $input = <<expectException(Exception::class); - $stub->parseIfdo($input); - } - - public function testParseIfdoInvalidHandle() - { - $stub = new ParsesMetadataStub; - $input = <<expectException(Exception::class); - $stub->parseIfdo($input); - } - - public function testParseIfdoInvalidDataHandle() - { - $stub = new ParsesMetadataStub; - $input = <<expectException(Exception::class); - $stub->parseIfdo($input); - } - - public function testParseIfdoInvalidYaml() - { - $stub = new ParsesMetadataStub; - $input = <<expectException(Exception::class); - $stub->parseIfdo($input); - } - - public function testParseIfdoNoYamlArray() - { - $stub = new ParsesMetadataStub; - $this->expectException(Exception::class); - $stub->parseIfdo('abc123'); - } -} - -class ParsesMetadataStub -{ - use ParsesMetadata; -} From c05bc0b724745b86224d1eed420aba479f3e9e3a Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Mon, 11 Mar 2024 16:21:30 +0100 Subject: [PATCH 020/209] Remove ParseIfdoController --- .../Api/ProjectVolumeController.php | 5 -- .../Api/Volumes/ParseIfdoController.php | 29 --------- app/Http/Requests/StoreParseIfdo.php | 61 ------------------- routes/api.php | 4 -- .../Api/Volumes/ParseIfdoControllerTest.php | 41 ------------- 5 files changed, 140 deletions(-) delete mode 100644 app/Http/Controllers/Api/Volumes/ParseIfdoController.php delete mode 100644 app/Http/Requests/StoreParseIfdo.php delete mode 100644 tests/php/Http/Controllers/Api/Volumes/ParseIfdoControllerTest.php diff --git a/app/Http/Controllers/Api/ProjectVolumeController.php b/app/Http/Controllers/Api/ProjectVolumeController.php index 9e7dfc49b..14112f77a 100644 --- a/app/Http/Controllers/Api/ProjectVolumeController.php +++ b/app/Http/Controllers/Api/ProjectVolumeController.php @@ -64,7 +64,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` @@ -127,10 +126,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); 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/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/routes/api.php b/routes/api.php index 4cafd5ab0..420e6592c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -315,10 +315,6 @@ $router->get('videos/{disk}', 'BrowserController@indexVideos'); }); - $router->post('parse-ifdo', [ - 'uses' => 'ParseIfdoController@store', - ]); - $router->get('{id}/files/filter/labels', [ 'uses' => 'Filters\AnyFileLabelController@index', ]); diff --git a/tests/php/Http/Controllers/Api/Volumes/ParseIfdoControllerTest.php b/tests/php/Http/Controllers/Api/Volumes/ParseIfdoControllerTest.php deleted file mode 100644 index cb7edc016..000000000 --- a/tests/php/Http/Controllers/Api/Volumes/ParseIfdoControllerTest.php +++ /dev/null @@ -1,41 +0,0 @@ -doTestApiRoute('POST', "/api/v1/volumes/parse-ifdo"); - - $this->beUser(); - $this->postJson("/api/v1/volumes/parse-ifdo") - ->assertStatus(422); - - $file = new UploadedFile(__DIR__."/../../../../../files/image-metadata.csv", 'metadata.csv', 'text/csv', null, true); - - $this->postJson("/api/v1/volumes/parse-ifdo", ['file' => $file]) - ->assertStatus(422); - - $file = new UploadedFile(__DIR__."/../../../../../files/image-ifdo.yaml", 'ifdo.yaml', 'application/yaml', null, true); - $expect = [ - 'name' => 'SO268 SO268-2_100-1_OFOS SO_CAM-1_Photo_OFOS', - 'url' => 'https://hdl.handle.net/20.500.12085/d7546c4b-307f-4d42-8554-33236c577450@data', - 'handle' => '20.500.12085/d7546c4b-307f-4d42-8554-33236c577450', - 'uuid' => 'd7546c4b-307f-4d42-8554-33236c577450', - 'media_type' => 'image', - 'files' => [ - ['filename', 'area', 'distance_to_ground', 'gps_altitude', 'lat', 'lng', 'taken_at', 'yaw'], - ['SO268-2_100-1_OFOS_SO_CAM-1_20190406_042927.JPG', 5.0, 2, -2248.0, 11.8581802, -117.0214864, '2019-04-06 04:29:27.000000', 20], - ['SO268-2_100-1_OFOS_SO_CAM-1_20190406_052726.JPG', 5.1, 2.1, -4129.6, 11.8582192, -117.0214286, '2019-04-06 05:27:26.000000', 21], - ], - ]; - - $this->postJson("/api/v1/volumes/parse-ifdo", ['file' => $file]) - ->assertStatus(200) - ->assertExactJson($expect); - } -} From 6b66cb99e0e6a7ce5b8c74d7a405abe528d0b764 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Mon, 11 Mar 2024 16:21:53 +0100 Subject: [PATCH 021/209] Implement 500MB file size for volume metadata files --- app/Http/Requests/StorePendingVolume.php | 3 ++- app/Http/Requests/StoreVolume.php | 3 +-- app/Http/Requests/StoreVolumeMetadata.php | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/Http/Requests/StorePendingVolume.php b/app/Http/Requests/StorePendingVolume.php index a267f6d40..a58286e46 100644 --- a/app/Http/Requests/StorePendingVolume.php +++ b/app/Http/Requests/StorePendingVolume.php @@ -31,7 +31,8 @@ public function rules(): array { return [ 'media_type' => ['required', Rule::in(array_keys(MediaType::INSTANCES))], - 'metadata_file' => ['file'], + // Allow a maximum of 500 MB. + 'metadata_file' => 'file|max:500000', ]; } diff --git a/app/Http/Requests/StoreVolume.php b/app/Http/Requests/StoreVolume.php index f8b1ef3cd..9560f3f96 100644 --- a/app/Http/Requests/StoreVolume.php +++ b/app/Http/Requests/StoreVolume.php @@ -70,8 +70,7 @@ public function rules() 'array', ], 'handle' => ['nullable', 'max:256', new Handle], - 'metadata_csv' => 'file|mimetypes:text/plain,text/csv,application/csv', - 'ifdo_file' => 'file', + '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. diff --git a/app/Http/Requests/StoreVolumeMetadata.php b/app/Http/Requests/StoreVolumeMetadata.php index 3cc7fe2bc..90fec22d8 100644 --- a/app/Http/Requests/StoreVolumeMetadata.php +++ b/app/Http/Requests/StoreVolumeMetadata.php @@ -44,6 +44,7 @@ public function rules() 'required_without_all:metadata_text,ifdo_file', 'file', 'mimetypes:text/plain,text/csv,application/csv', + 'max:500000', new Utf8, ], 'metadata_text' => 'required_without_all:metadata_csv,ifdo_file', From a740de75925860a55352cfc8c88f18521110a2b2 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Mon, 11 Mar 2024 17:02:40 +0100 Subject: [PATCH 022/209] WIP Implement getInsertData for file metadata classes --- app/Services/MetadataParsing/FileMetadata.php | 8 ++ .../MetadataParsing/ImageMetadata.php | 44 ++++++++ .../MetadataParsing/ImageMetadataTest.php | 31 ++++++ .../MetadataParsing/VideoMetadataTest.php | 103 ++++++++++++++++++ 4 files changed, 186 insertions(+) diff --git a/app/Services/MetadataParsing/FileMetadata.php b/app/Services/MetadataParsing/FileMetadata.php index 9ccdf5d40..1f4cfa778 100644 --- a/app/Services/MetadataParsing/FileMetadata.php +++ b/app/Services/MetadataParsing/FileMetadata.php @@ -13,4 +13,12 @@ public function isEmpty(): bool { return true; } + + /** + * Get the array of metadata that can be used for Model::insert(); + */ + public function getInsertData(): array + { + return []; + } } diff --git a/app/Services/MetadataParsing/ImageMetadata.php b/app/Services/MetadataParsing/ImageMetadata.php index 36920aef8..783d276fe 100644 --- a/app/Services/MetadataParsing/ImageMetadata.php +++ b/app/Services/MetadataParsing/ImageMetadata.php @@ -31,4 +31,48 @@ public function isEmpty(): bool && is_null($this->gpsAltitude) && is_null($this->yaw); } + + /** + * Get the array of metadata that can be used for Model::insert(); + */ + public function getInsertData(): array + { + $data = ['filename' => $this->name]; + + if (!is_null($this->lat)) { + $data['lat'] = $this->lat; + } + + if (!is_null($this->lng)) { + $data['lng'] = $this->lng; + } + + if (!is_null($this->takenAt)) { + $data['taken_at'] = $this->takenAt; + } + + $attrs = []; + + if (!is_null($this->area)) { + $attrs['area'] = $this->area; + } + + if (!is_null($this->distanceToGround)) { + $attrs['distance_to_ground'] = $this->distanceToGround; + } + + if (!is_null($this->gpsAltitude)) { + $attrs['gps_altitude'] = $this->gpsAltitude; + } + + if (!is_null($this->yaw)) { + $attrs['yaw'] = $this->yaw; + } + + if (!empty($attrs)) { + $data['attrs'] = ['metadata' => $attrs]; + } + + return $data; + } } diff --git a/tests/php/Services/MetadataParsing/ImageMetadataTest.php b/tests/php/Services/MetadataParsing/ImageMetadataTest.php index aaa39f6a5..d7b049e3f 100644 --- a/tests/php/Services/MetadataParsing/ImageMetadataTest.php +++ b/tests/php/Services/MetadataParsing/ImageMetadataTest.php @@ -15,4 +15,35 @@ public function testIsEmpty() $data = new ImageMetadata('filename', area: 10); $this->assertFalse($data->isEmpty()); } + + public function testGetInsertData() + { + $data = new ImageMetadata( + '1.jpg', + lat: 100, + lng: 120, + takenAt: '2024-03-11 16:43:00', + area: 2.5, + distanceToGround: 5, + gpsAltitude: -1500, + yaw: 50 + ); + + $expect = [ + 'filename' => '1.jpg', + 'lat' => 100, + 'lng' => 120, + 'taken_at' => '2024-03-11 16:43:00', + 'attrs' => [ + 'metadata' => [ + 'area' => 2.5, + 'distance_to_ground' => 5, + 'gps_altitude' => -1500, + 'yaw' => 50, + ], + ], + ]; + + $this->assertEquals($expect, $data->getInsertData()); + } } diff --git a/tests/php/Services/MetadataParsing/VideoMetadataTest.php b/tests/php/Services/MetadataParsing/VideoMetadataTest.php index 7cc771962..488f7174c 100644 --- a/tests/php/Services/MetadataParsing/VideoMetadataTest.php +++ b/tests/php/Services/MetadataParsing/VideoMetadataTest.php @@ -39,4 +39,107 @@ public function testAddFrame() $this->assertEquals('filename', $frame->name); $this->assertEquals('2023-12-12 20:26:00', $frame->takenAt); } + + public function testGetInsertDataPlain() + { + $data = new VideoMetadata( + '1.mp4', + lat: 100, + lng: 120, + area: 2.5, + distanceToGround: 5, + gpsAltitude: -1500, + yaw: 50 + ); + + $expect = [ + 'filename' => '1.mp4', + 'lat' => [100], + 'lng' => [120], + 'attrs' => [ + 'metadata' => [ + 'area' => [2.5], + 'distance_to_ground' => [5], + 'gps_altitude' => [-1500], + 'yaw' => [50], + ], + ], + ]; + + $this->assertEquals($expect, $data->getInsertData()); + } + + public function testGetInsertDataFrame() + { + $data = new VideoMetadata( + '1.mp4', + lat: 100, + lng: 120, + takenAt: '2024-03-11 16:43:00', + area: 2.5, + distanceToGround: 5, + gpsAltitude: -1500, + yaw: 50 + ); + + $expect = [ + 'filename' => '1.mp4', + 'lat' => [100], + 'lng' => [120], + 'taken_at' => ['2024-03-11 16:43:00'], + 'attrs' => [ + 'metadata' => [ + 'area' => [2.5], + 'distance_to_ground' => [5], + 'gps_altitude' => [-1500], + 'yaw' => [50], + ], + ], + ]; + + $this->assertEquals($expect, $data->getInsertData()); + } + + public function testGetInsertDataFrames() + { + $data = new VideoMetadata('1.mp4'); + + $data->addFrame( + '2024-03-11 16:44:00', + lat: 110, + lng: 130, + area: 3, + distanceToGround: 4, + gpsAltitude: -1501, + yaw: 51 + ); + + $data->addFrame( + '2024-03-11 16:43:00', + lat: 100, + lng: 120, + area: 2.5, + distanceToGround: 5, + gpsAltitude: -1500, + yaw: 50 + ); + + $expect = [ + 'filename' => '1.mp4', + 'lat' => [100, 110], + 'lng' => [120, 130], + // Metadata should be sorted by taken_at. + 'taken_at' => ['2024-03-11 16:43:00', '2024-03-11 16:44:00'], + 'attrs' => [ + 'metadata' => [ + 'area' => [2.5, 3], + 'distance_to_ground' => [5, 4], + 'gps_altitude' => [-1500, -1501], + 'yaw' => [50, 51], + ], + ], + ]; + + $this->assertEquals($expect, $data->getInsertData()); + } } From c7a1f397baf142a1bc2534c6462f432e51a463db Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Tue, 12 Mar 2024 10:04:17 +0100 Subject: [PATCH 023/209] Implement getInsertData for VideoMetadata --- .../MetadataParsing/VideoMetadata.php | 111 ++++++++++++++++++ .../MetadataParsing/VideoMetadataTest.php | 36 ++++++ 2 files changed, 147 insertions(+) diff --git a/app/Services/MetadataParsing/VideoMetadata.php b/app/Services/MetadataParsing/VideoMetadata.php index eba7df169..b3a5eb321 100644 --- a/app/Services/MetadataParsing/VideoMetadata.php +++ b/app/Services/MetadataParsing/VideoMetadata.php @@ -2,6 +2,7 @@ namespace Biigle\Services\MetadataParsing; +use Carbon\Carbon; use Illuminate\Support\Collection; class VideoMetadata extends FileMetadata @@ -78,4 +79,114 @@ public function isEmpty(): bool && is_null($this->gpsAltitude) && is_null($this->yaw); } + + /** + * Get the array of metadata that can be used for Model::insert(); + */ + public function getInsertData(): array + { + if ($this->frames->isEmpty()) { + return $this->getInsertDataPlain(); + } + + return $this->getInsertDataFrames(); + + } + + /** + * Get the metadata insert array if no frames are present. + */ + protected function getInsertDataPlain(): array + { + $data = ['filename' => $this->name]; + + if (!is_null($this->lat)) { + $data['lat'] = [$this->lat]; + } + + if (!is_null($this->lng)) { + $data['lng'] = [$this->lng]; + } + + if (!is_null($this->takenAt)) { + $data['taken_at'] = [$this->takenAt]; + } + + $attrs = []; + + if (!is_null($this->area)) { + $attrs['area'] = [$this->area]; + } + + if (!is_null($this->distanceToGround)) { + $attrs['distance_to_ground'] = [$this->distanceToGround]; + } + + if (!is_null($this->gpsAltitude)) { + $attrs['gps_altitude'] = [$this->gpsAltitude]; + } + + if (!is_null($this->yaw)) { + $attrs['yaw'] = [$this->yaw]; + } + + if (!empty($attrs)) { + $data['attrs'] = ['metadata' => $attrs]; + } + + return $data; + } + + /** + * Get the metadata insert array from all frames, sorted by taken_at. + * If one frame has data that another frame doesn't have, it is added as null. + */ + protected function getInsertDataFrames(): array + { + $data = [ + 'lat' => [], + 'lng' => [], + 'taken_at' => [], + ]; + + $attrs = [ + 'area' => [], + 'distance_to_ground' => [], + 'gps_altitude' => [], + 'yaw' => [], + ]; + + $sortedFrames = $this->frames->sort(fn ($a, $b) => + Carbon::parse($a->takenAt)->gt($b->takenAt) ? 1 : -1 + )->values(); + + foreach ($sortedFrames as $frame) { + $data['lat'][] = $frame->lat; + $data['lng'][] = $frame->lng; + $data['taken_at'][] = $frame->takenAt; + + $attrs['area'][] = $frame->area; + $attrs['distance_to_ground'][] = $frame->distanceToGround; + $attrs['gps_altitude'][] = $frame->gpsAltitude; + $attrs['yaw'][] = $frame->yaw; + } + + // Remove all items that are full of null. + $data = array_filter($data, function ($item) { + return !empty(array_filter($item, fn ($i) => !is_null($i))); + }); + + // Remove all items that are full of null. + $attrs = array_filter($attrs, function ($item) { + return !empty(array_filter($item, fn ($i) => !is_null($i))); + }); + + $data['filename'] = $this->name; + + if (!empty($attrs)) { + $data['attrs'] = ['metadata' => $attrs]; + } + + return $data; + } } diff --git a/tests/php/Services/MetadataParsing/VideoMetadataTest.php b/tests/php/Services/MetadataParsing/VideoMetadataTest.php index 488f7174c..ab64e092b 100644 --- a/tests/php/Services/MetadataParsing/VideoMetadataTest.php +++ b/tests/php/Services/MetadataParsing/VideoMetadataTest.php @@ -142,4 +142,40 @@ public function testGetInsertDataFrames() $this->assertEquals($expect, $data->getInsertData()); } + + public function testGetInsertDataFramesWithGaps() + { + $data = new VideoMetadata('1.mp4'); + + $data->addFrame( + '2024-03-11 16:44:00', + lat: 110, + lng: 130, + gpsAltitude: -1501 + ); + + $data->addFrame( + '2024-03-11 16:43:00', + area: 2.5, + distanceToGround: 5, + gpsAltitude: -1500 + ); + + $expect = [ + 'filename' => '1.mp4', + 'lat' => [null, 110], + 'lng' => [null, 130], + // Metadata should be sorted by taken_at. + 'taken_at' => ['2024-03-11 16:43:00', '2024-03-11 16:44:00'], + 'attrs' => [ + 'metadata' => [ + 'area' => [2.5, null], + 'distance_to_ground' => [5, null], + 'gps_altitude' => [-1500, -1501], + ], + ], + ]; + + $this->assertEquals($expect, $data->getInsertData()); + } } From ad99725eab88bc8dbc44cdb39a5d4f7dfce5c09d Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Tue, 12 Mar 2024 10:59:02 +0100 Subject: [PATCH 024/209] Update CreateNewImagesOrVideos with new metadata handling --- app/Jobs/CreateNewImagesOrVideos.php | 171 ++++-------------- .../MetadataParsing/ImageMetadata.php | 4 +- .../MetadataParsing/VideoMetadata.php | 15 +- .../php/Jobs/CreateNewImagesOrVideosTest.php | 128 +++++++------ .../MetadataParsing/ImageMetadataTest.php | 2 +- .../MetadataParsing/VideoMetadataTest.php | 2 +- 6 files changed, 119 insertions(+), 203 deletions(-) diff --git a/app/Jobs/CreateNewImagesOrVideos.php b/app/Jobs/CreateNewImagesOrVideos.php index c266698b2..84eaf51b4 100644 --- a/app/Jobs/CreateNewImagesOrVideos.php +++ b/app/Jobs/CreateNewImagesOrVideos.php @@ -4,7 +4,7 @@ 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; @@ -16,7 +16,7 @@ class CreateNewImagesOrVideos extends Job implements ShouldQueue { - use InteractsWithQueue, SerializesModels, ChecksMetadataStrings; + use InteractsWithQueue, SerializesModels; /** * The volume to create the files for. @@ -44,15 +44,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 +65,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 +104,42 @@ 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]; + $metaKeys += array_keys($insert); - $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']); - } - - 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)); - - 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/Services/MetadataParsing/ImageMetadata.php b/app/Services/MetadataParsing/ImageMetadata.php index 783d276fe..7d2dc3c93 100644 --- a/app/Services/MetadataParsing/ImageMetadata.php +++ b/app/Services/MetadataParsing/ImageMetadata.php @@ -2,6 +2,8 @@ namespace Biigle\Services\MetadataParsing; +use Carbon\Carbon; + class ImageMetadata extends FileMetadata { public function __construct( @@ -48,7 +50,7 @@ public function getInsertData(): array } if (!is_null($this->takenAt)) { - $data['taken_at'] = $this->takenAt; + $data['taken_at'] = Carbon::parse($this->takenAt)->toDateTimeString(); } $attrs = []; diff --git a/app/Services/MetadataParsing/VideoMetadata.php b/app/Services/MetadataParsing/VideoMetadata.php index b3a5eb321..39501b1ca 100644 --- a/app/Services/MetadataParsing/VideoMetadata.php +++ b/app/Services/MetadataParsing/VideoMetadata.php @@ -108,10 +108,6 @@ protected function getInsertDataPlain(): array $data['lng'] = [$this->lng]; } - if (!is_null($this->takenAt)) { - $data['taken_at'] = [$this->takenAt]; - } - $attrs = []; if (!is_null($this->area)) { @@ -156,14 +152,15 @@ protected function getInsertDataFrames(): array 'yaw' => [], ]; - $sortedFrames = $this->frames->sort(fn ($a, $b) => - Carbon::parse($a->takenAt)->gt($b->takenAt) ? 1 : -1 - )->values(); + $timestamps = $this->frames + ->map(fn ($f) => Carbon::parse($f->takenAt)) + ->sort(fn ($a, $b) => $a->gt($b) ? 1 : -1); - foreach ($sortedFrames as $frame) { + foreach ($timestamps as $index => $timestamp) { + $frame = $this->frames->get($index); $data['lat'][] = $frame->lat; $data['lng'][] = $frame->lng; - $data['taken_at'][] = $frame->takenAt; + $data['taken_at'][] = $timestamp->toDateTimeString(); $attrs['area'][] = $frame->area; $attrs['distance_to_ground'][] = $frame->distanceToGround; diff --git a/tests/php/Jobs/CreateNewImagesOrVideosTest.php b/tests/php/Jobs/CreateNewImagesOrVideosTest.php index 06044f071..b7c1b00b6 100644 --- a/tests/php/Jobs/CreateNewImagesOrVideosTest.php +++ b/tests/php/Jobs/CreateNewImagesOrVideosTest.php @@ -9,6 +9,7 @@ use Carbon\Carbon; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Facades\Storage; use TestCase; class CreateNewImagesOrVideosTest extends TestCase @@ -65,14 +66,16 @@ public function testHandleImageMetadata() { $volume = VolumeTest::create([ 'media_type_id' => MediaType::imageId(), + 'metadata_file_path' => 'mymeta.csv', ]); + $disk = Storage::fake('metadata'); + $disk->put($volume->metadata_file_path, <<handle(); + with(new CreateNewImagesOrVideos($volume, $filenames))->handle(); $image = $volume->images()->first(); $this->assertEquals('2016-12-19 12:27:00', $image->taken_at); $this->assertEquals(52.220, $image->lng); @@ -87,14 +90,16 @@ public function testHandleImageMetadataEmptyCells() { $volume = VolumeTest::create([ 'media_type_id' => MediaType::imageId(), + 'metadata_file_path' => 'mymeta.csv', ]); + $disk = Storage::fake('metadata'); + $disk->put($volume->metadata_file_path, <<handle(); + with(new CreateNewImagesOrVideos($volume, $filenames))->handle(); $image = $volume->images()->first(); $this->assertEquals(52.220, $image->lng); $this->assertEquals(28.123, $image->lat); @@ -105,14 +110,16 @@ public function testHandleImageMetadataIncomplete() { $volume = VolumeTest::create([ 'media_type_id' => MediaType::imageId(), + 'metadata_file_path' => 'mymeta.csv', ]); + $disk = Storage::fake('metadata'); + $disk->put($volume->metadata_file_path, <<handle(); + with(new CreateNewImagesOrVideos($volume, $filenames))->handle(); $this->assertEquals(2, $volume->images()->count()); } @@ -120,16 +127,17 @@ public function testHandleVideoMetadata() { $volume = VolumeTest::create([ 'media_type_id' => MediaType::videoId(), + 'metadata_file_path' => 'mymeta.csv', ]); + $disk = Storage::fake('metadata'); + $disk->put($volume->metadata_file_path, <<handle(); + with(new CreateNewImagesOrVideos($volume, $filenames))->handle(); $video = $volume->videos()->first(); $expect = [ Carbon::parse('2016-12-19 12:27:00'), @@ -148,15 +156,17 @@ public function testHandleVideoMetadataEmptyCells() { $volume = VolumeTest::create([ 'media_type_id' => MediaType::videoId(), + 'metadata_file_path' => 'mymeta.csv', ]); + $disk = Storage::fake('metadata'); + $disk->put($volume->metadata_file_path, <<handle(); + with(new CreateNewImagesOrVideos($volume, $filenames))->handle(); $video = $volume->videos()->first(); $expect = ['gps_altitude' => [-1500, null]]; $this->assertSame($expect, $video->metadata); @@ -166,14 +176,16 @@ public function testHandleVideoMetadataZeroSingle() { $volume = VolumeTest::create([ 'media_type_id' => MediaType::videoId(), + 'metadata_file_path' => 'mymeta.csv', ]); + $disk = Storage::fake('metadata'); + $disk->put($volume->metadata_file_path, <<handle(); + with(new CreateNewImagesOrVideos($volume, $filenames))->handle(); $video = $volume->videos()->first(); $expect = ['distance_to_ground' => [0]]; $this->assertSame($expect, $video->metadata); @@ -183,15 +195,17 @@ public function testHandleVideoMetadataZero() { $volume = VolumeTest::create([ 'media_type_id' => MediaType::videoId(), + 'metadata_file_path' => 'mymeta.csv', ]); + $disk = Storage::fake('metadata'); + $disk->put($volume->metadata_file_path, <<handle(); + with(new CreateNewImagesOrVideos($volume, $filenames))->handle(); $video = $volume->videos()->first(); $expect = ['distance_to_ground' => [0, 1]]; $this->assertSame($expect, $video->metadata); @@ -201,14 +215,16 @@ public function testHandleVideoMetadataBasic() { $volume = VolumeTest::create([ 'media_type_id' => MediaType::videoId(), + 'metadata_file_path' => 'mymeta.csv', ]); + $disk = Storage::fake('metadata'); + $disk->put($volume->metadata_file_path, <<handle(); + with(new CreateNewImagesOrVideos($volume, $filenames))->handle(); $video = $volume->videos()->first(); $expect = ['gps_altitude' => [-1500]]; $this->assertSame($expect, $video->metadata); @@ -219,14 +235,16 @@ public function testHandleVideoMetadataIncomplete() { $volume = VolumeTest::create([ 'media_type_id' => MediaType::videoId(), + 'metadata_file_path' => 'mymeta.csv', ]); + $disk = Storage::fake('metadata'); + $disk->put($volume->metadata_file_path, <<handle(); + with(new CreateNewImagesOrVideos($volume, $filenames))->handle(); $this->assertEquals(2, $volume->videos()->count()); } @@ -234,14 +252,16 @@ public function testHandleMetadataDateParsing() { $volume = VolumeTest::create([ 'media_type_id' => MediaType::imageId(), + 'metadata_file_path' => 'mymeta.csv', ]); + $disk = Storage::fake('metadata'); + $disk->put($volume->metadata_file_path, <<handle(); + with(new CreateNewImagesOrVideos($volume, $filenames))->handle(); $image = $volume->images()->first(); $this->assertEquals('2019-05-01 10:35:00', $image->taken_at); } diff --git a/tests/php/Services/MetadataParsing/ImageMetadataTest.php b/tests/php/Services/MetadataParsing/ImageMetadataTest.php index d7b049e3f..1fbb7eab8 100644 --- a/tests/php/Services/MetadataParsing/ImageMetadataTest.php +++ b/tests/php/Services/MetadataParsing/ImageMetadataTest.php @@ -22,7 +22,7 @@ public function testGetInsertData() '1.jpg', lat: 100, lng: 120, - takenAt: '2024-03-11 16:43:00', + takenAt: '03/11/2024 16:43:00', area: 2.5, distanceToGround: 5, gpsAltitude: -1500, diff --git a/tests/php/Services/MetadataParsing/VideoMetadataTest.php b/tests/php/Services/MetadataParsing/VideoMetadataTest.php index ab64e092b..647450a53 100644 --- a/tests/php/Services/MetadataParsing/VideoMetadataTest.php +++ b/tests/php/Services/MetadataParsing/VideoMetadataTest.php @@ -75,7 +75,7 @@ public function testGetInsertDataFrame() '1.mp4', lat: 100, lng: 120, - takenAt: '2024-03-11 16:43:00', + takenAt: '03/11/2024 16:43:00', area: 2.5, distanceToGround: 5, gpsAltitude: -1500, From 8137363526923e147bd3b599c1f4c32073d3a95c Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Tue, 12 Mar 2024 12:57:32 +0100 Subject: [PATCH 025/209] Update volume MetadataController with new metadata handling --- .../Api/PendingVolumeController.php | 2 +- .../Api/Volumes/MetadataController.php | 227 +--------- app/Http/Requests/StoreVolumeMetadata.php | 80 +--- app/Image.php | 16 + app/Jobs/UpdateVolumeMetadata.php | 66 +++ app/Rules/Utf8.php | 31 -- app/Services/MetadataParsing/CsvParser.php | 6 +- app/Traits/ChecksMetadataStrings.php | 14 - app/Video.php | 13 +- app/Volume.php | 2 +- app/VolumeFile.php | 2 +- .../files/image-metadata-strange-encoding.csv | 2 + .../files/video-metadata-strange-encoding.csv | 4 +- .../Api/Volumes/MetadataControllerTest.php | 397 +----------------- tests/php/Jobs/UpdateVolumeMetadataTest.php | 291 +++++++++++++ .../MetadataParsing/ImageCsvParserTest.php | 4 + .../MetadataParsing/VideoCsvParserTest.php | 6 +- 17 files changed, 452 insertions(+), 711 deletions(-) create mode 100644 app/Jobs/UpdateVolumeMetadata.php delete mode 100644 app/Rules/Utf8.php delete mode 100644 app/Traits/ChecksMetadataStrings.php create mode 100644 tests/files/image-metadata-strange-encoding.csv create mode 100644 tests/php/Jobs/UpdateVolumeMetadataTest.php diff --git a/app/Http/Controllers/Api/PendingVolumeController.php b/app/Http/Controllers/Api/PendingVolumeController.php index dff453881..88e77ee09 100644 --- a/app/Http/Controllers/Api/PendingVolumeController.php +++ b/app/Http/Controllers/Api/PendingVolumeController.php @@ -30,7 +30,7 @@ class PendingVolumeController extends Controller * * @apiParam (Required attributes) {String} media_type The media type of the new volume (`image` or `video`). * - * @apiParam (Optional attributes) {File} metadata_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 formats may be supported through modules. + * @apiParam (Optional attributes) {File} metadata_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 (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` diff --git a/app/Http/Controllers/Api/Volumes/MetadataController.php b/app/Http/Controllers/Api/Volumes/MetadataController.php index 18f2853da..c43224450 100644 --- a/app/Http/Controllers/Api/Volumes/MetadataController.php +++ b/app/Http/Controllers/Api/Volumes/MetadataController.php @@ -4,22 +4,14 @@ 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 Biigle\Jobs\UpdateVolumeMetadata; use Biigle\Volume; -use Carbon\Carbon; -use DB; use Illuminate\Http\Response; -use Illuminate\Support\Collection; -use Illuminate\Validation\ValidationException; +use Queue; use Storage; class MetadataController extends Controller { - use ChecksMetadataStrings; - /** * Get a metadata file attached to a volume * @@ -62,13 +54,13 @@ public function show($id) * @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 (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` @@ -78,31 +70,17 @@ public function show($id) * @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 */ 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(); - } + // 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')); + Queue::push(new UpdateVolumeMetadata($request->volume)); } /** @@ -112,7 +90,8 @@ public function store(StoreVolumeMetadata $request) * @apiGroup Volumes * @apiName DestroyVolumeMetadata * @apiPermission projectAdmin - * @apiDescription This does not delete the metadata that was attached to the volume files. + * @apiDescription This does not delete the metadata that was already attached to the + * volume files. ~ * @param int $id * @@ -124,184 +103,4 @@ public function destroy($id) $this->authorize('update', $volume); $volume->deleteMetadata(); } - - /** - * 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(); - } - } - - /** - * Update volume metadata for each video. - * - * @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 - */ - protected function hasMetadata(Video $video) - { - foreach (VideoMetadata::ALLOWED_ATTRIBUTES as $key) { - if (!is_null($video->$key)) { - return true; - } - } - - return !empty($video->metadata); - } } diff --git a/app/Http/Requests/StoreVolumeMetadata.php b/app/Http/Requests/StoreVolumeMetadata.php index 90fec22d8..7342ef809 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); } @@ -39,41 +37,10 @@ public function authorize() public function rules() { return [ - 'metadata_csv' => [ - 'bail', - 'required_without_all:metadata_text,ifdo_file', - 'file', - 'mimetypes:text/plain,text/csv,application/csv', - 'max:500000', - new Utf8, - ], - 'metadata_text' => 'required_without_all:metadata_csv,ifdo_file', - 'ifdo_file' => 'required_without_all:metadata_csv,metadata_text|file', - 'metadata' => 'filled', + 'file' => 'required|file|max:500000', ]; } - /** - * 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. * @@ -82,36 +49,25 @@ 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 ($this->volume->isImageVolume()) { - $rule = new ImageMetadata($files); - } else { - $rule = new VideoMetadata($files); - } + if ($validator->errors()->isNotEmpty()) { + return; + } - if (!$rule->passes('metadata', $this->input('metadata'))) { - $validator->errors()->add('metadata', $rule->message()); - } + $type = $this->volume->isImageVolume() ? 'image' : 'video'; + $parser = ParserFactory::getParserForFile($this->file('file'), $type); + if (is_null($parser)) { + $validator->errors()->add('file', 'Unknown metadata file format for this media type.'); + 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/Image.php b/app/Image.php index c6eab803b..937053be6 100644 --- a/app/Image.php +++ b/app/Image.php @@ -25,6 +25,22 @@ 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. * diff --git a/app/Jobs/UpdateVolumeMetadata.php b/app/Jobs/UpdateVolumeMetadata.php new file mode 100644 index 000000000..ab0238bf4 --- /dev/null +++ b/app/Jobs/UpdateVolumeMetadata.php @@ -0,0 +1,66 @@ +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(); + } + } + } +} diff --git a/app/Rules/Utf8.php b/app/Rules/Utf8.php deleted file mode 100644 index f2a9bdf83..000000000 --- a/app/Rules/Utf8.php +++ /dev/null @@ -1,31 +0,0 @@ -get(); - return mb_detect_encoding($value, 'UTF-8', true) !== false; - } - - /** - * Get the validation error message. - * - * @return string - */ - public function message() - { - return "The :attribute must be UTF-8 encoded."; - } -} diff --git a/app/Services/MetadataParsing/CsvParser.php b/app/Services/MetadataParsing/CsvParser.php index 94ea8ee94..1d263c4b1 100644 --- a/app/Services/MetadataParsing/CsvParser.php +++ b/app/Services/MetadataParsing/CsvParser.php @@ -34,7 +34,11 @@ public function recognizesFile(): bool { $file = $this->getCsvIterator(); $line = $file->current(); - if (!is_array($line)) { + if (!is_array($line) || empty($line)) { + return false; + } + + if (mb_detect_encoding($line[0], 'UTF-8', true) === false) { return false; } diff --git a/app/Traits/ChecksMetadataStrings.php b/app/Traits/ChecksMetadataStrings.php deleted file mode 100644 index ff965a8c2..000000000 --- a/app/Traits/ChecksMetadataStrings.php +++ /dev/null @@ -1,14 +0,0 @@ -attributes['taken_at'] = json_encode($value); + $this->attributes['taken_at'] = json_encode($value); + } else { + $this->attributes['taken_at'] = $value; + } } /** diff --git a/app/Volume.php b/app/Volume.php index 1d97f5508..df4d71e06 100644 --- a/app/Volume.php +++ b/app/Volume.php @@ -501,7 +501,7 @@ public function getMetadata(): ?VolumeMetadata /** * @param boolean $noUpdate Do not set metadata_file_path to null. */ - public function deleteMetadata($noUpdate=false): void + public function deleteMetadata($noUpdate = false): void { if ($this->hasMetadata()) { Storage::disk(config('volumes.metadata_storage_disk'))->delete($this->metadata_file_path); diff --git a/app/VolumeFile.php b/app/VolumeFile.php index 146ee5e73..78cbad11c 100644 --- a/app/VolumeFile.php +++ b/app/VolumeFile.php @@ -52,7 +52,7 @@ public function volume() * * @param array $value */ - public function setMetadataAttribute(array $value) + public function setMetadataAttribute(?array $value) { return $this->setJsonAttr('metadata', $value); } diff --git a/tests/files/image-metadata-strange-encoding.csv b/tests/files/image-metadata-strange-encoding.csv new file mode 100644 index 000000000..bd460d2c8 --- /dev/null +++ b/tests/files/image-metadata-strange-encoding.csv @@ -0,0 +1,2 @@ + taken_at,filename,gps_altitude +2023-03-26 12:40:47,my-image ,-10 diff --git a/tests/files/video-metadata-strange-encoding.csv b/tests/files/video-metadata-strange-encoding.csv index c74083723..9ab5ea006 100644 --- a/tests/files/video-metadata-strange-encoding.csv +++ b/tests/files/video-metadata-strange-encoding.csv @@ -1,2 +1,2 @@ -filename,taken_at,gps_altitude - my-video ,2023-03-26 12:40:47,-10 + taken_at,filename,gps_altitude +2023-03-26 12:40:47,my-video ,-10 diff --git a/tests/php/Http/Controllers/Api/Volumes/MetadataControllerTest.php b/tests/php/Http/Controllers/Api/Volumes/MetadataControllerTest.php index a9f6fb612..547b52ae0 100644 --- a/tests/php/Http/Controllers/Api/Volumes/MetadataControllerTest.php +++ b/tests/php/Http/Controllers/Api/Volumes/MetadataControllerTest.php @@ -3,10 +3,12 @@ namespace Biigle\Tests\Http\Controllers\Api\Volumes; use ApiTestCase; +use Biigle\Jobs\UpdateVolumeMetadata; use Biigle\MediaType; use Biigle\Tests\ImageTest; use Biigle\Tests\VideoTest; use Illuminate\Http\UploadedFile; +use Queue; use Storage; class MetadataControllerTest extends ApiTestCase @@ -54,113 +56,31 @@ public function testStoreImageMetadata() $csv = new UploadedFile(__DIR__."/../../../../../files/image-metadata.csv", 'image-metadata.csv', 'text/csv', null, true); $this->beEditor(); // no permissions - $this->postJson("/api/v1/volumes/{$id}/metadata", ['metadata_csv' => $csv]) + $this->postJson("/api/v1/volumes/{$id}/metadata", ['file' => $csv]) ->assertStatus(403); $this->beAdmin(); // file required $this->postJson("/api/v1/volumes/{$id}/metadata")->assertStatus(422); - // image does not exist - $this->postJson("/api/v1/volumes/{$id}/metadata", ['metadata_csv' => $csv]) - ->assertStatus(422); - $png = ImageTest::create([ - 'filename' => 'abc.png', - 'volume_id' => $id, - ]); - $jpg = ImageTest::create([ - 'filename' => 'abc.jpg', - 'volume_id' => $id, - 'attrs' => ['metadata' => [ - 'water_depth' => 4000, - 'distance_to_ground' => 20, - ]], - ]); - - $this->assertFalse($this->volume()->hasGeoInfo()); - - $this->postJson("/api/v1/volumes/{$id}/metadata", ['metadata_csv' => $csv]) + $this->postJson("/api/v1/volumes/{$id}/metadata", ['file' => $csv]) ->assertStatus(200); - $this->assertTrue($this->volume()->hasGeoInfo()); - - $png = $png->fresh(); - $jpg = $jpg->fresh(); - - $this->assertEquals('2016-12-19 12:27:00', $jpg->taken_at); - $this->assertEquals(52.220, $jpg->lng); - $this->assertEquals(28.123, $jpg->lat); - $this->assertEquals(-1500, $jpg->metadata['gps_altitude']); - $this->assertEquals(2.6, $jpg->metadata['area']); - // Import should update but not destroy existing metadata. - $this->assertEquals(10, $jpg->metadata['distance_to_ground']); - $this->assertEquals(4000, $jpg->metadata['water_depth']); - $this->assertEquals(180, $jpg->metadata['yaw']); - - $this->assertNull($png->taken_at); - $this->assertNull($png->lng); - $this->assertNull($png->lat); - $this->assertEmpty($png->metadata); - } - - public function testStoreStringMetadata() - { - $id = $this->volume()->id; - - $this->beAdmin(); + Queue::assertPushed(UpdateVolumeMetadata::class, function ($job) { + $this->assertEquals($this->volume()->id, $job->volume->id); - $this->postJson("/api/v1/volumes/{$id}/metadata", ['metadata_csv' => "metadata_string"]) - ->assertStatus(422); + return true; + }); } - public function testStoreDeprecatedFileAttribute() + public function testStoreImageMetadataInvalid() { $id = $this->volume()->id; - - $image = ImageTest::create([ - 'filename' => 'abc.jpg', - 'volume_id' => $id, - 'attrs' => ['metadata' => [ - 'water_depth' => 4000, - 'distance_to_ground' => 20, - ]], - ]); - - $csv = new UploadedFile(__DIR__."/../../../../../files/image-metadata.csv", 'metadata.csv', 'text/csv', null, true); - + $csv = new UploadedFile(__DIR__."/../../../../../files/image-metadata-invalid.csv", 'image-metadata-invalid.csv', 'text/csv', null, true); $this->beAdmin(); $this->postJson("/api/v1/volumes/{$id}/metadata", ['file' => $csv]) - ->assertSuccessful(); - - $image->refresh(); - $this->assertEquals(4000, $image->metadata['water_depth']); - $this->assertEquals(10, $image->metadata['distance_to_ground']); - $this->assertEquals(2.6, $image->metadata['area']); - } - - public function testStoreImageMetadataText() - { - $id = $this->volume()->id; - - $image = ImageTest::create([ - 'filename' => 'abc.jpg', - 'volume_id' => $id, - 'attrs' => ['metadata' => [ - 'water_depth' => 4000, - 'distance_to_ground' => 20, - ]], - ]); - - $this->beAdmin(); - $this->postJson("/api/v1/volumes/{$id}/metadata", [ - 'metadata_text' => "filename,area,distance_to_ground\nabc.jpg,2.5,10", - ])->assertSuccessful(); - - $image->refresh(); - $this->assertEquals(4000, $image->metadata['water_depth']); - $this->assertEquals(10, $image->metadata['distance_to_ground']); - $this->assertEquals(2.5, $image->metadata['area']); + ->assertStatus(422); } public function testStoreVideoMetadataCsv() @@ -169,313 +89,30 @@ public function testStoreVideoMetadataCsv() $this->volume()->media_type_id = MediaType::videoId(); $this->volume()->save(); - $video = VideoTest::create([ - 'filename' => 'abc.mp4', - 'volume_id' => $id, - 'taken_at' => ['2016-12-19 12:27:00', '2016-12-19 12:28:00'], - 'attrs' => ['metadata' => [ - 'distance_to_ground' => [20, 120], - ]], - ]); - $csv = new UploadedFile(__DIR__."/../../../../../files/video-metadata.csv", 'metadata.csv', 'text/csv', null, true); $this->beAdmin(); $this->postJson("/api/v1/volumes/{$id}/metadata", ['file' => $csv]) ->assertSuccessful(); - $video->refresh(); - $this->assertSame([10, 5], $video->metadata['distance_to_ground']); - $this->assertSame([180, 181], $video->metadata['yaw']); - } - - public function testStoreVideoMetadataText() - { - $id = $this->volume()->id; - $this->volume()->media_type_id = MediaType::videoId(); - $this->volume()->save(); - - $video = VideoTest::create([ - 'filename' => 'abc.mp4', - 'volume_id' => $id, - 'taken_at' => ['2022-02-24 16:07:00', '2022-02-24 16:08:00'], - 'attrs' => ['metadata' => [ - 'water_depth' => [4000, 4100], - 'distance_to_ground' => [20, 120], - ]], - ]); - - $text = <<beAdmin(); - $this->postJson("/api/v1/volumes/{$id}/metadata", [ - 'metadata_text' => $text, - ])->assertSuccessful(); - - $video->refresh(); - $this->assertCount(2, $video->taken_at); - $this->assertSame([4000, 4100], $video->metadata['water_depth']); - $this->assertSame([10, 150], $video->metadata['distance_to_ground']); - $this->assertSame([2.5, 3.5], $video->metadata['area']); - } - - public function testStoreVideoMetadataMerge() - { - $id = $this->volume()->id; - $this->volume()->media_type_id = MediaType::videoId(); - $this->volume()->save(); - - $video = VideoTest::create([ - 'filename' => 'abc.mp4', - 'volume_id' => $id, - 'taken_at' => ['2022-02-24 16:07:00', '2022-02-24 16:08:00'], - 'attrs' => ['metadata' => [ - 'water_depth' => [4000, 4100], - 'distance_to_ground' => [20, 120], - ]], - ]); - - $text = <<assertEquals($this->volume()->id, $job->volume->id); - $this->beAdmin(); - $this->postJson("/api/v1/volumes/{$id}/metadata", [ - 'metadata_text' => $text, - ])->assertSuccessful(); - - $video->refresh(); - $this->assertCount(3, $video->taken_at); - $this->assertSame([4000, 4100, null], $video->metadata['water_depth']); - $this->assertSame([20, 120, 150], $video->metadata['distance_to_ground']); - $this->assertSame([2.5, null, 3.5], $video->metadata['area']); + return true; + }); } - public function testStoreVideoMetadataFillOneVideoButNotTheOther() + public function testStoreMetadataIncorrectEncoding() { $id = $this->volume()->id; - $this->volume()->media_type_id = MediaType::videoId(); - $this->volume()->save(); - $video1 = VideoTest::create([ - 'filename' => 'abc.mp4', - 'volume_id' => $id, - 'taken_at' => ['2022-02-24 16:07:00', '2022-02-24 16:08:00'], - 'attrs' => ['metadata' => [ - 'distance_to_ground' => [20, 120], - ]], - ]); - - $video2 = VideoTest::create([ - 'filename' => 'def.mp4', - 'volume_id' => $id, - 'taken_at' => ['2022-02-24 16:07:00', '2022-02-24 16:08:00'], - 'attrs' => ['metadata' => []], - ]); - - $text = <<beAdmin(); - $this->postJson("/api/v1/volumes/{$id}/metadata", [ - 'metadata_text' => $text, - ])->assertSuccessful(); - - $video1->refresh(); - $video2->refresh(); - $this->assertSame([10, 120, 150], $video1->metadata['distance_to_ground']); - $this->assertArrayNotHasKey('distance_to_ground', $video2->metadata); - } - - public function testStoreVideoMetadataCannotUpdateTimestampedWithBasic() - { - $id = $this->volume()->id; - $this->volume()->media_type_id = MediaType::videoId(); - $this->volume()->save(); - - $video = VideoTest::create([ - 'filename' => 'abc.mp4', - 'volume_id' => $id, - 'taken_at' => ['2022-02-24 16:07:00', '2022-02-24 16:08:00'], - 'attrs' => ['metadata' => [ - 'water_depth' => [4000, 4100], - 'distance_to_ground' => [20, 120], - ]], - ]); - - $this->beAdmin(); - // The video has timestamped metadata. There is no way the new area data without - // timestamp can be merged into the timestamped data. - $this->postJson("/api/v1/volumes/{$id}/metadata", [ - 'metadata_text' => "filename,area\nabc.mp4,2.5", - ])->assertStatus(422); - } - - public function testStoreVideoMetadataCannotUpdateBasicWithTimestamped() - { - $id = $this->volume()->id; - $this->volume()->media_type_id = MediaType::videoId(); - $this->volume()->save(); - - $video = VideoTest::create([ - 'filename' => 'abc.mp4', - 'volume_id' => $id, - 'attrs' => ['metadata' => [ - 'water_depth' => [4000], - 'distance_to_ground' => [20], - ]], - ]); - - $text = <<beAdmin(); - // The video has basic metadata. There is no way the new area data with - // timestamp can be merged into the basic data. - $this->postJson("/api/v1/volumes/{$id}/metadata", [ - 'metadata_text' => $text, - ])->assertStatus(422); - } - - public function testStoreVideoMetadataZerosSingle() - { - $id = $this->volume()->id; - $this->volume()->media_type_id = MediaType::videoId(); - $this->volume()->save(); - - $video = VideoTest::create([ - 'filename' => 'abc.mp4', - 'volume_id' => $id, - ]); - - $text = <<beAdmin(); - $this->postJson("/api/v1/volumes/{$id}/metadata", [ - 'metadata_text' => $text, - ])->assertSuccessful(); - - $video->refresh(); - $this->assertSame([0], $video->metadata['area']); - } - - public function testStoreVideoMetadataZeros() - { - $id = $this->volume()->id; - $this->volume()->media_type_id = MediaType::videoId(); - $this->volume()->save(); - - $video = VideoTest::create([ - 'filename' => 'abc.mp4', - 'volume_id' => $id, - ]); - - $text = <<beAdmin(); - $this->postJson("/api/v1/volumes/{$id}/metadata", [ - 'metadata_text' => $text, - ])->assertSuccessful(); - - $video->refresh(); - $this->assertSame([0, 1], $video->metadata['area']); - } - - public function testStoreVideoMetadataIncorrectEncoding() - { - $id = $this->volume()->id; - $this->volume()->media_type_id = MediaType::videoId(); - $this->volume()->save(); - - $video = VideoTest::create([ - 'filename' => 'my-video.mp4', - 'volume_id' => $id, - ]); - - $csv = new UploadedFile(__DIR__."/../../../../../files/video-metadata-incorrect-encoding.csv", 'metadata.csv', 'text/csv', null, true); + $csv = new UploadedFile(__DIR__."/../../../../../files/image-metadata-strange-encoding.csv", 'metadata.csv', 'text/csv', null, true); $this->beAdmin(); $this->postJson("/api/v1/volumes/{$id}/metadata", ['file' => $csv]) ->assertStatus(422); } - public function testStoreImageIfdoFile() - { - $id = $this->volume()->id; - $this->beAdmin(); - $file = new UploadedFile(__DIR__."/../../../../../files/image-ifdo.yaml", 'ifdo.yaml', 'application/yaml', null, true); - - Storage::fake('ifdos'); - - $this->assertFalse($this->volume()->hasIfdo()); - - $this->postJson("/api/v1/volumes/{$id}/metadata", ['ifdo_file' => $file]) - ->assertSuccessful(); - - $this->assertTrue($this->volume()->hasIfdo()); - } - - public function testStoreVideoIfdoFile() - { - $id = $this->volume()->id; - $this->volume()->media_type_id = MediaType::videoId(); - $this->volume()->save(); - $this->beAdmin(); - $file = new UploadedFile(__DIR__."/../../../../../files/video-ifdo.yaml", 'ifdo.yaml', 'application/yaml', null, true); - - Storage::fake('ifdos'); - - $this->assertFalse($this->volume()->hasIfdo()); - - $this->postJson("/api/v1/volumes/{$id}/metadata", ['ifdo_file' => $file]) - ->assertSuccessful(); - - $this->assertTrue($this->volume()->hasIfdo()); - } - - public function testStoreVideoIfdoFileForImageVolume() - { - $id = $this->volume()->id; - $this->beAdmin(); - $file = new UploadedFile(__DIR__."/../../../../../files/video-ifdo.yaml", 'ifdo.yaml', 'application/yaml', null, true); - - $this->postJson("/api/v1/volumes/{$id}/metadata", ['ifdo_file' => $file]) - ->assertStatus(422); - } - - public function testStoreImageIfdoFileForVideoVolume() - { - $id = $this->volume()->id; - $this->volume()->media_type_id = MediaType::videoId(); - $this->volume()->save(); - $this->beAdmin(); - $file = new UploadedFile(__DIR__."/../../../../../files/image-ifdo.yaml", 'ifdo.yaml', 'application/yaml', null, true); - - $this->postJson("/api/v1/volumes/{$id}/metadata", ['ifdo_file' => $file]) - ->assertStatus(422); - } - public function testDestroy() { $volume = $this->volume(); diff --git a/tests/php/Jobs/UpdateVolumeMetadataTest.php b/tests/php/Jobs/UpdateVolumeMetadataTest.php new file mode 100644 index 000000000..15c0907d9 --- /dev/null +++ b/tests/php/Jobs/UpdateVolumeMetadataTest.php @@ -0,0 +1,291 @@ +markTestIncomplete('clear geo info cache'); + $volume = Volume::factory()->create([ + 'media_type_id' => MediaType::imageId(), + 'metadata_file_path' => 'mymeta.csv', + ]); + + $image = Image::factory()->create([ + 'filename' => 'a.jpg', + 'volume_id' => $volume->id, + 'attrs' => [ + 'size' => 100, + ], + ]); + + + $disk = Storage::fake('metadata'); + $disk->put($volume->metadata_file_path, <<handle(); + $image->refresh(); + $this->assertEquals(100, $image->size); + $this->assertEquals('2016-12-19 12:27:00', $image->taken_at); + $this->assertEquals(52.220, $image->lng); + $this->assertEquals(28.123, $image->lat); + $this->assertEquals(-1500, $image->metadata['gps_altitude']); + $this->assertEquals(10, $image->metadata['distance_to_ground']); + $this->assertEquals(2.6, $image->metadata['area']); + $this->assertEquals(180, $image->metadata['yaw']); + } + + public function testHandleImageUpdate() + { + $volume = Volume::factory()->create([ + 'media_type_id' => MediaType::imageId(), + 'metadata_file_path' => 'mymeta.csv', + ]); + + $image = Image::factory()->create([ + 'filename' => 'a.jpg', + 'volume_id' => $volume->id, + 'taken_at' => '2024-03-12 11:23:00', + 'lng' => 12, + 'lat' => 34, + 'attrs' => [ + 'size' => 100, + 'metadata' => [ + 'gps_altitude' => -1000, + 'distance_to_ground' => 5, + 'area' => 2.5, + 'yaw' => 100, + ], + ], + ]); + + + $disk = Storage::fake('metadata'); + $disk->put($volume->metadata_file_path, <<handle(); + $image->refresh(); + $this->assertEquals(100, $image->size); + $this->assertEquals('2016-12-19 12:27:00', $image->taken_at); + $this->assertEquals(52.220, $image->lng); + $this->assertEquals(28.123, $image->lat); + $this->assertEquals(-1500, $image->metadata['gps_altitude']); + $this->assertEquals(10, $image->metadata['distance_to_ground']); + $this->assertEquals(2.6, $image->metadata['area']); + $this->assertEquals(180, $image->metadata['yaw']); + } + + public function testHandleImageMerge() + { + $volume = Volume::factory()->create([ + 'media_type_id' => MediaType::imageId(), + 'metadata_file_path' => 'mymeta.csv', + ]); + + $image = Image::factory()->create([ + 'filename' => 'a.jpg', + 'volume_id' => $volume->id, + 'lng' => 12, + 'lat' => 34, + 'attrs' => [ + 'size' => 100, + 'metadata' => [ + 'gps_altitude' => -1000, + 'distance_to_ground' => 5, + ], + ], + ]); + + + $disk = Storage::fake('metadata'); + $disk->put($volume->metadata_file_path, <<handle(); + $image->refresh(); + $this->assertEquals(100, $image->size); + $this->assertEquals('2016-12-19 12:27:00', $image->taken_at); + $this->assertEquals(12, $image->lng); + $this->assertEquals(34, $image->lat); + $this->assertEquals(-1500, $image->metadata['gps_altitude']); + $this->assertEquals(5, $image->metadata['distance_to_ground']); + $this->assertArrayNotHasKey('area', $image->metadata); + $this->assertArrayNotHasKey('yaw', $image->metadata); + } + + public function testHandleVideoAdd() + { + $this->markTestIncomplete('clear geo info cache'); + $volume = Volume::factory()->create([ + 'media_type_id' => MediaType::videoId(), + 'metadata_file_path' => 'mymeta.csv', + ]); + + $video = Video::factory()->create([ + 'filename' => 'a.mp4', + 'volume_id' => $volume->id, + 'attrs' => [ + 'size' => 100, + ], + ]); + + + $disk = Storage::fake('metadata'); + $disk->put($volume->metadata_file_path, <<handle(); + $video->refresh(); + $this->assertEquals(100, $video->size); + $this->assertEquals([Carbon::parse('2016-12-19 12:27:00')], $video->taken_at); + $this->assertEquals([52.220], $video->lng); + $this->assertEquals([28.123], $video->lat); + $this->assertEquals([-1500], $video->metadata['gps_altitude']); + $this->assertEquals([10], $video->metadata['distance_to_ground']); + $this->assertEquals([2.6], $video->metadata['area']); + $this->assertEquals([180], $video->metadata['yaw']); + } + + public function testHandleVideoUpdate() + { + $volume = Volume::factory()->create([ + 'media_type_id' => MediaType::videoId(), + 'metadata_file_path' => 'mymeta.csv', + ]); + + $video = Video::factory()->create([ + 'filename' => 'a.mp4', + 'volume_id' => $volume->id, + 'taken_at' => ['2024-03-12 11:23:00'], + 'lng' => [12], + 'lat' => [34], + 'attrs' => [ + 'size' => 100, + 'metadata' => [ + 'gps_altitude' => [-1000], + 'distance_to_ground' => [5], + 'area' => [2.5], + 'yaw' => [100], + ], + ], + ]); + + + $disk = Storage::fake('metadata'); + $disk->put($volume->metadata_file_path, <<handle(); + $video->refresh(); + $this->assertEquals(100, $video->size); + $this->assertEquals([Carbon::parse('2016-12-19 12:27:00')], $video->taken_at); + $this->assertEquals([52.220], $video->lng); + $this->assertEquals([28.123], $video->lat); + $this->assertEquals([-1500], $video->metadata['gps_altitude']); + $this->assertEquals([10], $video->metadata['distance_to_ground']); + $this->assertEquals([2.6], $video->metadata['area']); + $this->assertEquals([180], $video->metadata['yaw']); + } + + public function testHandleVideoMergeWithoutTakenAt() + { + $volume = Volume::factory()->create([ + 'media_type_id' => MediaType::videoId(), + 'metadata_file_path' => 'mymeta.csv', + ]); + + $video = Video::factory()->create([ + 'filename' => 'a.mp4', + 'volume_id' => $volume->id, + 'lng' => [12], + 'lat' => [34], + 'attrs' => [ + 'size' => 100, + 'metadata' => [ + 'gps_altitude' => [-1000], + 'distance_to_ground' => [5], + ], + ], + ]); + + + $disk = Storage::fake('metadata'); + $disk->put($volume->metadata_file_path, <<handle(); + $video->refresh(); + $this->assertEquals(100, $video->size); + $this->assertNull($video->taken_at); + $this->assertEquals([12], $video->lng); + $this->assertEquals([34], $video->lat); + $this->assertEquals([-1500], $video->metadata['gps_altitude']); + $this->assertEquals([5], $video->metadata['distance_to_ground']); + $this->assertArrayNotHasKey('area', $video->metadata); + $this->assertArrayNotHasKey('yaw', $video->metadata); + } + + public function testHandleVideoReplaceWithTakenAt() + { + $volume = Volume::factory()->create([ + 'media_type_id' => MediaType::videoId(), + 'metadata_file_path' => 'mymeta.csv', + ]); + + $video = Video::factory()->create([ + 'filename' => 'a.mp4', + 'volume_id' => $volume->id, + 'lng' => [12], + 'lat' => [34], + 'attrs' => [ + 'size' => 100, + 'metadata' => [ + 'gps_altitude' => [-1000], + 'distance_to_ground' => [5], + ], + ], + ]); + + + $disk = Storage::fake('metadata'); + $disk->put($volume->metadata_file_path, <<handle(); + $video->refresh(); + $this->assertEquals(100, $video->size); + $this->assertEquals([Carbon::parse('2016-12-19 12:27:00')], $video->taken_at); + $this->assertNull($video->lng); + $this->assertNull($video->lat); + $this->assertEquals([-1500], $video->metadata['gps_altitude']); + $this->assertArrayNotHasKey('distance_to_ground', $video->metadata); + $this->assertArrayNotHasKey('area', $video->metadata); + $this->assertArrayNotHasKey('yaw', $video->metadata); + } +} diff --git a/tests/php/Services/MetadataParsing/ImageCsvParserTest.php b/tests/php/Services/MetadataParsing/ImageCsvParserTest.php index 1100aa2b3..71d556a5c 100644 --- a/tests/php/Services/MetadataParsing/ImageCsvParserTest.php +++ b/tests/php/Services/MetadataParsing/ImageCsvParserTest.php @@ -23,6 +23,10 @@ public function testRecognizesFile() $file = new File(__DIR__."/../../../files/test.mp4"); $parser = new ImageCsvParser($file); $this->assertFalse($parser->recognizesFile()); + + $file = new File(__DIR__."/../../../files/image-metadata-strange-encoding.csv"); + $parser = new ImageCsvParser($file); + $this->assertFalse($parser->recognizesFile()); } public function testGetMetadata() diff --git a/tests/php/Services/MetadataParsing/VideoCsvParserTest.php b/tests/php/Services/MetadataParsing/VideoCsvParserTest.php index 4bc75ec46..c9f5e2278 100644 --- a/tests/php/Services/MetadataParsing/VideoCsvParserTest.php +++ b/tests/php/Services/MetadataParsing/VideoCsvParserTest.php @@ -19,6 +19,10 @@ public function testRecognizesFile() $file = new File(__DIR__."/../../../files/test.mp4"); $parser = new VideoCsvParser($file); $this->assertFalse($parser->recognizesFile()); + + $file = new File(__DIR__."/../../../files/video-metadata-strange-encoding.csv"); + $parser = new VideoCsvParser($file); + $this->assertFalse($parser->recognizesFile()); } public function testGetMetadata() @@ -196,7 +200,7 @@ public function testGetMetadataStrangeEncoding() $parser = new VideoCsvParser($file); $data = $parser->getMetadata(); $this->assertCount(1, $data->getFiles()); - $this->assertCount(1, $data->getFiles()->first()->getFrames()); + $this->assertCount(0, $data->getFiles()->first()->getFrames()); } } From ddf8e1ec0a16c9993bd33c91eeabf3472e0da22a Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Tue, 12 Mar 2024 14:40:25 +0100 Subject: [PATCH 026/209] Update CloneImagesorVideos job with new metadata handling --- app/Http/Controllers/Api/VolumeController.php | 3 ++ app/Jobs/CloneImagesOrVideos.php | 23 ++++--------- .../Controllers/Api/VolumeControllerTest.php | 34 ++++++++++++------- tests/php/Jobs/CloneImagesOrVideosTest.php | 21 ++++++------ 4 files changed, 41 insertions(+), 40 deletions(-) diff --git a/app/Http/Controllers/Api/VolumeController.php b/app/Http/Controllers/Api/VolumeController.php index 2ced33713..ee0672618 100644 --- a/app/Http/Controllers/Api/VolumeController.php +++ b/app/Http/Controllers/Api/VolumeController.php @@ -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); diff --git a/app/Jobs/CloneImagesOrVideos.php b/app/Jobs/CloneImagesOrVideos.php index 8af39fd47..bfcf9842d 100644 --- a/app/Jobs/CloneImagesOrVideos.php +++ b/app/Jobs/CloneImagesOrVideos.php @@ -11,7 +11,6 @@ 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 +25,7 @@ class CloneImagesOrVideos extends Job implements ShouldQueue { - use InteractsWithQueue, SerializesModels, ChecksMetadataStrings; + use InteractsWithQueue, SerializesModels; /** @@ -140,9 +139,8 @@ public function handle() $this->postProcessCloning($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; @@ -448,7 +446,6 @@ 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') @@ -507,16 +504,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/tests/php/Http/Controllers/Api/VolumeControllerTest.php b/tests/php/Http/Controllers/Api/VolumeControllerTest.php index 44c8cdc43..ceba24518 100644 --- a/tests/php/Http/Controllers/Api/VolumeControllerTest.php +++ b/tests/php/Http/Controllers/Api/VolumeControllerTest.php @@ -263,12 +263,7 @@ public function testUpdateRedirect() public function testCloneVolume() { - $volume = $this - ->volume([ - 'created_at' => '2022-11-09 14:37:00', - 'updated_at' => '2022-11-09 14:37:00', - ]) - ->fresh(); + $volume = $this->volume(['metadata_file_path' => 'mymeta.csv']); $project = ProjectTest::create(); $this->doTestApiRoute('POST', "/api/v1/volumes/{$volume->id}/clone-to/{$project->id}"); @@ -289,21 +284,34 @@ public function testCloneVolume() Cache::flush(); - Queue::fake(); - $this ->postJson("/api/v1/volumes/{$volume->id}/clone-to/{$project->id}") ->assertStatus(201); Queue::assertPushed(CloneImagesOrVideos::class); - // The target project. + $this->assertTrue($project->volumes()->exists()); + $copy = $project->volumes()->first(); + $this->assertEquals($copy->name, $this->volume()->name); + $this->assertEquals($copy->media_type_id, $this->volume()->media_type_id); + $this->assertEquals($copy->url, $this->volume()->url); + $this->assertTrue($copy->creating_async); + $this->assertEquals("{$copy->id}.csv", $copy->metadata_file_path); + } + + public function testCloneVolumeNewName() + { + $volume = $this->volume(['name' => 'myvolume']); $project = ProjectTest::create(); + $project->addUserId($this->admin()->id, Role::adminId()); $this->beAdmin(); - $project->addUserId($this->admin()->id, Role::adminId()); - $response = $this->postJson("/api/v1/volumes/{$volume->id}/clone-to/{$project->id}"); - $response->assertStatus(201); - Queue::assertPushed(CloneImagesOrVideos::class); + $this + ->postJson("/api/v1/volumes/{$volume->id}/clone-to/{$project->id}", [ + 'name' => 'volumecopy', + ]) + ->assertStatus(201); + $copy = $project->volumes()->first(); + $this->assertEquals($copy->name, 'volumecopy'); } } diff --git a/tests/php/Jobs/CloneImagesOrVideosTest.php b/tests/php/Jobs/CloneImagesOrVideosTest.php index 568bdd8f0..e016c605f 100644 --- a/tests/php/Jobs/CloneImagesOrVideosTest.php +++ b/tests/php/Jobs/CloneImagesOrVideosTest.php @@ -2,6 +2,7 @@ namespace Biigle\Tests\Jobs; +use ApiTestCase; use Biigle\Jobs\CloneImagesOrVideos; use Biigle\Jobs\ProcessNewVolumeFiles; use Biigle\MediaType; @@ -24,7 +25,7 @@ use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; -class CloneImagesOrVideosTest extends \ApiTestCase +class CloneImagesOrVideosTest extends ApiTestCase { public function testCloneImageVolume() { @@ -572,9 +573,9 @@ public function testCloneVolumeVideoWithoutAnnotations() $this->assertEmpty($newVideo->annotations()->get()); } - public function testCloneVolumeIfDoFiles() + public function testCloneVolumeMetadataFile() { - Event::fake(); + Storage::fake('metadata'); $volume = $this->volume([ 'media_type_id' => MediaType::imageId(), 'created_at' => '2022-11-09 14:37:00', @@ -582,13 +583,13 @@ public function testCloneVolumeIfDoFiles() ])->fresh(); $copy = $volume->replicate(); + $copy->metadata_file_path = 'mymeta.csv'; $copy->save(); // Use fresh() to load even the null fields. - Storage::fake('ifdos'); - $csv = __DIR__."/../../files/image-ifdo.yaml"; - $file = new UploadedFile($csv, 'ifdo.yaml', 'application/yaml', null, true); - $volume->saveIfdo($file); + $csv = __DIR__."/../../files/image-metadata.csv"; + $file = new UploadedFile($csv, 'metadata.csv', 'text/csv', null, true); + $volume->saveMetadata($file); // The target project. $project = ProjectTest::create(); @@ -597,12 +598,10 @@ public function testCloneVolumeIfDoFiles() $request = new Request(['project' => $project, 'volume' => $volume]); with(new CloneImagesOrVideos($request, $copy))->handle(); - Event::assertDispatched('volume.cloned'); $copy = $project->volumes()->first(); - $this->assertNotNull($copy->getIfdo()); - $this->assertTrue($copy->hasIfdo()); - $this->assertEquals($volume->getIfdo(), $copy->getIfdo()); + $this->assertTrue($copy->hasMetadata()); + $this->assertNotNull($copy->getMetadata()); } public function testHandleVolumeImages() From 1a5c2e8bda86990d5da4ce872a6dc9738d245f9e Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Tue, 12 Mar 2024 14:50:14 +0100 Subject: [PATCH 027/209] Fix destructor of StoreVolume request The File facade may not be available at this point. --- app/Http/Requests/StoreVolume.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Requests/StoreVolume.php b/app/Http/Requests/StoreVolume.php index 9560f3f96..6a7fc4f46 100644 --- a/app/Http/Requests/StoreVolume.php +++ b/app/Http/Requests/StoreVolume.php @@ -38,7 +38,7 @@ class StoreVolume extends FormRequest */ function __destruct() { if (isset($this->metadataPath)) { - File::delete($this->metadataPath); + unlink($this->metadataPath); } } From dc7192275100043c50c8c7d58a9f919767ee8136 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Tue, 12 Mar 2024 14:58:07 +0100 Subject: [PATCH 028/209] Fix leaking metadata files from tests --- app/Volume.php | 6 ++++-- .../Controllers/Api/ProjectVolumeControllerTest.php | 13 +++++++++---- .../Api/Volumes/MetadataControllerTest.php | 2 ++ 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/app/Volume.php b/app/Volume.php index df4d71e06..cf9b41237 100644 --- a/app/Volume.php +++ b/app/Volume.php @@ -467,8 +467,10 @@ public function hasMetadata(): bool public function saveMetadata(UploadedFile $file): void { $disk = config('volumes.metadata_storage_disk'); - $extension = $file->getExtension(); - $this->metadata_file_path = "{$this->id}.{$extension}"; + $this->metadata_file_path = $this->id; + if ($extension = $file->getExtension()) { + $this->metadata_file_path .= '.'.$extension; + } $file->storeAs('', $this->metadata_file_path, $disk); $this->save(); } diff --git a/tests/php/Http/Controllers/Api/ProjectVolumeControllerTest.php b/tests/php/Http/Controllers/Api/ProjectVolumeControllerTest.php index 723f6b2f6..26848c85f 100644 --- a/tests/php/Http/Controllers/Api/ProjectVolumeControllerTest.php +++ b/tests/php/Http/Controllers/Api/ProjectVolumeControllerTest.php @@ -446,11 +446,13 @@ public function testStoreEmptyImageMetadataText() ]) ->assertSuccessful(); - Queue::assertPushed(CreateNewImagesOrVideos::class, fn ($job) => empty($job->metadata)); + $volume = $this->project()->volumes()->first(); + $this->assertNull($volume->metadata_file_path); } public function testStoreImageMetadataText() { + Storage::fake('metadata'); $id = $this->project()->id; $this->beAdmin(); Storage::disk('test')->makeDirectory('images'); @@ -472,7 +474,7 @@ public function testStoreImageMetadataText() public function testStoreImageMetadataCsv() { - Storage::disk('metadata'); + Storage::fake('metadata'); $id = $this->project()->id; $this->beAdmin(); @@ -529,11 +531,13 @@ public function testStoreEmptyVideoMetadataText() ]) ->assertSuccessful(); - Queue::assertPushed(CreateNewImagesOrVideos::class, fn ($job) => empty($job->metadata)); + $volume = $this->project()->volumes()->first(); + $this->assertNull($volume->metadata_file_path); } public function testStoreVideoMetadataText() { + Storage::fake('metadata'); $id = $this->project()->id; $this->beAdmin(); Storage::disk('test')->makeDirectory('videos'); @@ -555,6 +559,8 @@ public function testStoreVideoMetadataText() public function testStoreVideoMetadataCsv() { + Storage::fake('metadata'); + $id = $this->project()->id; $this->beAdmin(); $csv = __DIR__."/../../../../files/video-metadata.csv"; @@ -665,7 +671,6 @@ public function testAttach() $secondProject = ProjectTest::create(); $pid = $secondProject->id; - // $secondProject->addUserId($this->admin()->id, Role::adminId()); $this->doTestApiRoute('POST', "/api/v1/projects/{$pid}/volumes/{$tid}"); diff --git a/tests/php/Http/Controllers/Api/Volumes/MetadataControllerTest.php b/tests/php/Http/Controllers/Api/Volumes/MetadataControllerTest.php index 547b52ae0..8149b6651 100644 --- a/tests/php/Http/Controllers/Api/Volumes/MetadataControllerTest.php +++ b/tests/php/Http/Controllers/Api/Volumes/MetadataControllerTest.php @@ -49,6 +49,7 @@ public function testStoreDeprecated() public function testStoreImageMetadata() { + Storage::fake('metadata'); $id = $this->volume()->id; $this->doTestApiRoute('POST', "/api/v1/volumes/{$id}/metadata"); @@ -85,6 +86,7 @@ public function testStoreImageMetadataInvalid() public function testStoreVideoMetadataCsv() { + Storage::fake('metadata'); $id = $this->volume()->id; $this->volume()->media_type_id = MediaType::videoId(); $this->volume()->save(); From c9afa06e22b2e377967a6fb841c89823009305a5 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Tue, 12 Mar 2024 15:28:25 +0100 Subject: [PATCH 029/209] Fix volume metadata file extension --- app/Volume.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Volume.php b/app/Volume.php index cf9b41237..0e1549f3d 100644 --- a/app/Volume.php +++ b/app/Volume.php @@ -468,7 +468,7 @@ public function saveMetadata(UploadedFile $file): void { $disk = config('volumes.metadata_storage_disk'); $this->metadata_file_path = $this->id; - if ($extension = $file->getExtension()) { + if ($extension = $file->getClientOriginalExtension()) { $this->metadata_file_path .= '.'.$extension; } $file->storeAs('', $this->metadata_file_path, $disk); From b1b9c4a5d876a909cead72615278caa600923846 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Tue, 12 Mar 2024 15:29:42 +0100 Subject: [PATCH 030/209] Update volume edit view for new metadata handling --- .../assets/js/volumes/api/parseIfdoFile.js | 10 ---- resources/assets/js/volumes/api/volumeIfdo.js | 8 --- resources/assets/js/volumes/createForm.vue | 2 +- .../assets/js/volumes/metadataUpload.vue | 59 +++++-------------- resources/views/volumes/edit.blade.php | 2 +- .../views/volumes/edit/metadata.blade.php | 44 +++++--------- 6 files changed, 32 insertions(+), 93 deletions(-) delete mode 100644 resources/assets/js/volumes/api/parseIfdoFile.js delete mode 100644 resources/assets/js/volumes/api/volumeIfdo.js diff --git a/resources/assets/js/volumes/api/parseIfdoFile.js b/resources/assets/js/volumes/api/parseIfdoFile.js deleted file mode 100644 index 388ca7e93..000000000 --- a/resources/assets/js/volumes/api/parseIfdoFile.js +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Resource for uploading an IFDO file and getting content as JSON. - * - * let resource = parseIfdoFile; - * let data = new FormData(); - * data.append('file', fileInputElement.files[0]); - * - * resource.save(data).then(...); - */ -export default Vue.resource('api/v1/volumes/parse-ifdo'); diff --git a/resources/assets/js/volumes/api/volumeIfdo.js b/resources/assets/js/volumes/api/volumeIfdo.js deleted file mode 100644 index 6cc046575..000000000 --- a/resources/assets/js/volumes/api/volumeIfdo.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Resource for getting and deleting iFDO files attached to a volume. - * - * let resource = biigle.$require('api.volumeIfdo'); - * - * resource.delete({id: volumeId}).then(...); - */ -export default Vue.resource('api/v1/volumes{/id}/ifdo'); diff --git a/resources/assets/js/volumes/createForm.vue b/resources/assets/js/volumes/createForm.vue index 8f51f06fb..d6638b52f 100644 --- a/resources/assets/js/volumes/createForm.vue +++ b/resources/assets/js/volumes/createForm.vue @@ -3,7 +3,7 @@ import BrowserApi from './api/browser'; import Dropdown from 'uiv/dist/Dropdown'; import FileBrowser from '../core/components/fileBrowser'; import LoaderMixin from '../core/mixins/loader'; -import ParseIfdoFileApi from '../volumes/api/parseIfdoFile'; +// import ParseIfdoFileApi from '../volumes/api/parseIfdoFile'; import {handleErrorResponse} from '../core/messages/store'; const MEDIA_TYPE = { diff --git a/resources/assets/js/volumes/metadataUpload.vue b/resources/assets/js/volumes/metadataUpload.vue index 2d4a72c90..cf0211395 100644 --- a/resources/assets/js/volumes/metadataUpload.vue +++ b/resources/assets/js/volumes/metadataUpload.vue @@ -2,10 +2,6 @@ import Dropdown from 'uiv/dist/Dropdown'; import LoaderMixin from '../core/mixins/loader'; import MetadataApi from './api/volumeMetadata'; -import ParseIfdoFileApi from './api/parseIfdoFile'; -import VolumeIfdoApi from './api/volumeIfdo'; -import Tab from 'uiv/dist/Tab'; -import Tabs from 'uiv/dist/Tabs'; import MessageStore from '../core/messages/store'; /** @@ -14,8 +10,6 @@ import MessageStore from '../core/messages/store'; export default { mixins: [LoaderMixin], components: { - tabs: Tabs, - tab: Tab, dropdown: Dropdown, }, data() { @@ -24,7 +18,7 @@ export default { error: false, success: false, message: undefined, - hasIfdo: false, + hasMetadata: false, }; }, computed: { @@ -37,7 +31,7 @@ export default { }, handleError(response) { this.success = false; - let knownError = response.body.errors && (response.body.errors.metadata || response.body.errors.ifdo_file || response.body.errors.file); + let knownError = response.body.errors && response.body.errors.file; if (knownError) { if (Array.isArray(knownError)) { this.error = knownError[0]; @@ -45,59 +39,36 @@ export default { this.error = knownError; } } else { - MessageStore.handleErrorResponse(response); + this.handleErrorResponse(response); } }, - submitCsv() { - this.$refs.csvInput.click(); + submitFile() { + this.$refs.fileInput.click(); }, - uploadCsv(event) { - this.startLoading(); - let data = new FormData(); - data.append('metadata_csv', event.target.files[0]); - this.upload(data) - .then(this.handleSuccess, this.handleError) - .finally(this.finishLoading); - }, - submitIfdo() { - this.$refs.ifdoInput.click(); - }, - handleIfdo(event) { + handleFile(event) { this.startLoading(); let data = new FormData(); data.append('file', event.target.files[0]); - ParseIfdoFileApi.save(data) - .then(this.uploadIfdo) - .then(() => this.hasIfdo = true) + MetadataApi.save({id: this.volumeId}, data) + .then(() => this.hasMetadata = true) .then(this.handleSuccess) .catch(this.handleError) .finally(this.finishLoading); }, - uploadIfdo(response) { - let ifdo = response.body; - let data = new FormData(); - data.append('ifdo_file', this.$refs.ifdoInput.files[0]); - data.append('metadata_text', ifdo.files.map(row => row.join(',')).join("\n")); - - return this.upload(data); - }, - upload(data) { - return MetadataApi.save({id: this.volumeId}, data); - }, - deleteIfdo() { + deleteFile() { this.startLoading(); - VolumeIfdoApi.delete({id: this.volumeId}) - .then(this.handleIfdoDeleted, MessageStore.handleErrorResponse) + MetadataApi.delete({id: this.volumeId}) + .then(this.handleFileDeleted, this.handleErrorResponse) .finally(this.finishLoading); }, - handleIfdoDeleted() { - this.hasIfdo = false; - MessageStore.success('The iFDO file was deleted.'); + handleFileDeleted() { + this.hasMetadata = false; + MessageStore.success('The metadata file was deleted.'); }, }, created() { this.volumeId = biigle.$require('volumes.id'); - this.hasIfdo = biigle.$require('volumes.hasIfdo'); + this.hasMetadata = biigle.$require('volumes.hasMetadata'); }, }; diff --git a/resources/views/volumes/edit.blade.php b/resources/views/volumes/edit.blade.php index 9507de6db..7b52f5e57 100644 --- a/resources/views/volumes/edit.blade.php +++ b/resources/views/volumes/edit.blade.php @@ -6,7 +6,7 @@ biigle.$declare('volumes.id', {!! $volume->id !!}); biigle.$declare('volumes.annotationSessions', {!! $annotationSessions !!}); biigle.$declare('volumes.type', '{!! $type !!}'); - biigle.$declare('volumes.hasIfdo', '{!! $volume->hasIfdo() !!}'); + biigle.$declare('volumes.hasMetadata', '{!! $volume->hasMetadata() !!}'); @mixin('volumesEditScripts') @endpush diff --git a/resources/views/volumes/edit/metadata.blade.php b/resources/views/volumes/edit/metadata.blade.php index 56f1769d4..308d14c79 100644 --- a/resources/views/volumes/edit/metadata.blade.php +++ b/resources/views/volumes/edit/metadata.blade.php @@ -7,47 +7,33 @@ @endif - - + +
- - -

- Upload an iFDO file to attach it to the volume and update the @if ($volume->isImageVolume()) image @else video @endif metadata. -

-
-
- - -
-
-
- -

- Upload a CSV file to update the metadata of the @if ($volume->isImageVolume()) images @else videos @endif of this volume. -

-
-
- - -
-
-
-
+

+ Upload a metadata file to attach it to the volume and update the @if ($volume->isImageVolume()) image @else video @endif metadata. +

+
+
+ + +
+
+
- The @if ($volume->isImageVolume()) image @else video @endif metadata was successfully updated. + The @if ($volume->isImageVolume()) image @else video @endif metadata file was successfully updated.

Learn more about @if ($volume->isImageVolume()) image @else video @endif metadata and the file formats in the manual. From 67746f80cc47bf2a4075406d4f9427eede2c7f1c Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Tue, 12 Mar 2024 15:45:14 +0100 Subject: [PATCH 031/209] Apply CS fixes --- app/Http/Requests/StoreVolume.php | 8 ++++---- app/Http/Requests/UpdatePendingVolume.php | 6 +++--- app/Jobs/CreateNewImagesOrVideos.php | 10 ++++------ app/Policies/PendingVolumePolicy.php | 14 ++++++++------ app/Rules/ImageMetadata.php | 4 ++-- app/Services/MetadataParsing/ImageMetadata.php | 3 +-- app/Services/MetadataParsing/VideoCsvParser.php | 2 +- app/Services/MetadataParsing/VideoMetadata.php | 14 ++++---------- app/Services/MetadataParsing/VolumeMetadata.php | 3 +-- app/Volume.php | 2 -- .../Api/PendingVolumeControllerTest.php | 2 +- .../Api/ProjectVolumeControllerTest.php | 13 ++++++------- .../Api/Volumes/MetadataControllerTest.php | 2 -- tests/php/Rules/ImageMetadataTest.php | 2 +- tests/php/Rules/VideoMetadataTest.php | 2 +- .../MetadataParsing/ImageCsvParserTest.php | 3 +-- .../MetadataParsing/VideoCsvParserTest.php | 3 +-- .../Services/MetadataParsing/VideoMetadataTest.php | 1 - .../MetadataParsing/VolumeMetadataTest.php | 2 +- tests/php/VolumeTest.php | 3 --- 20 files changed, 40 insertions(+), 59 deletions(-) diff --git a/app/Http/Requests/StoreVolume.php b/app/Http/Requests/StoreVolume.php index 6a7fc4f46..5428dece1 100644 --- a/app/Http/Requests/StoreVolume.php +++ b/app/Http/Requests/StoreVolume.php @@ -11,7 +11,6 @@ use Biigle\Rules\VolumeUrl; use Biigle\Services\MetadataParsing\ParserFactory; use Biigle\Volume; -use Exception; use File; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Http\UploadedFile; @@ -36,7 +35,8 @@ class StoreVolume extends FormRequest /** * Remove potential temporary files. */ - function __destruct() { + public function __destruct() + { if (isset($this->metadataPath)) { unlink($this->metadataPath); } @@ -86,8 +86,8 @@ public function rules() public function withValidator($validator) { $validator->after(function ($validator) { - // Only validate sample volume files after all other fields have been - // validated. + // Only validate sample volume files after all other fields have been + // validated. if ($validator->errors()->isNotEmpty()) { return; } diff --git a/app/Http/Requests/UpdatePendingVolume.php b/app/Http/Requests/UpdatePendingVolume.php index b8163b60e..a1f6d43f5 100644 --- a/app/Http/Requests/UpdatePendingVolume.php +++ b/app/Http/Requests/UpdatePendingVolume.php @@ -4,8 +4,8 @@ use Biigle\PendingVolume; use Biigle\Rules\Handle; -use Biigle\Rules\VolumeUrl; use Biigle\Rules\VolumeFiles; +use Biigle\Rules\VolumeUrl; use Illuminate\Foundation\Http\FormRequest; class UpdatePendingVolume extends FormRequest @@ -47,8 +47,8 @@ public function rules(): array public function withValidator($validator) { $validator->after(function ($validator) { - // Only validate sample volume files after all other fields have been - // validated. + // Only validate sample volume files after all other fields have been + // validated. if ($validator->errors()->isNotEmpty()) { return; } diff --git a/app/Jobs/CreateNewImagesOrVideos.php b/app/Jobs/CreateNewImagesOrVideos.php index 84eaf51b4..e5873a66b 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\Services\MetadataParsing\VolumeMetadata; use Biigle\Video; use Biigle\Volume; -use Carbon\Carbon; use DB; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\InteractsWithQueue; @@ -68,12 +66,12 @@ public function handle() $metadata = $this->volume->getMetadata(); if ($this->volume->isImageVolume()) { - $chunks->each(fn ($chunk) => - Image::insert($this->createFiles($chunk->toArray(), $metadata)) + $chunks->each( + fn ($chunk) => Image::insert($this->createFiles($chunk->toArray(), $metadata)) ); } else { - $chunks->each(fn ($chunk) => - Video::insert($this->createFiles($chunk->toArray(), $metadata)) + $chunks->each( + fn ($chunk) => Video::insert($this->createFiles($chunk->toArray(), $metadata)) ); } }); diff --git a/app/Policies/PendingVolumePolicy.php b/app/Policies/PendingVolumePolicy.php index f35306b8c..98cafe3fa 100644 --- a/app/Policies/PendingVolumePolicy.php +++ b/app/Policies/PendingVolumePolicy.php @@ -32,12 +32,14 @@ public function before($user, $ability) public function update(User $user, PendingVolume $pv): bool { return $user->id === $pv->user_id && - $this->remember("pending-volume-can-update-{$user->id}-{$pv->id}", fn () => - DB::table('project_user') - ->where('project_id', $pv->project_id) - ->where('user_id', $user->id) - ->where('project_role_id', Role::adminId()) - ->exists() + $this->remember( + "pending-volume-can-update-{$user->id}-{$pv->id}", + fn () => + DB::table('project_user') + ->where('project_id', $pv->project_id) + ->where('user_id', $user->id) + ->where('project_role_id', Role::adminId()) + ->exists() ); } } diff --git a/app/Rules/ImageMetadata.php b/app/Rules/ImageMetadata.php index c743d5286..c7ba060ae 100644 --- a/app/Rules/ImageMetadata.php +++ b/app/Rules/ImageMetadata.php @@ -2,9 +2,9 @@ namespace Biigle\Rules; -use Illuminate\Contracts\Validation\Rule; -use Biigle\Services\MetadataParsing\VolumeMetadata; use Biigle\Services\MetadataParsing\FileMetadata; +use Biigle\Services\MetadataParsing\VolumeMetadata; +use Illuminate\Contracts\Validation\Rule; class ImageMetadata implements Rule { diff --git a/app/Services/MetadataParsing/ImageMetadata.php b/app/Services/MetadataParsing/ImageMetadata.php index 7d2dc3c93..1a680d9ec 100644 --- a/app/Services/MetadataParsing/ImageMetadata.php +++ b/app/Services/MetadataParsing/ImageMetadata.php @@ -15,8 +15,7 @@ public function __construct( public ?float $distanceToGround = null, public ?float $gpsAltitude = null, public ?float $yaw = null - ) - { + ) { parent::__construct($name); } diff --git a/app/Services/MetadataParsing/VideoCsvParser.php b/app/Services/MetadataParsing/VideoCsvParser.php index 616c5ca72..9a66cc517 100644 --- a/app/Services/MetadataParsing/VideoCsvParser.php +++ b/app/Services/MetadataParsing/VideoCsvParser.php @@ -36,7 +36,7 @@ public function getMetadata(): VolumeMetadata continue; } - // Use null instead of ''. + // Use null instead of ''. $takenAt = $getValue($row, 'taken_at') ?: null; // If the file already exists but takenAt is null, replace the file by newly diff --git a/app/Services/MetadataParsing/VideoMetadata.php b/app/Services/MetadataParsing/VideoMetadata.php index 39501b1ca..a18d7f458 100644 --- a/app/Services/MetadataParsing/VideoMetadata.php +++ b/app/Services/MetadataParsing/VideoMetadata.php @@ -18,8 +18,7 @@ public function __construct( public ?float $distanceToGround = null, public ?float $gpsAltitude = null, public ?float $yaw = null - ) - { + ) { parent::__construct($name); $this->frames = collect([]); @@ -50,8 +49,7 @@ public function addFrame( ?float $distanceToGround = null, ?float $gpsAltitude = null, ?float $yaw = null - ): void - { + ): void { $frame = new ImageMetadata( name: $this->name, takenAt: $takenAt, @@ -169,14 +167,10 @@ protected function getInsertDataFrames(): array } // Remove all items that are full of null. - $data = array_filter($data, function ($item) { - return !empty(array_filter($item, fn ($i) => !is_null($i))); - }); + $data = array_filter($data, fn ($item) => !empty(array_filter($item, fn ($i) => !is_null($i)))); // Remove all items that are full of null. - $attrs = array_filter($attrs, function ($item) { - return !empty(array_filter($item, fn ($i) => !is_null($i))); - }); + $attrs = array_filter($attrs, fn ($item) => !empty(array_filter($item, fn ($i) => !is_null($i)))); $data['filename'] = $this->name; diff --git a/app/Services/MetadataParsing/VolumeMetadata.php b/app/Services/MetadataParsing/VolumeMetadata.php index ce5885e29..7a689cf53 100644 --- a/app/Services/MetadataParsing/VolumeMetadata.php +++ b/app/Services/MetadataParsing/VolumeMetadata.php @@ -14,8 +14,7 @@ public function __construct( public ?string $name = null, public ?string $url = null, public ?string $handle = null - ) - { + ) { $this->files = collect([]); } diff --git a/app/Volume.php b/app/Volume.php index 0e1549f3d..bcf701855 100644 --- a/app/Volume.php +++ b/app/Volume.php @@ -8,10 +8,8 @@ use Cache; use Carbon\Carbon; use DB; -use Exception; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Http\Response; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Storage; diff --git a/tests/php/Http/Controllers/Api/PendingVolumeControllerTest.php b/tests/php/Http/Controllers/Api/PendingVolumeControllerTest.php index 3879fcfcd..4195454bf 100644 --- a/tests/php/Http/Controllers/Api/PendingVolumeControllerTest.php +++ b/tests/php/Http/Controllers/Api/PendingVolumeControllerTest.php @@ -292,7 +292,7 @@ public function testUpdateHandle() ])->id; $this->beAdmin(); - // Invalid handle format. + // Invalid handle format. $this->putJson("/api/v1/pending-volumes/{$id}", [ 'name' => 'my volume no. 1', 'url' => 'test://images', diff --git a/tests/php/Http/Controllers/Api/ProjectVolumeControllerTest.php b/tests/php/Http/Controllers/Api/ProjectVolumeControllerTest.php index 26848c85f..d819e5850 100644 --- a/tests/php/Http/Controllers/Api/ProjectVolumeControllerTest.php +++ b/tests/php/Http/Controllers/Api/ProjectVolumeControllerTest.php @@ -484,13 +484,12 @@ public function testStoreImageMetadataCsv() Storage::disk('test')->put('images/abc.jpg', 'abc'); $this->postJson("/api/v1/projects/{$id}/volumes", [ - 'name' => 'my volume no. 1', - 'url' => 'test://images', - 'media_type' => 'image', - 'files' => 'abc.jpg', - 'metadata_csv' => $file, - ]) - ->assertSuccessful(); + 'name' => 'my volume no. 1', + 'url' => 'test://images', + 'media_type' => 'image', + 'files' => 'abc.jpg', + 'metadata_csv' => $file, + ])->assertSuccessful(); $volume = Volume::orderBy('id', 'desc')->first(); $this->assertNotNull($volume->metadata_file_path); diff --git a/tests/php/Http/Controllers/Api/Volumes/MetadataControllerTest.php b/tests/php/Http/Controllers/Api/Volumes/MetadataControllerTest.php index 8149b6651..d19573602 100644 --- a/tests/php/Http/Controllers/Api/Volumes/MetadataControllerTest.php +++ b/tests/php/Http/Controllers/Api/Volumes/MetadataControllerTest.php @@ -5,8 +5,6 @@ use ApiTestCase; use Biigle\Jobs\UpdateVolumeMetadata; use Biigle\MediaType; -use Biigle\Tests\ImageTest; -use Biigle\Tests\VideoTest; use Illuminate\Http\UploadedFile; use Queue; use Storage; diff --git a/tests/php/Rules/ImageMetadataTest.php b/tests/php/Rules/ImageMetadataTest.php index 910297830..9ee4d5410 100644 --- a/tests/php/Rules/ImageMetadataTest.php +++ b/tests/php/Rules/ImageMetadataTest.php @@ -3,8 +3,8 @@ namespace Biigle\Tests\Rules; use Biigle\Rules\ImageMetadata as ImageMetadataRule; -use Biigle\Services\MetadataParsing\VolumeMetadata; use Biigle\Services\MetadataParsing\ImageMetadata; +use Biigle\Services\MetadataParsing\VolumeMetadata; use TestCase; class ImageMetadataTest extends TestCase diff --git a/tests/php/Rules/VideoMetadataTest.php b/tests/php/Rules/VideoMetadataTest.php index 530acc302..017658a44 100644 --- a/tests/php/Rules/VideoMetadataTest.php +++ b/tests/php/Rules/VideoMetadataTest.php @@ -3,8 +3,8 @@ namespace Biigle\Tests\Rules; use Biigle\Rules\VideoMetadata as VideoMetadataRule; -use Biigle\Services\MetadataParsing\VolumeMetadata; use Biigle\Services\MetadataParsing\VideoMetadata; +use Biigle\Services\MetadataParsing\VolumeMetadata; use TestCase; class VideoMetadataTest extends TestCase diff --git a/tests/php/Services/MetadataParsing/ImageCsvParserTest.php b/tests/php/Services/MetadataParsing/ImageCsvParserTest.php index 71d556a5c..d324181c6 100644 --- a/tests/php/Services/MetadataParsing/ImageCsvParserTest.php +++ b/tests/php/Services/MetadataParsing/ImageCsvParserTest.php @@ -2,9 +2,8 @@ namespace Biigle\Tests\Services\MetadataParsing; -use Biigle\Services\MetadataParsing\ImageCsvParser; use Biigle\MediaType; -use Biigle\Volume; +use Biigle\Services\MetadataParsing\ImageCsvParser; use Symfony\Component\HttpFoundation\File\File; use TestCase; diff --git a/tests/php/Services/MetadataParsing/VideoCsvParserTest.php b/tests/php/Services/MetadataParsing/VideoCsvParserTest.php index c9f5e2278..2b56ae861 100644 --- a/tests/php/Services/MetadataParsing/VideoCsvParserTest.php +++ b/tests/php/Services/MetadataParsing/VideoCsvParserTest.php @@ -2,9 +2,8 @@ namespace Biigle\Tests\Services\MetadataParsing; -use Biigle\Services\MetadataParsing\VideoCsvParser; use Biigle\MediaType; -use Biigle\Volume; +use Biigle\Services\MetadataParsing\VideoCsvParser; use Symfony\Component\HttpFoundation\File\File; use TestCase; diff --git a/tests/php/Services/MetadataParsing/VideoMetadataTest.php b/tests/php/Services/MetadataParsing/VideoMetadataTest.php index 647450a53..be359e156 100644 --- a/tests/php/Services/MetadataParsing/VideoMetadataTest.php +++ b/tests/php/Services/MetadataParsing/VideoMetadataTest.php @@ -3,7 +3,6 @@ namespace Biigle\Tests\Services\MetadataParsing; use Biigle\Services\MetadataParsing\VideoMetadata; -use Biigle\Services\MetadataParsing\ImageMetadata; use TestCase; class VideoMetadataTest extends TestCase diff --git a/tests/php/Services/MetadataParsing/VolumeMetadataTest.php b/tests/php/Services/MetadataParsing/VolumeMetadataTest.php index b2c75cf13..f57b8a5fe 100644 --- a/tests/php/Services/MetadataParsing/VolumeMetadataTest.php +++ b/tests/php/Services/MetadataParsing/VolumeMetadataTest.php @@ -3,9 +3,9 @@ namespace Biigle\Tests\Services\MetadataParsing; use Biigle\MediaType; -use Biigle\Services\MetadataParsing\VolumeMetadata; use Biigle\Services\MetadataParsing\FileMetadata; use Biigle\Services\MetadataParsing\ImageMetadata; +use Biigle\Services\MetadataParsing\VolumeMetadata; use TestCase; class VolumeMetadataTest extends TestCase diff --git a/tests/php/VolumeTest.php b/tests/php/VolumeTest.php index 057990121..bdea4bf9e 100644 --- a/tests/php/VolumeTest.php +++ b/tests/php/VolumeTest.php @@ -11,13 +11,10 @@ use Cache; use Carbon\Carbon; use Event; -use Exception; use Illuminate\Database\QueryException; use Illuminate\Http\UploadedFile; use ModelTestCase; use Storage; -use Symfony\Component\HttpFoundation\StreamedResponse; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class VolumeTest extends ModelTestCase { From 75b328f1415dac9e7986fbf5daee8c35e3accada Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Tue, 12 Mar 2024 16:00:26 +0100 Subject: [PATCH 032/209] Fix geo info cache in UpdateVolumeMetadata job --- app/Jobs/UpdateVolumeMetadata.php | 2 ++ tests/php/Jobs/UpdateVolumeMetadataTest.php | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/Jobs/UpdateVolumeMetadata.php b/app/Jobs/UpdateVolumeMetadata.php index ab0238bf4..c884375ad 100644 --- a/app/Jobs/UpdateVolumeMetadata.php +++ b/app/Jobs/UpdateVolumeMetadata.php @@ -62,5 +62,7 @@ public function handle() $file->save(); } } + + $this->volume->flushGeoInfoCache(); } } diff --git a/tests/php/Jobs/UpdateVolumeMetadataTest.php b/tests/php/Jobs/UpdateVolumeMetadataTest.php index 15c0907d9..4d94ece3f 100644 --- a/tests/php/Jobs/UpdateVolumeMetadataTest.php +++ b/tests/php/Jobs/UpdateVolumeMetadataTest.php @@ -15,7 +15,6 @@ class UpdateVolumeMetadataTest extends TestCase { public function testHandleImageAdd() { - $this->markTestIncomplete('clear geo info cache'); $volume = Volume::factory()->create([ 'media_type_id' => MediaType::imageId(), 'metadata_file_path' => 'mymeta.csv', @@ -36,6 +35,8 @@ public function testHandleImageAdd() a.jpg,2016-12-19 12:27:00,52.220,28.123,-1500,10,2.6,180 CSV); + $this->assertFalse($volume->hasGeoInfo()); + with(new UpdateVolumeMetadata($volume))->handle(); $image->refresh(); $this->assertEquals(100, $image->size); @@ -46,6 +47,7 @@ public function testHandleImageAdd() $this->assertEquals(10, $image->metadata['distance_to_ground']); $this->assertEquals(2.6, $image->metadata['area']); $this->assertEquals(180, $image->metadata['yaw']); + $this->assertTrue($volume->hasGeoInfo()); } public function testHandleImageUpdate() @@ -133,7 +135,6 @@ public function testHandleImageMerge() public function testHandleVideoAdd() { - $this->markTestIncomplete('clear geo info cache'); $volume = Volume::factory()->create([ 'media_type_id' => MediaType::videoId(), 'metadata_file_path' => 'mymeta.csv', From 549e9e358b2004110d278bf7df19f555cbd93e22 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Tue, 12 Mar 2024 16:17:16 +0100 Subject: [PATCH 033/209] Implement create volume v2 storage of metadata file --- .../Api/PendingVolumeController.php | 11 +++ .../Api/PendingVolumeControllerTest.php | 78 ++++++++++++++++--- 2 files changed, 77 insertions(+), 12 deletions(-) diff --git a/app/Http/Controllers/Api/PendingVolumeController.php b/app/Http/Controllers/Api/PendingVolumeController.php index 88e77ee09..47361471e 100644 --- a/app/Http/Controllers/Api/PendingVolumeController.php +++ b/app/Http/Controllers/Api/PendingVolumeController.php @@ -8,6 +8,7 @@ use Biigle\Volume; use DB; use Queue; +use Storage; class PendingVolumeController extends Controller { @@ -108,6 +109,16 @@ public function update(UpdatePendingVolume $request) $request->pendingVolume->project->volumes()->attach($volume); + if ($request->pendingVolume->hasMetadata()) { + $volume->update([ + 'metadata_file_path' => $volume->id.'.'.pathinfo($request->pendingVolume->metadata_file_path, PATHINFO_EXTENSION) + ]); + $stream = Storage::disk(config('volumes.pending_metadata_storage_disk')) + ->readStream($request->pendingVolume->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 diff --git a/tests/php/Http/Controllers/Api/PendingVolumeControllerTest.php b/tests/php/Http/Controllers/Api/PendingVolumeControllerTest.php index 4195454bf..8d1431980 100644 --- a/tests/php/Http/Controllers/Api/PendingVolumeControllerTest.php +++ b/tests/php/Http/Controllers/Api/PendingVolumeControllerTest.php @@ -182,7 +182,6 @@ public function testUpdateImages() $this->putJson("/api/v1/pending-volumes/{$id}", [ 'name' => 'my volume no. 1', 'url' => 'test', - 'media_type' => 'image', 'files' => ['1.jpg', '2.jpg'], ])->assertStatus(422); @@ -190,7 +189,6 @@ public function testUpdateImages() $this->putJson("/api/v1/pending-volumes/{$id}", [ 'name' => 'my volume no. 1', 'url' => 'random', - 'media_type' => 'image', 'files' => ['1.jpg', '2.jpg'], ])->assertStatus(422); @@ -198,7 +196,6 @@ public function testUpdateImages() $this->putJson("/api/v1/pending-volumes/{$id}", [ 'name' => 'my volume no. 1', 'url' => 'test://images', - 'media_type' => 'image', 'files' => ['1.jpg', '2.jpg'], ])->assertStatus(422); @@ -211,7 +208,6 @@ public function testUpdateImages() $this->putJson("/api/v1/pending-volumes/{$id}", [ 'name' => 'my volume no. 1', 'url' => 'test://images', - 'media_type' => 'image', 'files' => [], ])->assertStatus(422); @@ -219,7 +215,6 @@ public function testUpdateImages() $this->putJson("/api/v1/pending-volumes/{$id}", [ 'name' => 'my volume no. 1', 'url' => 'test://images', - 'media_type' => 'image', 'files' => ['1.jpg', '1.jpg'], ])->assertStatus(422); @@ -227,7 +222,6 @@ public function testUpdateImages() $this->putJson("/api/v1/pending-volumes/{$id}", [ 'name' => 'my volume no. 1', 'url' => 'test://images', - 'media_type' => 'image', 'files' => ['1.bmp'], ])->assertStatus(422); @@ -235,19 +229,16 @@ public function testUpdateImages() $this->putJson("/api/v1/pending-volumes/{$id}", [ 'name' => 'my volume no. 1', 'url' => 'test://images', - 'media_type' => 'image', 'files' => ['aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.jpg'], ])->assertStatus(422); $response = $this->putJson("/api/v1/pending-volumes/{$id}", [ 'name' => 'my volume no. 1', 'url' => 'test://images', - 'media_type' => 'image', // Elements should be sanitized and empty elements should be discarded 'files' => ['" 1.jpg"', '', '\'2.jpg\' ', '', ''], ])->assertSuccessful(); $content = $response->getContent(); - $this->assertEquals(1, $this->project()->volumes()->count()); $this->assertStringStartsWith('{', $content); $this->assertStringEndsWith('}', $content); @@ -261,11 +252,43 @@ public function testUpdateImages() }); $this->assertNull($pv->fresh()); + + $this->assertEquals(1, $this->project()->volumes()->count()); + $volume = $this->project()->volumes()->first(); + $this->assertEquals('my volume no. 1', $volume->name); + $this->assertEquals('test://images', $volume->url); + $this->assertEquals(MediaType::imageId(), $volume->media_type_id); } public function testUpdateImagesWithMetadata() { - $this->markTestIncomplete(); + $pendingMetaDisk = Storage::fake('pending-metadata'); + $metaDisk = Storage::fake('metadata'); + $fileDisk = Storage::fake('test'); + config(['volumes.editor_storage_disks' => ['test']]); + $pv = PendingVolume::factory()->create([ + 'project_id' => $this->project()->id, + 'media_type_id' => MediaType::imageId(), + 'user_id' => $this->admin()->id, + 'metadata_file_path' => 'mymeta.csv', + ]); + $id = $pv->id; + $pendingMetaDisk->put('mymeta.csv', 'abc'); + + $fileDisk->makeDirectory('images'); + $fileDisk->put('images/1.jpg', 'abc'); + + $this->beAdmin(); + $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'my volume no. 1', + 'url' => 'test://images', + 'files' => ['1.jpg'], + ])->assertSuccessful(); + + $volume = $this->project()->volumes()->first(); + $this->assertTrue($volume->hasMetadata()); + $metaDisk->assertExists($volume->metadata_file_path); + $pendingMetaDisk->assertMissing($pv->metadata_file_path); } public function testUpdateImagesWithAnnotationImport() @@ -481,7 +504,6 @@ public function testUpdateVideos() // Elements should be sanitized and empty elements should be discarded 'files' => ['" 1.mp4"', '', '\'2.mp4\' ', '', ''], ])->assertSuccessful(); - $this->assertEquals(1, $this->project()->volumes()->count()); $id = json_decode($response->getContent())->volume_id; Queue::assertPushed(CreateNewImagesOrVideos::class, function ($job) use ($id) { @@ -493,11 +515,43 @@ public function testUpdateVideos() }); $this->assertNull($pv->fresh()); + + $this->assertEquals(1, $this->project()->volumes()->count()); + $volume = $this->project()->volumes()->first(); + $this->assertEquals('my volume no. 1', $volume->name); + $this->assertEquals('test://videos', $volume->url); + $this->assertEquals(MediaType::videoId(), $volume->media_type_id); } public function testUpdateVideosWithMetadata() { - $this->markTestIncomplete(); + $pendingMetaDisk = Storage::fake('pending-metadata'); + $metaDisk = Storage::fake('metadata'); + $fileDisk = Storage::fake('test'); + config(['volumes.editor_storage_disks' => ['test']]); + $pv = PendingVolume::factory()->create([ + 'project_id' => $this->project()->id, + 'media_type_id' => MediaType::videoId(), + 'user_id' => $this->admin()->id, + 'metadata_file_path' => 'mymeta.csv', + ]); + $id = $pv->id; + $pendingMetaDisk->put('mymeta.csv', 'abc'); + + $fileDisk->makeDirectory('videos'); + $fileDisk->put('videos/1.mp4', 'abc'); + + $this->beAdmin(); + $this->putJson("/api/v1/pending-volumes/{$id}", [ + 'name' => 'my volume no. 1', + 'url' => 'test://videos', + 'files' => ['1.mp4'], + ])->assertSuccessful(); + + $volume = $this->project()->volumes()->first(); + $this->assertTrue($volume->hasMetadata()); + $metaDisk->assertExists($volume->metadata_file_path); + $pendingMetaDisk->assertMissing($pv->metadata_file_path); } public function testUpdateVideosWithAnnotationImport() From 7eb79c5bfa3c8d44fe172df7086634db309e8a2f Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Wed, 13 Mar 2024 11:58:36 +0100 Subject: [PATCH 034/209] Implement the first step of the new volume creation UI --- .../Views/Volumes/VolumeController.php | 60 +-- resources/assets/js/volumes/createForm.vue | 434 +----------------- resources/views/volumes/create.blade.php | 290 ++---------- 3 files changed, 85 insertions(+), 699 deletions(-) diff --git a/app/Http/Controllers/Views/Volumes/VolumeController.php b/app/Http/Controllers/Views/Volumes/VolumeController.php index 17266ecea..f77c7cf83 100644 --- a/app/Http/Controllers/Views/Volumes/VolumeController.php +++ b/app/Http/Controllers/Views/Volumes/VolumeController.php @@ -27,48 +27,48 @@ public function create(Request $request) $project = Project::findOrFail($request->input('project')); $this->authorize('update', $project); - $disks = collect([]); - $user = $request->user(); + // $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')); - } + // 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(); + // // 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)); + // // 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]); + // 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); - } + // $disks = $disks->merge($userDisks); + // } $mediaType = old('media_type', 'image'); - $filenames = str_replace(["\r", "\n", '"', "'"], '', old('files')); - $offlineMode = config('biigle.offline_mode'); + // $filenames = str_replace(["\r", "\n", '"', "'"], '', old('files')); + // $offlineMode = config('biigle.offline_mode'); - if (class_exists(UserStorageServiceProvider::class)) { - $userDisk = "user-{$user->id}"; - } else { - $userDisk = null; - } + // if (class_exists(UserStorageServiceProvider::class)) { + // $userDisk = "user-{$user->id}"; + // } else { + // $userDisk = null; + // } return view('volumes.create', [ 'project' => $project, - 'disks' => $disks, - 'hasDisks' => !empty($disks), 'mediaType' => $mediaType, - 'filenames' => $filenames, - 'offlineMode' => $offlineMode, - 'userDisk' => $userDisk, + // 'disks' => $disks, + // 'hasDisks' => !empty($disks), + // 'filenames' => $filenames, + // 'offlineMode' => $offlineMode, + // 'userDisk' => $userDisk, ]); } diff --git a/resources/assets/js/volumes/createForm.vue b/resources/assets/js/volumes/createForm.vue index d6638b52f..1cef0b474 100644 --- a/resources/assets/js/volumes/createForm.vue +++ b/resources/assets/js/volumes/createForm.vue @@ -1,459 +1,53 @@ diff --git a/resources/views/volumes/create.blade.php b/resources/views/volumes/create.blade.php index 20cc92520..7fd187e53 100644 --- a/resources/views/volumes/create.blade.php +++ b/resources/views/volumes/create.blade.php @@ -4,268 +4,60 @@ @push('scripts') @endpush @section('content') -

-
-

New volume for {{ $project->name }}

-
-
- - 1. Choose a media type - -
-
-
- -
-
- -
- +
+

New volume for {{ $project->name }}

+ id}/pending-volumes") }}" enctype="multipart/form-data" v-on:submit="startLoading"> +
+ + 1. Choose a media type + +
+
+
+
- @if($errors->has('media_type')) - {{ $errors->first('media_type') }} - @endif -
-
- -
- - 2. Choose a name or import from file - -
-
- - @if($errors->has('name')) - {{ $errors->first('name') }} - @endif -
-
- - - - - - - +
+
-
- - - -
- - 3. Choose a file source - - 0 files - - -
-
- @if ($offlineMode && $disks->isEmpty()) -
-
- Please configure available storage disks with the VOLUME_ADMIN_STORAGE_DISKS and VOLUME_EDITOR_STORAGE_DISKS environment variables. -
-
- @endif - @unless ($offlineMode) -
- -
- @endunless - @if ($userDisk) -
- -
- @endif - @if ($disks->count() > 1) -
- - - - -
- @elseif ($disks->count() === 1) -
- -
- @endif -
-
-
- - @unless ($offlineMode) -
-
- - - @if ($errors->has('url')) - {{ $errors->first('url') }} - @endif -
- -
-
- Remote locations for image volumes must support cross-origin resource sharing. -
-
- -
- -

- The filenames have been extracted from the provided metadata file. -

-
- -

- The filenames of the images in the volume directory formatted as comma separated values. Example: 1.jpg, 2.jpg, 3.jpg. The supported image file formats are: JPEG, PNG, WebP and TIFF. -

-
-
- -

- The filenames of the videos in the volume directory formatted as comma separated values. Example: 1.mp4, 2.mp4, 3.mp4. The supported video file formats are: MP4 (H.264) and WebM (VP8, VP9, AV1). -

-
- @if($errors->has('files')) - {{ $errors->first('files') }} - @endif -
-
- @endunless - -
- -

- loading available files... +

+ The media type determines the type of files that can be included in the volume. Each type requires different annotation tools.

-

- - No files found. - @if (Route::has('create-storage-requests')) - Upload new files. - @endif - - - Select a directory or files below. All selected files will be used for the new volume. - - Only files with a supported image file format are shown (JPEG, PNG, WebP and TIFF). - Only files with a supported video file format are shown (MP4 (H.264) and WebM (VP8, VP9, AV1)). + +

+
+
+ + 2. Select a metadata file (optional) + + - -
-
- Most browsers do not support the TIFF format. Only use it for very large images with more than {{config('image.tiles.threshold')}} pixels at one edge, as these will be automatically converted by BIIGLE. -
+

+ By default, BIIGLE supports a CSV metadata format. Other supported formats may be listed in the manual. Image metadata may be overridden by EXIF information during the creation of the volume. +

- -
- - 4. Set a handle or DOI (optional) - - -
- - - A handle or DOI to be associated with the volume. - - @if($errors->has('handle')) - {{ $errors->first('handle') }} - @endif -
-
- -
- - - Cancel - -
- -
+ +
+ + Cancel + +
+
+ @endsection From 9f74bceb7d1252bc78915fdcabfe77fbc1f3d666 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Wed, 13 Mar 2024 11:58:51 +0100 Subject: [PATCH 035/209] Fix pending volume metadata file extension --- app/PendingVolume.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/PendingVolume.php b/app/PendingVolume.php index e997e6cc9..3a7fbeae4 100644 --- a/app/PendingVolume.php +++ b/app/PendingVolume.php @@ -52,7 +52,7 @@ public function hasMetadata(): bool public function saveMetadata(UploadedFile $file): void { $disk = config('volumes.pending_metadata_storage_disk'); - $extension = $file->getExtension(); + $extension = $file->getClientOriginalExtension(); $this->metadata_file_path = "{$this->id}.{$extension}"; $file->storeAs('', $this->metadata_file_path, $disk); $this->save(); From d2298c3817fac31e55eccf3a81663684ecd6538e Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Thu, 14 Mar 2024 11:48:08 +0100 Subject: [PATCH 036/209] Finish create volume v2 UI without metadata import --- .../Api/PendingVolumeController.php | 41 +- .../Views/Volumes/PendingVolumeController.php | 75 ++++ .../Views/Volumes/VolumeController.php | 41 +- app/Http/Requests/UpdatePendingVolume.php | 10 +- app/Policies/PendingVolumePolicy.php | 22 +- .../{createForm.vue => createFormStep1.vue} | 2 +- .../assets/js/volumes/createFormStep2.vue | 359 ++++++++++++++++++ resources/assets/js/volumes/main.js | 6 +- .../step1.blade.php} | 4 +- .../views/volumes/create/step2.blade.php | 208 ++++++++++ routes/api.php | 2 +- routes/web.php | 7 + .../Api/PendingVolumeControllerTest.php | 102 +++++ .../Volumes/PendingVolumeControllerTest.php | 27 ++ .../php/Policies/PendingVolumePolicyTest.php | 22 ++ 15 files changed, 872 insertions(+), 56 deletions(-) create mode 100644 app/Http/Controllers/Views/Volumes/PendingVolumeController.php rename resources/assets/js/volumes/{createForm.vue => createFormStep1.vue} (97%) create mode 100644 resources/assets/js/volumes/createFormStep2.vue rename resources/views/volumes/{create.blade.php => create/step1.blade.php} (95%) create mode 100644 resources/views/volumes/create/step2.blade.php create mode 100644 tests/php/Http/Controllers/Views/Volumes/PendingVolumeControllerTest.php diff --git a/app/Http/Controllers/Api/PendingVolumeController.php b/app/Http/Controllers/Api/PendingVolumeController.php index 47361471e..a8f3d8297 100644 --- a/app/Http/Controllers/Api/PendingVolumeController.php +++ b/app/Http/Controllers/Api/PendingVolumeController.php @@ -5,8 +5,10 @@ use Biigle\Http\Requests\StorePendingVolume; use Biigle\Http\Requests\UpdatePendingVolume; use Biigle\Jobs\CreateNewImagesOrVideos; +use Biigle\PendingVolume; use Biigle\Volume; use DB; +use Illuminate\Http\Request; use Queue; use Storage; @@ -63,7 +65,11 @@ public function store(StorePendingVolume $request) $pv->saveMetadata($request->file('metadata_file')); } - return $pv; + if ($this->isAutomatedRequest()) { + return $pv; + } + + return redirect()->route('pending-volume', $pv->id); } /** @@ -80,7 +86,7 @@ public function store(StorePendingVolume $request) * * @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. + * @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. * @@ -141,6 +147,35 @@ public function update(UpdatePendingVolume $request) $request->pendingVolume->delete(); - return $request->pendingVolume; + if ($this->isAutomatedRequest()) { + return $request->pendingVolume; + } + + return redirect() + ->route('volume', $volume->id) + ->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 redirect()->route('create-volume', ['project' => $pv->project_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..32cb92eeb --- /dev/null +++ b/app/Http/Controllers/Views/Volumes/PendingVolumeController.php @@ -0,0 +1,75 @@ +findOrFail($request->route('id')); + $this->authorize('access', $pv); + + $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); + } + + $filenames = str_replace(["\r", "\n", '"', "'"], '', old('files')); + $offlineMode = config('biigle.offline_mode'); + + if (class_exists(UserStorageServiceProvider::class)) { + $userDisk = "user-{$user->id}"; + } else { + $userDisk = null; + } + + $mediaType = match ($pv->media_type_id) { + MediaType::videoId() => 'video', + default => 'image', + }; + + return view('volumes.create.step2', [ + 'pv' => $pv, + 'project' => $pv->project, + 'disks' => $disks, + 'hasDisks' => !empty($disks), + 'filenames' => $filenames, + 'offlineMode' => $offlineMode, + 'userDisk' => $userDisk, + 'mediaType' => $mediaType, + ]); + } +} diff --git a/app/Http/Controllers/Views/Volumes/VolumeController.php b/app/Http/Controllers/Views/Volumes/VolumeController.php index f77c7cf83..694740675 100644 --- a/app/Http/Controllers/Views/Volumes/VolumeController.php +++ b/app/Http/Controllers/Views/Volumes/VolumeController.php @@ -5,8 +5,6 @@ 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\User; @@ -27,48 +25,11 @@ 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); - // } - $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; - // } - return view('volumes.create', [ + return view('volumes.create.step1', [ 'project' => $project, 'mediaType' => $mediaType, - // 'disks' => $disks, - // 'hasDisks' => !empty($disks), - // 'filenames' => $filenames, - // 'offlineMode' => $offlineMode, - // 'userDisk' => $userDisk, ]); } diff --git a/app/Http/Requests/UpdatePendingVolume.php b/app/Http/Requests/UpdatePendingVolume.php index a1f6d43f5..78f1dc362 100644 --- a/app/Http/Requests/UpdatePendingVolume.php +++ b/app/Http/Requests/UpdatePendingVolume.php @@ -30,7 +30,7 @@ public function rules(): array return [ 'name' => 'required|max:512', 'url' => ['required', 'string', 'max:256', new VolumeUrl], - 'files' => ['required', 'array'], + 'files' => ['required', 'array', 'min:1'], 'handle' => ['nullable', 'max:256', new Handle], // 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 @@ -69,9 +69,11 @@ public function withValidator($validator) protected function prepareForValidation() { $files = $this->input('files'); - if (is_array($files)) { - $files = array_map(fn ($f) => trim($f, " \n\r\t\v\x00'\""), $files); - $this->merge(['files' => array_filter($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/Policies/PendingVolumePolicy.php b/app/Policies/PendingVolumePolicy.php index 98cafe3fa..078c758b7 100644 --- a/app/Policies/PendingVolumePolicy.php +++ b/app/Policies/PendingVolumePolicy.php @@ -27,13 +27,13 @@ public function before($user, $ability) } /** - * Determine if the given volume can be updated by the user. + * Determine if the given pending volume can be accessed by the user. */ - public function update(User $user, PendingVolume $pv): bool + public function access(User $user, PendingVolume $pv): bool { return $user->id === $pv->user_id && $this->remember( - "pending-volume-can-update-{$user->id}-{$pv->id}", + "pending-volume-can-access-{$user->id}-{$pv->id}", fn () => DB::table('project_user') ->where('project_id', $pv->project_id) @@ -42,4 +42,20 @@ public function update(User $user, PendingVolume $pv): bool ->exists() ); } + + /** + * Determine if the given pending volume can be updated by the user. + */ + public function update(User $user, PendingVolume $pv): bool + { + return $this->access($user, $pv); + } + + /** + * Determine if the given pending volume can be deleted by the user. + */ + public function destroy(User $user, PendingVolume $pv): bool + { + return $this->access($user, $pv); + } } diff --git a/resources/assets/js/volumes/createForm.vue b/resources/assets/js/volumes/createFormStep1.vue similarity index 97% rename from resources/assets/js/volumes/createForm.vue rename to resources/assets/js/volumes/createFormStep1.vue index 1cef0b474..084d43fa7 100644 --- a/resources/assets/js/volumes/createForm.vue +++ b/resources/assets/js/volumes/createFormStep1.vue @@ -1,7 +1,7 @@ diff --git a/resources/assets/js/volumes/main.js b/resources/assets/js/volumes/main.js index 95e8cd011..bdaf173b9 100644 --- a/resources/assets/js/volumes/main.js +++ b/resources/assets/js/volumes/main.js @@ -1,6 +1,7 @@ import './export'; import AnnotationSessionPanel from './annotationSessionPanel'; -import CreateForm from './createForm'; +import CreateFormStep1 from './createFormStep1'; +import CreateFormStep2 from './createFormStep2'; import CloneForm from './cloneForm'; import FileCount from './fileCount'; import FilePanel from './filePanel'; @@ -10,7 +11,8 @@ import SearchResults from './searchResults'; import VolumeContainer from './volumeContainer'; biigle.$mount('annotation-session-panel', AnnotationSessionPanel); -biigle.$mount('create-volume-form', CreateForm); +biigle.$mount('create-volume-form-step-1', CreateFormStep1); +biigle.$mount('create-volume-form-step-2', CreateFormStep2); biigle.$mount('clone-volume-form', CloneForm); biigle.$mount('file-panel', FilePanel); biigle.$mount('projects-breadcrumb', ProjectsBreadcrumb); diff --git a/resources/views/volumes/create.blade.php b/resources/views/volumes/create/step1.blade.php similarity index 95% rename from resources/views/volumes/create.blade.php rename to resources/views/volumes/create/step1.blade.php index 7fd187e53..8323524b8 100644 --- a/resources/views/volumes/create.blade.php +++ b/resources/views/volumes/create/step1.blade.php @@ -1,6 +1,6 @@ @extends('app') -@section('title', 'Create new volume') +@section('title', 'Start creating a new volume') @push('scripts') +@endpush + +@section('content') + +
+

New volume for {{ $project->name }}

+
id}") }}" v-on:submit="startLoading"> +
+ + 2. Choose a volume name + +
+ + @if ($errors->has('name')) + {{ $errors->first('name') }} + @endif +
+
+ +
+ + 3. Choose a file source + + 0 files + + +
+
+ @if ($offlineMode && $disks->isEmpty()) +
+
+ Please configure available storage disks with the VOLUME_ADMIN_STORAGE_DISKS and VOLUME_EDITOR_STORAGE_DISKS environment variables. +
+
+ @endif + @unless ($offlineMode) +
+ +
+ @endunless + @if ($userDisk) +
+ +
+ @endif + @if ($disks->count() > 1) +
+ + + + +
+ @elseif ($disks->count() === 1) +
+ +
+ @endif +
+
+
+ + @unless ($offlineMode) +
+
+ + + @if ($errors->has('url')) + {{ $errors->first('url') }} + @endif +
+ +
+
+ Remote locations for image volumes must support cross-origin resource sharing. +
+
+ +
+ +
+ +

+ The filenames of the images in the volume directory formatted as comma separated values. Example: 1.jpg, 2.jpg, 3.jpg. The supported image file formats are: JPEG, PNG, WebP and TIFF. +

+
+
+ +

+ The filenames of the videos in the volume directory formatted as comma separated values. Example: 1.mp4, 2.mp4, 3.mp4. The supported video file formats are: MP4 (H.264) and WebM (VP8, VP9, AV1). +

+
+ @if($errors->has('files')) + {{ $errors->first('files') }} + @endif +
+
+ @endunless + +
+ +

+ loading available files... +

+

+ + No files found. + @if (Route::has('create-storage-requests')) + Upload new files. + @endif + + + Select a directory or files below. All selected files will be used for the new volume. + + Only files with a supported image file format are shown (JPEG, PNG, WebP and TIFF). + Only files with a supported video file format are shown (MP4 (H.264) and WebM (VP8, VP9, AV1)). +

+ + + + + + + + @if ($errors->has('url')) +
+ {{ $errors->first('url') }} +
+ @endif + @if ($errors->has('files')) +
+ {{ $errors->first('files') }} +
+ @endif +
+ +
+
+ Most browsers do not support the TIFF format. Only use it for very large images with more than {{config('image.tiles.threshold')}} pixels at one edge, as these will be automatically converted by BIIGLE. +
+
+ +
+ + 4. Set a handle or DOI (optional) + + +
+ + + A handle or DOI to be associated with the volume. + + @if ($errors->has('handle')) + {{ $errors->first('handle') }} + @endif +
+
+
+ + + + +
+
+
id}") }}" v-on:submit="startLoading"> + + +
+
+ +@endsection diff --git a/routes/api.php b/routes/api.php index 420e6592c..aa25f2250 100644 --- a/routes/api.php +++ b/routes/api.php @@ -148,7 +148,7 @@ ]); $router->resource('pending-volumes', 'PendingVolumeController', [ - 'only' => ['update'], + 'only' => ['update', 'destroy'], 'parameters' => ['pending-volumes' => 'id'], ]); diff --git a/routes/web.php b/routes/web.php index 0970d1c79..a4175adf6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -247,6 +247,13 @@ ]); }); + $router->group(['namespace' => 'Volumes', 'prefix' => 'pending-volumes'], function ($router) { + $router->get('{id}', [ + 'as' => 'pending-volume', + 'uses' => 'PendingVolumeController@show', + ]); + }); + $router->group(['namespace' => 'Volumes', 'prefix' => 'volumes'], function ($router) { $router->get('create', [ 'as' => 'create-volume', diff --git a/tests/php/Http/Controllers/Api/PendingVolumeControllerTest.php b/tests/php/Http/Controllers/Api/PendingVolumeControllerTest.php index 8d1431980..d7d8670dc 100644 --- a/tests/php/Http/Controllers/Api/PendingVolumeControllerTest.php +++ b/tests/php/Http/Controllers/Api/PendingVolumeControllerTest.php @@ -155,6 +155,20 @@ public function testStoreVideoWithFileInvalid() ])->assertStatus(422); } + public function testStoreFromUI() + { + $id = $this->project()->id; + + $this->beAdmin(); + $response = $this->post("/api/v1/projects/{$id}/pending-volumes", [ + 'media_type' => 'image', + ]); + + $pv = PendingVolume::first(); + + $response->assertRedirectToRoute('pending-volume', $pv->id); + } + public function testUpdateImages() { config(['volumes.editor_storage_disks' => ['test']]); @@ -247,6 +261,7 @@ public function testUpdateImages() $this->assertEquals($id, $job->volume->id); $this->assertContains('1.jpg', $job->filenames); $this->assertContains('2.jpg', $job->filenames); + $this->assertCount(2, $job->filenames); return true; }); @@ -301,6 +316,63 @@ public function testUpdateImagesWithImageLabelImport() $this->markTestIncomplete(); } + public function testUpdateFromUIWithoutImport() + { + $disk = Storage::fake('test'); + config(['volumes.editor_storage_disks' => ['test']]); + $pv = PendingVolume::factory()->create([ + 'project_id' => $this->project()->id, + 'media_type_id' => MediaType::imageId(), + 'user_id' => $this->admin()->id, + ]); + $id = $pv->id; + + $disk->makeDirectory('images'); + $disk->put('images/1.jpg', 'abc'); + + $this->beAdmin(); + $response = $this->put("/api/v1/pending-volumes/{$pv->id}", [ + 'name' => 'my volume no. 1', + 'url' => 'test://images', + 'files' => ['1.jpg'], + ]); + $volume = Volume::first(); + + $response->assertRedirectToRoute('volume', $volume->id); + } + + public function testUpdateFileString() + { + $disk = Storage::fake('test'); + config(['volumes.editor_storage_disks' => ['test']]); + $pv = PendingVolume::factory()->create([ + 'project_id' => $this->project()->id, + 'media_type_id' => MediaType::imageId(), + 'user_id' => $this->admin()->id, + ]); + $id = $pv->id; + + $disk->makeDirectory('images'); + $disk->put('images/1.jpg', 'abc'); + $disk->put('images/2.jpg', 'abc'); + + $this->beAdmin(); + $this->putJson("/api/v1/pending-volumes/{$pv->id}", [ + 'name' => 'my volume no. 1', + 'url' => 'test://images', + 'files' => '"1.jpg" , 2.jpg , ,', + ])->assertSuccessful(); + + $volume = Volume::first(); + Queue::assertPushed(CreateNewImagesOrVideos::class, function ($job) { + $this->assertContains('1.jpg', $job->filenames); + $this->assertContains('2.jpg', $job->filenames); + $this->assertCount(2, $job->filenames); + + return true; + }); + } + public function testUpdateHandle() { config(['volumes.editor_storage_disks' => ['test']]); @@ -628,4 +700,34 @@ public function testUpdateAuthorizeDisk() 'files' => ['1.jpg'], ])->assertSuccessful(); } + + public function testDestroy() + { + $pv = PendingVolume::factory()->create([ + 'project_id' => $this->project()->id, + 'media_type_id' => MediaType::imageId(), + 'user_id' => $this->admin()->id, + ]); + + $this->beExpert(); + $this->deleteJson("/api/v1/pending-volumes/{$pv->id}")->assertStatus(403); + + $this->beAdmin(); + $this->deleteJson("/api/v1/pending-volumes/{$pv->id}")->assertStatus(200); + $this->assertNull($pv->fresh()); + } + + public function testDestroyFromUI() + { + $pv = PendingVolume::factory()->create([ + 'project_id' => $this->project()->id, + 'media_type_id' => MediaType::imageId(), + 'user_id' => $this->admin()->id, + ]); + + $this->beAdmin(); + $this + ->delete("/api/v1/pending-volumes/{$pv->id}") + ->assertRedirectToRoute('create-volume', ['project' => $pv->project_id]); + } } diff --git a/tests/php/Http/Controllers/Views/Volumes/PendingVolumeControllerTest.php b/tests/php/Http/Controllers/Views/Volumes/PendingVolumeControllerTest.php new file mode 100644 index 000000000..bedf6ff58 --- /dev/null +++ b/tests/php/Http/Controllers/Views/Volumes/PendingVolumeControllerTest.php @@ -0,0 +1,27 @@ +create([ + 'user_id' => $this->admin()->id, + 'project_id' => $this->project()->id, + ]); + + // not logged in + $this->get("pending-volumes/{$pv->id}")->assertStatus(302); + + // doesn't belong to pending volume + $this->beExpert(); + $this->get("pending-volumes/{$pv->id}")->assertStatus(403); + + $this->beAdmin(); + $this->get("pending-volumes/{$pv->id}")->assertStatus(200); + } +} diff --git a/tests/php/Policies/PendingVolumePolicyTest.php b/tests/php/Policies/PendingVolumePolicyTest.php index 866eacbfd..0d762c5de 100644 --- a/tests/php/Policies/PendingVolumePolicyTest.php +++ b/tests/php/Policies/PendingVolumePolicyTest.php @@ -33,6 +33,17 @@ public function setUp(): void $project->addUserId($this->owner->id, Role::adminId()); } + public function testAccess() + { + $this->assertFalse($this->user->can('access', $this->pv)); + $this->assertFalse($this->guest->can('access', $this->pv)); + $this->assertFalse($this->editor->can('access', $this->pv)); + $this->assertFalse($this->expert->can('access', $this->pv)); + $this->assertFalse($this->admin->can('access', $this->pv)); + $this->assertTrue($this->owner->can('access', $this->pv)); + $this->assertTrue($this->globalAdmin->can('access', $this->pv)); + } + public function testUpdate() { $this->assertFalse($this->user->can('update', $this->pv)); @@ -43,4 +54,15 @@ public function testUpdate() $this->assertTrue($this->owner->can('update', $this->pv)); $this->assertTrue($this->globalAdmin->can('update', $this->pv)); } + + public function testDestroy() + { + $this->assertFalse($this->user->can('destroy', $this->pv)); + $this->assertFalse($this->guest->can('destroy', $this->pv)); + $this->assertFalse($this->editor->can('destroy', $this->pv)); + $this->assertFalse($this->expert->can('destroy', $this->pv)); + $this->assertFalse($this->admin->can('destroy', $this->pv)); + $this->assertTrue($this->owner->can('destroy', $this->pv)); + $this->assertTrue($this->globalAdmin->can('destroy', $this->pv)); + } } From 64553640d4b24f370295d56bd73eff0ba43e617c Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Thu, 14 Mar 2024 12:05:51 +0100 Subject: [PATCH 037/209] Improve UX of create volume v2 step 1 --- resources/assets/js/volumes/createFormStep1.vue | 15 +++++++++++++-- resources/views/volumes/create/step1.blade.php | 7 ++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/resources/assets/js/volumes/createFormStep1.vue b/resources/assets/js/volumes/createFormStep1.vue index 084d43fa7..85de24d15 100644 --- a/resources/assets/js/volumes/createFormStep1.vue +++ b/resources/assets/js/volumes/createFormStep1.vue @@ -11,6 +11,8 @@ export default { data() { return { mediaType: MEDIA_TYPE.IMAGE, + hasFile: false, + initialized: false, }; }, computed: { @@ -22,18 +24,22 @@ export default { }, imageTypeButtonClass() { return { - 'btn-default': !this.isImageMediaType, active: this.isImageMediaType, 'btn-info': this.isImageMediaType, }; }, videoTypeButtonClass() { return { - 'btn-default': !this.isVideoMediaType, active: this.isVideoMediaType, 'btn-info': this.isVideoMediaType, }; }, + fileButtonClass() { + return { + active: this.hasFile, + 'btn-info': this.hasFile, + }; + }, }, methods: { selectImageMediaType() { @@ -45,9 +51,14 @@ export default { selectFile() { this.$refs.metadataFileField.click(); }, + handleSelectedFile() { + this.hasFile = this.$refs.metadataFileField.files.length > 0; + }, }, created() { this.mediaType = biigle.$require('volumes.mediaType'); + // Used to hide a dummy button that masks a flashing selected state on load. + this.initialized = true; }, }; diff --git a/resources/views/volumes/create/step1.blade.php b/resources/views/volumes/create/step1.blade.php index 8323524b8..407a7f7af 100644 --- a/resources/views/volumes/create/step1.blade.php +++ b/resources/views/volumes/create/step1.blade.php @@ -20,7 +20,8 @@
- + +
@@ -41,9 +42,9 @@