Skip to content
Open
15 changes: 15 additions & 0 deletions app/controllers/alma_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
class AlmaController < ApplicationController
layout false

def sru
return unless AlmaSru.enabled? && expected_params?

@availability = AlmaSru.lookup(params[:doc_id])
end

private

def expected_params?
params[:doc_id].present?
end
end
38 changes: 1 addition & 37 deletions app/helpers/record_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -139,36 +139,6 @@ def deduplicate_subjects(subjects)
subjects.map { |subject| subject['value'].uniq(&:downcase) }.uniq { |values| values.map(&:downcase) }
end

# Formats availability information for display.
# Expects:
# - status: a string indicating availability status
# - location: an array with three elements: [library name, location name, call number]
# - other_availability: a boolean string indicating if there is availability at other locations
def availability(status, location, other_availability)
blurb = case status.downcase
# `available` is a common status used in Alma/Primo VE for items that are not checked out and should be
# on the shelf
when 'available'
"#{icon('check')} Available in #{location(location)}"
# `check_holdings`: unclear when (or if) this is used. Bento handled this so we did too assuming it was
# meaningful
when 'check_holdings'
"#{icon('question')} May be available in #{location(location)}"
# 'unavailable' is used for items that are checked out, missing, or otherwise not on the shelf
when 'unavailable'
"#{icon('times')} Not currently available in #{location(location)}"
# Unclear if there are other statuses we should handle here. For now we log and display a generic message.
else
Rails.logger.error("Unhandled availability status: #{status.inspect}")
"#{icon('question')} Uncertain availability in #{location(location)} #{status}"
end

blurb += ' and other locations.' if other_availability.present?

# We are generating HTML in this helper, so we need to mark it as safe or it will be escaped in the view.
blurb.html_safe
end

# Fontawesome helper.
#
# Accepts name of the icon and optionally a collection. Defaults to solid sharp collection.
Expand All @@ -177,13 +147,7 @@ def availability(status, location, other_availability)
# purely for decoration. If an icon is used in a more meaningful way, we should extend this helper
# to allow passing in additional aria attributes rather than always hiding.
def icon(fa, collection = 'fa-sharp fa-solid')
"<i class='#{collection} fa-#{fa}' aria-hidden='true''></i>"
end

# Formats location information for availability display.
# Expects an array with three elements: [library name, location name, call number]
def location(loc)
"<strong>#{loc[:name]}</strong> #{loc[:collection]} (#{loc[:call_number]})"
"<i class='#{collection} fa-#{fa}' aria-hidden='true'></i>"
end

private
Expand Down
2 changes: 1 addition & 1 deletion app/helpers/results_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ def full_record_url(result)
# @return [Boolean] True if the result has links, availability, or ThirdIron/OpenAlex triggers
def result_get?(result)
renderable_links?(result) ||
result[:availability].present? ||
AlmaSru.valid_alma_id?(result[:identifier]) ||
(Feature.enabled?(:oa_always) && article?(result[:format])) ||
thirdiron_content?(result)
end
Expand Down
22 changes: 20 additions & 2 deletions app/javascript/controllers/content_loader_controller.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
static values = { url: String }
static values = { url: String, lazyLoading: Boolean }

connect() {
this.load()
if (this.lazyLoadingValue) {
// The content loader included a lazy loading directive.
this.observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
this.load()
this.observer.disconnect()
}
}
)
this.observer.observe(this.element)
} else {
// Load the content immediately.
this.load()
}
}

disconnect() {
this.observer?.disconnect()
}

