Browse Source

initial commit

main
astanley 4 weeks ago
commit
bf39286614
  1. BIN
      .DS_Store
  2. 8
      islandora_ror.info.yml
  3. 6
      islandora_ror.routing.yml
  4. 12
      islandora_ror.services.yml
  5. BIN
      src/.DS_Store
  6. 72
      src/Controller/RorAutocompleteController.php
  7. BIN
      src/Plugin/.DS_Store
  8. 172
      src/Plugin/Field/FieldFormatter/RorFormatter.php
  9. 79
      src/Plugin/Field/FieldType/RorFieldItem.php
  10. 184
      src/Plugin/Field/FieldWidget/RorWidget.php
  11. 231
      src/Service/RorClient.php

BIN
.DS_Store vendored

Binary file not shown.

8
islandora_ror.info.yml

@ -0,0 +1,8 @@
name: Islandora ROR
type: module
description: 'Provides a ROR (Research Organization Registry) field type and widgets.'
core_version_requirement: ^10 || ^11
package: Islandora
dependencies:
- drupal:field
- drupal:system

6
islandora_ror.routing.yml

@ -0,0 +1,6 @@
islandora_ror.autocomplete:
path: '/islandora-ror/autocomplete'
defaults:
_controller: '\Drupal\islandora_ror\Controller\RorAutocompleteController::autocomplete'
requirements:
_permission: 'access content'

12
islandora_ror.services.yml

@ -0,0 +1,12 @@
services:
logger.channel.islandora_ror:
class: Drupal\Core\Logger\LoggerChannel
arguments: ['islandora_ror']
islandora_ror.ror_client:
class: Drupal\islandora_ror\Service\RorClient
arguments:
- '@http_client'
- '@logger.channel.islandora_ror'
- '@cache.default'

BIN
src/.DS_Store vendored

Binary file not shown.

72
src/Controller/RorAutocompleteController.php

@ -0,0 +1,72 @@
<?php
namespace Drupal\islandora_ror\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\islandora_ror\Service\RorClient;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
/**
* Returns autocomplete responses for ROR organizations.
*/
class RorAutocompleteController extends ControllerBase {
/**
* ROR client service.
*
* @var \Drupal\islandora_ror\Service\RorClient
*/
protected RorClient $rorClient;
/**
* Constructs a RorAutocompleteController object.
*/
public function __construct(RorClient $ror_client) {
$this->rorClient = $ror_client;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container): self {
return new static(
$container->get('islandora_ror.ror_client')
);
}
/**
* Autocomplete callback.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* The JSON response.
*/
public function autocomplete(Request $request): JsonResponse {
$search_string = (string) $request->query->get('q', '');
$matches = [];
if ($search_string !== '') {
$items = $this->rorClient->search($search_string, 20);
foreach ($items as $item) {
$id = $item['id'] ?? '';
$name = $this->rorClient->getName($item['names']);
if (!$id || !$name) {
continue;
}
$matches[] = [
'value' => $id,
'label' => sprintf('%s (%s)', $name, $id),
];
}
}
return new JsonResponse($matches);
}
}

BIN
src/Plugin/.DS_Store vendored

Binary file not shown.

172
src/Plugin/Field/FieldFormatter/RorFormatter.php

