labimotion 2.2.0.rc21 → 2.2.0.rc22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 006f7a0d5c1bc994e1ed1be862881e579b83a91844e176858571471a4225a6e5
4
- data.tar.gz: 9d40a31313cc7717bd0a9cb971e457bc6c2ad93a3818a3ad305dec05ad9bc406
3
+ metadata.gz: c79a031d59f881b26d04e1acb6863bff76a6164e096068268d7495042d8db0f0
4
+ data.tar.gz: 3d84dc19e636a8c57734675e41d58ef16bb821e7d7be34be2cbe7b3196a3b804
5
5
  SHA512:
6
- metadata.gz: 399cb835f06e3cc7e30a8c59f3e64ae7738da938a7bb4a68e3ffd9c331854e36c84743b90a07769407c910d2b49042996734d2572517932a97869a18f0e6174b
7
- data.tar.gz: 8ce94d81213aba4205aff04328c3eba89ca095b84301ddf7eee22497e0964635a0998735175f47fa00642f4d47d481e9ef0aa95bb1b04357705a1c1ad21c3c6b
6
+ metadata.gz: 4ad9ee1d24b63e2ebdc8a6bf6ca338fe3170644c63bc50cafcd89a660250fef0747375617b394a8174644ead7461f2dcc1d81fa4095e7365e11b07517781af00
7
+ data.tar.gz: 103e68dc679f80f3a214946361dc70ad5c1204336f567d17aaae4a9aa32298e56c23f4af4f545f07e27c9cdc8ccaea7bcc00e4978918af0b9d5f9baba6188785
@@ -8,7 +8,9 @@ module Labimotion
8
8
  # Grape route and helper DSL blocks are necessarily large.
9
9
  KLASS_TYPES = Labimotion::TemplateDoiHelpers::KLASS_BY_TYPE.keys.freeze
10
10
  # Publication fields exposed on the public (anonymous) DOI endpoints.
11
- PUBLIC_PUBLICATION_KEYS = %w[title description authors contributor license].freeze
11
+ # `contributor` (singular) is kept for DOIs released before contributors
12
+ # became a list — their snapshot still carries it.
13
+ PUBLIC_PUBLICATION_KEYS = %w[title description authors contributors contributor references license].freeze
12
14
 
13
15
  helpers do
14
16
  def resolve_klass!(type, id)
@@ -10,7 +10,9 @@ module Labimotion
10
10
  KLASS_BY_TYPE = Labimotion::TemplateDoiHelpers::KLASS_BY_TYPE
11
11
  KLASS_TYPES = KLASS_BY_TYPE.keys.freeze
12
12
  # Publication fields exposed alongside a template / its released DOIs.
13
- PUBLICATION_KEYS = %w[title description authors contributor license].freeze
13
+ # `contributor` (singular) is kept for DOIs released before contributors
14
+ # became a list — their snapshot still carries it.
15
+ PUBLICATION_KEYS = %w[title description authors contributors contributor references license].freeze
14
16
  # type => [revision model, foreign key to the klass]
