From d1b2652d15007d22f23124b899194fc3c6beda85 Mon Sep 17 00:00:00 2001 From: trenc Date: Thu, 11 Jun 2026 13:39:58 +0200 Subject: [PATCH 1/9] test: remove dump from test --- src/tests/Feature/ProjectStatsTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tests/Feature/ProjectStatsTest.php b/src/tests/Feature/ProjectStatsTest.php index e9e8132..00c40f1 100644 --- a/src/tests/Feature/ProjectStatsTest.php +++ b/src/tests/Feature/ProjectStatsTest.php @@ -51,7 +51,6 @@ public function test_get_statistics_for_a_project(): void $endpoint = '/projects/' . $project_id . '/statistics'; $response = $this->get($endpoint); - $response->dump(); $response ->assertOk() From a8008e7e066238ba956750a1159591fc410e6d57 Mon Sep 17 00:00:00 2001 From: trenc Date: Thu, 9 Jul 2026 15:10:10 +0200 Subject: [PATCH 2/9] fix: don't log InvalidArgumentException --- src/app/Exceptions/Handler.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/app/Exceptions/Handler.php b/src/app/Exceptions/Handler.php index 68911a3..02f5977 100644 --- a/src/app/Exceptions/Handler.php +++ b/src/app/Exceptions/Handler.php @@ -7,6 +7,7 @@ use Illuminate\Auth\AuthenticationException; use Illuminate\Validation\ValidationException; use Illuminate\Database\Eloquent\ModelNotFoundException; +use InvalidArgumentException; use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Throwable; @@ -20,6 +21,7 @@ class Handler extends ExceptionHandler */ protected $dontReport = [ AltoNotPreparedException::class, + InvalidArgumentException::class, ]; /** @@ -47,6 +49,10 @@ protected function unauthenticated($request, AuthenticationException $exception) public function render($request, Throwable $exception) { + if ($exception instanceof InvalidArgumentException) { + return ResponseController::sendError('Invalid data', $exception->getMessage(), 400); + } + if ($exception instanceof AuthenticationException) { return ResponseController::sendError($exception->getMessage(), '', 401); } @@ -62,6 +68,7 @@ public function render($request, Throwable $exception) if ($exception instanceof ValidationException) { return ResponseController::sendError('Unprocessable Content', $exception->getMessage(), 422); } + // catches abort(401), abort(403), abort(404), etc. if ($exception instanceof HttpException) { return ResponseController::sendError($exception->getMessage(), '', $exception->getStatusCode()); From b1e89de3af1da4dc0e03e008938a75ece81abc1e Mon Sep 17 00:00:00 2001 From: trenc Date: Thu, 9 Jul 2026 15:12:11 +0200 Subject: [PATCH 3/9] test: enhance migration and seeder for annotation and enrichments --- src/database/seeders/AnnotationDataSeeder.php | 43 +++++++++++++++++++ src/database/seeders/ItemDataSeeder.php | 12 ++++++ .../2024_06_13_123800_create_item_table.php | 6 +++ ...6_06_11_131300_create_annotation_table.php | 35 +++++++++++++++ ...6_07_09_105800_create_motivation_table.php | 28 ++++++++++++ ..._09_110500_create_annotationtype_table.php | 28 ++++++++++++ 6 files changed, 152 insertions(+) create mode 100644 src/database/seeders/AnnotationDataSeeder.php create mode 100644 src/database/testMigrations/2026_06_11_131300_create_annotation_table.php create mode 100644 src/database/testMigrations/2026_07_09_105800_create_motivation_table.php create mode 100644 src/database/testMigrations/2026_07_09_110500_create_annotationtype_table.php diff --git a/src/database/seeders/AnnotationDataSeeder.php b/src/database/seeders/AnnotationDataSeeder.php new file mode 100644 index 0000000..23cc93a --- /dev/null +++ b/src/database/seeders/AnnotationDataSeeder.php @@ -0,0 +1,43 @@ + 1, + 'Text' => 'Annotation Text', + 'TextNoTags' => 'Annotation Text', + 'ItemId' => 1, + 'AnnotationTypeId' => 1, + 'EuropeanaAnnotationId' => null, + 'X_Coord' => 10, + 'Y_Coord' => 20, + 'Width' => 100, + 'Height' => 50, + 'Timestamp' => '2025-01-31T12:00:00.000000Z', + ], + [ + 'AnnotationId' => 2, + 'Text' => 'Second Annotation', + 'TextNoTags' => 'Second Annotation', + 'ItemId' => 1, + 'AnnotationTypeId' => 1, + 'EuropeanaAnnotationId' => null, + 'X_Coord' => 30, + 'Y_Coord' => 40, + 'Width' => 120, + 'Height' => 60, + 'Timestamp' => '2025-01-15T12:00:00.000000Z', + ], + ]; + + public function run(): void + { + DB::table('Annotation')->insert(self::$data); + } +} diff --git a/src/database/seeders/ItemDataSeeder.php b/src/database/seeders/ItemDataSeeder.php index 600bb08..68e85fc 100644 --- a/src/database/seeders/ItemDataSeeder.php +++ b/src/database/seeders/ItemDataSeeder.php @@ -22,6 +22,8 @@ class ItemDataSeeder extends Seeder 'DescriptionLanguage' => 1, 'Description' => 'Test Description Item 1', 'Manifest' => 'http://example.com/manifest1fromItem.json', + 'edm:WebResource' => 'https://digi.ub.uni-heidelberg.de/diglitData/image/matrikelregister2/4/000a_Titel.jpg', + 'Exported' => false, ], [ 'ItemId' => 2, @@ -37,6 +39,8 @@ class ItemDataSeeder extends Seeder 'DescriptionLanguage' => 1, 'Description' => 'Test Description Item 2', 'Manifest' => '', + 'edm:WebResource' => 'https://digi.ub.uni-heidelberg.de/diglitData/image/matrikelregister2/4/000a_Titel.jpg', + 'Exported' => false, ], [ 'ItemId' => 3, @@ -52,6 +56,8 @@ class ItemDataSeeder extends Seeder 'DescriptionLanguage' => 1, 'Description' => 'Test Description Item 3', 'Manifest' => '', + 'edm:WebResource' => 'https://digi.ub.uni-heidelberg.de/diglitData/image/matrikelregister2/4/000a_Titel.jpg', + 'Exported' => false, ], [ 'ItemId' => 5, @@ -67,6 +73,8 @@ class ItemDataSeeder extends Seeder 'DescriptionLanguage' => 1, 'Description' => 'Test Description Item 5', 'Manifest' => '', + 'edm:WebResource' => 'https://digi.ub.uni-heidelberg.de/diglitData/image/matrikelregister2/4/000a_Titel.jpg', + 'Exported' => false, ], [ 'ItemId' => 6, @@ -82,6 +90,8 @@ class ItemDataSeeder extends Seeder 'DescriptionLanguage' => 2, 'Description' => 'Test Description Item 6', 'Manifest' => '', + 'edm:WebResource' => 'https://digi.ub.uni-heidelberg.de/diglitData/image/matrikelregister2/4/000a_Titel.jpg', + 'Exported' => false, ], [ 'ItemId' => 7, @@ -97,6 +107,8 @@ class ItemDataSeeder extends Seeder 'DescriptionLanguage' => 2, 'Description' => 'Test Description Item 7', 'Manifest' => '', + 'edm:WebResource' => 'https://digi.ub.uni-heidelberg.de/diglitData/image/matrikelregister2/4/000a_Titel.jpg', + 'Exported' => false, ], ]; diff --git a/src/database/testMigrations/2024_06_13_123800_create_item_table.php b/src/database/testMigrations/2024_06_13_123800_create_item_table.php index ef60703..35723ed 100644 --- a/src/database/testMigrations/2024_06_13_123800_create_item_table.php +++ b/src/database/testMigrations/2024_06_13_123800_create_item_table.php @@ -25,6 +25,12 @@ public function up(): void $table->text('ImageLink'); $table->text('Manifest')->nullable(); $table->integer('OrderIndex'); + + // columns used by enrichment query + $table->text('edm:WebResource')->nullable(); + $table->string('EuropeanaAttachment')->nullable(); + $table->boolean('Exported')->default(false); + $table->dateTime('LastUpdated')->useCurrent(); $table->dateTime('Timestamp')->useCurrent(); $table->enum('TranscriptionSource', ['manual','htr','occam'])->default('manual'); diff --git a/src/database/testMigrations/2026_06_11_131300_create_annotation_table.php b/src/database/testMigrations/2026_06_11_131300_create_annotation_table.php new file mode 100644 index 0000000..4ec9f9a --- /dev/null +++ b/src/database/testMigrations/2026_06_11_131300_create_annotation_table.php @@ -0,0 +1,35 @@ +integer('AnnotationId')->primary(); + $table->text('Text')->nullable(); + $table->text('TextNoTags')->nullable(); + $table->integer('ItemId'); + $table->integer('AnnotationTypeId')->nullable(); + $table->integer('EuropeanaAnnotationId')->nullable(); + $table->float('X_Coord')->default(0); + $table->float('Y_Coord')->default(0); + $table->float('Width')->default(0); + $table->float('Height')->default(0); + $table->timestamp('Timestamp')->nullable(); + + $table->foreign('ItemId') + ->references('ItemId') + ->on('Item') + ->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('Annotation'); + } +}; diff --git a/src/database/testMigrations/2026_07_09_105800_create_motivation_table.php b/src/database/testMigrations/2026_07_09_105800_create_motivation_table.php new file mode 100644 index 0000000..e0ddc79 --- /dev/null +++ b/src/database/testMigrations/2026_07_09_105800_create_motivation_table.php @@ -0,0 +1,28 @@ +integer('MotivationId')->primary(); + $table->string('Name', 100); + $table->integer('ProjectId'); + + $table->foreign('ProjectId') + ->references('ProjectId') + ->on('Project') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('Motivation'); + } +}; diff --git a/src/database/testMigrations/2026_07_09_110500_create_annotationtype_table.php b/src/database/testMigrations/2026_07_09_110500_create_annotationtype_table.php new file mode 100644 index 0000000..ae52090 --- /dev/null +++ b/src/database/testMigrations/2026_07_09_110500_create_annotationtype_table.php @@ -0,0 +1,28 @@ +integer('AnnotationTypeId')->primary(); + $table->string('Name', 100); + $table->integer('MotivationId'); + + $table->foreign('MotivationId') + ->references('MotivationId') + ->on('Motivation') + ->restrictOnUpdate() + ->restrictOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('AnnotationType'); + } +}; From 8179698434431d420c4f4979b08f7cfdce95bc4c Mon Sep 17 00:00:00 2001 From: trenc Date: Fri, 10 Jul 2026 10:23:09 +0200 Subject: [PATCH 4/9] test: add test cases for enrichment endpoint --- src/tests/Feature/EnrichmentTest.php | 311 +++++++++++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 src/tests/Feature/EnrichmentTest.php diff --git a/src/tests/Feature/EnrichmentTest.php b/src/tests/Feature/EnrichmentTest.php new file mode 100644 index 0000000..705c08f --- /dev/null +++ b/src/tests/Feature/EnrichmentTest.php @@ -0,0 +1,311 @@ +artisan('db:seed', ['--class' => ItemDataSeeder::class]); + $this->artisan('db:seed', ['--class' => AnnotationDataSeeder::class]); + $this->artisan('db:seed', ['--class' => StoryDataSeeder::class]); + $this->artisan('db:seed', ['--class' => TranscriptionDataSeeder::class]); + } + + public function test_rejects_unknown_filter_fields(): void + { + $response = $this->getJson($this->endpoint . '?foo=bar'); + + $response + ->assertStatus(400) + ->assertJson([ + 'success' => false, + 'message' => 'Invalid data', + ]); + } + + public function test_rejects_unknown_transcription_update_fields(): void + { + $response = $this->patchJson($this->endpoint . '/transcription/1', [ + 'Text' => 'not allowed', + ]); + + $response + ->assertStatus(400) + ->assertJson([ + 'success' => false, + 'message' => 'Invalid data', + ]); + } + + public function test_requires_europeana_annotation_id_for_transcription_update(): void + { + $response = $this->patchJson($this->endpoint . '/transcription/1', []); + + $response->assertStatus(422); + } + + public function test_rejects_unknown_annotation_update_fields(): void + { + $response = $this->patchJson($this->endpoint . '/annotation/1', [ + 'ItemId' => 99, + ]); + + $response + ->assertStatus(400) + ->assertJson([ + 'success' => false, + 'message' => 'Invalid data', + ]); + } + + public function test_update_transcription_sets_europeana_annotation_id_and_marks_item_as_exported(): void + { + $payload = ['EuropeanaAnnotationId' => 12345]; + + $response = $this->patch($this->endpoint . '/transcription/1', $payload); + + $response + ->assertOk() + ->assertJson([ + 'success' => true, + 'data' => [ + 'TranscriptionId' => 1, + 'EuropeanaAnnotationId' => 12345, + ], + ]); + + $this->assertDatabaseHas('Transcription', [ + 'TranscriptionId' => 1, + 'EuropeanaAnnotationId' => 12345, + ]); + + $this->assertDatabaseHas('Item', [ + 'ItemId' => 1, + 'Exported' => 1, + ]); + } + + public function test_update_annotation_sets_europeana_annotation_id_and_marks_item_as_exported(): void + { + $payload = ['EuropeanaAnnotationId' => 67890]; + + $response = $this->patch($this->endpoint . '/annotation/1', $payload); + + $response + ->assertOk() + ->assertJson([ + 'success' => true, + 'data' => [ + 'AnnotationId' => 1, + 'EuropeanaAnnotationId' => 67890, + ], + ]); + + $this->assertDatabaseHas('Annotation', [ + 'AnnotationId' => 1, + 'EuropeanaAnnotationId' => 67890, + ]); + + $this->assertDatabaseHas('Item', [ + 'ItemId' => 1, + 'Exported' => 1, + ]); + } + + public function test_get_all_enrichments_returns_union_of_annotations_and_transcriptions(): void + { + Item::query()->update([ + 'TranscriptionStatusId' => 4, + ]); + + $awaitedSuccess = ['success' => true]; + + $response = $this->get($this->endpoint); + + $response + ->assertOk() + ->assertJson($awaitedSuccess); + + // Very lightweight structural check: we get at least two rows + $this->assertGreaterThanOrEqual(2, count($response->json('data') ?? [])); + } + + public function test_get_all_enrichments_excludes_exported_items_by_default(): void + { + // Mark all items as exported + Item::query()->update(['Exported' => 1]); + + $awaitedSuccess = ['success' => true]; + $awaitedData = ['data' => []]; + + $response = $this->get($this->endpoint); + + $response + ->assertOk() + ->assertJson($awaitedSuccess) + ->assertJson($awaitedData); + } + + public function test_get_all_enrichments_can_include_exported_items_with_flag(): void + { + // Mark all items as exported + Item::query()->update([ + 'TranscriptionStatusId' => 4, + 'Exported' => 1, + ]); + + $awaitedSuccess = ['success' => true]; + + $response = $this->get($this->endpoint . '?includeExported=1'); + + $response + ->assertOk() + ->assertJson($awaitedSuccess); + + $this->assertGreaterThanOrEqual(2, count($response->json('data') ?? [])); + } + + public function test_get_all_enrichments_can_be_filtered_by_storyid(): void + { + // Assume your seeder exposes at least two stories with distinct StoryId/RecordId + $storyId = StoryDataSeeder::$data[0]['RecordId']; + + $response = $this->get($this->endpoint . '?storyId=' . $storyId); + + $response->assertOk()->assertJson(['success' => true]); + + $data = $response->json('data') ?? []; + + // Every returned row should belong to that story + foreach ($data as $row) { + $this->assertStringStartsWith($storyId, $row['StoryId']); + } + } + + public function test_update_transcription_sets_europeanaannotationid_and_marks_item_exported(): void + { + $transcription = Transcription::query()->firstOrFail(); + + $createData = [ + 'EuropeanaAnnotationId' => 999, + ]; + + $awaitedSuccess = ['success' => true]; + $awaitedData = [ + 'data' => [ + 'TranscriptionId' => $transcription->TranscriptionId, + 'EuropeanaAnnotationId' => 999, + ], + ]; + + $response = $this->patch( + $this->endpoint . '/transcription/' . $transcription->TranscriptionId, + $createData, + ); + + $response + ->assertOk() + ->assertJson($awaitedSuccess) + ->assertJson($awaitedData); + + $this->assertDatabaseHas('Transcription', [ + 'TranscriptionId' => $transcription->TranscriptionId, + 'EuropeanaAnnotationId' => 999, + ]); + + $this->assertDatabaseHas('Item', [ + 'ItemId' => $transcription->ItemId, + 'Exported' => 1, + ]); + } + + public function test_update_annotation_sets_europeanaannotationid_and_marks_item_exported(): void + { + $annotation = Annotation::query()->firstOrFail(); + + $createData = [ + 'EuropeanaAnnotationId' => 888, + ]; + + $response = $this->patch( + $this->endpoint . '/annotation/' . $annotation->AnnotationId, + $createData, + ); + + $awaitedSuccess = ['success' => true]; + $awaitedData = [ + 'data' => [ + 'AnnotationId' => $annotation->AnnotationId, + 'EuropeanaAnnotationId' => 888, + ], + ]; + + $response + ->assertOk() + ->assertJson($awaitedSuccess) + ->assertJson($awaitedData); + + $this->assertDatabaseHas('Annotation', [ + 'AnnotationId' => $annotation->AnnotationId, + 'EuropeanaAnnotationId' => 888, + ]); + + $this->assertDatabaseHas('Item', [ + 'ItemId' => $annotation->ItemId, + 'Exported' => 1, + ]); + } + + public function test_update_transcription_with_missing_europeanaannotationid_returns_422(): void + { + $transcription = Transcription::query()->firstOrFail(); + + $createData = []; // missing required field + + $response = $this->patch( + $this->endpoint . '/transcription/' . $transcription->TranscriptionId, + $createData, + ); + + $response->assertStatus(422); + } + + public function test_update_transcription_rejects_unknown_fields_with_400(): void + { + $transcription = Transcription::query()->firstOrFail(); + + $createData = [ + 'EuropeanaAnnotationId' => 123, + 'foo' => 'bar', + ]; + + $response = $this->patch( + $this->endpoint . '/transcription/' . $transcription->TranscriptionId, + $createData, + ); + + $response->assertStatus(400); + } + + public function test_unknown_filter_is_rejected_with_400(): void + { + $response = $this->get($this->endpoint . '?foo=bar'); + + $response->assertStatus(400); + } +} + From 7c2bb0df502002e13dbd1be1bcca32c855573ce5 Mon Sep 17 00:00:00 2001 From: trenc Date: Fri, 10 Jul 2026 10:23:35 +0200 Subject: [PATCH 5/9] feature: introduce new enrichment endpoint --- .../Http/Controllers/EnrichmentController.php | 80 +++++++ .../Resources/EnrichmentExportResource.php | 13 ++ src/app/Models/Annotation.php | 16 ++ src/app/Models/Item.php | 4 +- .../Services/EnrichmentExportQueryService.php | 210 ++++++++++++++++++ src/routes/api.php | 7 +- 6 files changed, 325 insertions(+), 5 deletions(-) create mode 100644 src/app/Http/Controllers/EnrichmentController.php create mode 100644 src/app/Http/Resources/EnrichmentExportResource.php create mode 100644 src/app/Models/Annotation.php create mode 100644 src/app/Services/EnrichmentExportQueryService.php diff --git a/src/app/Http/Controllers/EnrichmentController.php b/src/app/Http/Controllers/EnrichmentController.php new file mode 100644 index 0000000..41c7e67 --- /dev/null +++ b/src/app/Http/Controllers/EnrichmentController.php @@ -0,0 +1,80 @@ +queryService->get($request); + + return $this->sendResponse( + EnrichmentExportResource::collection($data), + 'Enrichments fetched.', + ); + } + + public function updateTranscription(Request $request, int $id): JsonResponse + { + $data = $this->validateExportUpdate($request); + + $transcription = Transcription::findOrFail($id); + $transcription->EuropeanaAnnotationId = $data['EuropeanaAnnotationId']; + $transcription->save(); + + Item::where('ItemId', $transcription->ItemId)->update(['Exported' => 1]); + + return $this->sendResponse([ + 'TranscriptionId' => $transcription->TranscriptionId, + 'EuropeanaAnnotationId' => $transcription->EuropeanaAnnotationId, + ], 'Transcription updated.'); + } + + public function updateAnnotation(Request $request, int $id): JsonResponse + { + $data = $this->validateExportUpdate($request); + + $annotation = Annotation::findOrFail($id); + $annotation->EuropeanaAnnotationId = $data['EuropeanaAnnotationId']; + $annotation->save(); + + Item::where('ItemId', $annotation->ItemId)->update(['Exported' => 1]); + + return $this->sendResponse([ + 'AnnotationId' => $annotation->AnnotationId, + 'EuropeanaAnnotationId' => $annotation->EuropeanaAnnotationId, + ], 'Annotation updated.'); + } + + private function validateExportUpdate(Request $request): array + { + $allowed = ['EuropeanaAnnotationId']; + $payload = $request->all(); + $unknown = array_diff(array_keys($payload), $allowed); + + if ($unknown !== []) { + throw new InvalidArgumentException('Unknown fields: ' . implode(', ', $unknown)); + } + + $validated = validator($payload, [ + 'EuropeanaAnnotationId' => ['required', 'integer'], + ])->validate(); + + return $validated; + } +} diff --git a/src/app/Http/Resources/EnrichmentExportResource.php b/src/app/Http/Resources/EnrichmentExportResource.php new file mode 100644 index 0000000..8145a4d --- /dev/null +++ b/src/app/Http/Resources/EnrichmentExportResource.php @@ -0,0 +1,13 @@ + ItemCompletionStatus::class, 'TaggingStatusId' => ItemCompletionStatus::class, 'CompletionStatusId' => ItemCompletionStatus::class, + 'Exported' => 'boolean', ]; // declare relationships diff --git a/src/app/Services/EnrichmentExportQueryService.php b/src/app/Services/EnrichmentExportQueryService.php new file mode 100644 index 0000000..aa0a201 --- /dev/null +++ b/src/app/Services/EnrichmentExportQueryService.php @@ -0,0 +1,210 @@ +fromSub($this->buildUnionQuery($request), 'enrichments'); + + $this->applyRequestFilters($baseQuery, $request); + $this->applySorting($baseQuery, $request); + $this->applyPagination($baseQuery, $request); + + return $baseQuery->get()->map(function ($row) { + $entry = (array) $row; + $entry['Languages'] = $this->mapLanguages( + $entry['LanguageCode'] ?? null, + $entry['LanguageName'] ?? null, + ); + + return $entry; + }); + } + + private function buildUnionQuery(Request $request): Builder + { + $annotationQuery = DB::table('Story as s') + ->selectRaw('a.AnnotationId AS AnnotationId') + ->selectRaw('a.Text AS Text') + ->selectRaw('a.TextNoTags AS TextNoTags') + ->selectRaw('a.Timestamp AS Timestamp') + ->selectRaw('a.X_Coord AS X_Coord') + ->selectRaw('a.Y_Coord AS Y_Coord') + ->selectRaw('a.Width AS Width') + ->selectRaw('a.Height AS Height') + ->selectRaw('a.EuropeanaAnnotationId AS EuropeanaAnnotationId') + ->selectRaw('m.Name AS Motivation') + ->selectRaw('i.ProjectItemId AS ItemId') + ->selectRaw('i.OrderIndex AS OrderIndex') + ->selectRaw('i.ItemId AS TranscribathonItemId') + ->selectRaw("i.`edm:WebResource` AS ImageLink") + ->selectRaw('s.StoryId AS TranscribathonStoryId') + ->selectRaw("s.`edm:landingPage` AS StoryUrl") + ->selectRaw("s.RecordId || IFNULL(i.EuropeanaAttachment, '') AS StoryId") + ->selectRaw('NULL AS LanguageCode') + ->selectRaw('NULL AS LanguageName') + ->leftJoinSub($this->buildEligibleItemsQuery($request), 'i', 's.StoryId', '=', 'i.StoryId') + ->leftJoin('Annotation as a', 'i.ItemId', '=', 'a.ItemId') + ->leftJoin('AnnotationType as at', 'a.AnnotationTypeId', '=', 'at.AnnotationTypeId') + ->leftJoin('Motivation as m', 'at.MotivationId', '=', 'm.MotivationId') + ->whereNotNull('a.AnnotationId'); + + if ($request->filled('storyId')) { + $annotationQuery->where('s.RecordId', $request->string('storyId')->toString()); + } + + $languageAggregate = DB::table('Transcription as t') + ->selectRaw('t.TranscriptionId AS TranscriptionId') + ->selectRaw('GROUP_CONCAT(l.Code) AS LanguageCode') + ->selectRaw('GROUP_CONCAT(l.Name) AS LanguageName') + ->join('TranscriptionLanguage as tl', 't.TranscriptionId', '=', 'tl.TranscriptionId') + ->join('Language as l', 'tl.LanguageId', '=', 'l.LanguageId') + ->groupBy('t.TranscriptionId'); + + $transcriptionQuery = DB::table('Story as s') + ->selectRaw('t.TranscriptionId AS AnnotationId') + ->selectRaw('t.Text AS Text') + ->selectRaw('t.TextNoTags AS TextNoTags') + ->selectRaw('t.Timestamp AS Timestamp') + ->selectRaw('0 AS X_Coord') + ->selectRaw('0 AS Y_Coord') + ->selectRaw('0 AS Width') + ->selectRaw('0 AS Height') + ->selectRaw('t.EuropeanaAnnotationId AS EuropeanaAnnotationId') + ->selectRaw("'transcribing' AS Motivation") + ->selectRaw('i.ProjectItemId AS ItemId') + ->selectRaw('i.OrderIndex AS OrderIndex') + ->selectRaw('i.ItemId AS TranscribathonItemId') + ->selectRaw("i.`edm:WebResource` AS ImageLink") + ->selectRaw('s.StoryId AS TranscribathonStoryId') + ->selectRaw("s.`edm:landingPage` AS StoryUrl") + ->selectRaw("s.RecordId || IFNULL(i.EuropeanaAttachment, '') AS StoryId") + ->selectRaw('lang.LanguageCode AS LanguageCode') + ->selectRaw('lang.LanguageName AS LanguageName') + ->leftJoinSub($this->buildEligibleItemsQuery($request), 'i', 's.StoryId', '=', 'i.StoryId') + ->leftJoin('Transcription as t', 'i.ItemId', '=', 't.ItemId') + ->leftJoinSub($languageAggregate, 'lang', 'lang.TranscriptionId', '=', 't.TranscriptionId') + ->where('t.CurrentVersion', 1) + ->where('t.NoText', 0) + ->whereNotNull('t.TranscriptionId'); + + if ($request->filled('storyId')) { + $storyId = $request->string('storyId')->toString(); + $transcriptionQuery->where('s.RecordId', 'like', $storyId . '%'); + } + + return $annotationQuery->union($transcriptionQuery); + } + + private function buildEligibleItemsQuery(Request $request): Builder + { + $query = DB::table('Item')->select('*'); + + if ($request->input('includeExported') !== '1') { + $query->where('Exported', 0); + } + + return $query->where('TranscriptionStatusId', 4); + } + + private function applyRequestFilters(Builder $query, Request $request): void + { + foreach ($request->query() as $key => $value) { + if (in_array($key, ['includeExported', 'storyId', 'limit', 'page', 'orderBy', 'orderDir'], true)) { + continue; + } + + if (!in_array($key, self::ALLOWED_FILTERS, true)) { + throw new InvalidArgumentException('Filter [' . $key . '] is not allowed.'); + } + + $values = array_filter(array_map('trim', explode(',', (string) $value)), static fn ($item) => $item !== ''); + + if ($values === []) { + continue; + } + + $query->where(function (Builder $builder) use ($key, $values): void { + foreach ($values as $filterValue) { + $builder->orWhere($key, $filterValue); + } + }); + } + } + + private function applySorting(Builder $query, Request $request): void + { + $allowedSorts = [ + 'AnnotationId', + 'EuropeanaAnnotationId', + 'ItemId', + 'OrderIndex', + 'Timestamp', + 'TranscribathonItemId', + 'TranscribathonStoryId', + ]; + + $orderBy = $request->input('orderBy', 'AnnotationId'); + $orderDir = strtolower((string) $request->input('orderDir', 'asc')) === 'desc' ? 'desc' : 'asc'; + + if (!in_array($orderBy, $allowedSorts, true)) { + $orderBy = 'AnnotationId'; + } + + $query->orderBy($orderBy, $orderDir); + } + + private function applyPagination(Builder $query, Request $request): void + { + $limit = max(1, (int) $request->input('limit', 100)); + $page = max(1, (int) $request->input('page', 1)); + $offset = ($page - 1) * $limit; + + $query->limit($limit)->offset($offset); + } + + private function mapLanguages(?string $codes, ?string $names): array + { + if ($codes === null || $names === null) { + return []; + } + + $codeList = array_map('trim', explode(',', $codes)); + $nameList = array_map('trim', explode(',', $names)); + $languages = []; + + foreach ($nameList as $index => $name) { + if ($name === '') { + continue; + } + + $languages[] = [ + 'Name' => $name, + 'Code' => $codeList[$index] ?? null, + ]; + } + + return $languages; + } +} diff --git a/src/routes/api.php b/src/routes/api.php index bb9a27a..0d0904f 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -4,6 +4,7 @@ use App\Http\Controllers\CampaignController; use App\Http\Controllers\CampaignStatsController; use App\Http\Controllers\DatasetController; +use App\Http\Controllers\EnrichmentController; use App\Http\Controllers\HealthController; use App\Http\Controllers\HtrDataController; use App\Http\Controllers\ImportController; @@ -16,8 +17,6 @@ use App\Http\Controllers\PersonController; use App\Http\Controllers\ProjectController; use App\Http\Controllers\ProjectStatsController; - -; use App\Http\Controllers\PropertyController; use App\Http\Controllers\SolrController; use App\Http\Controllers\StoryController; @@ -105,6 +104,10 @@ Route::put('/autoenrichments/{id}', [AutoEnrichmentController::class, 'update']); Route::delete('/autoenrichments/{id}', [AutoEnrichmentController::class, 'destroy']); + Route::get('/enrichments', [EnrichmentController::class, 'index']); + Route::patch('/enrichments/transcription/{id}', [EnrichmentController::class, 'updateTranscription']); + Route::patch('/enrichments/annotation/{id}', [EnrichmentController::class, 'updateAnnotation']); + Route::get('/languages', [LanguageController::class, 'index']); Route::get('/languages/{id}', [LanguageController::class, 'show']); From 1e6761d83180dd374efd8a16dcf6e90dffb1b26b Mon Sep 17 00:00:00 2001 From: trenc Date: Fri, 10 Jul 2026 11:27:00 +0200 Subject: [PATCH 6/9] test: enhance test for enrichments endpoint --- src/database/seeders/StoryDataSeeder.php | 2 +- src/tests/Feature/EnrichmentTest.php | 255 +++++++++-------------- 2 files changed, 100 insertions(+), 157 deletions(-) diff --git a/src/database/seeders/StoryDataSeeder.php b/src/database/seeders/StoryDataSeeder.php index 6e89d6e..6ff78ea 100644 --- a/src/database/seeders/StoryDataSeeder.php +++ b/src/database/seeders/StoryDataSeeder.php @@ -21,7 +21,7 @@ class StoryDataSeeder extends Seeder 'Public' => 1, 'ImportName' => '', 'ProjectId' => 1, - 'RecordId' => '', + 'RecordId' => 'RecordId-1', 'PreviewImage' => '{"@id":"rhus-209.man.poznan.pl/fcgi-bin/iipsrv.fcgi?IIIF=1//2025903/_nnVvTgs/PAN044_Page0000.tif/full/full/0/default.jpg","@type":"dctypes:Image","width":3533,"height":5000,"service":{"@id":"rhus-209.man.poznan.pl/fcgi-bin/iipsrv.fcgi?IIIF=1//2025903/_nnVvTgs/PAN044_Page0000.tif","@context":"http://iiif.io/api/image/2/context.json","profile":"http://iiif.io/api/image/2/level1.json"}}', 'DatasetId' => 1, 'StoryLanguage' => '', diff --git a/src/tests/Feature/EnrichmentTest.php b/src/tests/Feature/EnrichmentTest.php index 705c08f..4f0af57 100644 --- a/src/tests/Feature/EnrichmentTest.php +++ b/src/tests/Feature/EnrichmentTest.php @@ -15,7 +15,7 @@ class EnrichmentTest extends TestCase { private string $endpoint = 'enrichments'; - public function setUp(): void + protected function setUp(): void { parent::setUp(); @@ -23,6 +23,11 @@ public function setUp(): void $this->artisan('db:seed', ['--class' => AnnotationDataSeeder::class]); $this->artisan('db:seed', ['--class' => StoryDataSeeder::class]); $this->artisan('db:seed', ['--class' => TranscriptionDataSeeder::class]); + + Item::query()->update([ + 'TranscriptionStatusId' => 4, + 'Exported' => 0, + ]); } public function test_rejects_unknown_filter_fields(): void @@ -37,6 +42,13 @@ public function test_rejects_unknown_filter_fields(): void ]); } + public function test_unknown_filter_is_rejected_with_400(): void + { + $response = $this->getJson($this->endpoint . '?foo=bar'); + + $response->assertStatus(400); + } + public function test_rejects_unknown_transcription_update_fields(): void { $response = $this->patchJson($this->endpoint . '/transcription/1', [ @@ -51,13 +63,6 @@ public function test_rejects_unknown_transcription_update_fields(): void ]); } - public function test_requires_europeana_annotation_id_for_transcription_update(): void - { - $response = $this->patchJson($this->endpoint . '/transcription/1', []); - - $response->assertStatus(422); - } - public function test_rejects_unknown_annotation_update_fields(): void { $response = $this->patchJson($this->endpoint . '/annotation/1', [ @@ -72,240 +77,178 @@ public function test_rejects_unknown_annotation_update_fields(): void ]); } + public function test_requires_europeana_annotation_id_for_transcription_update(): void + { + $response = $this->patchJson($this->endpoint . '/transcription/1', []); + + $response->assertStatus(422); + } + + public function test_update_transcription_with_missing_europeanaannotationid_returns_422(): void + { + $transcription = Transcription::query()->firstOrFail(); + + $response = $this->patchJson( + $this->endpoint . '/transcription/' . $transcription->TranscriptionId, + [], + ); + + $response->assertStatus(422); + } + + public function test_update_transcription_rejects_unknown_fields_with_400(): void + { + $transcription = Transcription::query()->firstOrFail(); + + $response = $this->patchJson( + $this->endpoint . '/transcription/' . $transcription->TranscriptionId, + [ + 'EuropeanaAnnotationId' => 123, + 'foo' => 'bar', + ], + ); + + $response->assertStatus(400); + } + public function test_update_transcription_sets_europeana_annotation_id_and_marks_item_as_exported(): void { + $transcription = Transcription::query()->firstOrFail(); + $payload = ['EuropeanaAnnotationId' => 12345]; - $response = $this->patch($this->endpoint . '/transcription/1', $payload); + $response = $this->patchJson($this->endpoint . '/transcription/' . $transcription->TranscriptionId, $payload); $response ->assertOk() ->assertJson([ 'success' => true, 'data' => [ - 'TranscriptionId' => 1, + 'TranscriptionId' => $transcription->TranscriptionId, 'EuropeanaAnnotationId' => 12345, ], ]); $this->assertDatabaseHas('Transcription', [ - 'TranscriptionId' => 1, + 'TranscriptionId' => $transcription->TranscriptionId, 'EuropeanaAnnotationId' => 12345, ]); $this->assertDatabaseHas('Item', [ - 'ItemId' => 1, + 'ItemId' => $transcription->ItemId, 'Exported' => 1, ]); } public function test_update_annotation_sets_europeana_annotation_id_and_marks_item_as_exported(): void { + $annotation = Annotation::query()->firstOrFail(); + $payload = ['EuropeanaAnnotationId' => 67890]; - $response = $this->patch($this->endpoint . '/annotation/1', $payload); + $response = $this->patchJson($this->endpoint . '/annotation/' . $annotation->AnnotationId, $payload); $response ->assertOk() ->assertJson([ 'success' => true, 'data' => [ - 'AnnotationId' => 1, + 'AnnotationId' => $annotation->AnnotationId, 'EuropeanaAnnotationId' => 67890, ], ]); $this->assertDatabaseHas('Annotation', [ - 'AnnotationId' => 1, + 'AnnotationId' => $annotation->AnnotationId, 'EuropeanaAnnotationId' => 67890, ]); $this->assertDatabaseHas('Item', [ - 'ItemId' => 1, + 'ItemId' => $annotation->ItemId, 'Exported' => 1, ]); } public function test_get_all_enrichments_returns_union_of_annotations_and_transcriptions(): void { - Item::query()->update([ - 'TranscriptionStatusId' => 4, - ]); - - $awaitedSuccess = ['success' => true]; - - $response = $this->get($this->endpoint); + $response = $this->getJson($this->endpoint); $response ->assertOk() - ->assertJson($awaitedSuccess); + ->assertJson(['success' => true]); - // Very lightweight structural check: we get at least two rows - $this->assertGreaterThanOrEqual(2, count($response->json('data') ?? [])); + $data = $response->json('data') ?? []; + + // lightweight structural check: at least one annotation + one transcription row + $this->assertGreaterThanOrEqual(2, count($data)); } public function test_get_all_enrichments_excludes_exported_items_by_default(): void { - // Mark all items as exported Item::query()->update(['Exported' => 1]); - $awaitedSuccess = ['success' => true]; - $awaitedData = ['data' => []]; - - $response = $this->get($this->endpoint); + $response = $this->getJson($this->endpoint); $response ->assertOk() - ->assertJson($awaitedSuccess) - ->assertJson($awaitedData); + ->assertJson([ + 'success' => true, + 'data' => [], + ]); } - public function test_get_all_enrichments_can_include_exported_items_with_flag(): void + public function test_include_exported_flag_controls_visibility_of_exported_items(): void { - // Mark all items as exported Item::query()->update([ 'TranscriptionStatusId' => 4, - 'Exported' => 1, + 'Exported' => 0, ]); - $awaitedSuccess = ['success' => true]; - - $response = $this->get($this->endpoint . '?includeExported=1'); - - $response - ->assertOk() - ->assertJson($awaitedSuccess); - - $this->assertGreaterThanOrEqual(2, count($response->json('data') ?? [])); - } - - public function test_get_all_enrichments_can_be_filtered_by_storyid(): void - { - // Assume your seeder exposes at least two stories with distinct StoryId/RecordId - $storyId = StoryDataSeeder::$data[0]['RecordId']; - - $response = $this->get($this->endpoint . '?storyId=' . $storyId); + $item = Item::query()->firstOrFail(); - $response->assertOk()->assertJson(['success' => true]); + $item->update(['Exported' => 1]); - $data = $response->json('data') ?? []; - - // Every returned row should belong to that story - foreach ($data as $row) { - $this->assertStringStartsWith($storyId, $row['StoryId']); - } - } + // by default, exported items should be excluded + $withoutFlag = $this->getJson($this->endpoint); - public function test_update_transcription_sets_europeanaannotationid_and_marks_item_exported(): void - { - $transcription = Transcription::query()->firstOrFail(); + $withoutFlag + ->assertOk() + ->assertJson(['success' => true]); - $createData = [ - 'EuropeanaAnnotationId' => 999, - ]; + $withoutFlagIds = collect($withoutFlag->json('data') ?? []) + ->pluck('TranscribathonItemId') + ->all(); - $awaitedSuccess = ['success' => true]; - $awaitedData = [ - 'data' => [ - 'TranscriptionId' => $transcription->TranscriptionId, - 'EuropeanaAnnotationId' => 999, - ], - ]; + $this->assertNotContains($item->ItemId, $withoutFlagIds); - $response = $this->patch( - $this->endpoint . '/transcription/' . $transcription->TranscriptionId, - $createData, - ); + // with includeExported=1, the same item should be included + $withFlag = $this->getJson('/v2/' . $this->endpoint . '?includeExported=1'); - $response + $withFlag ->assertOk() - ->assertJson($awaitedSuccess) - ->assertJson($awaitedData); + ->assertJson(['success' => true]); - $this->assertDatabaseHas('Transcription', [ - 'TranscriptionId' => $transcription->TranscriptionId, - 'EuropeanaAnnotationId' => 999, - ]); + $withFlagIds = collect($withFlag->json('data') ?? []) + ->pluck('TranscribathonItemId') + ->all(); - $this->assertDatabaseHas('Item', [ - 'ItemId' => $transcription->ItemId, - 'Exported' => 1, - ]); + $this->assertContains($item->ItemId, $withFlagIds); } - public function test_update_annotation_sets_europeanaannotationid_and_marks_item_exported(): void + public function test_get_all_enrichments_can_be_filtered_by_storyid(): void { - $annotation = Annotation::query()->firstOrFail(); - - $createData = [ - 'EuropeanaAnnotationId' => 888, - ]; - - $response = $this->patch( - $this->endpoint . '/annotation/' . $annotation->AnnotationId, - $createData, - ); + $storyId = StoryDataSeeder::$data[0]['RecordId']; - $awaitedSuccess = ['success' => true]; - $awaitedData = [ - 'data' => [ - 'AnnotationId' => $annotation->AnnotationId, - 'EuropeanaAnnotationId' => 888, - ], - ]; + $response = $this->getJson($this->endpoint . '?storyId=' . $storyId); $response ->assertOk() - ->assertJson($awaitedSuccess) - ->assertJson($awaitedData); - - $this->assertDatabaseHas('Annotation', [ - 'AnnotationId' => $annotation->AnnotationId, - 'EuropeanaAnnotationId' => 888, - ]); + ->assertJson(['success' => true]); - $this->assertDatabaseHas('Item', [ - 'ItemId' => $annotation->ItemId, - 'Exported' => 1, - ]); - } - - public function test_update_transcription_with_missing_europeanaannotationid_returns_422(): void - { - $transcription = Transcription::query()->firstOrFail(); - - $createData = []; // missing required field - - $response = $this->patch( - $this->endpoint . '/transcription/' . $transcription->TranscriptionId, - $createData, - ); - - $response->assertStatus(422); - } - - public function test_update_transcription_rejects_unknown_fields_with_400(): void - { - $transcription = Transcription::query()->firstOrFail(); - - $createData = [ - 'EuropeanaAnnotationId' => 123, - 'foo' => 'bar', - ]; - - $response = $this->patch( - $this->endpoint . '/transcription/' . $transcription->TranscriptionId, - $createData, - ); - - $response->assertStatus(400); - } - - public function test_unknown_filter_is_rejected_with_400(): void - { - $response = $this->get($this->endpoint . '?foo=bar'); + $data = $response->json('data') ?? []; - $response->assertStatus(400); + foreach ($data as $row) { + $this->assertStringStartsWith($storyId, $row['StoryId']); + } } } - From 3fc013ea32cdb29b61a31e5255e96c916e8bdb3e Mon Sep 17 00:00:00 2001 From: trenc Date: Fri, 10 Jul 2026 12:01:49 +0200 Subject: [PATCH 7/9] docs: describe new enrichments endpoint --- src/storage/api-docs/api-docs.yaml | 17 ++ .../enrichments-annotationId-path.yaml | 45 +++++ src/storage/api-docs/enrichments-path.yaml | 46 +++++ src/storage/api-docs/enrichments-schema.yaml | 166 ++++++++++++++++++ .../enrichments-transcriptionId-path.yaml | 45 +++++ 5 files changed, 319 insertions(+) create mode 100644 src/storage/api-docs/enrichments-annotationId-path.yaml create mode 100644 src/storage/api-docs/enrichments-path.yaml create mode 100644 src/storage/api-docs/enrichments-schema.yaml create mode 100644 src/storage/api-docs/enrichments-transcriptionId-path.yaml diff --git a/src/storage/api-docs/api-docs.yaml b/src/storage/api-docs/api-docs.yaml index 08bf9b7..b694401 100644 --- a/src/storage/api-docs/api-docs.yaml +++ b/src/storage/api-docs/api-docs.yaml @@ -22,12 +22,16 @@ security: - bearerAuth: [] tags: + - name: annotations + description: Operations related to annotations - name: autoenrichments description: Operations related to automatically generated enrichments (Test phase) - name: campaigns description: Operations related to campaigns - name: datasets description: Operations related to datasets + - name: enrichments + description: Operations related to enrichments (transcriptions, annotations) - name: health description: Check health and performance of some server values - name: htrdata @@ -106,6 +110,13 @@ paths: /autoenrichments/{AutoEnrichmentId}: $ref: 'autoenrichments-autoEnrichmentId-path.yaml' + /enrichments: + $ref: 'enrichments-path.yaml' + /enrichments/transcription/{TranscriptionId}: + $ref: 'enrichments-transcriptionId-path.yaml' + /enrichments/annotation/{AnnotationId}: + $ref: 'enrichments-annotationId-path.yaml' + /items: $ref: 'items-path.yaml' /items/{ItemId}: @@ -243,6 +254,12 @@ components: $ref: 'datasets-schema.yaml#/DatasetsPostRequestSchema' DatasetsPutRequestSchema: $ref: 'datasets-schema.yaml#/DatasetsPutRequestSchema' + EnrichmentsGetResponseSchema: + $ref: 'enrichments-schema.yaml#/EnrichmentsGetResponseSchema' + EnrichmentsUpdateTranscriptionRequestSchema: + $ref: 'enrichments-schema.yaml#/EnrichmentsUpdateTranscriptionRequestSchema' + EnrichmentsUpdateAnnotationRequestSchema: + $ref: 'enrichments-schema.yaml#/EnrichmentsUpdateAnnotationRequestSchema' HealthGetResponseSchema: $ref: 'health-schema.yaml#/HealthGetResponseSchema' HtrDataGetResponseSchema: diff --git a/src/storage/api-docs/enrichments-annotationId-path.yaml b/src/storage/api-docs/enrichments-annotationId-path.yaml new file mode 100644 index 0000000..cc02f86 --- /dev/null +++ b/src/storage/api-docs/enrichments-annotationId-path.yaml @@ -0,0 +1,45 @@ +patch: + tags: + - enrichments + - annotations + summary: Update export data for an annotation enrichment + description: > + Updates the enrichment data for a specific annotation and marks the + related item as exported. Currently only the EuropeanaAnnotationId can be + updated. + parameters: + - in: path + name: AnnotationId + description: Numeric ID of the annotation + required: true + schema: + type: integer + requestBody: + description: Data to be stored for the annotation enrichment + required: true + content: + application/json: + schema: + $ref: 'enrichments-schema.yaml#/EnrichmentsUpdateAnnotationRequestSchema' + responses: + '200': + description: Ok + content: + application/json: + schema: + allOf: + - $ref: 'responses.yaml#/BasicSuccessResponse' + - type: object + properties: + data: + $ref: 'enrichments-schema.yaml#/EnrichmentsGetResponseSchema' + message: + example: Annotation enrichment updated. + '400': + $ref: 'responses.yaml#/400ErrorResponse' + '401': + $ref: 'responses.yaml#/401ErrorResponse' + '404': + $ref: 'responses.yaml#/404ErrorResponse' + '422': + $ref: 'responses.yaml#/422ErrorResponse' diff --git a/src/storage/api-docs/enrichments-path.yaml b/src/storage/api-docs/enrichments-path.yaml new file mode 100644 index 0000000..35e48be --- /dev/null +++ b/src/storage/api-docs/enrichments-path.yaml @@ -0,0 +1,46 @@ +get: + tags: + - exports + - enrichments + summary: Get all enrichments ready for export + description: > + Returns a union of annotation and transcription enrichments that are ready + for export. By default, only not-yet-exported items are returned. Use the + includeExported flag to also include already exported items. + parameters: + - $ref: 'basic-query-parameter.yaml#/PaginationParameters/limit' + - $ref: 'basic-query-parameter.yaml#/PaginationParameters/page' + - in: query + name: includeExported + description: > + Include already exported items. If set to 1, results include items with + any Exported state as long as they are in the correct transcription + status; otherwise only items with Exported = 0 are returned. + schema: + type: string + enum: ['0', '1'] + example: '1' + - in: query + name: storyId + description: Filter enrichments by story identifier (RecordId). + schema: + type: string + responses: + '200': + description: Ok + content: + application/json: + schema: + allOf: + - $ref: 'responses.yaml#/BasicSuccessResponse' + - type: object + properties: + data: + type: array + description: A list of enrichment entries as array + items: + $ref: 'enrichments-schema.yaml#/EnrichmentsGetResponseSchema' + '400': + $ref: 'responses.yaml#/400ErrorResponse' + '401': + $ref: 'responses.yaml#/401ErrorResponse' diff --git a/src/storage/api-docs/enrichments-schema.yaml b/src/storage/api-docs/enrichments-schema.yaml new file mode 100644 index 0000000..c15bf5d --- /dev/null +++ b/src/storage/api-docs/enrichments-schema.yaml @@ -0,0 +1,166 @@ +EnrichmentBaseSchema: + description: Base fields of an enrichment entry + type: object + properties: + EuropeanaAnnotationId: + type: + - integer + - 'null' + description: Europeana Annotation ID linked to the enrichment + example: 123456 + Motivation: + type: string + description: Motivation of the enrichment (e.g. annotation type or 'transcribing') + example: transcribing + OrderIndex: + type: integer + description: Order index of the item within the story + example: 1 + TranscribathonItemId: + type: integer + description: ItemId within Transcribathon + example: 2259965 + TranscribathonStoryId: + type: integer + description: StoryId within Transcribathon + example: 1234 + StoryUrl: + type: string + description: Landing page URL of the story + example: https://transcribathon.eu/story/1234 + StoryId: + type: string + description: Story identifier used for export (RecordId + optional suffix) + example: 'DE-123_0001' + ImageLink: + type: string + description: Image URL for the item + example: https://transcribathon.eu/files/1234/5678.jpg + +EnrichmentAnnotationPartSchema: + description: Annotation-specific fields of an enrichment + type: object + properties: + AnnotationId: + type: + - integer + - 'null' + description: ID of the annotation entry + example: 42 + Text: + type: + - string + - 'null' + description: Text of the annotation + example: Some annotated text + TextNoTags: + type: + - string + - 'null' + description: Annotation text without markup + example: Some annotated text + Timestamp: + type: + - string + - 'null' + format: date-time + description: Timestamp of the annotation + example: '2024-05-01 12:34:56' + XCoord: + type: number + format: float + description: X coordinate of the annotation region + example: 10.5 + YCoord: + type: number + format: float + description: Y coordinate of the annotation region + example: 20.5 + Width: + type: number + format: float + description: Width of the annotation region + example: 100.0 + Height: + type: number + format: float + description: Height of the annotation region + example: 50.0 + +EnrichmentTranscriptionPartSchema: + description: Transcription-specific fields of an enrichment + type: object + properties: + TranscriptionId: + type: + - integer + - 'null' + description: ID of the transcription entry + example: 77 + Text: + type: + - string + - 'null' + description: Text of the transcription + example: This is a transcription + TextNoTags: + type: + - string + - 'null' + description: Transcription text without markup + example: This is a transcription + Timestamp: + type: + - string + - 'null' + format: date-time + description: Timestamp of the transcription + example: '2024-05-01 12:34:56' + Languages: + type: + - array + - 'null' + description: > + Languages of the transcription, if available. Each element contains a + code and a name. + items: + type: object + properties: + Code: + type: string + description: Language code + example: en + Name: + type: string + description: Language name + example: English + +EnrichmentsGetResponseSchema: + description: The data object of a single enrichment entry in the response + allOf: + - $ref: '#/EnrichmentBaseSchema' + # One of the two parts will be populated depending on the enrichment type + - $ref: '#/EnrichmentAnnotationPartSchema' + - $ref: '#/EnrichmentTranscriptionPartSchema' + +EnrichmentsUpdateTranscriptionRequestSchema: + description: The data object of a PATCH request body for transcription enrichment + type: object + required: + - EuropeanaAnnotationId + properties: + EuropeanaAnnotationId: + type: integer + description: Europeana Annotation ID to store for the transcription + example: 987654 + +EnrichmentsUpdateAnnotationRequestSchema: + description: The data object of a PATCH request body for annotation enrichment + type: object + required: + - EuropeanaAnnotationId + properties: + EuropeanaAnnotationId: + type: integer + description: Europeana Annotation ID to store for the annotation + example: 987654 diff --git a/src/storage/api-docs/enrichments-transcriptionId-path.yaml b/src/storage/api-docs/enrichments-transcriptionId-path.yaml new file mode 100644 index 0000000..4fbec61 --- /dev/null +++ b/src/storage/api-docs/enrichments-transcriptionId-path.yaml @@ -0,0 +1,45 @@ +patch: + tags: + - enrichments + - transcriptions + summary: Update export data for a transcription enrichment + description: > + Updates the enrichment data for a specific transcription and marks the + related item as exported. Currently only the EuropeanaAnnotationId can be + updated. + parameters: + - in: path + name: TranscriptionId + description: Numeric ID of the transcription + required: true + schema: + type: integer + requestBody: + description: Data to be stored for the transcription enrichment + required: true + content: + application/json: + schema: + $ref: 'enrichments-schema.yaml#/EnrichmentsUpdateTranscriptionRequestSchema' + responses: + '200': + description: Ok + content: + application/json: + schema: + allOf: + - $ref: 'responses.yaml#/BasicSuccessResponse' + - type: object + properties: + data: + $ref: 'enrichments-schema.yaml#/EnrichmentsGetResponseSchema' + message: + example: Transcription enrichment updated. + '400': + $ref: 'responses.yaml#/400ErrorResponse' + '401': + $ref: 'responses.yaml#/401ErrorResponse' + '404': + $ref: 'responses.yaml#/404ErrorResponse' + '422': + $ref: 'responses.yaml#/422ErrorResponse' From 99281dbaee6bdeeee5290dd8e5fc18ccc3245699 Mon Sep 17 00:00:00 2001 From: trenc Date: Fri, 10 Jul 2026 12:02:15 +0200 Subject: [PATCH 8/9] build: bump version --- src/storage/api-docs/api-docs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/storage/api-docs/api-docs.yaml b/src/storage/api-docs/api-docs.yaml index b694401..9d4095f 100644 --- a/src/storage/api-docs/api-docs.yaml +++ b/src/storage/api-docs/api-docs.yaml @@ -1,7 +1,7 @@ openapi: 3.1.1 info: - version: 2.1.0 + version: 2.2.0 title: Transcribathon Platform API v2 description: This is the documentation of the Transcribathon API v2 used by [https:transcribathon.eu](https://transcribathon.eu/).
For authorization you can use the the bearer token you are provided with. From 0f9ece3d4d65ead6918bc5eab7ee176bb36f73a3 Mon Sep 17 00:00:00 2001 From: trenc Date: Fri, 10 Jul 2026 12:04:16 +0200 Subject: [PATCH 9/9] refactor: apply PER style --- src/app/Http/Controllers/EnrichmentController.php | 4 +--- src/app/Services/EnrichmentExportQueryService.php | 2 +- .../2026_06_11_131300_create_annotation_table.php | 3 +-- .../2026_07_09_105800_create_motivation_table.php | 3 +-- .../2026_07_09_110500_create_annotationtype_table.php | 3 +-- 5 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/app/Http/Controllers/EnrichmentController.php b/src/app/Http/Controllers/EnrichmentController.php index 41c7e67..76462e2 100644 --- a/src/app/Http/Controllers/EnrichmentController.php +++ b/src/app/Http/Controllers/EnrichmentController.php @@ -2,7 +2,6 @@ namespace App\Http\Controllers; -use App\Http\Controllers\ResponseController; use App\Http\Resources\EnrichmentExportResource; use App\Models\Annotation; use App\Models\Item; @@ -16,8 +15,7 @@ class EnrichmentController extends ResponseController { public function __construct( private readonly EnrichmentExportQueryService $queryService, - ) { - } + ) {} public function index(Request $request): JsonResponse { diff --git a/src/app/Services/EnrichmentExportQueryService.php b/src/app/Services/EnrichmentExportQueryService.php index aa0a201..e7363cd 100644 --- a/src/app/Services/EnrichmentExportQueryService.php +++ b/src/app/Services/EnrichmentExportQueryService.php @@ -139,7 +139,7 @@ private function applyRequestFilters(Builder $query, Request $request): void throw new InvalidArgumentException('Filter [' . $key . '] is not allowed.'); } - $values = array_filter(array_map('trim', explode(',', (string) $value)), static fn ($item) => $item !== ''); + $values = array_filter(array_map('trim', explode(',', (string) $value)), static fn($item) => $item !== ''); if ($values === []) { continue; diff --git a/src/database/testMigrations/2026_06_11_131300_create_annotation_table.php b/src/database/testMigrations/2026_06_11_131300_create_annotation_table.php index 4ec9f9a..c3ce25e 100644 --- a/src/database/testMigrations/2026_06_11_131300_create_annotation_table.php +++ b/src/database/testMigrations/2026_06_11_131300_create_annotation_table.php @@ -4,8 +4,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration -{ +return new class() extends Migration { public function up(): void { Schema::create('Annotation', function (Blueprint $table) { diff --git a/src/database/testMigrations/2026_07_09_105800_create_motivation_table.php b/src/database/testMigrations/2026_07_09_105800_create_motivation_table.php index e0ddc79..b0eef12 100644 --- a/src/database/testMigrations/2026_07_09_105800_create_motivation_table.php +++ b/src/database/testMigrations/2026_07_09_105800_create_motivation_table.php @@ -4,8 +4,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration -{ +return new class() extends Migration { public function up(): void { Schema::create('Motivation', function (Blueprint $table) { diff --git a/src/database/testMigrations/2026_07_09_110500_create_annotationtype_table.php b/src/database/testMigrations/2026_07_09_110500_create_annotationtype_table.php index ae52090..aac7d34 100644 --- a/src/database/testMigrations/2026_07_09_110500_create_annotationtype_table.php +++ b/src/database/testMigrations/2026_07_09_110500_create_annotationtype_table.php @@ -4,8 +4,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration -{ +return new class() extends Migration { public function up(): void { Schema::create('AnnotationType', function (Blueprint $table) {