labimotion 2.2.0.rc20 → 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: c9df5db25f00061785cdfbc01efc3bcf1331ce6e028f4059e766a20adbda0c93
4
- data.tar.gz: b3a45b638d8d2cf2e44cdd97ec0b4306af12f3abc25e44c414b1f49783b38f5e
3
+ metadata.gz: c79a031d59f881b26d04e1acb6863bff76a6164e096068268d7495042d8db0f0
4
+ data.tar.gz: 3d84dc19e636a8c57734675e41d58ef16bb821e7d7be34be2cbe7b3196a3b804
5
5
  SHA512:
6
- metadata.gz: 7ffd28b485709f2bc8f795e1ecd4dee5fb1a0ebbe412f9fee0dd8635ad5807757f3d88609bb6ca8e5a80c5c8fc2709dc9d2ce70545db4ee53be0ddee84d4bad8
7
- data.tar.gz: 8e06a2b44be3cbc288838b1aede00a788e740df1f1eacb62467772c8addf610d3644807f13de409a1fee3da362651d01ce92508d5a24c2c89480ff1647e18a4d
6
+ metadata.gz: 4ad9ee1d24b63e2ebdc8a6bf6ca338fe3170644c63bc50cafcd89a660250fef0747375617b394a8174644ead7461f2dcc1d81fa4095e7365e11b07517781af00
7
+ data.tar.gz: 103e68dc679f80f3a214946361dc70ad5c1204336f567d17aaae4a9aa32298e56c23f4af4f545f07e27c9cdc8ccaea7bcc00e4978918af0b9d5f9baba6188785
@@ -7,6 +7,10 @@ module Labimotion
7
7
  # rubocop:disable Metrics/BlockLength
8
8
  # Grape route and helper DSL blocks are necessarily large.
9
9
  KLASS_TYPES = Labimotion::TemplateDoiHelpers::KLASS_BY_TYPE.keys.freeze
10
+ # Publication fields exposed on the public (anonymous) DOI endpoints.
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
10
14
 
11
15
  helpers do
12
16
  def resolve_klass!(type, id)
@@ -41,25 +45,36 @@ module Labimotion
41
45
  )
42
46
  end
43
47
 
44
- # Public, read-only view of one released DOI version.
45
- def released_doi_version(doi)
48
+ # Public, read-only view of one released DOI version, carrying the
49
+ # publication metadata that version was released with.
50
+ def released_doi_version(record, doi)
46
51
  full = doi.full_doi
47
52
  {
48
53
  doi: full,
49
54
  doi_url: "https://dx.doi.org/#{full}",
50
55
  version: ::Doi.labimotion_doi_version(doi),
51
- minted_at: doi.minted_at
56
+ minted_at: doi.minted_at,
57
+ publication: released_publication(record, doi)
52
58
  }
53
59
  end
54
60
 
61
+ # The snapshot taken when this version was released. DOIs released before
62
+ # snapshots were kept have none, and fall back to the template's current
63
+ # publication — the best available answer for them.
64
+ def released_publication(record, doi)
65
+ publication = ::Doi.labimotion_doi_publication(doi).presence ||
66
+ (record.properties_template || {})['publication']
67
+ publication.is_a?(Hash) ? publication.slice(*PUBLIC_PUBLICATION_KEYS) : {}
68
+ end
69
+
55
70
  # Public payload for a template that has at least one released DOI: the
56
71
  # latest released DOI promoted to the top level, plus its publication
57
- # metadata, hub deep-link and every released version. `record_id` matches
58
- # the hub grid's row id (identifier || uuid) so the frontend can merge it.
72
+ # metadata, hub deep-link and every released version. Each version brings
73
+ # its own publication, so the top-level one is the latest release's rather
74
+ # than the template's work-in-progress. `record_id` matches the hub grid's
75
+ # row id (identifier || uuid) so the frontend can merge it.
59
76
  def released_template_doi(record, dois)