15
17
  REVISION_BY_TYPE = {
16
18
  'element' => [::Labimotion::ElementKlassesRevision, :element_klass_id],
@@ -15,8 +15,8 @@
15
15
  <% if creator['orcid'].present? %>
16
16
  <nameIdentifier schemeURI="https://orcid.org/" nameIdentifierScheme="ORCID"><%= creator['orcid'] %></nameIdentifier>
17
17
  <% end %>
18
- <% if creator['affiliation'].present? %>
19
- <affiliation><%= creator['affiliation'] %></affiliation>
18
+ <% if creator['affiliation'].present? || creator['ror'].present? %>
19
+ <affiliation<% if creator['ror'].present? %> affiliationIdentifier="<%= creator['ror'] %>" affiliationIdentifierScheme="ROR" schemeURI="https://ror.org"<% end %>><%= creator['affiliation'] %></affiliation>
20
20
  <% end %>
21
21
  </creator>
22
22
  <% end %>
@@ -29,18 +29,30 @@
29
29
  <subjects>
30
30
  <subject>Chemistry</subject>
31
31
  </subjects>
32
- <% if contributor %>
32
+ <% if contributors.any? %>
33
33
  <contributors>
34
- <contributor contributorType="Researcher">
34
+ <% contributors.each do |contributor| %>
35
+ <contributor contributorType="<%= contributor['contributorType'] %>">
35
36
  <contributorName nameType="Personal"><%= contributor['familyName'] %>, <%= contributor['givenName'] %></contributorName>
36
37
  <givenName><%= contributor['givenName'] %></givenName>
37
38
  <familyName><%= contributor['familyName'] %></familyName>
38
39
  <% if contributor['orcid'].present? %>
39
40
  <nameIdentifier schemeURI="https://orcid.org/" nameIdentifierScheme="ORCID"><%= contributor['orcid'] %></nameIdentifier>
40
41
  <% end %>
42
+ <% if contributor['affiliation'].present? || contributor['ror'].present? %>
43
+ <affiliation<% if contributor['ror'].present? %> affiliationIdentifier="<%= contributor['ror'] %>" affiliationIdentifierScheme="ROR" schemeURI="https://ror.org"<% end %>><%= contributor['affiliation'] %></affiliation>
44
+ <% end %>
41
45
  </contributor>
46
+ <% end %>
42
47
  </contributors>
43
48
  <% end %>
49
+ <% if references.any? %>
50
+ <relatedIdentifiers>
51
+ <% references.each do |reference| %>
52
+ <relatedIdentifier relatedIdentifierType="DOI" relationType="References">https://dx.doi.org/<%= reference['doi'] %></relatedIdentifier>
53
+ <% end %>
54
+ </relatedIdentifiers>
55
+ <% end %>
44
56
  <language>en</language>
45
57
  <resourceType resourceTypeGeneral="Model"><%= resource_label %></resourceType>
46
58
  <version><%= version %></version>
@@ -76,30 +76,57 @@ module Labimotion
76
76
  end
77
77
 
78
78
  def creators
79
- Array(publication['authors']).filter_map do |author|
80
- next unless author.is_a?(Hash)
81
-
82
- given = author['givenName'].to_s.strip
83
- family = author['familyName'].to_s.strip
84
- next if given.empty? && family.empty?
79
+ Array(publication['authors']).filter_map { |author| person(author) }
80
+ end
85
81
 
86
- { 'givenName' => h(given), 'familyName' => h(family),
87
- 'orcid' => h(author['orcid'].to_s.strip), 'affiliation' => h(author['affiliation'].to_s.strip) }
82
+ # The people credited as DataCite contributors, each in their own role.
83
+ # Held apart from the creators — a contributor need not be one of the
84
+ # authors — and empty (no contributors block) until one is named; they are
85
+ # never inferred from whoever happens to be publishing. DOIs released
86
+ # before contributors became a list carry a single `contributor`.
87
+ def contributors
88
+ list = publication['contributors']
89
+ list = [publication['contributor']].compact unless list.is_a?(Array)
90
+ list.filter_map do |entry|
91
+ person(entry)&.merge('contributorType' => h(contributor_type(entry)))
88
92
  end
89
93
  end
90
94
 
91
- # The author picked from the author list, rendered as a DataCite
92
- # "Researcher" contributor. Nil (no contributors block) when none has been
93
- # picked — it is never inferred from whoever happens to be publishing.
94
- def contributor
95
- picked = publication['contributor']
96
- return nil unless picked.is_a?(Hash)
95
+ def contributor_type(entry)
96
+ type = entry['contributorType'].to_s.strip
97
+ types = TemplateDoiHelpers::CONTRIBUTOR_TYPES
98
+ types.include?(type) ? type : TemplateDoiHelpers::DEFAULT_CONTRIBUTOR_TYPE
99
+ end
97
100
 
98
- given = picked['givenName'].to_s.strip
99
- family = picked['familyName'].to_s.strip
101
+ # A creator or contributor, escaped for the template. Nameless entries are
102
+ # dropped: DataCite has nowhere to put an ORCID or affiliation on its own.
103
+ # `ror` is the resolver URI DataCite expects in affiliationIdentifier,
104
+ # rebuilt from the bare id that is stored.
105
+ def person(entry)
106
+ return nil unless entry.is_a?(Hash)
107
+
108
+ given = entry['givenName'].to_s.strip
109
+ family = entry['familyName'].to_s.strip
100
110
  return nil if given.empty? && family.empty?
101
111
 
102
- { 'givenName' => h(given), 'familyName' => h(family), 'orcid' => h(picked['orcid'].to_s.strip) }
112
+ ror = entry['ror_id'].to_s.strip
113
+ { 'givenName' => h(given), 'familyName' => h(family), 'orcid' => h(entry['orcid'].to_s.strip),
114
+ 'affiliation' => h(entry['affiliation'].to_s.strip),
115
+ 'ror' => ror.empty? ? '' : h("https://ror.org/#{ror}") }
116
+ end
117
+
118
+ # Works this template cites, published as DataCite relatedIdentifiers of
119
+ # relationType "References". Only the DOI travels — a relatedIdentifier
120
+ # carries no title — so entries without one are skipped.
121
+ def references
122
+ Array(publication['references']).filter_map do |reference|
123
+ next unless reference.is_a?(Hash)
124
+
125
+ doi = reference['doi'].to_s.strip
126
+ next if doi.empty?
127
+
128
+ { 'doi' => h(doi) }
129
+ end
103
130
  end
104
131
 
105
132
  # What this DOI renders: the snapshot taken when it was released, so every
@@ -86,13 +86,12 @@ module Labimotion
86
86
  end
87
87
 
88
88
  # Everything the publication form owns that DataCite needs, named so the
89
- # modal can say what is still missing. The contributor is one of the
90
- # authors and has to be picked in the form a DOI is never released
91
- # crediting whoever happened to press the button.
89
+ # modal can say what is still missing. Contributors and references are
90
+ # optional: not every template credits a role beyond its authors, and not
91
+ # every template cites a work.
92
92
  def missing_metadata
93
93
  missing = %w[title description].reject { |key| publication[key].to_s.strip.present? }
94
94
  missing << 'authors' unless Array(publication['authors']).any? { |author| named?(author) }
95
- missing << 'contributor' unless named?(publication['contributor'])
96
95
  missing
97
96
  end
98
97
 
@@ -20,6 +20,24 @@ module Labimotion
20
20
  ::Labimotion::DatasetKlass => 'datasets'
21
21
  }.freeze
