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 +4 -4
- data/lib/labimotion/apis/labimotion_doi_api.rb +24 -10
- data/lib/labimotion/apis/labimotion_template_browse_api.rb +13 -1
- data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +16 -4
- data/lib/labimotion/models/concerns/template_doi.rb +20 -0
- data/lib/labimotion/usecases/build_template_doi_xml.rb +55 -21
- data/lib/labimotion/usecases/release_template_doi.rb +42 -17
- data/lib/labimotion/usecases/template_doi_helpers.rb +25 -3
- data/lib/labimotion/usecases/update_template_publication_metadata.rb +71 -2
- data/lib/labimotion/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c79a031d59f881b26d04e1acb6863bff76a6164e096068268d7495042d8db0f0
|
|
4
|
+
data.tar.gz: 3d84dc19e636a8c57734675e41d58ef16bb821e7d7be34be2cbe7b3196a3b804
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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.
|
|
58
|
-
#
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
32
|
+
<% if contributors.any? %>
|
|
33
33
|
<contributors>
|
|
34
|
-
|
|
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
|
|
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
|
|
81
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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 =
|
|
99
|
-
family =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
"#{
|
|
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(
|
|
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
|
data/lib/labimotion/version.rb
CHANGED
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.
|
|
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-
|
|
12
|
+
date: 2026-07-29 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: caxlsx
|