60
- versions = dois.sort_by(&:id).map { |doi| released_doi_version(doi) }
61
- publication = (record.properties_template || {})['publication']
62
- publication = publication.is_a?(Hash) ? publication.slice('title', 'description', 'authors', 'license') : {}
77
+ versions = dois.sort_by(&:id).map { |doi| released_doi_version(record, doi) }
63
78
  versions.last.merge(
64
79
  record_id: record.try(:identifier).presence || record.try(:uuid).presence || record.id,
65
80
  identifier: record.try(:identifier),
@@ -68,7 +83,6 @@ module Labimotion
68
83
  template_version: record.try(:version),
69
84
  next_doi_version: ::Doi.labimotion_version_segment(record),
70
85
  template_url: Labimotion::TemplateDoiHelpers.template_url(record),
71
- publication: publication,
72
86
  versions: versions
73
87
  )
74
88
  end
@@ -125,7 +139,7 @@ module Labimotion
125
139
  version: ::Doi.labimotion_doi_version(doi),
126
140
  full_doi: doi.full_doi,
127
141
  minted: doi.minted == true,
128
- xml: Labimotion::BuildTemplateDoiXml.new(record, doi, current_user).call
142
+ xml: Labimotion::BuildTemplateDoiXml.new(record, doi).call
129
143
  }
130
144
  end
131
145
  { versions: versions }
@@ -9,6 +9,10 @@ module Labimotion
9
9
  class LabimotionTemplateBrowseAPI < Grape::API
10
10
  KLASS_BY_TYPE = Labimotion::TemplateDoiHelpers::KLASS_BY_TYPE
11
11
  KLASS_TYPES = KLASS_BY_TYPE.keys.freeze
12
+ # Publication fields exposed alongside a template / its released DOIs.
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
12
16
  # type => [revision model, foreign key to the klass]
13
17
  REVISION_BY_TYPE = {
14
18
  'element' => [::Labimotion::ElementKlassesRevision, :element_klass_id],
@@ -76,7 +80,14 @@ module Labimotion
76
80
  # The template's DOI publication metadata (shown alongside the DOI).
77
81
  def template_publication(record)
78
82
  pub = (record.properties_template || {})['publication']
79
- pub.is_a?(Hash) ? pub.slice('title', 'description', 'authors', 'license') : {}
83
+ pub.is_a?(Hash) ? pub.slice(*PUBLICATION_KEYS) : {}
84
+ end
85
+
86
+ # The publication a released DOI was published with. Falls back to the
87
+ # template's current metadata for DOIs released before snapshots were kept.
88
+ def released_publication(record, doi)
89
+ pub = ::Doi.labimotion_doi_publication(doi)
90
+ pub.is_a?(Hash) && pub.any? ? pub.slice(*PUBLICATION_KEYS) : template_publication(record)
80
91
  end
81
92
 
82
93
  # Released (minted) DOIs of the template, each tagged with its DOI version
@@ -88,6 +99,7 @@ module Labimotion
88
99
  full = doi.full_doi
89
100
  { version: ::Doi.labimotion_doi_version(doi), doi: full,
90
101
  doi_url: "https://dx.doi.org/#{full}", minted_at: doi.minted_at,
102
+ publication: released_publication(record, doi),
91
103
  xml: Labimotion::BuildTemplateDoiXml.new(record, doi).call }
92
104
  end
93
105
  end
@@ -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>
@@ -82,6 +82,26 @@ module Labimotion
82
82
  { 'labimotion' => { 'doi_version' => labimotion_version_segment(record, version) } }
83
83
  end
84
84
 
85
+ # The publication metadata a released template DOI was published with.
86
+ # Nil while the DOI is still reserved, and for DOIs released before
87
+ # snapshots were kept — callers fall back to the template's current
88
+ # publication in both cases.
89
+ def labimotion_doi_publication(doi)
90
+ doi.metadata.to_h.dig('labimotion', 'publication')
91
+ end
92
+
93
+ # The DOI's metadata with the publication snapshotted into it, keeping
94
+ # the DOI version already stamped there. Taken at release: the template's
95
+ # publication keeps being edited for the next version, and this version
96
+ # must stop tracking those edits.
97
+ def labimotion_metadata_with_publication(doi, publication)
98
+ metadata = doi.metadata.to_h
99
+ # Deep-duped: the snapshot must not alias the template's publication,
100
+ # which goes on being edited for the next version.
101
+ labimotion = (metadata['labimotion'] || {}).merge('publication' => publication.deep_dup)
102
+ metadata.merge('labimotion' => labimotion)
103
+ end
104
+
85
105
  def labimotion_suffix_parts(record)
86
106
  # identifier/uuid may both be blank for a draft, which would collapse the
87
107
  # scope (colliding across templates). Fall back to the stable record id so
@@ -23,10 +23,9 @@ module Labimotion
23
23
  'dataset' => 'LabIMotion Dataset Template'
24
24
  }.freeze
