labimotion 2.2.0.rc20 → 2.2.0.rc21

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: 006f7a0d5c1bc994e1ed1be862881e579b83a91844e176858571471a4225a6e5
4
+ data.tar.gz: 9d40a31313cc7717bd0a9cb971e457bc6c2ad93a3818a3ad305dec05ad9bc406
5
5
  SHA512:
6
- metadata.gz: 7ffd28b485709f2bc8f795e1ecd4dee5fb1a0ebbe412f9fee0dd8635ad5807757f3d88609bb6ca8e5a80c5c8fc2709dc9d2ce70545db4ee53be0ddee84d4bad8
7
- data.tar.gz: 8e06a2b44be3cbc288838b1aede00a788e740df1f1eacb62467772c8addf610d3644807f13de409a1fee3da362651d01ce92508d5a24c2c89480ff1647e18a4d
6
+ metadata.gz: 399cb835f06e3cc7e30a8c59f3e64ae7738da938a7bb4a68e3ffd9c331854e36c84743b90a07769407c910d2b49042996734d2572517932a97869a18f0e6174b
7
+ data.tar.gz: 8ce94d81213aba4205aff04328c3eba89ca095b84301ddf7eee22497e0964635a0998735175f47fa00642f4d47d481e9ef0aa95bb1b04357705a1c1ad21c3c6b
@@ -7,6 +7,8 @@ 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
+ PUBLIC_PUBLICATION_KEYS = %w[title description authors contributor license].freeze
10
12
 
11
13
  helpers do
12
14
  def resolve_klass!(type, id)
@@ -41,25 +43,36 @@ module Labimotion
41
43
  )
42
44
  end
43
45
 
44
- # Public, read-only view of one released DOI version.
45
- def released_doi_version(doi)
46
+ # Public, read-only view of one released DOI version, carrying the
47
+ # publication metadata that version was released with.
48
+ def released_doi_version(record, doi)
46
49
  full = doi.full_doi
47
50
  {
48
51
  doi: full,
49
52
  doi_url: "https://dx.doi.org/#{full}",
50
53
  version: ::Doi.labimotion_doi_version(doi),
51
- minted_at: doi.minted_at
54
+ minted_at: doi.minted_at,
55
+ publication: released_publication(record, doi)
52
56
  }
53
57
  end
54
58
 
59
+ # The snapshot taken when this version was released. DOIs released before
60
+ # snapshots were kept have none, and fall back to the template's current
61
+ # publication — the best available answer for them.
62
+ def released_publication(record, doi)
63
+ publication = ::Doi.labimotion_doi_publication(doi).presence ||
64
+ (record.properties_template || {})['publication']
65
+ publication.is_a?(Hash) ? publication.slice(*PUBLIC_PUBLICATION_KEYS) : {}
66
+ end
67
+
55
68
  # Public payload for a template that has at least one released DOI: the
56
69
  # 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.
70
+ # metadata, hub deep-link and every released version. Each version brings
71
+ # its own publication, so the top-level one is the latest release's rather
72
+ # than the template's work-in-progress. `record_id` matches the hub grid's
73
+ # row id (identifier || uuid) so the frontend can merge it.
59
74
  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') : {}
75
+ versions = dois.sort_by(&:id).map { |doi| released_doi_version(record, doi) }
63
76
  versions.last.merge(
64
77
  record_id: record.try(:identifier).presence || record.try(:uuid).presence || record.id,
65
78
  identifier: record.try(:identifier),
@@ -68,7 +81,6 @@ module Labimotion
68
81
  template_version: record.try(:version),
69
82
  next_doi_version: ::Doi.labimotion_version_segment(record),
70
83
  template_url: Labimotion::TemplateDoiHelpers.template_url(record),
71
- publication: publication,
72
84
  versions: versions
73
85
  )
74
86
  end
@@ -125,7 +137,7 @@ module Labimotion
125
137
  version: ::Doi.labimotion_doi_version(doi),
126
138
  full_doi: doi.full_doi,
127
139
  minted: doi.minted == true,
128
- xml: Labimotion::BuildTemplateDoiXml.new(record, doi, current_user).call
140
+ xml: Labimotion::BuildTemplateDoiXml.new(record, doi).call
129
141
  }
130
142
  end
131
143
  { versions: versions }
@@ -9,6 +9,8 @@ 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
+ PUBLICATION_KEYS = %w[title description authors contributor license].freeze
12
14
  # type => [revision model, foreign key to the klass]