22
22
 
23
+ # Where the hub lives, most specific first. PUBLIC_URL is the instance's own
24
+ # public address (the host app's config/application.rb insists on it), so the
25
+ # link follows whichever instance is serving it rather than a hardcoded
26
+ # chemotion-repository.net. The DataCite-facing domains stay as fallbacks for
27
+ # deployments that only set those.
28
+ HOST_ENV_KEYS = %w[PUBLIC_URL REPO_HOST DOI_DOMAIN_TEST DOI_DOMAIN].freeze
29
+
30
+ # The DataCite kernel-4 contributorType vocabulary. A template DOI may name
31
+ # any number of contributors — including none — each credited in one of
32
+ # these roles. The list is closed: DataCite rejects anything else.
33
+ CONTRIBUTOR_TYPES = %w[
34
+ ContactPerson DataCollector DataCurator DataManager Distributor Editor
35
+ HostingInstitution Producer ProjectLeader ProjectManager ProjectMember
36
+ RegistrationAgency RegistrationAuthority RelatedPerson Researcher
37
+ ResearchGroup RightsHolder Sponsor Supervisor WorkPackageLeader Other
38
+ ].freeze
39
+ DEFAULT_CONTRIBUTOR_TYPE = 'Researcher'
40
+
23
41
  module_function