25
25
 
26
- def initialize(record, doi = nil, current_user = nil)
26
+ def initialize(record, doi = nil)
27
27
  @record = record
28
28
  @doi = doi || ::Doi.labimotion_latest_doi(record)
29
- @current_user = current_user
30
29
  end
31
30
 
32
31
  # Returns the XML string, or nil when no DOI has been reserved yet.
@@ -77,35 +76,70 @@ module Labimotion
77
76
  end
78
77
 
79
78
  def creators
80
- Array(publication['authors']).filter_map do |author|
81
- next unless author.is_a?(Hash)
82
-
83
- given = author['givenName'].to_s.strip
84
- family = author['familyName'].to_s.strip
85
- next if given.empty? && family.empty?
79
+ Array(publication['authors']).filter_map { |author| person(author) }
80
+ end
86
81
 
87
- { 'givenName' => h(given), 'familyName' => h(family),
88
- '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)))
89
92
  end
90
93
  end
91
94
 
92
- # The acting user, rendered as a DataCite "Researcher" contributor. Nil
93
- # (no contributors block) when there is no user or they have no name.
94
- def contributor
95
- user = @current_user
96
- return nil unless user
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
100
+
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)
97
107
 
98
- given = user.try(:first_name).to_s.strip
99
- family = user.try(:last_name).to_s.strip
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
- orcid = user.respond_to?(:orcid) ? user.orcid.to_s.strip : ''
103
- { 'givenName' => h(given), 'familyName' => h(family), 'orcid' => h(orcid) }
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}") }
104
116
  end
105
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
130
+ end
131
+
132
+ # What this DOI renders: the snapshot taken when it was released, so every
133
+ # released version keeps showing the metadata it was published with rather
134
+ # than the template's latest. A DOI that is still reserved has no snapshot
135
+ # and renders the template's current publication, which is what is still
136
+ # being edited for it.
106
137
  def publication
107
- @publication ||=
108
- (@record.properties_template.is_a?(Hash) ? @record.properties_template['publication'] : nil) || {}
138
+ @publication ||= ::Doi.labimotion_doi_publication(@doi).presence || record_publication
139
+ end
140
+
141
+ def record_publication
142
+ (@record.properties_template.is_a?(Hash) ? @record.properties_template['publication'] : nil) || {}
109
143
  end
110
144
 
111
145
  def h(value)
@@ -24,7 +24,8 @@ module Labimotion
24
24
  return failure('doi not reserved') if @doi.nil?
25
25
  return self if @doi.minted
26
26
 
27
- return failure('publication metadata is incomplete') unless metadata_ready?
27
+ missing = missing_metadata
28
+ return failure("publication metadata is incomplete: #{missing.join(', ')}") if missing.any?
28
29
 
29
30
  publish_at_datacite
30
31
  self
@@ -37,22 +38,33 @@ module Labimotion
37
38
  private
38
39
 
39
40
  def publish_at_datacite
40
- if send_to_datacite?
41
- mds = ::Repo::Datacite::Mds.new
42
- # DataCite requires the metadata to exist before the DOI can be minted.
43
- metadata = mds.upload_metadata(BuildTemplateDoiXml.new(record, doi, current_user).call)
44
- return failure(datacite_error('metadata upload', metadata)) unless metadata.is_a?(Net::HTTPSuccess)
45
-
46
- response = mds.mint(doi.full_doi, TemplateDoiHelpers.template_url(record))
47
- return failure(datacite_error('mint', response)) unless response.is_a?(Net::HTTPSuccess)
48
- end
41
+ datacite_failure = send_to_datacite? ? mint_at_datacite : nil
42
+ return failure(datacite_failure) if datacite_failure
49
43
 