load() {
Expand Down
94 changes: 67 additions & 27 deletions app/models/alma_sru.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@ class InvalidAlmaId < StandardError; end
#
# It accepts an "alma_client" argument for use when testing, but this is not used in normal operations.
def self.lookup(raw_identifier, alma_client: nil)
return [] unless alma_sru_enabled?
return [] unless enabled?

# Validate the raw identifier received. This will raise an InvalidAlmaId if validation fails.
identifier = validate_alma_id(raw_identifier)
raise InvalidAlmaId unless valid_alma_id?(raw_identifier)

# Extract numeric portion from provided raw identifier
identifier = extract_alma_id(raw_identifier)

# Build URL
url = alma_sru_url(identifier)
Expand Down Expand Up @@ -68,24 +71,12 @@ def self.parse_response(raw_response, reference_identifier)
# Look up all AVA tags
parsed_availabilities = fetch_availabilities(parsed)

parsed_availabilities.map(&method(:format_availability))
end
# Format list of entries
results = parsed_availabilities.map(&method(:format_availability))

# validate_alma_id ensures we are only submitting valid Alma IDs to the SRU endpoint.
#
# It needs to do two things:
# 1. Remove the "alma" prefix if one is present. Otherwise, no manipulation of the submitted value should occur.
# 2. Enforce the formatting requirements for a valid alma identifier (start with "99", and end with "6761").
def self.validate_alma_id(raw)
parsed = if raw.to_s.start_with?('alma')
raw.delete_prefix('alma')
else
raw
end

raise InvalidAlmaId unless parsed.present? && parsed.match?(/\A99\d+6761\z/)

parsed
# Reduce list to a single item if multiples exist
results[0] += ' and other locations' if results.length > 1
results.first(1)
end

# ava_to_hash takes an XML element that represents a single availability record
Expand Down Expand Up @@ -132,23 +123,72 @@ def self.format_availability(availability)
return ''
end

phrase = "#{availability['e']&.humanize} at #{availability['q']} #{availability['c']}".squish
phrase += " (#{availability['d']})" if availability['d'].present?

phrase = "#{_phrase_start(ERB::Util.html_escape(availability['e'].to_s))} <strong>#{ERB::Util.html_escape(availability['q'].to_s)}</strong> " \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line is too long. [147/120] [rubocop:Layout/LineLength]

"#{ERB::Util.html_escape(availability['c'].to_s)}".squish
phrase += " (#{ERB::Util.html_escape(availability['d'].to_s)})" if availability['d'].present?
phrase

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assignment Branch Condition size for format_availability is too high. [<2, 21, 3> 21.31/17] [rubocop:Metrics/AbcSize]

end

def self._phrase_start(status)
case status
when 'available'
"#{_icon('check')} Available in "
when 'check_holdings'
"#{_icon('question')} May be available in "
when 'unavailable'
"#{_icon('times')} Not currently available in "
else
Rails.logger.error("Unhandled availability status: #{status}")
"#{_icon('question')} Uncertain availability (#{status.humanize}) in "
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Method has too many lines. [11/10] [rubocop:Metrics/MethodLength]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method has one construct - the case statement for building message text based on the item's status. I'm not sure how this can be made shorter, except for maybe dropping the error handling for the unexpected branch - but I'm not certain that's a good idea.

I am open to another way of handling an unexpected status - maybe a Sentry exception would be more appropriate - but this would still result in a method of the same length.

end

def self._icon(icon, collection = 'fa-sharp fa-solid')
"<i class='#{collection} fa-#{icon}' aria-hidden='true'></i>"
end

def self.alma_base_url
ENV.fetch('MIT_ALMA_URL', nil)
end

def self.alma_sru_enabled?
if alma_base_url.to_s.empty? || exl_inst_id.to_s.empty?
Sentry.capture_message('Alma SRU not enabled')
return false
def self.enabled?
return @enabled if instance_variable_defined?(:@enabled)

@enabled = alma_base_url.to_s.present? && exl_inst_id.to_s.present?
Sentry.capture_message('Alma SRU not enabled') unless @enabled

@enabled
end
Comment on lines 154 to 161

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is addressed (perhaps not resolved entirely) in the next commit


# extract_alma_id receives a hypothetical document ID that references an alma
# record, and strips out any `alma` prefix which _may_ exist. This is a
# compensating strategy for our discovery environment attaching this prefix to
# flag the record as coming from alma, rather than other collections.
def self.extract_alma_id(raw)
if raw.to_s.start_with?('alma')
raw.to_s.delete_prefix('alma')
else
raw.to_s
end
end

true
# valid_alma_id? receives a document identifier and attempts to determine
# whether it is a reference to an alma document. This involves using a regular
# expression to confirm five attributes:
# 1. The identifier can be converted to a string
# 2. The identifier may begin with "alma"
# 3. After any "alma" prefix, the next two characters must be "99"
# 4. Following this must be a sequence of only digits
# 5. The identifier must end with "6761"
#
# The method returns "true" if all of these conditions are met, otherwise it
# returns "false". No further action is taken for failing results, as this
# method is called for a wide range of identifiers.
def self.valid_alma_id?(raw)
return false unless raw.present?
return true if raw.to_s.match?(/\A(alma)?99\d+6761\z/)

false
end

def self.alma_sru_url(identifier)
Expand Down
16 changes: 0 additions & 16 deletions app/models/normalize_primo_record.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@ def normalize
numbering:,
chapter_numbering:,
thumbnail:,
availability:,
other_availability:,
dedup_record: dedup_url.present?
}
end
Expand Down Expand Up @@ -317,20 +315,6 @@ def subjects
subs.flat_map { |sub| sub.split(' ; ') }
end

