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());
diff --git a/src/app/Http/Controllers/EnrichmentController.php b/src/app/Http/Controllers/EnrichmentController.php
new file mode 100644
index 0000000..76462e2
--- /dev/null
+++ b/src/app/Http/Controllers/EnrichmentController.php
@@ -0,0 +1,78 @@
+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..e7363cd
--- /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/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/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/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..c3ce25e
--- /dev/null
+++ b/src/database/testMigrations/2026_06_11_131300_create_annotation_table.php
@@ -0,0 +1,34 @@
+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..b0eef12
--- /dev/null
+++ b/src/database/testMigrations/2026_07_09_105800_create_motivation_table.php
@@ -0,0 +1,27 @@
+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..aac7d34
--- /dev/null
+++ b/src/database/testMigrations/2026_07_09_110500_create_annotationtype_table.php
@@ -0,0 +1,27 @@
+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');
+ }
+};
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']);
diff --git a/src/storage/api-docs/api-docs.yaml b/src/storage/api-docs/api-docs.yaml
index 08bf9b7..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.
@@ -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'
diff --git a/src/tests/Feature/EnrichmentTest.php b/src/tests/Feature/EnrichmentTest.php
new file mode 100644
index 0000000..4f0af57
--- /dev/null
+++ b/src/tests/Feature/EnrichmentTest.php
@@ -0,0 +1,254 @@
+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]);
+
+ Item::query()->update([
+ 'TranscriptionStatusId' => 4,
+ 'Exported' => 0,
+ ]);
+ }
+
+ 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_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', [
+ 'Text' => 'not allowed',
+ ]);
+
+ $response
+ ->assertStatus(400)
+ ->assertJson([
+ 'success' => false,
+ 'message' => 'Invalid data',
+ ]);
+ }
+
+ 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_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->patchJson($this->endpoint . '/transcription/' . $transcription->TranscriptionId, $payload);
+
+ $response
+ ->assertOk()
+ ->assertJson([
+ 'success' => true,
+ 'data' => [
+ 'TranscriptionId' => $transcription->TranscriptionId,
+ 'EuropeanaAnnotationId' => 12345,
+ ],
+ ]);
+
+ $this->assertDatabaseHas('Transcription', [
+ 'TranscriptionId' => $transcription->TranscriptionId,
+ 'EuropeanaAnnotationId' => 12345,
+ ]);
+
+ $this->assertDatabaseHas('Item', [
+ '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->patchJson($this->endpoint . '/annotation/' . $annotation->AnnotationId, $payload);
+
+ $response
+ ->assertOk()
+ ->assertJson([
+ 'success' => true,
+ 'data' => [
+ 'AnnotationId' => $annotation->AnnotationId,
+ 'EuropeanaAnnotationId' => 67890,
+ ],
+ ]);
+
+ $this->assertDatabaseHas('Annotation', [
+ 'AnnotationId' => $annotation->AnnotationId,
+ 'EuropeanaAnnotationId' => 67890,
+ ]);
+
+ $this->assertDatabaseHas('Item', [
+ 'ItemId' => $annotation->ItemId,
+ 'Exported' => 1,
+ ]);
+ }
+
+ public function test_get_all_enrichments_returns_union_of_annotations_and_transcriptions(): void
+ {
+ $response = $this->getJson($this->endpoint);
+
+ $response
+ ->assertOk()
+ ->assertJson(['success' => true]);
+
+ $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
+ {
+ Item::query()->update(['Exported' => 1]);
+
+ $response = $this->getJson($this->endpoint);
+
+ $response
+ ->assertOk()
+ ->assertJson([
+ 'success' => true,
+ 'data' => [],
+ ]);
+ }
+
+ public function test_include_exported_flag_controls_visibility_of_exported_items(): void
+ {
+ Item::query()->update([
+ 'TranscriptionStatusId' => 4,
+ 'Exported' => 0,
+ ]);
+
+ $item = Item::query()->firstOrFail();
+
+ $item->update(['Exported' => 1]);
+
+ // by default, exported items should be excluded
+ $withoutFlag = $this->getJson($this->endpoint);
+
+ $withoutFlag
+ ->assertOk()
+ ->assertJson(['success' => true]);
+
+ $withoutFlagIds = collect($withoutFlag->json('data') ?? [])
+ ->pluck('TranscribathonItemId')
+ ->all();
+
+ $this->assertNotContains($item->ItemId, $withoutFlagIds);
+
+ // with includeExported=1, the same item should be included
+ $withFlag = $this->getJson('/v2/' . $this->endpoint . '?includeExported=1');
+
+ $withFlag
+ ->assertOk()
+ ->assertJson(['success' => true]);
+
+ $withFlagIds = collect($withFlag->json('data') ?? [])
+ ->pluck('TranscribathonItemId')
+ ->all();
+
+ $this->assertContains($item->ItemId, $withFlagIds);
+ }
+
+ public function test_get_all_enrichments_can_be_filtered_by_storyid(): void
+ {
+ $storyId = StoryDataSeeder::$data[0]['RecordId'];
+
+ $response = $this->getJson($this->endpoint . '?storyId=' . $storyId);
+
+ $response
+ ->assertOk()
+ ->assertJson(['success' => true]);
+
+ $data = $response->json('data') ?? [];
+
+ foreach ($data as $row) {
+ $this->assertStringStartsWith($storyId, $row['StoryId']);
+ }
+ }
+}
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()