50
44
  ActiveRecord::Base.transaction do
51
- doi.update!(minted: true, minted_at: Time.current)
45
+ # The publication is snapshotted onto the DOI as it is minted, so this
46
+ # version keeps rendering what it was published with once the template's
47
+ # metadata moves on to the next version.
48
+ doi.update!(minted: true, minted_at: Time.current,
49
+ metadata: ::Doi.labimotion_metadata_with_publication(doi, publication))
52
50
  stamp_release_on_record
53
51
  end
54
52
  end
55
53
 
54
+ # Uploads the metadata and mints the DOI at DataCite. Returns an error
55
+ # message, or nil when both calls succeeded.
56
+ def mint_at_datacite
57
+ mds = ::Repo::Datacite::Mds.new
58
+ # DataCite requires the metadata to exist before the DOI can be minted.
59
+ metadata = mds.upload_metadata(BuildTemplateDoiXml.new(record, doi).call)
60
+ return datacite_error('metadata upload', metadata) unless metadata.is_a?(Net::HTTPSuccess)
61
+
62
+ response = mds.mint(doi.full_doi, TemplateDoiHelpers.template_url(record))
63
+ return datacite_error('mint', response) unless response.is_a?(Net::HTTPSuccess)
64
+
65
+ nil
66
+ end
67
+
56
68
  def datacite_error(step, response)
57
69
  status = response.respond_to?(:code) ? response.code : 'unknown'
58
70
  "datacite #{step} failed (status #{status})"
@@ -73,13 +85,26 @@ module Labimotion
73
85
  record.update!(attrs) if attrs.any?
74
86
  end
75
87
 
76
- def metadata_ready?
77
- publication = record.properties_template.is_a?(Hash) ? record.properties_template['publication'] : nil
78
- return false unless publication.is_a?(Hash)
88
+ # Everything the publication form owns that DataCite needs, named so the
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
+ def missing_metadata
93
+ missing = %w[title description].reject { |key| publication[key].to_s.strip.present? }
94
+ missing << 'authors' unless Array(publication['authors']).any? { |author| named?(author) }
95
+ missing
96
+ end
97
+
98
+ def named?(person)
99
+ person.is_a?(Hash) && person['familyName'].to_s.strip.present?
100
+ end
79
101
 
80
- publication['title'].to_s.strip.present? &&
81
- publication['description'].to_s.strip.present? &&
82
- Array(publication['authors']).any? { |a| a.is_a?(Hash) && a['familyName'].to_s.strip.present? }
102
+ # The template's current publication metadata — what this release publishes.
103
+ def publication
104
+ @publication ||= begin
105
+ pub = record.properties_template.is_a?(Hash) ? record.properties_template['publication'] : nil
106
+ pub.is_a?(Hash) ? pub : {}
107
+ end
83
108
  end
84
109
 
85
110
  def failure(message)
@@ -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 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,18 +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) }
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) }
42
52
  slice
43
53
  end
44
54
 
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
68
+
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)
72
+ end
73
+
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
86
+
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]
92
+ end
93
+
45
94
  def normalize_author(author)
46
95
  return nil unless author.is_a?(Hash)
47
96
 
48
- h = author.stringify_keys.slice('givenName', 'familyName', 'orcid', 'affiliation', 'affiliation_id')
97
+ h = author.stringify_keys.slice(*PERSON_KEYS)
49
98
  return nil if h.values_at('givenName', 'familyName').all? { |v| v.to_s.strip.empty? }
50
99
 
100
+ ror = normalize_ror(h['ror_id']) || ror_for(h['affiliation_id'])
101
+ ror ? h['ror_id'] = ror : h.delete('ror_id')
51
102
  h
52
103
  end
53
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
+
54
123
  def failure(message)
55
124
  @error = message
56
125
  self
@@ -2,5 +2,5 @@
2
2
 
3
3
  ## Labimotion Version
4
4
  module Labimotion
5
- VERSION = '2.2.0.rc20'
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.rc20
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-27 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