diff --git a/app/assets/images/bip-minimal.svg b/app/assets/images/bip-minimal.svg new file mode 100644 index 000000000..7579d0dec --- /dev/null +++ b/app/assets/images/bip-minimal.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index f2ad52044..48a5fd88e 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -48,6 +48,7 @@ @import "pages/about"; @import "pages/account"; @import "pages/activities"; +@import "pages/bip-scholar"; @import "pages/collaborations"; @import "pages/collection"; @import "pages/curators"; diff --git a/app/assets/stylesheets/pages/bip-scholar.scss b/app/assets/stylesheets/pages/bip-scholar.scss new file mode 100644 index 000000000..364658e3a --- /dev/null +++ b/app/assets/stylesheets/pages/bip-scholar.scss @@ -0,0 +1,110 @@ +.bip-scholar-embed { +margin: 6px 0; +display: block; + +.bip-scholar-badge { + * { box-sizing: border-box; } + + display: inline-flex; + align-items: center; + gap: 0; + background: #ffffff; + border: 1px solid #e0e0e0; + border-radius: 10px; + padding: 8px 14px; + box-shadow: 0 2px 8px rgba(0,0,0,0.08); + cursor: pointer; + font-family: 'Nunito', sans-serif; + position: relative; + transition: box-shadow 0.2s; + text-decoration: none !important; /* Force override global links */ + color: inherit !important; + + &:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.14); } +} + +.bip-scholar-stat { + display: flex; + flex-direction: column; + align-items: center; + padding: 0 10px; + min-width: 54px; + + & + .bip-scholar-stat { border-left: 1px solid #e8e8e8; } +} + +.bip-scholar-stat--group-sep { + border-left: 2px solid #c8c8c8 !important; + margin-left: 2px; +} + +.bip-scholar-stat-value { + font-size: 15px; + font-weight: 700; + color: #1a1a1a; + line-height: 1.2; + white-space: nowrap; +} + +.bip-scholar-stat-label { + font-size: 10px; + font-weight: 600; + color: #9e9e9e; + text-transform: uppercase; + letter-spacing: 0.03em; + margin-top: 2px; + white-space: nowrap; +} + +.bip-scholar-stat-icon { + font-size: 11px; + margin-right: 3px; + color: #757575; +} + +.bip-scholar-badge--compact { + .bip-scholar-stat { + min-width: auto; + padding: 0 8px; + } + .bip-scholar-stat-value { font-size: 14px; } + .bip-scholar-stat-icon { + font-size: 12px; + margin-right: 5px; + } +} + +.bip-scholar-logo { + display: flex; + align-items: center; + padding-right: 12px; + margin-right: 2px; + border-right: 1px solid #e8e8e8; + font-weight: bold; + color: #1a1a1a; +} + +.bip-scholar-no-profile { + display: inline-flex; + align-items: center; + gap: 7px; + background: #fafafa; + border: 1px dashed #d0d0d0; + border-radius: 10px; + padding: 7px 14px; + font-family: 'Nunito', sans-serif; + font-size: 12px; + color: #aaa; + + a { + color: #2e7d32; + font-weight: 600; + text-decoration: none; + opacity: 0.8; + &:hover { + text-decoration: underline; + opacity: 1; + } + } +} +} \ No newline at end of file diff --git a/app/helpers/bip_scholar_helper.rb b/app/helpers/bip_scholar_helper.rb new file mode 100644 index 000000000..35ca50672 --- /dev/null +++ b/app/helpers/bip_scholar_helper.rb @@ -0,0 +1,47 @@ +module BipScholarHelper + require 'net/http' + require 'cgi' + require 'json' + + def self.fetch_score(orcid) + return nil if orcid.blank? + + # Cache in Redis for 24 hours to prevent blocking TeSS web threads + Rails.cache.fetch("bip_scholar_score_#{orcid}", expires_in: 24.hours, skip_nil: true) do # `skip_nil: true` to avoid caching nil and then make the widget disappear + uri = URI("https://bip-api.imsi.athenarc.gr/scholar/scores/#{CGI.escape(orcid)}") + + # Use timeout to prevent hanging connections + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + http.read_timeout = 3 + http.open_timeout = 2 + + response = http.get(uri.request_uri) + + response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body) : nil + rescue StandardError => e + Rails.logger.error("BIP!Scholar API Error for #{orcid}: #{e.message}") + nil + end + end + + def format_scholar_number(num) + num.present? ? number_with_delimiter(num) : "—" + end + + def parse_work_types(data) + wt = data['work_types_num'] + if wt.is_a?(Array) + wt + elsif wt.is_a?(Hash) + [ + wt['papers'], + wt['dataset'] || wt['datasets'], + wt['software'], + wt['other'] + ] + else + [nil, nil, nil, nil] + end + end +end \ No newline at end of file diff --git a/app/views/common/_bip_scholar_infographics.html.erb b/app/views/common/_bip_scholar_infographics.html.erb new file mode 100644 index 000000000..444a78b7d --- /dev/null +++ b/app/views/common/_bip_scholar_infographics.html.erb @@ -0,0 +1,59 @@ +<% + # for the `layout` and `empty_mode` parameters, see https://github.com/athenarc/bip-plugin/tree/main + data = BipScholarHelper.fetch_score(orcid) + is_compact = local_assigns.fetch(:layout, 'default') == 'compact' # compact allows to hide most of the words, and shows only icons and numbers + empty_mode = local_assigns.fetch(:empty_mode, 'default') # empty_mode hides BIP!Scholar widget if no ORCID on BIP!Scholar is found +%> + +
> + <% if data.nil? %> + <% unless empty_mode == 'silent' %> +
+ + No BIP! Scholar profile found for this ORCID. + <%= link_to "Learn more ↗", "https://bip.imsi.athenarc.gr/scholar", target: "_blank", rel: "noopener" %> +
+ <% end %> + <% else %> + <% + wt = parse_work_types(data) + pct = data.dig('openness', 'open_percentage') ? "#{data['openness']['open_percentage'].to_i}%" : "—" + age = data['academic_age'] ? "#{data['academic_age']} yrs" : "—" + profile_url = "https://bip.imsi.athenarc.gr/scholar/profile/#{CGI.escape(orcid)}" + + stats = [ + { icon: "file-text-o", value: format_scholar_number(wt[0]), label: "Publications" }, + { icon: "database", value: format_scholar_number(wt[1]), label: "Datasets" }, + { icon: "code", value: format_scholar_number(wt[2]), label: "Software" }, + { icon: "archive", value: format_scholar_number(wt[3]), label: "Other" }, + { icon: "quote-left", value: format_scholar_number(data['citations_num']), label: "Citations", sep: true }, + { icon: "fire", value: format_scholar_number(data['popular_works_count']), label: "Popular" }, + { icon: "university", value: format_scholar_number(data['influential_works_count']), label: "Influential" }, + { icon: "line-chart", value: format_scholar_number(data['h_index']), label: "h-index" }, + { icon: "hourglass", value: age, label: "Acad. Age", sep: true }, + { icon: "unlock-alt", value: pct, label: "Open Access", sep: true } + ] + %> + + <%= link_to profile_url, target: "_blank", rel: "noopener", class: "bip-scholar-badge#{' bip-scholar-badge--compact' if is_compact}" do %> + + + <% stats.each do |stat| %> +
+ + + <% if is_compact %> + <%= stat[:label] %>: + <% end %> + <%= stat[:value] %> + + <% unless is_compact %> + <%= stat[:label] %> + <% end %> +
+ <% end %> + <% end %> + <% end %> +
\ No newline at end of file diff --git a/app/views/trainers/show.html.erb b/app/views/trainers/show.html.erb index f4627a69d..4d140a8ca 100644 --- a/app/views/trainers/show.html.erb +++ b/app/views/trainers/show.html.erb @@ -23,6 +23,9 @@
<%= orcid_link(@trainer, class: 'h5', track: true) %>
+ + + <%= render 'common/bip_scholar_infographics', orcid: @trainer.orcid, layout: 'default', empty_mode: 'default' %> <% end %> diff --git a/test/controllers/trainers_controller_test.rb b/test/controllers/trainers_controller_test.rb index e1063d8b5..1837c292a 100644 --- a/test/controllers/trainers_controller_test.rb +++ b/test/controllers/trainers_controller_test.rb @@ -61,4 +61,64 @@ class TrainersControllerTest < ActionController::TestCase assert_not_nil external_resource assert_equal external_resource.url, json_event['mentions'].first['url'] end + + test 'should show bip-scholar embed div when trainers feature is enabled' do + with_settings(feature: { trainers: true }) do + get :show, params: { id: @trainer } + assert_response :success + assert_select 'div.bip-scholar-embed[data-orcid="' + @trainer.orcid + '"]' + end + end + + test 'should not show bip-scholar embed div when trainers feature is disabled' do + with_settings(feature: { trainers: false }) do + assert_raises ActionController::RoutingError do + get :show, params: { id: @trainer } + end + end + end + + test 'should show default fallback message when no profile found and empty_mode is default' do + BipScholarHelper.stub :fetch_score, nil do + get :show, params: { id: @trainer } + assert_response :success + + assert_select 'div.bip-scholar-no-profile', text: /No BIP! Scholar profile found/ + end + end + + test 'should show bip-scholar embed in compact layout' do + fake_data = { + 'work_types_num' => [12, 3, 4, 1], + 'citations_num' => 5, + 'popular_works_count' => 1, + 'influential_works_count' => 2, + 'h_index' => 3, + 'academic_age' => 10, + 'openness' => { 'open_percentage' => 80 } + } + BipScholarHelper.stub :fetch_score, fake_data do + html = @controller.render_to_string( + partial: 'common/bip_scholar_infographics', + locals: { orcid: @trainer.orcid, layout: 'compact', empty_mode: 'default' } + ) + doc = Nokogiri::HTML(html) + + assert_equal 1, doc.css('a.bip-scholar-badge.bip-scholar-badge--compact').size + end + end + + test 'should show nothing when empty_mode is silent' do + BipScholarHelper.stub :fetch_score, nil do + # Simulates passing empty_mode: 'silent' in view partial render + html = @controller.render_to_string( + partial: 'common/bip_scholar_infographics', + locals: { orcid: @trainer.orcid, layout: 'default', empty_mode: 'silent' } + ) + doc = Nokogiri::HTML(html) + + assert_equal 0, doc.css('div.bip-scholar-no-profile').size + assert_equal 0, doc.css('a.bip-scholar-badge').size + end + end end