Skip to content

Commit

Permalink
Merge pull request #4415 from sul-dlss/oclc-citations
Browse files Browse the repository at this point in the history
Switch to OCLC Discovery API Citation service (from WorldCat Citation service)
  • Loading branch information
jcoyne authored Sep 17, 2024
2 parents 909e448 + a00cfba commit 7c9bc3d
Show file tree
Hide file tree
Showing 37 changed files with 685 additions and 312 deletions.
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

0 comments on commit 7c9bc3d

Please sign in to comment.