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.
*