Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Switch to OCLC Discovery API Citation service (from WorldCat Citation service) #4415

Merged
merged 1 commit into from
Sep 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ gem "devise"
gem "devise-guests"
gem 'devise-remote-user'
gem "faraday"
gem 'oauth2'
gem "config"
gem "mods_display", "~> 1.1"
gem "font-awesome-rails"
Expand Down
1 change: 1 addition & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,7 @@ DEPENDENCIES
mysql2
newrelic_rpm
nokogiri (>= 1.7.1)
oauth2
okcomputer
parslet (~> 2.0)
puma (~> 6.0)
Expand Down
12 changes: 12 additions & 0 deletions app/components/citations/citation_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<% citations.each do |style, citation| %>
<div class="mb-4">
<% unless style == 'NULL' %>
<h4><%= t("searchworks.citations.styles.#{style}") %></h4>
<% end %>
<% Array(citation).each do |cite| %>
<div class="mb-2">
<%= cite %>
</div>
<% end %>
</div>
<% end %>
16 changes: 16 additions & 0 deletions app/components/citations/citation_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true

module Citations
class CitationComponent < ViewComponent::Base
attr_reader :citations

def initialize(citations:)
@citations = citations
super()
end

def render?
citations.present?
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<%= render Citations::CitationComponent.new(citations: grouped_citations) %>
40 changes: 40 additions & 0 deletions app/components/citations/grouped_citation_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# frozen_string_literal: true

module Citations
class GroupedCitationComponent < ViewComponent::Base
attr_reader :citations

PREFERRED_CITATION_KEY = 'preferred'

# @param [Array<Hash>] citations in the form of [{ citation_style => citation_text }]
def initialize(citations:)
@citations = citations
super()
end

# @return [Hash] A hash of citations grouped by style in the form of { citation_style => [citation_text] }
def grouped_citations
citation_styles.index_with { |style| citations.pluck(style).compact }
end

def render?
citations.present?
end

private

def citation_styles
keys = citations.map(&:keys).flatten.uniq
# It doesn't make sense to display the NULL citation
# when grouping citations by style so remove it from the list
keys.delete('NULL')

# If the preferred citation is present, move it to the front of the list
# so that it always displays first
return keys unless keys.include?(PREFERRED_CITATION_KEY)

keys.delete(PREFERRED_CITATION_KEY)
keys.unshift(PREFERRED_CITATION_KEY)
end
end
end
13 changes: 13 additions & 0 deletions app/components/citations/multiple_citations_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="all" aria-labelledby="by-title-button">
<% @documents.each do |document| %>
<h3 class="mt-4 mb-3"><%= helpers.document_presenter(document).heading %></h3>
<%= render Citations::CitationComponent.new(citations: citations(document)) %>
<% end %>
</div>
<div role="tabpanel" class="tab-pane" id="biblio" aria-labelledby="by-format-button">
<div class="my-3">
<%= render Citations::GroupedCitationComponent.new(citations: @documents.map { |doc| citations(doc) }) %>
</div>
</div>
</div>
36 changes: 36 additions & 0 deletions app/components/citations/multiple_citations_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# frozen_string_literal: true

module Citations
class MultipleCitationsComponent < ViewComponent::Base
attr_reader :documents, :oclc_citations

# @param [Array<SolrDocument>] documents to generate citations for
# @param [Hash] oclc_citations in the form of { oclc_number => { citation_style => citation_text } }
# for lookup of pre-fetched OCLC citations
def initialize(documents:, oclc_citations:)
@documents = documents
@oclc_citations = oclc_citations
super()
end

# @param [SolrDocument] the document to return citations for
# @return [Hash] A hash of citations for the supplied document in the form of { citation_style => [citation_text] }
def citations(document)
citation_hash = {}

citation_hash.merge!(document.mods_citations)
citation_hash.merge!(document.eds_citations)
citation_hash.merge!(oclc_citation(document))

citation_hash.presence || Citation::NULL_CITATION
end

private

def oclc_citation(document)
return {} if document.oclc_number.blank?

oclc_citations.fetch(document.oclc_number, {})
end
end
end
10 changes: 10 additions & 0 deletions app/controllers/catalog_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,16 @@ def email
end
end

# Overridden from Blacklight to pre-fetch OCLC citations in bulk
# when more than one document's citation is being displayed.
def citation
@response, @documents = search_service.fetch(Array(params[:id]))
return unless @documents.size > 1

oclc_numbers = @documents.filter_map { |document| document.oclc_number.presence }
@oclc_citations = Citations::OclcCitation.new(oclc_numbers:).citations_by_oclc_number
end

def stackmap
params.require(:library) # Sometimes bots are calling this service without providing required parameters. Raise an error in this case.
render layout: !request.xhr?
Expand Down
4 changes: 0 additions & 4 deletions app/helpers/catalog_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,6 @@ def link_to_database_search(subject)
link_to(subject, search_catalog_path(f: { db_az_subject: [subject], SolrDocument::FORMAT_KEY => ['Database'] }))
end

def grouped_citations(documents)
Citation.grouped_citations(documents.map(&:citations))
end

