From 7fd767745e315b2672c0eefc9446e6bbed2dbeba Mon Sep 17 00:00:00 2001 From: Isra Jazairi Date: Tue, 8 Sep 2026 11:12:26 -0700 Subject: [PATCH 1/4] Implement full-text options links using SRU Why these changes are being introduced: Unless we enable delivery data in our Primo API calls, there is no way to determine the electronic availability from the Primo response. Alma SRU allows us to detect an AVE tag (electronic availability), which signals the presence of a 'full-text options' link in the Primo record. Relevant ticket(s): - [USE-663](https://mitlibraries.atlassian.net/browse/USE-663) How this addresses that need: This modifies the Alma SRU call, such that it returns a hash with an availability array (for holdings information) and an Alma-E boolean (true if an AVE tag was detected). If Alma-E is true, we construct a full-text options link. The content loader controller injects this data into the fufillment links div. Side effects of this change: The availability data is now in the `result-get` (fulfillment links) div, rather than the main `result-content` div. I haven't noticed this causing any issues in local testing. --- app/controllers/alma_controller.rb | 4 +- .../controllers/content_loader_controller.js | 10 +++ app/models/alma_sru.rb | 37 +++++++--- app/models/normalize_primo_record.rb | 8 --- app/views/alma/sru.html.erb | 24 ++++--- app/views/search/_result_primo.html.erb | 6 +- test/controllers/alma_controller_test.rb | 14 +++- test/models/alma_sru_test.rb | 68 +++++++++++++------ test/models/normalize_primo_record_test.rb | 34 ---------- 9 files changed, 120 insertions(+), 85 deletions(-) diff --git a/app/controllers/alma_controller.rb b/app/controllers/alma_controller.rb index ba81d555..7ce551fb 100644 --- a/app/controllers/alma_controller.rb +++ b/app/controllers/alma_controller.rb @@ -4,7 +4,9 @@ class AlmaController < ApplicationController def sru return unless AlmaSru.enabled? && expected_params? - @availability = AlmaSru.lookup(params[:doc_id]) + result = AlmaSru.lookup(params[:doc_id]) + @availability = result[:availability] + @alma_e = result[:alma_e] end private diff --git a/app/javascript/controllers/content_loader_controller.js b/app/javascript/controllers/content_loader_controller.js index 02450a68..e3af9f05 100644 --- a/app/javascript/controllers/content_loader_controller.js +++ b/app/javascript/controllers/content_loader_controller.js @@ -33,6 +33,16 @@ export default class extends Controller { // Replace the entire element with the fetched HTML, or remove if empty if (html.trim()) { this.element.outerHTML = html + + // Keep Alma availability in `.result-content` but place “Full-text options” + // with fulfillment links in the descendant `.result-get` container. + const resultContent = parentElement.closest('.result-content') || parentElement + const almaFulltextOptions = resultContent.querySelector('.alma-fulltext-options') + const resultGet = resultContent.querySelector('.result-get') + if (almaFulltextOptions && resultGet) { + resultGet.appendChild(almaFulltextOptions) + } + // Hide primo links if libkey link is present if (parentElement.querySelector('.libkey-link')) { const resultGet = parentElement.closest('.result-get') diff --git a/app/models/alma_sru.rb b/app/models/alma_sru.rb index 7777928a..ec6d3470 100644 --- a/app/models/alma_sru.rb +++ b/app/models/alma_sru.rb @@ -20,11 +20,15 @@ class InvalidAlmaId < StandardError; end # lookup is the primary method of interacting with this model. # - # It will receive an Alma ID, validate it, look it up in the Alma SRU, and return a formatted result. + # It will receive an Alma ID, validate it, look it up in the Alma SRU, and return availability info and Alma-E status. # # It accepts an "alma_client" argument for use when testing, but this is not used in normal operations. + # + # Returns a hash with: + # - :availability => formatted availability statements array + # - :alma_e => boolean indicating if record is Alma-E def self.lookup(raw_identifier, alma_client: nil) - return [] unless enabled? + return { availability: [], alma_e: false } unless enabled? # Validate the raw identifier received. This will raise an InvalidAlmaId if validation fails. raise InvalidAlmaId unless valid_alma_id?(raw_identifier) @@ -41,24 +45,22 @@ def self.lookup(raw_identifier, alma_client: nil) parse_response(alma_http.timeout(6).get(url), identifier) rescue InvalidAlmaId Rails.logger.debug("Invalid Alma ID: #{raw_identifier}") - - [] + { availability: [], alma_e: false } rescue LookupFailure => e Rails.logger.debug("Alma lookup failure: #{e}") - - [] + { availability: [], alma_e: false } rescue HTTP::Error Sentry.capture_message('Alma SRU connection failure') Rails.logger.error('Alma SRU connection error') - - [] + { availability: [], alma_e: false } end # parse_response receives the raw response from the Alma SRU endpoint. # # For any non-200 response, it raises a LookupFailure. # - # Other responses (in XML format) are parsed by Nokogiri, and we pluck content with an `AVA` tag. + # Other responses (in XML format) are parsed by Nokogiri to extract both AVA (holdings) and AVE (electronic) data. + # Returns a hash with :availability and :alma_e keys. def self.parse_response(raw_response, reference_identifier) raise LookupFailure, raw_response.status unless raw_response.status == 200 @@ -76,7 +78,15 @@ def self.parse_response(raw_response, reference_identifier) # Reduce list to a single item if multiples exist results[0] += ' and other locations' if results.length > 1 - results.first(1) + availability = results.first(1) + + # Check for AVE tags to determine if record is Alma-E + alma_e = alma_e?(parsed) + + { + availability: availability, + alma_e: alma_e + } end # ava_to_hash takes an XML element that represents a single availability record @@ -112,6 +122,13 @@ def self.fetch_controlfield(parsed_xml) parsed_xml.xpath("//holding:controlfield[@tag='001']", NAMESPACE)&.text end + # alma_e? receives a parsed XML document (Nokogiri::XML::Document) + # and returns true if the record contains AVE (electronic) datafields, false otherwise. + # AVE presence indicates the record is Alma-E. + def self.alma_e?(parsed_xml) + parsed_xml.xpath("//holding:datafield[@tag='AVE']", NAMESPACE).any? + end + # format_availability receives a hash representing a single availability # statement, and formats it for human readability. Values for "e" and "q" are # required, while "c" and "d" are optional. diff --git a/app/models/normalize_primo_record.rb b/app/models/normalize_primo_record.rb index 8c7a6cc5..dcfe7d52 100644 --- a/app/models/normalize_primo_record.rb +++ b/app/models/normalize_primo_record.rb @@ -131,14 +131,6 @@ def links end end - # Add Full-text options if pnx['links'] is nil and record has Alma-E (electronic availability) - full_record_link = record_link - if @record.dig('pnx', 'links').nil? && - @record.dig('delivery', 'deliveryCategory')&.include?('Alma-E') && - full_record_link.present? - links << { 'url' => "#{full_record_link}#nui.getit.service_viewit", 'kind' => 'Full-text options' } - end - # Return links if we found any links.any? ? links : [] end diff --git a/app/views/alma/sru.html.erb b/app/views/alma/sru.html.erb index 10c50e5c..7f636a12 100644 --- a/app/views/alma/sru.html.erb +++ b/app/views/alma/sru.html.erb @@ -1,9 +1,17 @@ -<% if AlmaSru.enabled? && @availability.present? %> -
- <% @availability.each do |statement| %> -

<%= link_to(sanitize(statement, tags: %w[i strong], attributes: %w[class aria-hidden]), - "#{PrimoLinkBuilder.new(record_id: params[:doc_id], context: 'L').full_record_link}#getit_link1_0", - data: {content_piece: 'Availability Link' }) %>

- <% end %> -
+<% if AlmaSru.enabled? %> + <% if @alma_e %> +
+ <% fulltext_url = PrimoLinkBuilder.new(record_id: params[:doc_id], context: 'L').full_record_link + '#nui.getit.service_viewit' %> + <%= link_to 'Full-text options', fulltext_url, class: 'button', data: { content_piece: 'Full-text Options' } %> +
+ <% end %> + <% if @availability.present? %> +
+ <% @availability.each do |statement| %> +

<%= link_to(sanitize(statement, tags: %w[i strong], attributes: %w[class aria-hidden]), + "#{PrimoLinkBuilder.new(record_id: params[:doc_id], context: 'L').full_record_link}#getit_link1_0", + data: {content_piece: 'Availability Link' }) %>

+ <% end %> +
+ <% end %> <% end %> diff --git a/app/views/search/_result_primo.html.erb b/app/views/search/_result_primo.html.erb index 7b8d47ac..efc0f7d8 100644 --- a/app/views/search/_result_primo.html.erb +++ b/app/views/search/_result_primo.html.erb @@ -78,11 +78,13 @@ <% end %> - <% if AlmaSru.enabled? && AlmaSru.valid_alma_id?(result[:identifier]) %> + <% should_render_almasru = AlmaSru.enabled? && AlmaSru.valid_alma_id?(result[:identifier]) %> + + <% if should_render_almasru %> <%= render(partial: 'trigger_almasru', locals: { doc_id: result[:identifier] }) %> <% end %> - <% if result_get?(result) %> + <% if result_get?(result) || should_render_almasru %>
<% if result[:links].present? %> <% result[:links].each do |link| %> diff --git a/test/controllers/alma_controller_test.rb b/test/controllers/alma_controller_test.rb index b6d9c00c..5f1d8da6 100644 --- a/test/controllers/alma_controller_test.rb +++ b/test/controllers/alma_controller_test.rb @@ -16,17 +16,27 @@ class AlmaControllerTest < ActionDispatch::IntegrationTest assert response.body.blank? end - test 'alma sru route returns nothing if lookup returns content with no AVA' do + test 'alma sru route returns full-text options when Alma-E is true' do VCR.use_cassette('alma sru no availability') do needle = 'alma9935053423706761' get almasru_path(doc_id: needle) + assert_response :success + assert_select 'a.button', { count: 1, text: 'Full-text options' } + end + end + + test 'alma sru route returns nothing when lookup has no AVA and no Alma-E' do + VCR.use_cassette('alma sru nonexistent record') do + needle = 'alma9900000000006761' + get almasru_path(doc_id: needle) + assert_response :success assert response.body.blank? end end - test 'alma sru route returns HTML for successful lookup' do + test 'alma sru route returns availability for successful lookup' do VCR.use_cassette('alma sru single record') do needle = 'alma990014651640106761' get almasru_path(doc_id: needle) diff --git a/test/models/alma_sru_test.rb b/test/models/alma_sru_test.rb index d2eec48a..6e1de6d8 100644 --- a/test/models/alma_sru_test.rb +++ b/test/models/alma_sru_test.rb @@ -43,8 +43,9 @@ class AlmaSruTest < ActiveSupport::TestCase assert_equal( [" Available in Rotch Library Stacks (NA680.C25 2007)"], - result + result[:availability] ) + assert_equal false, result[:alma_e] end end @@ -54,18 +55,40 @@ class AlmaSruTest < ActiveSupport::TestCase result = AlmaSru.lookup(needle) - assert_equal(1, result.length) - assert_includes result[0], 'and other locations' + assert_equal(1, result[:availability].length) + assert_includes result[:availability][0], 'and other locations' + assert_equal false, result[:alma_e] end end - test 'lookup returns empty list if no availability' do + test 'lookup returns empty availability if no AVA' do VCR.use_cassette('alma sru no availability') do needle = 'alma9935053423706761' result = AlmaSru.lookup(needle) - assert_equal([], result) + assert_equal([], result[:availability]) + end + end + + test 'lookup returns true Alma-E if AVE is present' do + # This cassette was generated to demonstrate a record with no AVA, but it has an AVE + VCR.use_cassette('alma sru no availability') do + needle = 'alma9935053423706761' + + result = AlmaSru.lookup(needle) + + assert_equal true, result[:alma_e] + end + end + + test 'lookup returns false Alma-E when AVE is absent' do + VCR.use_cassette('alma sru single record') do + needle = 'alma990014651640106761' + + result = AlmaSru.lookup(needle) + + assert_equal false, result[:alma_e] end end @@ -75,58 +98,63 @@ class AlmaSruTest < ActiveSupport::TestCase result = AlmaSru.lookup(needle) - assert_equal([], result) + assert_equal([], result[:availability]) + assert_equal false, result[:alma_e] end end - test 'lookup returns empty list if alma URL not set' do + test 'lookup returns empty availability if alma URL not set' do needle = 'alma990014651640106761' VCR.use_cassette('alma sru single record') do - assert_equal(1, AlmaSru.lookup(needle).length) + result = AlmaSru.lookup(needle) + assert_equal(1, result[:availability].length) end ClimateControl.modify(MIT_ALMA_URL: nil) do - assert_equal([], AlmaSru.lookup(needle)) + result = AlmaSru.lookup(needle) + assert_equal({ availability: [], alma_e: false }, result) end end - test 'lookup returns empty list if exl_inst_id not set' do + test 'lookup returns empty availability if exl_inst_id not set' do needle = 'alma990014651640106761' VCR.use_cassette('alma sru single record') do - assert_equal(1, AlmaSru.lookup(needle).length) + result = AlmaSru.lookup(needle) + assert_equal(1, result[:availability].length) end ClimateControl.modify(EXL_INST_ID: nil) do AlmaSru.remove_instance_variable(:@enabled) - assert_equal([], AlmaSru.lookup(needle)) + result = AlmaSru.lookup(needle) + assert_equal({ availability: [], alma_e: false }, result) end end - test 'lookup returns empty list with non-complying ID' do + test 'lookup returns empty hash for non-complying ID' do needle = 'foo' result = AlmaSru.lookup(needle) - assert_equal([], result) + assert_equal({ availability: [], alma_e: false }, result) end - test 'lookup returns empty list with empty string' do + test 'lookup returns empty hash with empty string' do needle = '' result = AlmaSru.lookup(needle) - assert_equal([], result) + assert_equal({ availability: [], alma_e: false }, result) end - test 'lookup returns empty list with nil input' do + test 'lookup returns empty hash with nil input' do needle = nil result = AlmaSru.lookup(needle) - assert_equal([], result) + assert_equal({ availability: [], alma_e: false }, result) end test 'lookup survives failing to connect to Alma SRU' do @@ -137,7 +165,7 @@ class AlmaSruTest < ActiveSupport::TestCase assert_nothing_raised do result = AlmaSru.lookup(needle, alma_client: alma_client) - assert_equal([], result) + assert_equal({ availability: [], alma_e: false }, result) end end @@ -149,7 +177,7 @@ class AlmaSruTest < ActiveSupport::TestCase assert_nothing_raised do result = AlmaSru.lookup(needle, alma_client: alma_client) - assert_equal([], result) + assert_equal({ availability: [], alma_e: false }, result) end end diff --git a/test/models/normalize_primo_record_test.rb b/test/models/normalize_primo_record_test.rb index 35d106e0..8b630bab 100644 --- a/test/models/normalize_primo_record_test.rb +++ b/test/models/normalize_primo_record_test.rb @@ -458,20 +458,6 @@ def cdi_record assert_not normalized[:dedup_record] end - test 'includes Full-text options link when pnx[links] is nil and both Alma-P and Alma-E present' do - record = alma_record.deep_dup - - # Ensure no direct links - record['pnx']['links'] = nil - - # Add delivery category with both physical and electronic - record['delivery']['deliveryCategory'] = %w[Alma-P Alma-E] - normalized = NormalizePrimoRecord.new(record, 'test').normalize - full_text_link = normalized[:links].find { |link| link['kind'] == 'Full-text options' } - assert_not_nil full_text_link - assert_match %r{/discovery/fulldisplay\?}, full_text_link['url'] - assert_match(/#nui\.getit\.service_viewit$/, full_text_link['url']) - end test 'excludes Full-text options link when pnx[links] is present' do record = full_record.deep_dup @@ -492,26 +478,6 @@ def cdi_record assert_nil full_text_link end - test 'includes Full-text options link when only Alma-E present' do - record = alma_record.deep_dup - record['pnx']['links'] = nil - record['delivery']['deliveryCategory'] = ['Alma-E'] - normalized = NormalizePrimoRecord.new(record, 'test').normalize - full_text_link = normalized[:links].find { |link| link['kind'] == 'Full-text options' } - assert_not_nil full_text_link - assert_match %r{/discovery/fulldisplay\?}, full_text_link['url'] - assert_match(/#nui\.getit\.service_viewit$/, full_text_link['url']) - end - - test 'excludes Full-text options link when no delivery category present' do - record = alma_record.deep_dup - record['pnx']['links'] = nil - record['delivery']['deliveryCategory'] = nil - normalized = NormalizePrimoRecord.new(record, 'test').normalize - full_text_link = normalized[:links].find { |link| link['kind'] == 'Full-text options' } - assert_nil full_text_link - end - test 'dedup_url requires both frbrized and alma_record conditions' do # CDI record that is frbrized - should return nil normalizer = NormalizePrimoRecord.new(cdi_record, 'test') From a02d7230bb07e7e511959104584d1ee417971046 Mon Sep 17 00:00:00 2001 From: Isra Jazairi Date: Thu, 10 Sep 2026 12:35:33 -0700 Subject: [PATCH 2/4] Add electronic access fallback for legacy records Some records do not have the AVE tag, but do have a 959 indicating electronic access. This is likely due to pre-Alma cataloging --- app/models/alma_sru.rb | 21 +++++++++++++--- test/models/alma_sru_test.rb | 48 ++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/app/models/alma_sru.rb b/app/models/alma_sru.rb index ec6d3470..971c4f35 100644 --- a/app/models/alma_sru.rb +++ b/app/models/alma_sru.rb @@ -122,11 +122,24 @@ def self.fetch_controlfield(parsed_xml) parsed_xml.xpath("//holding:controlfield[@tag='001']", NAMESPACE)&.text end - # alma_e? receives a parsed XML document (Nokogiri::XML::Document) - # and returns true if the record contains AVE (electronic) datafields, false otherwise. - # AVE presence indicates the record is Alma-E. + # alma_e? receives a parsed XML document (Nokogiri::XML::Document) and returns true if the record + # contains AVE (electronic) datafields. We fall back on the 959 subfield b for legacy records. def self.alma_e?(parsed_xml) - parsed_xml.xpath("//holding:datafield[@tag='AVE']", NAMESPACE).any? + return true if parsed_xml.xpath("//holding:datafield[@tag='AVE']", NAMESPACE).any? + + legacy_net_access?(parsed_xml) + end + + # Some records omit AVE but still indicate electronic access in local 959$b=NET. After consulting + # with Metadata and Enterprise Systems, we learned that this a deprecated practice from before the + # Alma migration. + # + # It is still unclear whether Primo is determining electronic access from this subfield, but it's + # the only electronic access indicator we can find in the record other than AVE. We can revisit + # this approach if it proves ineffective. + def self.legacy_net_access?(parsed_xml) + parsed_xml.xpath("//holding:datafield[@tag='959']/holding:subfield[@code='b']", NAMESPACE) + .any? { |node| node.text.to_s.strip.casecmp('NET').zero? } end # format_availability receives a hash representing a single availability diff --git a/test/models/alma_sru_test.rb b/test/models/alma_sru_test.rb index 6e1de6d8..cfbb65af 100644 --- a/test/models/alma_sru_test.rb +++ b/test/models/alma_sru_test.rb @@ -92,6 +92,54 @@ class AlmaSruTest < ActiveSupport::TestCase end end + test 'alma_e? returns true when AVE is absent but 959 subfield b is NET' do + xml_content = <<~XML + + + + + + 990027661060106761 + + MIT Access Only + n-mit + NET + **See URL(s) + + + + + + + XML + + parsed = Nokogiri::XML(xml_content) + assert_equal true, AlmaSru.alma_e?(parsed) + end + + test 'alma_e? returns false when AVE is absent and 959 subfield b is not NET' do + xml_content = <<~XML + + + + + + 990002941700106761 + + LSA + JRNAL + + + + + + + XML + + parsed = Nokogiri::XML(xml_content) + assert_equal false, AlmaSru.alma_e?(parsed) + end + test 'lookup returns empty list for non-existent records' do VCR.use_cassette('alma sru nonexistent record') do needle = 'alma9900000000006761' From 6c9549bf28b1f0fc5ba3dc7481caf14699b3a07a Mon Sep 17 00:00:00 2001 From: Isra Jazairi Date: Thu, 10 Sep 2026 12:55:58 -0700 Subject: [PATCH 3/4] Remove trailing whitespace --- app/models/alma_sru.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/alma_sru.rb b/app/models/alma_sru.rb index 971c4f35..fa570aa0 100644 --- a/app/models/alma_sru.rb +++ b/app/models/alma_sru.rb @@ -133,7 +133,7 @@ def self.alma_e?(parsed_xml) # Some records omit AVE but still indicate electronic access in local 959$b=NET. After consulting # with Metadata and Enterprise Systems, we learned that this a deprecated practice from before the # Alma migration. - # + # # It is still unclear whether Primo is determining electronic access from this subfield, but it's # the only electronic access indicator we can find in the record other than AVE. We can revisit # this approach if it proves ineffective. From 09ac9fba4b68343212413aaef0709ee377f580e3 Mon Sep 17 00:00:00 2001 From: Isra Jazairi Date: Thu, 10 Sep 2026 14:04:04 -0700 Subject: [PATCH 4/4] Remove full-text options div wrapper to normalize styling --- app/views/alma/sru.html.erb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/views/alma/sru.html.erb b/app/views/alma/sru.html.erb index 7f636a12..4da03e48 100644 --- a/app/views/alma/sru.html.erb +++ b/app/views/alma/sru.html.erb @@ -1,9 +1,7 @@ <% if AlmaSru.enabled? %> <% if @alma_e %> -
- <% fulltext_url = PrimoLinkBuilder.new(record_id: params[:doc_id], context: 'L').full_record_link + '#nui.getit.service_viewit' %> - <%= link_to 'Full-text options', fulltext_url, class: 'button', data: { content_piece: 'Full-text Options' } %> -
+ <% fulltext_url = PrimoLinkBuilder.new(record_id: params[:doc_id], context: 'L').full_record_link + '#nui.getit.service_viewit' %> + <%= link_to 'Full-text options', fulltext_url, class: 'button alma-fulltext-options', data: { content_piece: 'Full-text Options' } %> <% end %> <% if @availability.present? %>