From bf39286614ff9c2bbbd1559c8dfdbcc75453ce4c Mon Sep 17 00:00:00 2001 From: astanley Date: Thu, 20 Nov 2025 10:13:52 -0400 Subject: [PATCH] initial commit --- .DS_Store | Bin 0 -> 6148 bytes islandora_ror.info.yml | 8 + islandora_ror.routing.yml | 6 + islandora_ror.services.yml | 12 + src/.DS_Store | Bin 0 -> 6148 bytes src/Controller/RorAutocompleteController.php | 72 ++++++ src/Plugin/.DS_Store | Bin 0 -> 6148 bytes .../Field/FieldFormatter/RorFormatter.php | 172 +++++++++++++ src/Plugin/Field/FieldType/RorFieldItem.php | 79 ++++++ src/Plugin/Field/FieldWidget/RorWidget.php | 184 ++++++++++++++ src/Service/RorClient.php | 231 ++++++++++++++++++ 11 files changed, 764 insertions(+) create mode 100644 .DS_Store create mode 100644 islandora_ror.info.yml create mode 100644 islandora_ror.routing.yml create mode 100644 islandora_ror.services.yml create mode 100644 src/.DS_Store create mode 100644 src/Controller/RorAutocompleteController.php create mode 100644 src/Plugin/.DS_Store create mode 100644 src/Plugin/Field/FieldFormatter/RorFormatter.php create mode 100644 src/Plugin/Field/FieldType/RorFieldItem.php create mode 100644 src/Plugin/Field/FieldWidget/RorWidget.php create mode 100644 src/Service/RorClient.php diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..9a874b5768f336915163bb88cd434575b859f936 GIT binary patch literal 6148 zcmeH~Jr2S!425ml0g0s}V-^m;4I%_5-~tF3k&vj^b9A16778<}(6eNJu~Vz<8=6`~ zboab&MFtUB!i}=AFfm2m$tVxGT*u4pe81nUlA49C} z?O@64YO)2RT{MRe%{!}2F))pG(Sih~)xkgosK7*lF7m<7{{#Hn{6A@7N(HFEpDCdI z{-rR6JLDJUXrB%S8v%$Trp;APM`mc^RH9QQMhtN}^CjwPVCU#` zNDLnmCreByVyAQeV&#zPm@ySd1->hwvo~#`{r`pj!~Cx`X{kUe@JAJp(R?wV^5vqp z4qj7xZJ{seU&dN1XRuaGv{uXqZN*Q$x}x`7uYsMT(U~_oF@FS9m$X#iHxxJnWR)P- literal 0 HcmV?d00001 diff --git a/src/Controller/RorAutocompleteController.php b/src/Controller/RorAutocompleteController.php new file mode 100644 index 0000000..f287fd1 --- /dev/null +++ b/src/Controller/RorAutocompleteController.php @@ -0,0 +1,72 @@ +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); + } + +} diff --git a/src/Plugin/.DS_Store b/src/Plugin/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..f77f3aaf16b3ec0cadcc6e5c8d085362f313e00d GIT binary patch literal 6148 zcmeH~J#ND=422)l6bO(dV@54GKyM%fPA*cV*fMzvBg9wPgjKE8ecYgk#=+FB9 zS&2ds5P^S2z}Cb2aO6wn+4}4CJpYkdpEo)+wsUy+31Hwy@tz*W_2Lt1O>JG7q3K5; LFlZ2gKP7MnXE_s2 literal 0 HcmV?d00001 diff --git a/src/Plugin/Field/FieldFormatter/RorFormatter.php b/src/Plugin/Field/FieldFormatter/RorFormatter.php new file mode 100644 index 0000000..a51af0f --- /dev/null +++ b/src/Plugin/Field/FieldFormatter/RorFormatter.php @@ -0,0 +1,172 @@ + 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; + } + +} diff --git a/src/Plugin/Field/FieldType/RorFieldItem.php b/src/Plugin/Field/FieldType/RorFieldItem.php new file mode 100644 index 0000000..66192ef --- /dev/null +++ b/src/Plugin/Field/FieldType/RorFieldItem.php @@ -0,0 +1,79 @@ +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 === ''; + } + +} diff --git a/src/Plugin/Field/FieldWidget/RorWidget.php b/src/Plugin/Field/FieldWidget/RorWidget.php new file mode 100644 index 0000000..ac9a9d7 --- /dev/null +++ b/src/Plugin/Field/FieldWidget/RorWidget.php @@ -0,0 +1,184 @@ +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; + } + +} diff --git a/src/Service/RorClient.php b/src/Service/RorClient.php new file mode 100644 index 0000000..a7448ec --- /dev/null +++ b/src/Service/RorClient.php @@ -0,0 +1,231 @@ +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; + } + +}