<%= display_attribute_no_label(material, :resource_type) { |values| values.join(', ') } %>
diff --git a/app/views/materials/show.html.erb b/app/views/materials/show.html.erb
index 81131bcc6..0cd525900 100644
--- a/app/views/materials/show.html.erb
+++ b/app/views/materials/show.html.erb
@@ -3,6 +3,7 @@
<%= render partial: 'learning_paths/partials/navigation',
locals: { topic_link: @learning_path_topic_link,
topic_item: @learning_path_topic_item } if @learning_path_topic_link && @learning_path_topic_item %>
+ <%= render partial: 'common/origin_info', locals: { resource: @material } %>
<%= render partial: 'content_providers/partials/content_provider_info', locals: { content_provider: @material.content_provider } %>
<%= render partial: 'nodes/partials/associated_node_info', locals: { associated_nodes: @material.associated_nodes } %>
<%= render(partial: 'users/partials/user_info', locals: { user: @material.user }) if current_user.try(:is_admin?) %>
diff --git a/config/initializers/oai_provider.rb b/config/initializers/oai_provider.rb
index d2ae2a552..97e58b827 100644
--- a/config/initializers/oai_provider.rb
+++ b/config/initializers/oai_provider.rb
@@ -12,12 +12,27 @@ def initialize
end
end
+module OAI::Provider::Response
+ class RecordResponse < Base
+ private
+
+ # Allow for a custom identifier to be used in the record without affecting the query (which happens with `identifier_field`)
+ def identifier_for(record)
+ "#{provider.prefix}:#{record.respond_to?(:oai_identifier) ? record.oai_identifier : record.send(provider.model.identifier_field)}"
+ end
+ end
+end
+
class TrainingProvider < OAI::Provider::Base
repository_name TeSS::Config.site['title']
repository_url "#{TeSS::Config.base_url}/oai-pmh"
record_prefix "oai:#{URI(TeSS::Config.base_url).host}"
admin_email TeSS::Config.contact_email
- sample_id '142' # so that example id is oai:domain:142
+ sample_id 'materials/142' # so that example id is oai:domain:materials/142
+ extra_description %(
+
+
+ )
register_format(OAIRDF.instance)
end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 727d7ca70..5cb9f52b4 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -1188,4 +1188,8 @@ en:
any: Any time
week: Within the last week
month: Within the last month
- year: Within the last year
\ No newline at end of file
+ year: Within the last year
+ exchange:
+ title: Original entry
+ info: "This %{resource_type} originated from another TeSS registry. For complete, up-to-date information, please visit the original entry:"
+ link: Go to original entry
\ No newline at end of file
diff --git a/db/migrate/20260710154329_add_origin_uri_to_tables.rb b/db/migrate/20260710154329_add_origin_uri_to_tables.rb
new file mode 100644
index 000000000..65b3d961b
--- /dev/null
+++ b/db/migrate/20260710154329_add_origin_uri_to_tables.rb
@@ -0,0 +1,6 @@
+class AddOriginUriToTables < ActiveRecord::Migration[8.1]
+ def change
+ add_column :materials, :origin_uri, :string
+ add_column :events, :origin_uri, :string
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index e87ff4c9b..5bec071c7 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_04_21_144919) do
+ActiveRecord::Schema[8.1].define(version: 2026_07_10_154329) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -212,6 +212,7 @@
t.text "notes"
t.string "open_science", default: [], array: true
t.string "organizer"
+ t.string "origin_uri"
t.string "postcode"
t.text "prerequisites"
t.integer "presence", default: 0
@@ -371,6 +372,7 @@
t.date "last_scraped"
t.text "learning_objectives"
t.string "licence", default: "notspecified"
+ t.string "origin_uri"
t.string "other_types"
t.text "prerequisites"
t.date "remote_created_date"
diff --git a/lib/ingestors/event_ingestion.rb b/lib/ingestors/event_ingestion.rb
index 737a685e0..4dc1cca6c 100644
--- a/lib/ingestors/event_ingestion.rb
+++ b/lib/ingestors/event_ingestion.rb
@@ -6,7 +6,6 @@ def add_event(event)
if event.is_a?(Hash)
c = EventsController.new
c.params = { event: event }
- c.send(:event_params)
event = OpenStruct.new(c.send(:event_params))
end
event = handle_auto_parsing(event)
diff --git a/lib/ingestors/material_ingestion.rb b/lib/ingestors/material_ingestion.rb
index 81c8fadae..62c4ce407 100644
--- a/lib/ingestors/material_ingestion.rb
+++ b/lib/ingestors/material_ingestion.rb
@@ -6,7 +6,6 @@ def add_material(material)
if material.is_a?(Hash)
c = MaterialsController.new
c.params = { material: material }
- c.send(:material_params)
material = OpenStruct.new(c.send(:material_params))
end
material = handle_auto_parsing(material)
diff --git a/lib/ingestors/oai_pmh_ingestor.rb b/lib/ingestors/oai_pmh_ingestor.rb
index fff2426e1..386eb1a48 100644
--- a/lib/ingestors/oai_pmh_ingestor.rb
+++ b/lib/ingestors/oai_pmh_ingestor.rb
@@ -22,8 +22,11 @@ def initialize
def read(source_url)
client = OAI::Client.new source_url, headers: { 'From' => config[:mail], 'User-Agent' => config[:user_agent] }
+
+ tess_instance = client.identify.descriptions.any? { |d| d.get_elements('//tess-instance').any? }
+
found_bioschemas = begin
- read_oai_rdf(client)
+ read_oai_rdf(client, tess_instance: tess_instance)
rescue OAI::ArgumentException
false
end
@@ -75,7 +78,7 @@ def read_oai_dublin_core(client)
@messages << "found #{count} records"
end
- def read_oai_rdf(client)
+ def read_oai_rdf(client, tess_instance: false)
provider_events = []
provider_materials = []
totals = Hash.new(0)
@@ -86,6 +89,17 @@ def read_oai_rdf(client)
output = parse_bioschemas(bioschemas_xml)
next unless output
+ extra_metadata = {}
+ if tess_instance
+ origin_uri = metadata_tag.at_xpath('//rdf:RDF/*/@rdf:about', 'rdf' => 'http://www.w3.org/1999/02/22-rdf-syntax-ns#')&.value
+ extra_metadata[:origin_uri] = origin_uri
+ end
+
+ extra_metadata.each do |key, value|
+ output[:resources][:events].each { |r| r[key] ||= value }
+ output[:resources][:materials].each { |r| r[key] ||= value }
+ end
+
provider_events += output[:resources][:events]
provider_materials += output[:resources][:materials]
output[:totals].each do |key, value|
diff --git a/test/controllers/events_controller_test.rb b/test/controllers/events_controller_test.rb
index e3004fe7b..984f6a0a6 100644
--- a/test/controllers/events_controller_test.rb
+++ b/test/controllers/events_controller_test.rb
@@ -1704,4 +1704,19 @@ class EventsControllerTest < ActionController::TestCase
end
end
end
+
+ test 'should link back to original entry if origin URI set' do
+ get :show, params: { id: @event }
+ assert_response :success
+
+ assert_select '#origin-info', count: 0
+
+ uri = 'https://some-tess-instance.org/events/123'
+ @event.update(origin_uri: uri)
+
+ get :show, params: { id: @event }
+ assert_response :success
+
+ assert_select '#origin-info a[href=?]', uri
+ end
end
diff --git a/test/controllers/materials_controller_test.rb b/test/controllers/materials_controller_test.rb
index 0d390b046..07f1d1b14 100644
--- a/test/controllers/materials_controller_test.rb
+++ b/test/controllers/materials_controller_test.rb
@@ -1706,4 +1706,19 @@ class MaterialsControllerTest < ActionController::TestCase
assert_select 'p.scientific_topics a[href="http://edamontology.org/topic_0622"]', text: 'Genomics'
assert_select 'p.operations a[href="http://edamontology.org/operation_0292"]', text: 'Sequence alignment'
end
+
+ test 'should link back to original entry if origin URI set' do
+ get :show, params: { id: @material }
+ assert_response :success
+
+ assert_select '#origin-info', count: 0
+
+ uri = 'https://some-tess-instance.org/materials/123'
+ @material.update(origin_uri: uri)
+
+ get :show, params: { id: @material }
+ assert_response :success
+
+ assert_select '#origin-info a[href=?]', uri
+ end
end
diff --git a/test/models/material_test.rb b/test/models/material_test.rb
index c2d98aa5c..f5e35e861 100644
--- a/test/models/material_test.rb
+++ b/test/models/material_test.rb
@@ -751,4 +751,21 @@ class MaterialTest < ActiveSupport::TestCase
bioschemas = m.to_bioschemas.first.generate
refute bioschemas.key?(:license)
end
+
+ test 'validates origin_uri' do
+ @material.origin_uri = nil
+ assert @material.valid?
+
+ @material.origin_uri = ''
+ assert @material.valid?
+
+ @material.origin_uri = 'https://tess-instance.org/materials/123'
+ assert @material.valid?
+
+ @material.origin_uri = 'hamster'
+ refute @material.valid?
+
+ @material.origin_uri = 'ftp://tess-instance.org/materials/123'
+ refute @material.valid?
+ end
end
diff --git a/test/unit/ingestors/oai_pmh_test.rb b/test/unit/ingestors/oai_pmh_ingestor_test.rb
similarity index 63%
rename from test/unit/ingestors/oai_pmh_test.rb
rename to test/unit/ingestors/oai_pmh_ingestor_test.rb
index 3cb9acfb9..919b8ea82 100644
--- a/test/unit/ingestors/oai_pmh_test.rb
+++ b/test/unit/ingestors/oai_pmh_ingestor_test.rb
@@ -1,47 +1,19 @@
require 'test_helper'
-class FakeClient
- def initialize(rdf_strings, dc_strings)
- @rdf_response = Minitest::Mock.new
- rdf_response = rdf_strings.map do |s|
- inner_mock = Minitest::Mock.new
- outer_mock = Minitest::Mock.new
- inner_mock.expect(:metadata, outer_mock, [])
- outer_mock.expect(:to_s, s, [])
- inner_mock
- end
- dc_response = dc_strings.map do |s|
- inner_mock = Minitest::Mock.new
- outer_mock = Minitest::Mock.new
- inner_mock.expect(:metadata, outer_mock, [])
- outer_mock.expect(:to_s, s, [])
- inner_mock
- end
- @rdf_response.expect(:full, rdf_response, [])
- @dc_response = Minitest::Mock.new
- @dc_response.expect(:full, dc_response, [])
- end
-
- def list_records(metadata_prefix: nil)
- if metadata_prefix == 'rdf'
- @rdf_response
- elsif metadata_prefix == 'oai_dc'
- @dc_response
- end
- end
-end
-
-class OaiPmhTest < ActiveSupport::TestCase
+class OaiPmhIngestorTest < ActiveSupport::TestCase
setup do
@ingestor = Ingestors::OaiPmhIngestor.new
@user = users(:regular_user)
@content_provider = content_providers(:another_portal_provider)
+ @oai_pmh_url = 'https://example.org/oai-pmh'
+ @oai_pmh_id = 'example'
end
test 'should read empty oai pmh endpoint' do
- OAI::Client.stub(:new, FakeClient.new([], [])) do
- @ingestor.read('https://example.org')
- end
+ mock_oai_pmh([], [])
+
+ @ingestor.read(@oai_pmh_url)
+
assert_equal [], @ingestor.materials
assert_equal [], @ingestor.events
end
@@ -68,9 +40,10 @@ class OaiPmhTest < ActiveSupport::TestCase
METADATA
- OAI::Client.stub(:new, FakeClient.new([], [record])) do
- @ingestor.read('https://example.org')
- end
+ mock_oai_pmh([], [record])
+
+ @ingestor.read(@oai_pmh_url)
+
result = @ingestor.materials.first
assert_equal 'dc_title', result.title
@@ -103,9 +76,10 @@ class OaiPmhTest < ActiveSupport::TestCase
METADATA
- OAI::Client.stub(:new, FakeClient.new([], [record])) do
- @ingestor.read('https://example.org')
- end
+ mock_oai_pmh([], [record])
+
+ @ingestor.read(@oai_pmh_url)
+
result = @ingestor.events.first
assert_equal 'dc_title', result.title
@@ -152,9 +126,9 @@ class OaiPmhTest < ActiveSupport::TestCase
METADATA
- OAI::Client.stub(:new, FakeClient.new([], [material1, material2, event1, event2])) do
- @ingestor.read('https://example.org')
- end
+ mock_oai_pmh([], [material1, material2, event1, event2])
+
+ @ingestor.read(@oai_pmh_url)
assert_equal %w[title1 title2], @ingestor.events.map(&:title)
assert_equal %w[title3 title4], @ingestor.materials.map(&:title)
@@ -184,9 +158,9 @@ class OaiPmhTest < ActiveSupport::TestCase
METADATA
- OAI::Client.stub(:new, FakeClient.new([material, material, event], [])) do
- @ingestor.read('https://example.org')
- end
+ mock_oai_pmh([material, material, event], [])
+
+ @ingestor.read(@oai_pmh_url)
assert_equal 1, @ingestor.materials.length
result = @ingestor.materials.first
@@ -199,4 +173,91 @@ class OaiPmhTest < ActiveSupport::TestCase
assert_equal 'bioschemas title2', result.title
assert_equal 'https://example.org/bioschemas/event', result.url
end
+
+ test 'should read TeSS instance OAI-PMH endpoint and store origin URI' do
+ VCR.use_cassette('ingestors/tess_oai_pmh_listrecords') do
+ VCR.use_cassette('ingestors/tess_oai_pmh_identify') do
+ @ingestor.read('https://oai-pmh.tesshub.space/oai-pmh')
+ end
+ end
+
+ assert_equal 2, @ingestor.materials.length
+ assert @ingestor.materials.all? { |m| m.origin_uri.start_with?('https://tesshub.space/materials/') }
+ end
+
+ private
+
+ IDENTIFY = %(
+
+
+ 2026-07-22T09:37:47Z
+ https://tesshub.space/oai-pmh
+
+ OAI-PMH Provider
+ https://some-oai-pmh.org/oai-pmh
+ 2.0
+ contact@example.com
+ 2026-07-21T12:59:13Z
+ transient
+ YYYY-MM-DDThh:mm:ssZ
+
+
+ oai
+ some-oai-pmh
+ :
+ some-oai-pmh:142
+
+
+
+
+)
+
+ LIST_RECORDS_WRAPPER = %(
+
+
+
+ 2026-07-21T13:06:44Z
+ https://tesshub.space/oai-pmh
+
+ %{records}
+
+
+)
+
+
+
+ # ?verb=Identify
+ # ?metadataPrefix=rdf&verb=ListRecords
+ # ?metadataPrefix=oai_dc&verb=ListRecords
+ def mock_oai_pmh(rdf_strings, dc_strings)
+ WebMock.stub_request(:get, "#{@oai_pmh_url}?verb=Identify").to_return(status: 200, body: IDENTIFY)
+
+ id = 1
+ rdf_response = LIST_RECORDS_WRAPPER % { records: rdf_strings.map do |rdf|
+ %(
+
+
+ oai:#{@oai_pmh_id}:#{id += 1}
+ #{Time.now.utc.iso8601}
+
+ #{rdf}
+
+ )
+ end.join("\n") }
+ WebMock.stub_request(:get, "#{@oai_pmh_url}?metadataPrefix=rdf&verb=ListRecords").to_return(status: 200, body: rdf_response)
+
+ dc_response = LIST_RECORDS_WRAPPER % { records: dc_strings.map do |rdf|
+ %(
+
+
+ oai:#{@oai_pmh_id}:#{id += 1}
+ #{Time.now.utc.iso8601}
+
+ #{rdf}
+
+ )
+ end.join("\n") }
+ WebMock.stub_request(:get, "#{@oai_pmh_url}?metadataPrefix=oai_dc&verb=ListRecords").to_return(status: 200, body: dc_response)
+ end
end
diff --git a/test/vcr_cassettes/ingestors/tess_oai_pmh_identify.yml b/test/vcr_cassettes/ingestors/tess_oai_pmh_identify.yml
new file mode 100644
index 000000000..aa0e4bd6b
--- /dev/null
+++ b/test/vcr_cassettes/ingestors/tess_oai_pmh_identify.yml
@@ -0,0 +1,106 @@
+---
+http_interactions:
+- request:
+ method: get
+ uri: https://oai-pmh.tesshub.space/oai-pmh?verb=Identify
+ body:
+ encoding: US-ASCII
+ string: ''
+ headers:
+ User-Agent:
+ - Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:102.0) Gecko/20100101 Firefox/102.0
+ From:
+ - test@example.com
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ Accept:
+ - "*/*"
+ response:
+ status:
+ code: 200
+ message: OK
+ headers:
+ Date:
+ - Wed, 22 Jul 2026 09:37:47 GMT
+ Content-Type:
+ - text/xml; charset=utf-8
+ Content-Length:
+ - '1229'
+ Connection:
+ - keep-alive
+ Server:
+ - cloudflare
+ Cache-Control:
+ - max-age=0, private, must-revalidate
+ Vary:
+ - Accept,Origin,Accept-Encoding
+ Strict-Transport-Security:
+ - max-age=63072000; includeSubDomains
+ Referrer-Policy:
+ - strict-origin-when-cross-origin
+ X-Permitted-Cross-Domain-Policies:
+ - none
+ X-Xss-Protection:
+ - '0'
+ Feature-Policy:
+ - camera 'none'; gyroscope 'none'; microphone 'none'; usb 'none'; fullscreen
+ 'self'; payment 'none'
+ X-Request-Id:
+ - d0398130-56e7-4e4a-afb9-926d2f97a4a7
+ X-Frame-Options:
+ - SAMEORIGIN
+ X-Runtime:
+ - '0.016690'
+ X-Content-Type-Options:
+ - nosniff
+ Content-Security-Policy:
+ - 'default-src ''self'' https:; font-src ''self'' https: data:; img-src ''self''
+ https: data:; object-src ''none''; script-src ''self'' https: ''unsafe-inline'';
+ style-src ''self'' https: ''unsafe-inline'''
+ X-Powered-By:
+ - Phusion Passenger(R) 6.0.18
+ Etag:
+ - W/"0bcac93baeefbfbe7c7335e2fb044adf-gzip"
+ Status:
+ - 200 OK
+ Nel:
+ - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}'
+ Report-To:
+ - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=tgOwpWlqq7CGsthz3Gbpkvrxzfa63OAWYsItZtDASLiXdjA16f%2B2b%2B1Mv9vfjbyA4l0c6xk6BnljhdR1oSOJOAFhudUujuVZ%2B8v53ANz0eqhB%2BsYt5QIQtuN%2F9eMJx3df4uDL6mChQU%3D"}]}'
+ Cf-Cache-Status:
+ - DYNAMIC
+ Cf-Ray:
+ - a1f177feba4f467f-LHR
+ Alt-Svc:
+ - h3=":443"; ma=86400
+ body:
+ encoding: ASCII-8BIT
+ string: |-
+
+
+
+ 2026-07-22T09:37:47Z
+ https://tesshub.space/oai-pmh
+
+ TeSSHub
+ https://tesshub.space/oai-pmh
+ 2.0
+ contact@example.com
+ 2026-07-21T12:59:13Z
+ transient
+ YYYY-MM-DDThh:mm:ssZ
+
+
+ oai
+ tesshub.space
+ :
+ oai:tesshub.space:142
+
+
+
+
+
+
+
+ recorded_at: Wed, 22 Jul 2026 09:37:47 GMT
+recorded_with: VCR 6.2.0
diff --git a/test/vcr_cassettes/ingestors/tess_oai_pmh_listrecords.yml b/test/vcr_cassettes/ingestors/tess_oai_pmh_listrecords.yml
new file mode 100644
index 000000000..65d07fbf9
--- /dev/null
+++ b/test/vcr_cassettes/ingestors/tess_oai_pmh_listrecords.yml
@@ -0,0 +1,166 @@
+---
+http_interactions:
+- request:
+ method: get
+ uri: https://oai-pmh.tesshub.space/oai-pmh?metadataPrefix=rdf&verb=ListRecords
+ body:
+ encoding: US-ASCII
+ string: ''
+ headers:
+ User-Agent:
+ - Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:102.0) Gecko/20100101 Firefox/102.0
+ From:
+ - test@example.com
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ Accept:
+ - "*/*"
+ response:
+ status:
+ code: 200
+ message: OK
+ headers:
+ Date:
+ - Tue, 21 Jul 2026 13:06:44 GMT
+ Content-Type:
+ - text/xml; charset=utf-8
+ Content-Length:
+ - '3270'
+ Connection:
+ - keep-alive
+ Server:
+ - cloudflare
+ Cache-Control:
+ - max-age=0, private, must-revalidate
+ Vary:
+ - Accept,Origin,Accept-Encoding
+ Strict-Transport-Security:
+ - max-age=63072000; includeSubDomains
+ Referrer-Policy:
+ - strict-origin-when-cross-origin
+ X-Permitted-Cross-Domain-Policies:
+ - none
+ X-Xss-Protection:
+ - '0'
+ Feature-Policy:
+ - camera 'none'; gyroscope 'none'; microphone 'none'; usb 'none'; fullscreen
+ 'self'; payment 'none'
+ X-Request-Id:
+ - d0013d21-bc24-4956-b930-055f3f7c5956
+ X-Frame-Options:
+ - SAMEORIGIN
+ X-Runtime:
+ - '0.071256'
+ X-Content-Type-Options:
+ - nosniff
+ Content-Security-Policy:
+ - 'default-src ''self'' https:; font-src ''self'' https: data:; img-src ''self''
+ https: data:; object-src ''none''; script-src ''self'' https: ''unsafe-inline'';
+ style-src ''self'' https: ''unsafe-inline'''
+ X-Powered-By:
+ - Phusion Passenger(R) 6.0.18
+ Etag:
+ - W/"a3f07edfcb2ab6214614661382a7b916-gzip"
+ Status:
+ - 200 OK
+ Nel:
+ - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}'
+ Report-To:
+ - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=%2B%2FNroeqG4tsUqXtpDqDYWQwXVwloSkQ7imaOxw87RJRDIJsuNsAue9n%2Bx1hmc7Sebtdnt69rRi8vFg6Vv3L7NcHaWXd0IWe8r4ymgf744QWJwtIxvIbE%2B3vRftGM%2F2IokT%2BF9wYNAFE%3D"}]}'
+ Cf-Cache-Status:
+ - DYNAMIC
+ Cf-Ray:
+ - a1ea6cb55b4525c5-LHR
+ Alt-Svc:
+ - h3=":443"; ma=86400
+ body:
+ encoding: ASCII-8BIT
+ string: |-
+
+
+
+ 2026-07-21T13:06:44Z
+ https://tesshub.space/oai-pmh
+
+
+
+ oai:tesshub.space:3931
+ 2026-07-21T12:59:13Z
+
+
+
+
+
+
+
+
+ Example Material
+
+ An example material
+ example
+
+
+ Steve Steveson
+
+
+
+
+ Josiah Carberry
+ https://orcid.org/0000-0002-1825-0097
+
+
+
+
+ Joe Bloggs
+
+
+
+
+
+
+
+
+
+ oai:tesshub.space:3932
+ 2026-07-21T13:04:24Z
+
+
+
+
+
+
+
+
+ Another Material
+
+ Sir, a second material has hit the registry
+ testing
+ example
+
+
+ Jose Gonzales
+
+
+
+
+ Amelie Martin
+
+
+
+
+ Researchers
+
+
+
+
+
+
+
+
+
+ recorded_at: Tue, 21 Jul 2026 13:06:44 GMT
+recorded_with: VCR 6.2.0