@ -0,0 +1,172 @@
<?php
namespace Drupal\islandora_ror\Plugin\Field\FieldFormatter;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\FormatterBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Link;
use Drupal\Core\Url;
/**
* Display formatter for ROR organization fields.
*
* @FieldFormatter(
* id = "ror_formatter",
* label = @Translation("ROR organization formatter"),
* field_types = {
* "ror_field"
* }
* )
*/
class RorFormatter extends FormatterBase {
/**
* {@inheritdoc}
*/
public static function defaultSettings() {
return [
'show_ror_link' => TRUE,
'show_website' => TRUE,
'show_wikipedia' => TRUE,
'style' => 'inline', // inline or stacked
'separator' => ' • ',
] + parent::defaultSettings();
}
/**
* {@inheritdoc}
*/
public function settingsSummary() {
$summary = [];
$summary[] = $this->t('Style: @style', [
'@style' => $this->getSetting('style'),
]);
$summary[] = $this->t('Show links: ROR (@ror), Website (@web), Wikipedia (@wiki)', [
'@ror' => $this->getSetting('show_ror_link') ? 'yes' : 'no',
'@web' => $this->getSetting('show_website') ? 'yes' : 'no',
'@wiki' => $this->getSetting('show_wikipedia') ? 'yes' : 'no',
]);
return $summary;
}
/**
* {@inheritdoc}
*/
public function settingsForm(array $form, FormStateInterface $form_state) {
$form['style'] = [
'#type' => 'select',
'#title' => $this->t('Display style'),
'#options' => [
'inline' => $this->t('Inline'),
'stacked' => $this->t('Stacked'),
],
'#default_value' => $this->getSetting('style'),
];
$form['separator'] = [
'#type' => 'textfield',
'#title' => $this->t('Inline separator'),
'#default_value' => $this->getSetting('separator'),
'#states' => [
'visible' => [
':input[name="fields[' . $this->fieldDefinition->getName() . '][settings_edit_form][settings][style]"]' => ['value' => 'inline'],
],
],
];
$form['show_ror_link'] = [
'#type' => 'checkbox',
'#title' => $this->t('Show ROR link'),
'#default_value' => $this->getSetting('show_ror_link'),
];
$form['show_website'] = [
'#type' => 'checkbox',
'#title' => $this->t('Show website'),
'#default_value' => $this->getSetting('show_website'),
];
$form['show_wikipedia'] = [
'#type' => 'checkbox',
'#title' => $this->t('Show Wikipedia'),
'#default_value' => $this->getSetting('show_wikipedia'),
];
return $form;
}
/**
* {@inheritdoc}
*/
public function viewElements(FieldItemListInterface $items, $langcode): array {
$elements = [];
$settings = $this->getSettings();
$separator = $settings['separator'];
foreach ($items as $delta => $item) {
if (empty($item->id) || empty($item->name)) {
// Do not render empty values.
continue;
}
$links = [];
$name = $item->name;
// ROR link.
if ($settings['show_ror_link'] && !empty($item->id)) {
$id = $item->id;
if (str_starts_with($id, 'http://') || str_starts_with($id, 'https://')) {
$ror_uri = $id;
}
else {
$ror_uri = 'https://ror.org/' . ltrim($id, '/');
}
$ror_url = Url::fromUri($ror_uri);
$links[] = Link::fromTextAndUrl($this->t('ROR'), $ror_url)
->toRenderable();
}
if ($settings['show_website'] && !empty($item->website)) {
$website_url = Url::fromUri($item->website);
$links[] = Link::fromTextAndUrl($this->t('Website'), $website_url)
->toRenderable();
}
// Wikipedia.
if ($settings['show_wikipedia'] && !empty($item->wikipedia)) {
$wiki_url = Url::fromUri($item->wikipedia);
$links[] = Link::fromTextAndUrl($this->t('Wikipedia'), $wiki_url)
->toRenderable();
}
// Build output depending on style.
if ($settings['style'] === 'inline') {
$render_links = [];
foreach ($links as $link) {
$render_links[] = \Drupal::service('renderer')->renderPlain($link);
}
$elements[$delta] = [
'#markup' => $name . ' ' . $separator . implode($separator, $render_links),
'#allowed_tags' => ['a', 'span', 'div'],
];
}
else {
// Stacked version.
$elements[$delta] = [
'#theme' => 'item_list',
'#title' => $name,
'#items' => $links,
];
}
}
return $elements;
}
}

79
src/Plugin/Field/FieldType/RorFieldItem.php