24
42
 
25
43
  # True once the template's latest DOI has been released (minted) and the
@@ -49,13 +67,17 @@ module Labimotion
49
67
  # Used as the DOI's DataCite landing page and surfaced in the UI.
50
68
  def template_url(record)
51
69
  type = klass_type_for(record)
52
- host = ENV['REPO_HOST'].presence || ENV['DOI_DOMAIN_TEST'].presence ||
53
- ENV['DOI_DOMAIN'].presence || 'https://www.chemotion-repository.net'
54
70
  record_id = record.try(:identifier).presence || record.try(:uuid).presence || record.id
55
71
  query = "type=finder&kind=#{type}&id=#{record_id}"
56
72
  version = record.try(:version)
57
73
  query += "&version=#{version}" if version.present?
58
- "#{host.chomp('/')}/home/genericHub?#{query}"
74
+ "#{public_host}/home/genericHub?#{query}"
75
+ end
76
+
77
+ # The instance serving the hub, without its trailing slash.
78
+ def public_host
79
+ host = HOST_ENV_KEYS.lazy.filter_map { |key| ENV[key].presence }.first
80
+ (host || 'http://localhost:3000').chomp('/')
59
81
  end
60
82
 
61
83
  # A user may manage a template's DOI iff they hold the LabIMotion
@@ -7,7 +7,12 @@ module Labimotion
7
7
  class UpdateTemplatePublicationMetadata
8
8
  include TemplateDoiHelpers
9
9
 
10
- ALLOWED_KEYS = %w[title description authors contributor license].freeze
10
+ ALLOWED_KEYS = %w[title description authors contributors references license].freeze
11
+ REFERENCE_KEYS = %w[doi title url].freeze
12
+ # Authors and contributors are the same shape: a DataCite personal name plus
13
+ # the identifiers that make it resolvable — ORCID for the person, ROR for
14
+ # their organisation. Contributors carry a contributorType on top.
15
+ PERSON_KEYS = %w[givenName familyName orcid affiliation affiliation_id ror_id].freeze
11
16
 
12
17
  attr_reader :record, :current_user, :publication, :error
13
18
 
@@ -39,42 +44,82 @@ module Labimotion
39
44
  def normalized_publication
40
45
  slice = publication.stringify_keys.slice(*ALLOWED_KEYS)
41
46
  slice['authors'] = Array(slice['authors']).filter_map { |a| normalize_author(a) }
42
- picked = picked_contributor(slice['authors'], slice['contributor'])
43
- picked ? slice['contributor'] = picked : slice.delete('contributor')
47
+ # Contributors are people in their own right — none of them need be one of
48
+ # the authors so they are stored as given rather than matched back
49
+ # against the author list. There may be any number, including none.
50
+ slice['contributors'] = submitted_contributors.filter_map { |c| normalize_contributor(c) }
51
+ slice['references'] = Array(slice['references']).filter_map { |r| normalize_reference(r) }
44
52
  slice
45
53
  end
46
54
 
47
- # The contributor is one of the authors, so it is matched back against the
48
- # saved author list and stored as that author. Anything that matches none
49
- # of them — a stale pick whose author has since been removed, or a name
50
- # that was never on the list — is dropped.
51
- def picked_contributor(authors, contributor)
52
- picked = normalize_author(contributor)
53
- return nil if picked.nil?
55
+ # A single `contributor` is what the first version of the form sent; a
56
+ # template saved by it is read back into the list rather than dropped.
57
+ def submitted_contributors
58
+ given = publication.stringify_keys
59
+ given['contributors'].is_a?(Array) ? given['contributors'] : [given['contributor']].compact
60
+ end
61
+
62
+ # A person plus the DataCite role they are credited in. An unknown or
63
+ # missing role falls back to "Researcher" rather than failing the save —
64
+ # the vocabulary is closed, so anything else would be rejected at minting.
65
+ def normalize_contributor(contributor)
66
+ h = normalize_author(contributor)
67
+ return nil unless h
54
68
 
