recogito_integration-1.0.x-dev/src/Controller/AnnotationStorage.php
src/Controller/AnnotationStorage.php
<?php
namespace Drupal\recogito_integration\Controller;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Entity\EntityFieldManagerInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Session\AccountProxyInterface;
use Drupal\Component\Utility\Html;
use Drupal\node\Entity\Node;
use Drupal\paragraphs\Entity\Paragraph;
use Drupal\taxonomy\Entity\Term;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
/**
* Controller for Recogito JS operations on annotations and related content.
*/
class AnnotationStorage extends ControllerBase {
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountProxyInterface
*/
protected $currentUser;
/**
* The config factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The entity field manager.
*
* @var \Drupal\Core\Entity\EntityFieldManagerInterface
*/
protected $entityFieldManager;
/**
* The current route match.
*
* @var \Drupal\Core\Routing\RouteMatchInterface
*/
protected $routeMatch;
/**
* Constructs a new Annotation Storage Controller.
*
* @param \Drupal\Core\Session\AccountProxyInterface $current_user
* The current user.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\Core\Entity\EntityFieldManagerInterface $entity_field_manager
* The entity field manager.
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The route match.
*/
public function __construct(
AccountProxyInterface $current_user,
ConfigFactoryInterface $config_factory,
EntityTypeManagerInterface $entity_type_manager,
EntityFieldManagerInterface $entity_field_manager,
RouteMatchInterface $route_match,
) {
$this->currentUser = $current_user;
$this->configFactory = $config_factory;
$this->entityTypeManager = $entity_type_manager;
$this->entityFieldManager = $entity_field_manager;
$this->routeMatch = $route_match;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new self(
$container->get('current_user'),
$container->get('config.factory'),
$container->get('entity_type.manager'),
$container->get('entity_field.manager'),
$container->get('current_route_match')
);
}
/**
* Validate the style for an annotation.
*
* @param array $style
* The style to validate.
*
* @return array
* The validated style.
*/
public function validateStyle(array $style) {
$colorRegex = '/^#(?:[0-9a-fA-F]{3}){1,2}$/';
$lineStyles = ['dotted', 'dashed', 'double', 'solid', 'groove', 'ridge', 'inset', 'outset', 'none'];
$config = $this->configFactory->get('recogito_integration.settings');
$style['text_color'] = preg_match($colorRegex, $style['text_color']) ? $style['text_color'] : ($config->get('recogito_integration.text_color') ?? '#000000');
$style['background_color'] = preg_match($colorRegex, $style['background_color']) ? $style['background_color'] : ($config->get('recogito_integration.background_color') ?? '#000000');
$style['underline_color'] = preg_match($colorRegex, $style['underline_color']) ? $style['underline_color'] : ($config->get('recogito_integration.underline_color') ?? '#000000');
$style['underline_style'] = in_array($style['underline_style'], $lineStyles) ? $style['underline_style'] : ($config->get('recogito_integration.underline_style') ?? 'none');
$style['underline_stroke'] = is_numeric($style['underline_stroke']) ? $style['underline_stroke'] : ($config->get('recogito_integration.underline_stroke') ?? '0');
$style['background_transparency'] = is_numeric($style['background_transparency']) ? $style['background_transparency'] : ($config->get('recogito_integration.background_transparency') ?? '0');
return $style;
}
/**
* Constructs the style for an annotation based on its tags.
*
* @param string $style_field
* The field name for the annotation profile.
* @param array $tags
* The tags associated with the annotation.
*
* @return array
* The style for the annotation.
*/
public function constructStyle(string $style_field, array $tags) {
$config = $this->configFactory->get('recogito_integration.settings');
$style = [
'text_color' => $config->get('recogito_integration.text_color') ?? '#000000',
'background_color' => $config->get('recogito_integration.background_color') ?? '#000000',
'underline_color' => $config->get('recogito_integration.underline_color') ?? '#000000',
'underline_style' => $config->get('recogito_integration.underline_style') ?? 'none',
'underline_stroke' => $config->get('recogito_integration.underline_stroke') ?? '0',
'background_transparency' => $config->get('recogito_integration.background_transparency') ?? '0',
];
if (!$tags || empty($style_field)) {
return $style;
}
$min = reset($tags);
foreach ($tags as $tag) {
$min_style = $min->get($style_field)->getValue();
$tag_style = $tag->get($style_field)->getValue();
if (empty($min_style) || isset($tag_style) && !$min_style[0]['styling_choice']) {
$min = $tag;
}
elseif (!empty($tag_style) && $tag_style[0]['styling_choice'] && $tag_style[0]['styling_weight'] < $min_style[0]['styling_weight']) {
$min = $tag;
}
}
$min_style = $min->get($style_field)->getValue();
if (!empty($min_style) && $min_style[0]['styling_choice']) {
$style = [
'text_color' => $min_style[0]['text_color'],
'background_color' => $min_style[0]['background_color'],
'underline_style' => $min_style[0]['underline_style'],
'underline_stroke' => $min_style[0]['underline_stroke'],
'underline_color' => $min_style[0]['underline_color'],
'background_transparency' => $min_style[0]['background_transparency'],
];
$style = self::validateStyle($style);
}
return $style;
}
/**
* Retrieves annotations for a given page URL (URL in header).
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* The JSON response containing the annotations.
*/
public function getAnnotations(Request $request) {
$user = $this->currentUser;
if (!$user->hasPermission('recogito view annotations')) {
return new JsonResponse('Insufficient permissions - User cannot view annotations.', 403);
}
$nid = $request->headers->get('nodeId');
if (!$nid || !is_numeric($nid)) {
return new JsonResponse('Unable to retrieve annotations as no valid ID was passed.', 500);
}
$annotations = self::getAnnotationForNode($nid);
if (!$annotations) {
return new JsonResponse(json_encode([]), 200);
}
$config = $this->configFactory->get('recogito_integration.settings');
$field_definitions = $this->entityFieldManager->getFieldDefinitions('taxonomy_term', $config->get('recogito_integration.vocabulary_name'));
$style_field = '';
foreach ($field_definitions as $field_name => $field_definition) {
if ($field_definition->getType() === 'annotation_profile') {
$style_field = $field_name;
break;
}
}
$annotationData = [];
foreach ($annotations as $annotation) {
$textualbodies = [];
$tags = [];
$textualbodyParagraphs = $annotation->get('field_annotation_textualbody')->referencedEntities();
foreach ($textualbodyParagraphs as $textualbody) {
$textualOwner = $textualbody->get('field_annotation_creator')->entity;
$bodyContent = [
'created' => Html::escape($textualbody->get('field_annotation_date_created')->getString()),
'modified' => Html::escape($textualbody->get('field_annotation_last_modified')->getString()),
'purpose' => $textualbody->get('field_annotation_purpose')->getString(),
];
if ($textualOwner) {
$bodyContent['creator'] = [
'id' => $textualOwner->id(),
'name' => Html::escape($textualOwner->getDisplayName()),
];
}
else {
$bodyContent['creator'] = [
'id' => NULL,
'name' => 'Anonymous',
];
}
if ($bodyContent['purpose'] === 'tagging') {
$tag = $textualbody->get('field_annotation_tag_reference')->entity;
if ($tag) {
$tags[] = $tag;
$bodyContent['value'] = Html::escape($tag->getName());
}
}
else {
$bodyContent['value'] = Html::escape($textualbody->get('field_annotation_comment')->getString());
}
$textualbodies[] = $bodyContent;
}
$style = self::constructStyle($style_field, $tags);
$data = [
'id' => $annotation->get('field_annotation_id')->getString(),
'textualbodies' => $textualbodies,
'target_element' => $annotation->get('field_annotation_target_field')->getString(),
'type' => $annotation->get('field_annotation_type')->getString(),
];
switch ($data['type']) {
case 'Image':
$data['image_source'] = $annotation->get('field_image_annotation_source')->getString();
$data['image_value'] = $annotation->get('field_image_annotation_position')->getString();
break;
case 'Text':
$data['target_end'] = $annotation->get('field_text_annotation_end')->getString();
$data['target_exact'] = $annotation->get('field_text_annotation_exact')->getString();
$data['target_start'] = $annotation->get('field_text_annotation_start')->getString();
$data['style'] = $style;
break;
}
$annotationData[] = $data;
}
return new JsonResponse(json_encode($annotationData), 200);
}
/**
* Creates an annotation based on the request body.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* The JSON response containing the status of the operation.
*/
public function createAnnotation(Request $request) {
$user = $this->currentUser;
if (!$user->hasPermission('recogito create annotations')) {
return new JsonResponse('Insufficient permissions - User cannot create annotations.', 403);
}
$config = $this->configFactory->get('recogito_integration.settings');
$vocabulary = $config->get('recogito_integration.vocabulary_name');
if (!$vocabulary) {
return new JsonResponse('Unable to create annotation due to vocabulary name not set! Please select a vocabulary name in the recogito integration settings for tagging purposes!', 500);
}
$vocabulary_entity = $this->entityTypeManager->getStorage('taxonomy_vocabulary')->load($vocabulary);
if (!$vocabulary_entity) {
return new JsonResponse('Unable to create annotation due to vocabulary not found! Please select a valid vocabulary in the recogito integration settings for tagging purposes!', 500);
}
$body = json_decode($request->getContent(), TRUE);
$nid = $body['nodeId'];
if (!$nid || !is_numeric($nid)) {
return new JsonResponse('Unable to create annotation due to no valid node ID being passed.', 500);
}
self::createAnnotationNode($body, $nid);
return new JsonResponse('Annotation created successfully.', 200);
}
/**
* Updates an annotation based on the request body.
*
* @param string $annotation_id
* The ID of the annotation to update.
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* The JSON response containing the status of the operation.
*/
public function updateAnnotation(string $annotation_id, Request $request) {
$annotation_id = '#' . $annotation_id;
$node = self::queryAnnotationNode($annotation_id);
if (!$node) {
return new JsonResponse('Annotation not found.', 404);
}
$user = $this->currentUser;
$body = json_decode($request->getContent(), TRUE);
$editable = $user->hasPermission('recogito edit annotations') || ($user->hasPermission('recogito edit own annotations') && $node->getOwnerId() === $user->id());
if (!$editable) {
return new JsonResponse('Insufficient permissions - User cannot edit this annotation.', 403);
}
self::updateAnnotationNode($body, $node);
return new JsonResponse('Successfully updated annotation!', 200);
}
/**
* Deletes an annotation based on the request body.
*
* @param string $annotation_id
* The ID of the annotation to delete.
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* The JSON response containing the status of the operation.
*/
public function deleteAnnotation(string $annotation_id, Request $request) {
$annotation_id = '#' . $annotation_id;
$node = self::queryAnnotationNode($annotation_id);
if (!$node) {
return new JsonResponse('Annotation not found.', 404);
}
$user = $this->currentUser;
$deletable = $user->hasPermission('recogito delete annotations') || ($user->hasPermission('recogito delete own annotations') && $node->getOwnerId() === $user->id());
if (!$deletable) {
return new JsonResponse('Insufficient permissions - User cannot delete this annotation.', 403);
}
self::deleteTextualbody($node);
$node->delete();
return new JsonResponse('Successfully deleted annotation!', 200);
}
/**
* Queries the annotation node based on the annotation ID.
*
* @param string $annotationId
* The ID of the annotation.
*
* @return \Drupal\node\Entity\Node
* The annotation node.
*/
public function queryAnnotationNode(string $annotationId) {
$annotations = $this->entityTypeManager
->getStorage('node')
->loadByProperties([
'type' => 'annotation',
'field_annotation_id' => $annotationId,
'status' => 1,
]);
return reset($annotations);
}
/**
* Get all annotations for the node.
*
* @param int $nid
* The ID of the node the annotations are for.
*
* @return \Drupal\node\Entity\Node
* The annotation node.
*/
public function getAnnotationForNode(int $nid) {
$annotations = $this->entityTypeManager
->getStorage('node')
->loadByProperties([
'type' => 'annotation',
'field_annotation_node_reference' => $nid,
'status' => 1,
]);
return $annotations;
}
/**
* Creates a textual body node for an annotation.
*
* @param array $textualbody
* The textual body data.
*
* @return array
* The paragraph reference set of the textual body node.
*/
public function createTextualbody(array $textualbody) {
if (!isset($textualbody['modified'])) {
$textualbody['modified'] = $textualbody['created'];
}
$params = [
'type' => 'annotation_textualbody',
'langcode' => 'en',
'created' => time(),
'field_annotation_purpose' => $textualbody['purpose'],
'field_annotation_date_created' => $textualbody['created'],
'field_annotation_last_modified' => $textualbody['modified'],
];
if ($textualbody['creator']['id']) {
$params['field_annotation_creator'] = $textualbody['creator']['id'];
}
$config = $this->configFactory->get('recogito_integration.settings');
$vocabulary = $config->get('recogito_integration.vocabulary_name');
$tag_creation = $config->get('recogito_integration.create_nonexistent_tag');
$textual_value = urldecode($textualbody['value']);
if ($textualbody['purpose'] == 'tagging') {
$terms = $this->entityTypeManager
->getStorage('taxonomy_term')
->loadByProperties([
'name' => $textual_value,
'vid' => $vocabulary,
]);
$term = reset($terms);
if (!$term && $tag_creation) {
$term = Term::create([
'name' => $textual_value,
'vid' => $vocabulary,
]);
$term->save();
}
elseif (!$term && !$tag_creation) {
return NULL;
}
if ($term) {
$params['field_annotation_tag_reference'] = $term->id();
}
}
else {
$params['field_annotation_comment'] = $textual_value;
}
$paragraph = Paragraph::create($params);
$paragraph->save();
return [
'target_id' => $paragraph->id(),
'target_revision_id' => $paragraph->getRevisionId(),
];
}
/**
* Creates an annotation node based on the annotation data.
*
* @param array $annotation
* The annotation data.
* @param int $nid
* The ID of the node to which the annotation belongs.
*
* @return \Drupal\node\Entity\Node
* The annotation node.
*/
public function createAnnotationNode(array $annotation, int $nid) {
$params = [
'type' => 'annotation',
'langcode' => 'en',
'created' => time(),
'changed' => time(),
'uid' => $this->currentUser->id(),
'moderation_state' => 'published',
'title' => $annotation['id'],
'field_annotation_id' => $annotation['id'],
'field_annotation_type' => $annotation['type'],
'field_annotation_target_field' => urldecode($annotation['target_element']),
'field_annotation_node_reference' => $nid,
];
switch ($annotation['type']) {
case 'Image':
$params['field_image_annotation_source'] = $annotation['image_source'];
$params['field_image_annotation_position'] = $annotation['image_value'];
break;
case 'Text':
$params['field_text_annotation_end'] = $annotation['target_end'];
$params['field_text_annotation_exact'] = $annotation['target_exact'];
$params['field_text_annotation_start'] = $annotation['target_start'];
break;
}
$node = Node::create($params);
$node->save();
$references = [];
foreach ($annotation['textualbodies'] as $textualbody) {
$body = self::createTextualbody($textualbody);
if ($body) {
$references[] = $body;
}
}
$node->set('field_annotation_textualbody', $references);
$node->save();
return $node;
}
/**
* Updates an annotation node based on the annotation data.
*
* @param array $annotation
* The annotation data.
* @param \Drupal\node\Entity\Node $node
* The annotation node.
*/
public function updateAnnotationNode(array $annotation, Node $node) {
$node->set('changed', time());
if ($annotation['type'] === 'Image') {
$node->set('field_image_annotation_position', $annotation['image_value']);
}
$existingTextualbodies = $node->get('field_annotation_textualbody')->referencedEntities();
$existingCount = count($existingTextualbodies);
$count = 0;
$references = [];
foreach ($annotation['textualbodies'] as $textualbody) {
$body = NULL;
if ($count >= $existingCount) {
$body = self::createTextualbody($textualbody);
}
else {
$body = self::updateTextualbody($textualbody, $existingTextualbodies[$count]);
}
if ($body) {
$references[] = $body;
}
else {
continue;
}
$count++;
}
if ($count < $existingCount) {
for ($i = $count; $i < $existingCount; $i++) {
$existingTextualbodies[$i]->delete();
}
}
$node->set('field_annotation_textualbody', $references);
$node->save();
}
/**
* Updates a textual body paragraph based on the textual body data.
*
* @param array $textualbody
* The textual body data.
* @param \Drupal\paragraphs\Entity\Paragraph $paragraph
* The textual body paragraph.
*
* @return array
* The paragraph reference set of the textual body paragraph.
*/
public function updateTextualbody(array $textualbody, Paragraph $paragraph) {
$paragraph->set('created', time());
$paragraph->set('field_annotation_purpose', $textualbody['purpose']);
$paragraph->set('field_annotation_date_created', $textualbody['created']);
if (!isset($textualbody['modified'])) {
$textualbody['modified'] = $textualbody['created'];
}
$paragraph->set('field_annotation_last_modified', $textualbody['modified']);
$paragraph->set('field_annotation_creator', $textualbody['creator']['id']);
$paragraph->set('field_annotation_comment', NULL);
$paragraph->set('field_annotation_tag_reference', NULL);
$config = $this->configFactory->get('recogito_integration.settings');
$vocabulary = $config->get('recogito_integration.vocabulary_name');
$tag_creation = $config->get('recogito_integration.create_nonexistent_tag');
$textual_value = urldecode($textualbody['value']);
if ($textualbody['purpose'] == 'tagging') {
$terms = $this->entityTypeManager
->getStorage('taxonomy_term')
->loadByProperties([
'name' => $textual_value,
'vid' => $vocabulary,
]);
$term = reset($terms);
if (!$term && $tag_creation) {
$term = Term::create([
'name' => $textual_value,
'vid' => $vocabulary,
]);
$term->save();
}
elseif (!$term && !$tag_creation) {
return NULL;
}
if ($term) {
$paragraph->set('field_annotation_tag_reference', $term->id());
}
}
else {
$paragraph->set('field_annotation_comment', $textual_value);
}
$paragraph->save();
return [
'target_id' => $paragraph->id(),
'target_revision_id' => $paragraph->getRevisionId(),
];
}
/**
* Deletes the textual body nodes associated with an annotation node.
*
* @param \Drupal\node\Entity\Node $node
* The annotation node.
*/
public function deleteTextualbody(Node $node) {
$textualbodies = $node->get('field_annotation_textualbody')->referencedEntities();
foreach ($textualbodies as $textualbody) {
$textualbody->delete();
}
}
}