@ -0,0 +1,79 @@
<?php
namespace Drupal\islandora_ror\Plugin\Field\FieldType;
use Drupal\Core\Field\FieldItemBase;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\TypedData\DataDefinition;
/**
* Plugin implementation of the 'ror_field' field type.
*
* @FieldType(
* id = "ror_field",
* label = @Translation("ROR organization"),
* description = @Translation("Stores an organization identifier and metadata from the Research Organization Registry (ROR)."),
* default_widget = "ror_widget",
* default_formatter = "string"
* )
*/
class RorFieldItem extends FieldItemBase {
/**
* {@inheritdoc}
*/
public static function propertyDefinitions(FieldStorageDefinitionInterface $field_definition): array {
$properties['id'] = DataDefinition::create('string')
->setLabel(t('ROR ID'))
->setRequired(TRUE);
$properties['name'] = DataDefinition::create('string')
->setLabel(t('Organization name'));
$properties['website'] = DataDefinition::create('string')
->setLabel(t('Website URL'));
$properties['wikipedia'] = DataDefinition::create('string')
->setLabel(t('Wikipedia URL'));
return $properties;
}
/**
* {@inheritdoc}
*/
public static function schema(FieldStorageDefinitionInterface $field_definition): array {
return [
'columns' => [
'id' => [
'type' => 'varchar',
'length' => 255,
],
'name' => [
'type' => 'varchar',
'length' => 1024,
'not null' => FALSE,
],
'website' => [
'type' => 'varchar',
'length' => 1024,
'not null' => FALSE,
],
'wikipedia' => [
'type' => 'varchar',
'length' => 1024,
'not null' => FALSE,
],
],
];
}
/**
* {@inheritdoc}
*/
public function isEmpty(): bool {
$value = $this->get('id')->getValue();
return $value === NULL || $value === '';
}
}

184
src/Plugin/Field/FieldWidget/RorWidget.php