def tech_details(document)
details = []
details.push link_to(
Expand Down
132 changes: 31 additions & 101 deletions app/models/citation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,147 +2,77 @@

###
# Citation is a simple class that takes a Hash like object (SolrDocument)
# and returns a hash of citations for the configured formats
# and returns a hash of citations
class Citation
def initialize(document, formats = [])
NULL_CITATION = { 'NULL' => '<p>No citation available for this record</p>'.html_safe }.freeze

# @param document [SolrDocument] A document to generate citations for
def initialize(document)
@document = document
@formats = formats
end

# @return [Boolean] Whether or not the document is citable
def citable?
field.present? || citations_from_mods.present? || citations_from_eds.present?
show_oclc_citation? || citations_from_mods.present? || citations_from_eds.present?
end

# @return [Hash] A hash of all citations for the document
# in the form of { citation_style => [citation_text] }
def citations
return null_citation if return_null_citation?
return all_citations if all_formats_requested?

all_citations.select do |format, _|
desired_formats.include?(format)
end
all_citations.presence || NULL_CITATION
end

def api_url
"#{base_url}/#{field}?cformat=all&wskey=#{api_key}"
# @return [Hash] A hash of MODS citations for the document
# Used when assembling citations for multiple documents
# in the form of { citation_style => [citation_text] }
def mods_citations
citations_from_mods.presence || {}
end

class << self
def grouped_citations(all_citations)
citations = all_citations.each_with_object({}) do |cites, hash|
cites.each do |format, citation|
hash[format] ||= []
hash[format] << citation
end
end
# Append preferred citations to front of hash
citations = {
preferred_citation_key => citations[preferred_citation_key]
}.merge(citations.except(preferred_citation_key)) if citations[preferred_citation_key]
citations
end

def preferred_citation_key
'PREFERRED CITATION'
end

# This being a valid test URL is predicated on the fact
# that passing no OCLC number to the citations API responds successfully
def test_api_url
new(SolrDocument.new).api_url
end
# @return [Hash] A hash of EDS citations for the document
# Used when assembling citations for multiple documents
# in the form of { citation_style => [citation_text] }
def eds_citations
citations_from_eds.presence || {}
end

private

attr_reader :document, :formats
attr_reader :document

def return_null_citation?
all_citations.blank? || (field.blank? && all_citations.blank?)
end

def element_is_citation?(element)
element.attributes &&
element.attributes['class'] &&
element.attributes['class'].value =~ /^citation_style_/i
end

def all_formats_requested?
desired_formats == ['ALL']
end
delegate :oclc_number, to: :document

def all_citations
@all_citations ||= begin
citation_hash = {}
if citations_from_mods.present?
citation_hash[self.class.preferred_citation_key] = "<p>#{citations_from_mods}</p>".html_safe
end

citation_hash.merge!(citations_from_mods) if citations_from_mods.present?
citation_hash.merge!(citations_from_eds) if citations_from_eds.present?
citation_hash.merge!(citations_from_oclc) if citations_from_oclc.present?

citation_hash.merge!(citations_from_oclc_response) if field.present?
citation_hash
end
end

def citations_from_oclc_response
Nokogiri::HTML(response).css('p').each_with_object({}) do |element, hash|
next unless element_is_citation?(element)
def citations_from_oclc
return unless show_oclc_citation?

element.attributes['class'].value[/^citation_style_(.*)$/i]
hash[Regexp.last_match[1].upcase] = element.to_html.html_safe
end
@citations_from_oclc ||= Citations::OclcCitation.new(oclc_numbers: oclc_number).citations_by_oclc_number.fetch(oclc_number, {})
end

def citations_from_mods
return unless document.mods && document.mods.note.present?

document.mods.note.find do |note|
note.label.downcase =~ /preferred citation:?/
end.try(:values).try(:join)
@citations_from_mods ||= Citations::ModsCitation.new(notes: document.mods.note).all_citations
end

def citations_from_eds
return unless document.eds? && document['eds_citation_styles'].present?

document['eds_citation_styles'].each_with_object({}) do |citation, hash|
next unless citation['id'] && citation['data']

hash[citation['id'].upcase] = citation['data'].html_safe
end
end

def response
@response ||= begin
Faraday.get(api_url).body
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
Rails.logger.warn("HTTP GET for #{api_url} failed with #{e}")
''
end
end

def field
Array(document[config.DOCUMENT_FIELD]).try(:first)
end

def desired_formats
return config.CITATION_FORMATS.map(&:upcase) unless formats.present?

formats.map(&:upcase)
end

def base_url
config.BASE_URL
end

def api_key
config.API_KEY
end

def config
Settings.OCLC
@citations_from_eds ||= Citations::EdsCitation.new(eds_citations: document['eds_citation_styles']).all_citations
end

def null_citation
{ 'NULL' => '<p>No citation available for this record</p>'.html_safe }
def show_oclc_citation?
Settings.oclc_discovery.citations.enabled && oclc_number.present?
end
end
29 changes: 29 additions & 0 deletions app/models/citations/eds_citation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# frozen_string_literal: true

###
# Returns an EDS citation formatted for use by SearchWorks
module Citations
class EdsCitation
CITATION_STYLES = %w[apa chicago harvard mla turabian].freeze

attr_reader :eds_citations

# @param eds_citations [Array<Hash>] An array of EDS citations
def initialize(eds_citations:)
@eds_citations = eds_citations
end

# @return [Hash] A hash with citation styles as keys and citation text as values.
def all_citations
matching_styles.index_with do |id|
eds_citations.select { |style| style.fetch('id', nil) == id }.pick('data')&.html_safe # rubocop:disable Rails/OutputSafety
end.compact
end

private

def matching_styles
eds_citations.pluck('id').select { |id| CITATION_STYLES.include?(id) }
end
end
end
Loading