def availability
return unless location

@record['delivery']['bestlocation']['availabilityStatus']
end

def other_availability
return unless @record['delivery']
return unless @record['delivery']['bestlocation']
return unless @record['delivery']['holding']

@record['delivery']['holding'].length > 1
end

# FRBR Group check based on:
# https://knowledge.exlibrisgroup.com/Primo/Knowledge_Articles/Primo_Search_API_-_how_to_get_FRBR_Group_members_after_a_search
def frbrized?
Expand Down
9 changes: 9 additions & 0 deletions app/views/alma/sru.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<% if AlmaSru.enabled? && @availability.present? %>
<div class="availability">
<% @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' }) %><br>
<% end %>
</div>
<% end %>
10 changes: 2 additions & 8 deletions app/views/search/_result_primo.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,8 @@
<%= render(partial: 'trigger_openalex_setup', locals: { doi: result[:doi], pmid: result[:pmid] }) %>
<% end %>

<% if result[:availability].present? %>
<div class="availability">
<% if result[:links]&.find { |link| link['kind'] == 'full record' } %>
<%= link_to(availability(result[:availability], result[:location], result[:other_availability]), result[:links].find { |link| link['kind'] == 'full record' }['url'], data: { content_piece: 'Availability Link' }) %>
<% else %>
<%= availability(result[:availability], result[:location], result[:other_availability]) %>
<% end %>
</div>
<% if AlmaSru.enabled? && AlmaSru.valid_alma_id?(result[:identifier]) %>
<%= render(partial: 'trigger_almasru', locals: { doc_id: result[:identifier] }) %>
<% end %>
Comment thread
matt-bernhardt marked this conversation as resolved.

<%# Trigger BrowZine lookup (render inside result-get so injected HTML
Expand Down
10 changes: 10 additions & 0 deletions app/views/search/_trigger_almasru.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<% return unless AlmaSru.enabled? %>

<% data_url = almasru_path(doc_id: doc_id) %>

<span class="availability-container"
data-controller="content-loader"
data-content-loader-url-value="<%= data_url %>"
data-content-loader-lazy-loading-value="">
<span class="skeleton-loader">&nbsp;</span>
</span>
3 changes: 2 additions & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@

get 'analyze', to: 'tacos#analyze'

get 'libkey', to: 'thirdiron#libkey'
get 'almasru', to: 'alma#sru'
get 'browzine', to: 'thirdiron#browzine'
get 'libkey', to: 'thirdiron#libkey'
get 'oa_work', to: 'openalex#work'

get 'record/(:id)',
Expand Down
67 changes: 67 additions & 0 deletions test/controllers/alma_controller_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
require 'test_helper'

class AlmaControllerTest < ActionDispatch::IntegrationTest
test 'alma sru route exists with no content' do
get almasru_path

assert_response :success
assert response.body.blank?
end

test 'alma sru route returns nothing for gibberish content' do
needle = 'foo'
get almasru_path(doc_id: needle)

assert_response :success
assert response.body.blank?
end

test 'alma sru route returns nothing if lookup returns content with no AVA' do
VCR.use_cassette('alma sru no availability') do
needle = 'alma9935053423706761'
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
VCR.use_cassette('alma sru single record') do
needle = 'alma990014651640106761'
get almasru_path(doc_id: needle)

assert_response :success
assert_select 'div.availability a', { count: 1 }
refute_includes response.body, 'and other locations'
end
end

test 'alma sru route returns one statement including "and other locations" with multiple AVA' do
VCR.use_cassette('alma sru multiple records') do
needle = 'alma990002935920106761'
get almasru_path(doc_id: needle)

assert_response :success
assert_select 'div.availability a', { count: 1 }
assert_includes response.body, 'and other locations'
end
end

test 'alma sru route does nothing for valid id if required env undefined' do
VCR.use_cassette('alma sru single record') do
needle = 'alma990014651640106761'
get almasru_path(doc_id: needle)

assert_response :success
refute response.body.blank?

ClimateControl.modify(MIT_ALMA_URL: nil) do
get almasru_path(doc_id: needle)

assert_response :success
assert response.body.blank?
end
end
end
end
Loading