@ -0,0 +1,184 @@
<?php
namespace Drupal\islandora_ror\Plugin\Field\FieldWidget;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\WidgetBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\islandora_ror\Service\RorClient;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Plugin implementation of the 'ror_widget' widget.
*
* @FieldWidget(
* id = "ror_widget",
* label = @Translation("ROR organization widget"),
* field_types = {
* "ror_field"
* }
* )
*/
class RorWidget extends WidgetBase {
/**
* The ROR client service.
*
* @var \Drupal\islandora_ror\Service\RorClient
*/
protected RorClient $rorClient;
/**
* {@inheritdoc}
*
* Widgets MUST implement ::create() manually to support DI.
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
$instance = parent::create($container, $configuration, $plugin_id, $plugin_definition);
$instance->rorClient = $container->get('islandora_ror.ror_client');
return $instance;
}
/**
* {@inheritdoc}
*/
public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state): array {
$id = $items[$delta]->id ?? '';
$name = $items[$delta]->name ?? '';
$website = $items[$delta]->website ?? '';
$wikipedia = $items[$delta]->wikipedia ?? '';
$wrapper_id = 'islandora-ror-details-' . $this->fieldDefinition->getName() . '-' . $delta;
$element['search'] = [
'#type' => 'textfield',
'#title' => $this->t('ROR identifier (search by name)'),
'#description' => $this->t('Start typing to search the Research Organization Registry (ROR). Selecting a result will populate the fields below.'),
'#default_value' => $id,
'#autocomplete_route_name' => 'islandora_ror.autocomplete',
'#ajax' => [
'callback' => [$this, 'updateDetails'],
'event' => 'autocompleteclose',
'wrapper' => $wrapper_id,
],
'#attributes' => [
'onclick' => 'this.value = "";',
],
];
$element['details'] = [
'#type' => 'container',
'#attributes' => ['id' => $wrapper_id],
];
$element['details']['id'] = [
'#type' => 'textfield',
'#title' => $this->t('ROR ID'),
'#default_value' => $id,
'#attributes' => ['readonly' => 'readonly'],
];
$element['details']['name'] = [
'#type' => 'textfield',
'#title' => $this->t('Name'),
'#default_value' => $name,
'#attributes' => ['readonly' => 'readonly'],
];
$element['details']['website'] = [
'#type' => 'textfield',
'#title' => $this->t('Website'),
'#default_value' => $website,
'#attributes' => ['readonly' => 'readonly'],
];
$element['details']['wikipedia'] = [
'#type' => 'textfield',
'#title' => $this->t('Wikipedia'),
'#default_value' => $wikipedia,
'#attributes' => ['readonly' => 'readonly'],
];
return $element;
}
/**
* AJAX callback: update the details based on selected ROR ID.
*/
public function updateDetails(array &$form, FormStateInterface $form_state): array {
$trigger = $form_state->getTriggeringElement();
$id_value = $trigger['#value'] ?? '';
if (is_string($id_value) && $id_value !== '') {
try {
$record = $this->rorClient->get($id_value);
if ($record) {
[$website, $wikipedia] = $this->rorClient->extractLinks($record);
// Find details container.
$container_parents = $trigger['#array_parents'];
array_pop($container_parents);
$container_parents[] = 'details';
$details =& $form;
foreach ($container_parents as $parent) {
if (!isset($details[$parent])) {
break;
}
$details =& $details[$parent];
}
if (is_array($details)) {
$details['id']['#value'] = $record['id'] ?? $id_value;
$details['name']['#value'] = $this->rorClient->getName($record['names']) ?? '';
$details['website']['#value'] = $website;
$details['wikipedia']['#value'] = $wikipedia;
}
}
}
catch (\Throwable $e) {
\Drupal::logger('islandora_ror')->error('ROR widget update failed: @message', [
'@message' => $e->getMessage(),
]);
}
}
return $this->getDetailsSubform($form, $form_state);
}
/**
* Extract the container that needs updating.
*/
protected function getDetailsSubform(array &$form, FormStateInterface $form_state): array {
$trigger = $form_state->getTriggeringElement();
$container_parents = $trigger['#array_parents'];
array_pop($container_parents);
$container_parents[] = 'details';
$element =& $form;
foreach ($container_parents as $parent) {
if (!isset($element[$parent])) {
return [];
}
$element =& $element[$parent];
}
return $element;
}
/**
* {@inheritdoc}
*/
public function massageFormValues(array $values, array $form, FormStateInterface $form_state): array {
foreach ($values as &$value) {
if (isset($value['details']) && is_array($value['details'])) {
$value['id'] = $value['details']['id'] ?? '';
$value['name'] = $value['details']['name'] ?? '';
$value['website'] = $value['details']['website'] ?? '';
$value['wikipedia'] = $value['details']['wikipedia'] ?? '';
}
}
return $values;
}
}

231
src/Service/RorClient.php