13
15
  REVISION_BY_TYPE = {
14
16
  'element' => [::Labimotion::ElementKlassesRevision, :element_klass_id],
@@ -76,7 +78,14 @@ module Labimotion
76
78
  # The template's DOI publication metadata (shown alongside the DOI).
77
79
  def template_publication(record)
78
80
  pub = (record.properties_template || {})['publication']
79
- pub.is_a?(Hash) ? pub.slice('title', 'description', 'authors', 'license') : {}
81
+ pub.is_a?(Hash) ? pub.slice(*PUBLICATION_KEYS) : {}
82
+ end
83
+
84
+ # The publication a released DOI was published with. Falls back to the
85
+ # template's current metadata for DOIs released before snapshots were kept.
86
+ def released_publication(record, doi)
87
+ pub = ::Doi.labimotion_doi_publication(doi)
88
+ pub.is_a?(Hash) && pub.any? ? pub.slice(*PUBLICATION_KEYS) : template_publication(record)
80
89
  end
81
90
 
82
91
  # Released (minted) DOIs of the template, each tagged with its DOI version
@@ -88,6 +97,7 @@ module Labimotion
88
97
  full = doi.full_doi
89
98
  { version: ::Doi.labimotion_doi_version(doi), doi: full,
90
99
  doi_url: "https://dx.doi.org/#{full}", minted_at: doi.minted_at,
100
+ publication: released_publication(record, doi),
91
101
  xml: Labimotion::BuildTemplateDoiXml.new(record, doi).call }
92
102
  end
93
103
  end
@@ -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.
@@ -89,23 +88,31 @@ module Labimotion
89
88
  end
90
89
  end
91
90
 
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.
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
94
  def contributor
95
- user = @current_user
96
- return nil unless user
95
+ picked = publication['contributor']
96
+ return nil unless picked.is_a?(Hash)
97
97
 
98
- given = user.try(:first_name).to_s.strip
99
- family = user.try(:last_name).to_s.strip
98
+ given = picked['givenName'].to_s.strip
99
+ family = picked['familyName'].to_s.strip
100
100
  return nil if given.empty? && family.empty?
101
101
 
102
- orcid = user.respond_to?(:orcid) ? user.orcid.to_s.strip : ''
103
- { 'givenName' => h(given), 'familyName' => h(family), 'orcid' => h(orcid) }
102
+ { 'givenName' => h(given), 'familyName' => h(family), 'orcid' => h(picked['orcid'].to_s.strip) }
104
103
  end
105
104
 
105
+ # What this DOI renders: the snapshot taken when it was released, so every
106
+ # released version keeps showing the metadata it was published with rather
107
+ # than the template's latest. A DOI that is still reserved has no snapshot
108
+ # and renders the template's current publication, which is what is still
109
+ # being edited for it.
106
110
  def publication
107
- @publication ||=
108
- (@record.properties_template.is_a?(Hash) ? @record.properties_template['publication'] : nil) || {}
111
+ @publication ||= ::Doi.labimotion_doi_publication(@doi).presence || record_publication
112
+ end
113
+
114
+ def record_publication
115
+ (@record.properties_template.is_a?(Hash) ? @record.properties_template['publication'] : nil) || {}
109
116
  end
110
117
 
111
118
  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,27 @@ 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. 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.
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 << 'contributor' unless named?(publication['contributor'])
96
+ missing
97
+ end
98
+
99
+ def named?(person)
100
+ person.is_a?(Hash) && person['familyName'].to_s.strip.present?
101
+ end
79
102
 
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? }
103
+ # The template's current publication metadata — what this release publishes.
104
+ def publication
105
+ @publication ||= begin
106
+ pub = record.properties_template.is_a?(Hash) ? record.properties_template['publication'] : nil
107
+ pub.is_a?(Hash) ? pub : {}
108
+ end
83
109
  end
84
110
 
85
111
  def failure(message)
@@ -7,7 +7,7 @@ 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 contributor license].freeze
11
11
 
12
12
  attr_reader :record, :current_user, :publication, :error
13
13
 
@@ -39,9 +39,33 @@ module Labimotion
39
39
  def normalized_publication
40
40
  slice = publication.stringify_keys.slice(*ALLOWED_KEYS)
41
41
  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')
42
44
  slice
43
45
  end
44
46
 
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?
54
+
55
+ authors.find { |author| same_person?(author, picked) }
56
+ end
57
+
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?)
63
+
64
+ %w[givenName familyName].all? do |key|
65
+ one[key].to_s.strip.casecmp?(other[key].to_s.strip)
66
+ end
67
+ end
68
+
45
69
  def normalize_author(author)
46
70
  return nil unless author.is_a?(Hash)
47
71
 
@@ -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.rc21'
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.rc21
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-28 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: caxlsx