55
- authors.find { |author| same_person?(author, picked) }
69
+ type = contributor.stringify_keys['contributorType'].to_s.strip
70
+ known = TemplateDoiHelpers::CONTRIBUTOR_TYPES.include?(type)
71
+ h.merge('contributorType' => known ? type : TemplateDoiHelpers::DEFAULT_CONTRIBUTOR_TYPE)
56
72
  end
57
73
 
58
- # ORCID identifies a person when both sides carry one; otherwise fall back
59
- # to the name pair, which is what the editor matches on too.
60
- def same_person?(one, other)
61
- orcids = [one, other].map { |a| a['orcid'].to_s.strip }
62
- return orcids[0].casecmp?(orcids[1]) if orcids.all?(&:present?)
74
+ # A cited work, identified by its DOI. Anything without one is dropped: the
75
+ # DOI is what goes to DataCite as a relatedIdentifier, and a reference
76
+ # without it would be published as an empty relation.
77
+ def normalize_reference(reference)
78
+ return nil unless reference.is_a?(Hash)
79
+
80
+ h = reference.stringify_keys.slice(*REFERENCE_KEYS)
81
+ h['doi'] = sanitize_doi(h['doi'])
82
+ return nil if h['doi'].nil?
83
+
84
+ h.transform_values { |v| v.to_s.strip }
85
+ end
63
86
 
64
- %w[givenName familyName].all? do |key|
65
- one[key].to_s.strip.casecmp?(other[key].to_s.strip)
66
- end
87
+ # Accepts a bare DOI, a doi: prefix or a resolver URL, and keeps the bare
88
+ # "10.x/y" form. Nil for anything that is not a DOI.
89
+ def sanitize_doi(value)
90
+ match = value.to_s.strip.match(%r{(10\.\d{4,9}/\S+)\z})
91
+ match && match[1]
67
92
  end
68
93
 
69
94
  def normalize_author(author)
70
95
  return nil unless author.is_a?(Hash)
71
96
 
72
- h = author.stringify_keys.slice('givenName', 'familyName', 'orcid', 'affiliation', 'affiliation_id')
97
+ h = author.stringify_keys.slice(*PERSON_KEYS)
73
98
  return nil if h.values_at('givenName', 'familyName').all? { |v| v.to_s.strip.empty? }
74
99
 
100
+ ror = normalize_ror(h['ror_id']) || ror_for(h['affiliation_id'])
101
+ ror ? h['ror_id'] = ror : h.delete('ror_id')
75
102
  h
76
103
  end
77
104
 
105
+ # ROR IDs travel either bare ("05f950310") or as a resolver URL; DataCite
106
+ # wants the URI, so the bare form is what gets stored and the URI is built
107
+ # when the XML is rendered.
108
+ def normalize_ror(value)
109
+ value.to_s.strip.sub(%r{\Ahttps?://ror\.org/}i, '').presence
110
+ end
111
+
112
+ # The authoritative ROR for a picked affiliation. Whoever the editor picked
113
+ # came from an Affiliation record, so its id is the better source than
114
+ # anything retyped — and the current-user payload carries no ROR at all.
115
+ def ror_for(affiliation_id)
116
+ return nil if affiliation_id.blank? || !defined?(::Affiliation)
117
+
118
+ normalize_ror(::Affiliation.where(id: affiliation_id).pick(:ror_id))
119
+ rescue StandardError
120
+ nil
121
+ end
122
+
78
123
  def failure(message)
79
124
  @error = message
80
125
  self
@@ -2,5 +2,5 @@
2
2
 
3
3
  ## Labimotion Version
4
4
  module Labimotion
5
- VERSION = '2.2.0.rc21'
5
+ VERSION = '2.2.0.rc22'
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: labimotion
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.2.0.rc21
4
+ version: 2.2.0.rc22
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chia-Lin Lin
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2026-07-28 00:00:00.000000000 Z
12
+ date: 2026-07-29 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: caxlsx