@ -0,0 +1,231 @@
<?php
namespace Drupal\islandora_ror\Service;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use Psr\Log\LoggerInterface;
use Drupal\Core\Cache\CacheBackendInterface;
/**
* Client for interacting with the Research Organization Registry (ROR) API.
*
* Provides caching, ID normalization, name extraction, and lookup utilities.
*/
class RorClient {
/**
* HTTP client used for API requests.
*
* @var \GuzzleHttp\ClientInterface
*/
protected ClientInterface $httpClient;
/**
* Logger service for reporting API or cache failures.
*
* @var \Psr\Log\LoggerInterface
*/
protected LoggerInterface $logger;
/**
* Cache backend to store API results.
*
* @var \Drupal\Core\Cache\CacheBackendInterface
*/
protected CacheBackendInterface $cache;
/**
* Base URL for the ROR API.
*
* @var string
*/
protected string $baseUrl = 'https://api.ror.org/v2';
/**
* Constructs the ROR client.
*
* @param \GuzzleHttp\ClientInterface $http_client
* The HTTP client service.
* @param \Psr\Log\LoggerInterface $logger
* The logger for this service.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache
* The cache backend for storing API responses.
*/
public function __construct(
ClientInterface $http_client,
LoggerInterface $logger,
CacheBackendInterface $cache
) {
$this->httpClient = $http_client;
$this->logger = $logger;
$this->cache = $cache;
}
/**
* Searches the ROR API for organizations matching a query string.
*
* Results are cached for the specified TTL.
*
* @param string $query
* The search term (organization name).
* @param int $ttl
* Time-to-live in seconds for cached results.
*
* @return array
* A list of organization entries returned by the ROR API.
*/
public function search(string $query, int $ttl = 21600): array {
if ($query === '') {
return [];
}
$cid = 'ror:search:' . md5($query);
if ($cache = $this->cache->get($cid)) {
return $cache->data;
}
try {
$response = $this->httpClient->get($this->baseUrl . '/organizations', [
'query' => ['query' => $query],
'timeout' => 5,
]);
$data = json_decode((string) $response->getBody(), TRUE);
$items = $data['items'] ?? [];
$this->cache->set($cid, $items, time() + $ttl);
return $items;
}
catch (GuzzleException $e) {
$this->logger->error('ROR search failed: @msg', ['@msg' => $e->getMessage()]);
return [];
}
}
/**
* Retrieves a single organization record by ROR ID.
*
* Accepts full URLs, domain-based IDs, or bare IDs.
* Uses caching to reduce API calls.
*
* @param string $id
* The ROR identifier (URL or slug).
* @param int $ttl
* Time-to-live for caching in seconds.
*
* @return array|null
* A ROR organization record or NULL if lookup fails.
*/
public function get(string $id, int $ttl = 86400): ?array {
if ($id === '') {
return NULL;
}
$id_part = $this->normalizeId($id);
$cid = 'ror:get:' . $id_part;
if ($cache = $this->cache->get($cid)) {
return $cache->data;
}
try {
$response = $this->httpClient->get($this->baseUrl . '/organizations/' . rawurlencode($id_part), [
'timeout' => 5,
]);
$data = json_decode((string) $response->getBody(), TRUE);
if (is_array($data)) {
$this->cache->set($cid, $data, time() + $ttl);
return $data;
}
}
catch (GuzzleException $e) {
$this->logger->error('ROR get failed for @id: @msg', [
'@id' => $id,
'@msg' => $e->getMessage(),
]);
}
return NULL;
}
/**
* Extracts website and Wikipedia URLs from a ROR record.
*
* @param array $record
* A single ROR organization record.
*
* @return array
* An array containing:
* - string $website
* - string $wikipedia
*/
public function extractLinks(array $record): array {
$website = '';
$wikipedia = '';
if (!empty($record['links'])) {
foreach ($record['links'] as $link) {
if ($link['type'] === 'website' && !$website) {
$website = $link['value'];
}
elseif ($link['type'] === 'wikipedia' && !$wikipedia) {
$wikipedia = $link['value'];
}
}
}
return [$website, $wikipedia];
}
/**
* Normalizes a ROR ID into its canonical slug form.
*
* Examples:
* - https://ror.org/02xh9x144 → 02xh9x144
* - ror.org/02xh9x144 → 02xh9x144
*
* @param string $id
* A ROR ID in any known format.
*
* @return string
* The normalized ROR ID.
*/
protected function normalizeId(string $id): string {
$id = trim($id);
if (str_starts_with($id, 'http://') || str_starts_with($id, 'https://')) {
$parts = parse_url($id);
return ltrim($parts['path'] ?? '', '/');
}
if (str_starts_with($id, 'ror.org/')) {
return substr($id, strlen('ror.org/'));
}
return $id;
}
/**
* Extracts the display name from a ROR “names” array.
*
* @param array $names
* The `names` array from a ROR record.
*
* @return string|null
* The preferred organization name if found, or NULL otherwise.
*/
public function getName(array $names): ?string {
foreach ($names as $name) {
if (in_array('ror_display', $name['types'] ?? [])) {
return $name['value'] ?? NULL;
}
}
return NULL;
}
}
Loading…
Cancel
Save