diff --git a/config/install/core.entity_form_display.node.ill_institution_request.default.yml b/config/install/core.entity_form_display.node.ill_institution_request.default.yml index 5ba32aa..2f22b03 100644 --- a/config/install/core.entity_form_display.node.ill_institution_request.default.yml +++ b/config/install/core.entity_form_display.node.ill_institution_request.default.yml @@ -9,6 +9,7 @@ dependencies: - field.field.node.ill_institution_request.field_ill_chapter_author - field.field.node.ill_institution_request.field_ill_chapter_title - field.field.node.ill_institution_request.field_ill_copyright_agreement + - field.field.node.ill_institution_request.field_ill_doi - field.field.node.ill_institution_request.field_ill_edition - field.field.node.ill_institution_request.field_ill_institution_code - field.field.node.ill_institution_request.field_ill_isbn @@ -58,6 +59,14 @@ content: region: content settings: {} third_party_settings: {} + field_ill_doi: + type: string_textfield + weight: 5 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: {} field_ill_req_email: type: email_default weight: 12 diff --git a/config/install/core.entity_view_display.node.ill_institution_request.default.yml b/config/install/core.entity_view_display.node.ill_institution_request.default.yml index ea3a958..0d2bb09 100644 --- a/config/install/core.entity_view_display.node.ill_institution_request.default.yml +++ b/config/install/core.entity_view_display.node.ill_institution_request.default.yml @@ -9,6 +9,7 @@ dependencies: - field.field.node.ill_institution_request.field_ill_chapter_author - field.field.node.ill_institution_request.field_ill_chapter_title - field.field.node.ill_institution_request.field_ill_copyright_agreement + - field.field.node.ill_institution_request.field_ill_doi - field.field.node.ill_institution_request.field_ill_edition - field.field.node.ill_institution_request.field_ill_institution_code - field.field.node.ill_institution_request.field_ill_isbn @@ -59,6 +60,13 @@ content: label: above settings: {} third_party_settings: {} + field_ill_doi: + type: string + weight: 5 + region: content + label: above + settings: {} + third_party_settings: {} field_ill_req_email: type: email_mailto weight: 12 diff --git a/config/install/field.field.node.ill_institution_request.field_ill_doi.yml b/config/install/field.field.node.ill_institution_request.field_ill_doi.yml new file mode 100644 index 0000000..7080cf9 --- /dev/null +++ b/config/install/field.field.node.ill_institution_request.field_ill_doi.yml @@ -0,0 +1,21 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.node.field_ill_doi + - node.type.ill_institution_request + enforced: + module: + - ill_corporate_forms +id: node.ill_institution_request.field_ill_doi +field_name: field_ill_doi +entity_type: node +bundle: ill_institution_request +label: 'DOI' +description: 'Digital Object Identifier (e.g., 10.1016/j.ypmed.2019.01.018)' +required: false +translatable: false +default_value: {} +default_value_callback: '' +settings: {} +field_type: string diff --git a/config/install/field.storage.node.field_ill_doi.yml b/config/install/field.storage.node.field_ill_doi.yml new file mode 100644 index 0000000..ede1a6d --- /dev/null +++ b/config/install/field.storage.node.field_ill_doi.yml @@ -0,0 +1,23 @@ +langcode: en +status: true +dependencies: + module: + - node + enforced: + module: + - ill_corporate_forms +id: node.field_ill_doi +field_name: field_ill_doi +entity_type: node +type: string +settings: + max_length: 255 + is_ascii: false + case_sensitive: false +module: core +locked: false +cardinality: 1 +translatable: true +indexes: {} +persist_with_no_fields: false +custom_storage: false diff --git a/ill_corporate_forms.install b/ill_corporate_forms.install index 8033094..0c855b4 100644 --- a/ill_corporate_forms.install +++ b/ill_corporate_forms.install @@ -5,6 +5,108 @@ * Install, update, and uninstall functions for the ILL Corporate Forms module. */ +/** + * Add DOI field to the ill_institution_request content type. + */ +function ill_corporate_forms_update_10001(): void { + $entity_type_manager = \Drupal::entityTypeManager(); + + // Create field storage if it doesn't already exist. + $field_storage_config = $entity_type_manager->getStorage('field_storage_config'); + if (!$field_storage_config->load('node.field_ill_doi')) { + $field_storage_config->create([ + 'langcode' => 'en', + 'status' => TRUE, + 'dependencies' => [ + 'module' => ['node'], + 'enforced' => [ + 'module' => ['ill_corporate_forms'], + ], + ], + 'id' => 'node.field_ill_doi', + 'field_name' => 'field_ill_doi', + 'entity_type' => 'node', + 'type' => 'string', + 'settings' => [ + 'max_length' => 255, + 'is_ascii' => FALSE, + 'case_sensitive' => FALSE, + ], + 'module' => 'core', + 'locked' => FALSE, + 'cardinality' => 1, + 'translatable' => TRUE, + 'indexes' => [], + 'persist_with_no_fields' => FALSE, + 'custom_storage' => FALSE, + ])->save(); + } + + // Create field instance if it doesn't already exist. + $field_config = $entity_type_manager->getStorage('field_config'); + if (!$field_config->load('node.ill_institution_request.field_ill_doi')) { + $field_config->create([ + 'langcode' => 'en', + 'status' => TRUE, + 'dependencies' => [ + 'config' => [ + 'field.storage.node.field_ill_doi', + 'node.type.ill_institution_request', + ], + 'enforced' => [ + 'module' => ['ill_corporate_forms'], + ], + ], + 'id' => 'node.ill_institution_request.field_ill_doi', + 'field_name' => 'field_ill_doi', + 'entity_type' => 'node', + 'bundle' => 'ill_institution_request', + 'label' => 'DOI', + 'description' => 'Digital Object Identifier (e.g., 10.1016/j.ypmed.2019.01.018)', + 'required' => FALSE, + 'translatable' => FALSE, + 'default_value' => [], + 'default_value_callback' => '', + 'settings' => [], + 'field_type' => 'string', + ])->save(); + } + + // Add the field to the form display. + /** @var \Drupal\Core\Entity\Display\EntityFormDisplayInterface $form_display */ + $form_display = $entity_type_manager + ->getStorage('entity_form_display') + ->load('node.ill_institution_request.default'); + if ($form_display && !$form_display->getComponent('field_ill_doi')) { + $form_display->setComponent('field_ill_doi', [ + 'type' => 'string_textfield', + 'weight' => 5, + 'region' => 'content', + 'settings' => [ + 'size' => 60, + 'placeholder' => '', + ], + 'third_party_settings' => [], + ])->save(); + } + + // Add the field to the view display. + /** @var \Drupal\Core\Entity\Display\EntityViewDisplayInterface $view_display */ + $view_display = $entity_type_manager + ->getStorage('entity_view_display') + ->load('node.ill_institution_request.default'); + if ($view_display && !$view_display->getComponent('field_ill_doi')) { + $view_display->setComponent('field_ill_doi', [ + 'type' => 'string', + 'weight' => 5, + 'region' => 'content', + 'label' => 'above', + 'settings' => [], + 'third_party_settings' => [], + ])->save(); + } +} + /** * Implements hook_uninstall(). */ diff --git a/ill_corporate_forms.module b/ill_corporate_forms.module index 241a78a..9c7fe06 100644 --- a/ill_corporate_forms.module +++ b/ill_corporate_forms.module @@ -6,12 +6,14 @@ */ use Drupal\Core\Form\FormStateInterface; +use GuzzleHttp\Exception\GuzzleException; /** * Implements hook_form_FORM_ID_alter() for the ill_institution_request node form. */ function ill_corporate_forms_form_node_ill_institution_request_form_alter(array &$form, FormStateInterface $form_state, string $form_id): void { _ill_corporate_forms_add_item_type_states($form); + _ill_corporate_forms_add_doi_lookup($form, $form_state); $form['#validate'][] = '_ill_corporate_forms_validate_partner_email'; $form['#validate'][] = '_ill_corporate_forms_validate_institution_code'; $form['#validate'][] = '_ill_corporate_forms_validate_request_emails'; @@ -25,6 +27,7 @@ function ill_corporate_forms_form_node_ill_institution_request_form_alter(array */ function ill_corporate_forms_form_node_ill_institution_request_edit_form_alter(array &$form, FormStateInterface $form_state, string $form_id): void { _ill_corporate_forms_add_item_type_states($form); + _ill_corporate_forms_add_doi_lookup($form, $form_state); $form['#validate'][] = '_ill_corporate_forms_validate_partner_email'; $form['#validate'][] = '_ill_corporate_forms_validate_institution_code'; $form['#validate'][] = '_ill_corporate_forms_validate_request_emails'; @@ -63,6 +66,340 @@ function ill_corporate_forms_form_node_ill_request_tracking_edit_form_alter(arra $form['title']['#access'] = FALSE; } +/** + * Adds the DOI lookup button and AJAX handling to the request form. + * + * @param array $form + * The form render array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + */ +function _ill_corporate_forms_add_doi_lookup(array &$form, FormStateInterface $form_state): void { + // Wrap the form so AJAX can replace it. + $form['#prefix'] = '
'; + $form['#suffix'] = '
'; + + // Add the Lookup DOI button right after the DOI field. + if (isset($form['field_ill_doi'])) { + $form['doi_lookup_button'] = [ + '#type' => 'submit', + '#value' => t('Lookup DOI'), + '#weight' => 6, + '#limit_validation_errors' => [['field_ill_doi']], + '#submit' => ['_ill_corporate_forms_doi_lookup_submit'], + '#ajax' => [ + 'callback' => '_ill_corporate_forms_doi_lookup_callback', + 'wrapper' => 'ill-request-form-wrapper', + 'effect' => 'fade', + ], + '#attributes' => ['class' => ['button--secondary']], + ]; + } + +} + +/** + * Submit handler for the Lookup DOI button. + * + * Fetches CrossRef data and stores it in form state for rebuilding. + */ +function _ill_corporate_forms_doi_lookup_submit(array &$form, FormStateInterface $form_state): void { + $doi_value = $form_state->getValue('field_ill_doi'); + $doi = _ill_corporate_forms_extract_field_value($doi_value); + + if (empty($doi)) { + \Drupal::messenger()->addWarning(t('Please enter a DOI before clicking Lookup DOI.')); + $form_state->setRebuild(TRUE); + return; + } + + // Normalize the DOI (strip URL prefix, "doi:" prefix, etc.). + $doi = _ill_corporate_forms_normalize_doi($doi); + + // Fetch data from CrossRef. + $data = _ill_corporate_forms_crossref_fetch($doi); + if ($data === NULL) { + \Drupal::messenger()->addWarning(t('Could not retrieve data for DOI "@doi". Please verify the DOI and try again.', ['@doi' => $doi])); + } + else { + // Inject the fetched data directly into user input so the form widgets + // pick up the values on rebuild. Setting #default_value does not work + // because Drupal's Form API uses cached user input over defaults. + _ill_corporate_forms_inject_doi_values($form_state, $data); + \Drupal::messenger()->addStatus(t('DOI lookup successful. Fields have been populated.')); + } + + $form_state->setRebuild(TRUE); +} + +/** + * AJAX callback for the DOI lookup button. + * + * Returns the rebuilt form. + */ +function _ill_corporate_forms_doi_lookup_callback(array &$form, FormStateInterface $form_state) { + return $form; +} + +/** + * Normalizes a DOI string by removing common prefixes. + * + * Handles formats like: + * - https://doi.org/10.1234/example + * - http://dx.doi.org/10.1234/example + * - doi:10.1234/example + * - 10.1234/example + * + * @param string $doi + * The raw DOI input. + * + * @return string + * The normalized DOI (e.g., "10.1234/example"). + */ +function _ill_corporate_forms_normalize_doi(string $doi): string { + $doi = trim($doi); + // Remove URL prefixes. + $doi = preg_replace('#^https?://(dx\.)?doi\.org/#i', '', $doi); + // Remove "doi:" prefix. + $doi = preg_replace('/^doi:\s*/i', '', $doi); + return $doi; +} + +/** + * Fetches bibliographic data from the CrossRef REST API. + * + * @param string $doi + * A normalized DOI string. + * + * @return array|null + * An associative array of bibliographic data, or NULL on failure. + */ +function _ill_corporate_forms_crossref_fetch(string $doi): ?array { + $url = 'https://api.crossref.org/works/' . urlencode($doi); + + try { + /** @var \GuzzleHttp\ClientInterface $client */ + $client = \Drupal::httpClient(); + $response = $client->request('GET', $url, [ + 'headers' => [ + 'Accept' => 'application/json', + 'User-Agent' => 'ILLCorporateForms/1.0 (Drupal module; mailto:ill@library.example.com)', + ], + 'timeout' => 10, + ]); + + if ($response->getStatusCode() !== 200) { + return NULL; + } + + $body = json_decode((string) $response->getBody(), TRUE); + if (empty($body['message'])) { + return NULL; + } + + return _ill_corporate_forms_parse_crossref($body['message']); + } + catch (GuzzleException $e) { + \Drupal::logger('ill_corporate_forms')->warning('CrossRef API request failed for DOI @doi: @message', [ + '@doi' => $doi, + '@message' => $e->getMessage(), + ]); + return NULL; + } +} + +/** + * Parses a CrossRef API message into a normalized data array. + * + * @param array $message + * The "message" portion of a CrossRef API response. + * + * @return array + * Associative array with keys matching our field names. + */ +function _ill_corporate_forms_parse_crossref(array $message): array { + $data = []; + + // Determine the item type. + $type = $message['type'] ?? ''; + switch ($type) { + case 'journal-article': + $data['item_type'] = 'article'; + break; + + case 'book': + case 'monograph': + case 'edited-book': + case 'reference-book': + $data['item_type'] = 'book'; + break; + + case 'book-chapter': + case 'book-section': + case 'book-part': + $data['item_type'] = 'book_chapter'; + break; + + default: + // Default to article for journal-related types, book otherwise. + if (str_contains($type, 'journal') || str_contains($type, 'article')) { + $data['item_type'] = 'article'; + } + } + + // Title — maps differently based on type. + $title = $message['title'][0] ?? ''; + $container_title = $message['container-title'][0] ?? ''; + + if (($data['item_type'] ?? '') === 'article') { + $data['article_title'] = $title; + $data['journal_title'] = $container_title; + } + elseif (($data['item_type'] ?? '') === 'book_chapter') { + $data['chapter_title'] = $title; + $data['item_title'] = $container_title; + } + else { + $data['item_title'] = $title; + } + + // Authors. + $authors = []; + $editors = []; + foreach ($message['author'] ?? [] as $person) { + $name_parts = []; + if (!empty($person['given'])) { + $name_parts[] = $person['given']; + } + if (!empty($person['family'])) { + $name_parts[] = $person['family']; + } + if (!empty($name_parts)) { + $authors[] = implode(' ', $name_parts); + } + } + foreach ($message['editor'] ?? [] as $person) { + $name_parts = []; + if (!empty($person['given'])) { + $name_parts[] = $person['given']; + } + if (!empty($person['family'])) { + $name_parts[] = $person['family']; + } + if (!empty($name_parts)) { + $editors[] = implode(' ', $name_parts); + } + } + + $author_string = implode('; ', $authors); + if (($data['item_type'] ?? '') === 'article') { + $data['article_author'] = $author_string; + } + elseif (($data['item_type'] ?? '') === 'book_chapter') { + $data['chapter_author'] = $author_string; + if (!empty($editors)) { + $data['author_editor'] = implode('; ', $editors); + } + } + else { + $data['author_editor'] = !empty($editors) ? implode('; ', $editors) : $author_string; + } + + // Identifiers. + if (!empty($message['ISSN'])) { + $data['issn'] = $message['ISSN'][0]; + } + if (!empty($message['ISBN'])) { + $data['isbn'] = $message['ISBN'][0]; + } + + // Volume, issue, pages. + if (!empty($message['volume'])) { + $data['volume'] = $message['volume']; + } + if (!empty($message['issue'])) { + $data['issue'] = $message['issue']; + } + if (!empty($message['page'])) { + $data['pages'] = $message['page']; + } + + // Publication date. + $date_parts = $message['published']['date-parts'][0] + ?? $message['published-print']['date-parts'][0] + ?? $message['published-online']['date-parts'][0] + ?? NULL; + if (!empty($date_parts[0])) { + $data['publication_date'] = (string) $date_parts[0]; + } + + // Publisher. + if (!empty($message['publisher'])) { + $data['publisher'] = $message['publisher']; + } + + // Edition. + if (!empty($message['edition-number'])) { + $data['edition'] = $message['edition-number']; + } + + return $data; +} + +/** + * Injects DOI lookup data into the form state user input. + * + * This modifies the raw user input array so that when the form rebuilds, + * widget elements pick up the new values. Setting #default_value on form + * rebuild does not work because Drupal prioritizes cached user input. + * + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * @param array $data + * The parsed CrossRef data. + */ +function _ill_corporate_forms_inject_doi_values(FormStateInterface $form_state, array $data): void { + $field_map = [ + 'item_type' => 'field_ill_item_type', + 'item_title' => 'field_ill_item_title', + 'journal_title' => 'field_ill_journal_title', + 'article_title' => 'field_ill_article_title', + 'article_author' => 'field_ill_article_author', + 'chapter_title' => 'field_ill_chapter_title', + 'chapter_author' => 'field_ill_chapter_author', + 'author_editor' => 'field_ill_author_editor', + 'isbn' => 'field_ill_isbn', + 'issn' => 'field_ill_issn', + 'volume' => 'field_ill_volume', + 'issue' => 'field_ill_issue', + 'pages' => 'field_ill_pages', + 'publication_date' => 'field_ill_publication_date', + 'publisher' => 'field_ill_publisher', + 'edition' => 'field_ill_edition', + ]; + + $user_input = $form_state->getUserInput(); + + foreach ($field_map as $data_key => $field_name) { + if (empty($data[$data_key])) { + continue; + } + + $value = $data[$data_key]; + + // Select/radio widgets use the field name directly in user input. + if ($field_name === 'field_ill_item_type') { + $user_input[$field_name] = $value; + } + else { + // Text field widgets use a nested array: field_name[0][value]. + $user_input[$field_name][0]['value'] = $value; + } + } + + $form_state->setUserInput($user_input); +} + /** * Adds #states for item-type conditional fields on the request form. *