Adds annotations to Mirador 4 implementation
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

317 lines
9.9 KiB

<?php
declare(strict_types=1);
namespace Drupal\islandora_mirador_annotations\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Database\Connection;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Session\AccountProxyInterface;
use Drupal\Component\Uuid\UuidInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Provides the annotation persistence endpoint used by the Mirador adapter.
*/
final class AnnotationController extends ControllerBase {
public function __construct(
private readonly Connection $database,
private readonly AccountProxyInterface $currentAccount,
private readonly TimeInterface $time,
private readonly UuidInterface $uuid,
) {}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container): static {
return new static(
$container->get('database'),
$container->get('current_user'),
$container->get('datetime.time'),
$container->get('uuid'),
);
}
/**
* Returns an IIIF AnnotationPage for a canvas.
*/
public function collection(Request $request): JsonResponse {
$canvas_id = trim((string) $request->query->get('canvas', ''));
if ($canvas_id === '') {
throw new BadRequestHttpException('The canvas query parameter is required.');
}
return new JsonResponse($this->annotationPage($canvas_id, $request));
}
/**
* Returns annotations for a set of canvases in one request.
*
* This is used by the "All annotations" manifest sidebar. It deliberately
* accepts canvas IDs rather than re-fetching the manifest on the server, so
* it also works with authenticated/proxied manifests already available to
* the browser.
*/
public function manifestCollection(Request $request): JsonResponse {
$data = $this->decodeRequest($request);
$canvas_ids = $data['canvasIds'] ?? NULL;
if (!is_array($canvas_ids)) {
throw new BadRequestHttpException('canvasIds must be an array.');
}
$canvas_ids = array_values(array_unique(array_filter(array_map(
static fn ($value) => is_string($value) ? trim($value) : '',
$canvas_ids
))));
// Keep an accidental or malicious request from building an enormous IN
// clause. IIIF manifests with thousands of canvases remain supported.
if (count($canvas_ids) > 10000) {
throw new BadRequestHttpException('Too many canvas IDs requested.');
}
if (!$canvas_ids) {
return new JsonResponse(['items' => []]);
}
$items = [];
foreach (array_chunk($canvas_ids, 500) as $chunk) {
$keys = array_map(static fn ($id) => hash('sha256', $id), $chunk);
$result = $this->database->select('islandora_mirador_annotation', 'a')
->fields('a', ['canvas_id', 'annotation_json', 'created'])
->condition('canvas_key', $keys, 'IN')
->orderBy('created', 'ASC')
->execute();
foreach ($result as $row) {
try {
$annotation = json_decode($row->annotation_json, TRUE, 512, JSON_THROW_ON_ERROR);
if (is_array($annotation)) {
$items[] = [
'canvasId' => $row->canvas_id,
'annotation' => $annotation,
];
}
}
catch (\JsonException) {
// Ignore a corrupt row rather than breaking the entire manifest list.
}
}
}
return new JsonResponse(['items' => $items]);
}
/**
* Creates an annotation and returns the updated AnnotationPage.
*/
public function store(Request $request): JsonResponse {
[$canvas_id, $annotation] = $this->payload($request);
if (empty($annotation['id'])) {
$annotation['id'] = 'urn:uuid:' . $this->uuid->generate();
}
$annotation_id = (string) $annotation['id'];
$now = $this->time->getRequestTime();
$record = [
'annotation_key' => hash('sha256', $annotation_id),
'annotation_id' => $annotation_id,
'canvas_key' => hash('sha256', $canvas_id),
'canvas_id' => $canvas_id,
'uid' => (int) $this->currentAccount->id(),
'annotation_json' => json_encode($annotation, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'created' => $now,
'changed' => $now,
];
// Treat a repeated create as an upsert; this makes retries safe.
$existing = $this->database->select('islandora_mirador_annotation', 'a')
->fields('a', ['id'])
->condition('annotation_key', $record['annotation_key'])
->execute()
->fetchAssoc();
if ($existing) {
unset($record['created']);
$this->database->update('islandora_mirador_annotation')
->fields($record)
->condition('id', $existing['id'])
->execute();
}
else {
$this->database->insert('islandora_mirador_annotation')
->fields($record)
->execute();
}
return new JsonResponse($this->annotationPage($canvas_id, $request), 201);
}
/**
* Updates an annotation and returns the updated AnnotationPage.
*/
public function update(Request $request): JsonResponse {
[$canvas_id, $annotation] = $this->payload($request);
$annotation_id = trim((string) ($annotation['id'] ?? ''));
if ($annotation_id === '') {
throw new BadRequestHttpException('The annotation must contain an id.');
}
$count = $this->database->update('islandora_mirador_annotation')
->fields([
'canvas_key' => hash('sha256', $canvas_id),
'canvas_id' => $canvas_id,
'annotation_json' => json_encode($annotation, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'changed' => $this->time->getRequestTime(),
])
->condition('annotation_key', hash('sha256', $annotation_id))
->execute();
if (!$count) {
throw new NotFoundHttpException('Annotation not found.');
}
return new JsonResponse($this->annotationPage($canvas_id, $request));
}
/**
* Deletes an annotation and returns the updated AnnotationPage.
*/
public function delete(Request $request): JsonResponse {
$data = $this->decodeRequest($request);
$canvas_id = trim((string) ($data['canvasId'] ?? ''));
$annotation_id = trim((string) ($data['annotationId'] ?? ''));
if ($canvas_id === '' || $annotation_id === '') {
throw new BadRequestHttpException('canvasId and annotationId are required.');
}
$this->database->delete('islandora_mirador_annotation')
->condition('annotation_key', hash('sha256', $annotation_id))
->execute();
return new JsonResponse($this->annotationPage($canvas_id, $request));
}
/**
* Parses a create/update request.
*/
private function payload(Request $request): array {
$data = $this->decodeRequest($request);
$canvas_id = trim((string) ($data['canvasId'] ?? ''));
$annotation = $data['annotation'] ?? NULL;
if ($canvas_id === '' || !is_array($annotation)) {
throw new BadRequestHttpException('canvasId and annotation are required.');
}
// The Web Annotation target is authoritative. MAE can occasionally save
// through an adapter instance that was created for a previously active
// canvas during rapid page changes. Store the annotation against its
// actual target canvas so Mirador can retrieve and render it correctly.
$target_canvas_id = $this->targetCanvasId($annotation);
if ($target_canvas_id !== NULL) {
$canvas_id = $target_canvas_id;
}
return [$canvas_id, $annotation];
}
/**
* Returns the canvas targeted by a Web Annotation.
*/
private function targetCanvasId(array $annotation): ?string {
$target = $annotation['target'] ?? NULL;
if (is_string($target)) {
$value = explode('#', $target, 2)[0];
return $value !== '' ? $value : NULL;
}
if (!is_array($target)) {
return NULL;
}
$source = $target['source'] ?? NULL;
if (is_string($source)) {
$value = explode('#', $source, 2)[0];
return $value !== '' ? $value : NULL;
}
if (is_array($source)) {
$value = $source['id'] ?? $source['@id'] ?? NULL;
if (is_string($value) && $value !== '') {
return explode('#', $value, 2)[0];
}
}
$value = $target['id'] ?? $target['@id'] ?? NULL;
if (is_string($value) && $value !== '') {
return explode('#', $value, 2)[0];
}
return NULL;
}
/**
* Decodes a JSON request body.
*/
private function decodeRequest(Request $request): array {
try {
$data = json_decode($request->getContent(), TRUE, 512, JSON_THROW_ON_ERROR);
}
catch (\JsonException $e) {
throw new BadRequestHttpException('Invalid JSON request body.', $e);
}
if (!is_array($data)) {
throw new BadRequestHttpException('A JSON object is required.');
}
return $data;
}
/**
* Builds an AnnotationPage from stored rows.
*/
private function annotationPage(string $canvas_id, Request $request): array {
$items = [];
$result = $this->database->select('islandora_mirador_annotation', 'a')
->fields('a', ['annotation_json'])
->condition('canvas_key', hash('sha256', $canvas_id))
->orderBy('created', 'ASC')
->execute();
foreach ($result as $row) {
try {
$annotation = json_decode($row->annotation_json, TRUE, 512, JSON_THROW_ON_ERROR);
if (is_array($annotation)) {
$items[] = $annotation;
}
}
catch (\JsonException) {
// Ignore a corrupt row rather than breaking every annotation on canvas.
}
}
$page_id = $request->getSchemeAndHttpHost() . $request->getBaseUrl()
. '/islandora-mirador/annotations?canvas=' . rawurlencode($canvas_id);
return [
'id' => $page_id,
'type' => 'AnnotationPage',
'items' => $items,
];
}
}