labimotion 2.2.0.rc22 → 2.3.0.rc1

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: c79a031d59f881b26d04e1acb6863bff76a6164e096068268d7495042d8db0f0
4
- data.tar.gz: 3d84dc19e636a8c57734675e41d58ef16bb821e7d7be34be2cbe7b3196a3b804
3
+ metadata.gz: ed76c84363276af955102072d3491035f014dedb9fcdc835b4ec9c2e9ba199ee
4
+ data.tar.gz: 8a5e3a1d1c5ac1fb497d278c1f9a3237dfc0f93271729e05c602339879d6a204
5
5
  SHA512:
6
- metadata.gz: 4ad9ee1d24b63e2ebdc8a6bf6ca338fe3170644c63bc50cafcd89a660250fef0747375617b394a8174644ead7461f2dcc1d81fa4095e7365e11b07517781af00
7
- data.tar.gz: 103e68dc679f80f3a214946361dc70ad5c1204336f567d17aaae4a9aa32298e56c23f4af4f545f07e27c9cdc8ccaea7bcc00e4978918af0b9d5f9baba6188785
6
+ metadata.gz: 1d9d033f42be38117263ab30d502a3eafa5f9fa3b983f834dd1b1e14ab55537c91fafafd5b1643a6d3e2ea5e2e2ec17a8a072220cef56a4b80a34deca8f6600c
7
+ data.tar.gz: 926082586397c99e47f634733d2e258b463a9340809e45d5a0d4a788ff508c0335279e80baabef1f89b851691777d764b8657b6825b40a420e962dab4c6e6f36
@@ -432,7 +432,6 @@ module Labimotion
432
432
  desc 'Return serialized elements of current user'
433
433
  params do
434
434
  optional :collection_id, type: Integer, desc: 'Collection id'
435
- optional :sync_collection_id, type: Integer, desc: 'SyncCollectionsUser id'
436
435
  optional :el_type, type: String, desc: 'element klass name'
437
436
  optional :from_date, type: Integer, desc: 'created_date from in ms'
438
437
  optional :to_date, type: Integer, desc: 'created_date to in ms'
@@ -7,10 +7,6 @@ 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
14
10
 
15
11
  helpers do
16
12
  def resolve_klass!(type, id)
@@ -45,36 +41,25 @@ module Labimotion
45
41
  )
46
42
  end
47
43
 
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)
44
+ # Public, read-only view of one released DOI version.
45
+ def released_doi_version(doi)
51
46
  full = doi.full_doi
52
47
  {
53
48
  doi: full,
54
49
  doi_url: "https://dx.doi.org/#{full}",
55
50
  version: ::Doi.labimotion_doi_version(doi),
56
- minted_at: doi.minted_at,
57
- publication: released_publication(record, doi)
51
+ minted_at: doi.minted_at
58
52
  }
59
53
  end
60
54
 
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
-
70
55
  # Public payload for a template that has at least one released DOI: the
71
56
  # latest released DOI promoted to the top level, plus its publication
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.
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.
76
59
  def released_template_doi(record, dois)
77
- versions = dois.sort_by(&:id).map { |doi| released_doi_version(record, doi) }
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') : {}
78
63
  versions.last.merge(
79
64
  record_id: record.try(:identifier).presence || record.try(:uuid).presence || record.id,
80
65
  identifier: record.try(:identifier),
@@ -83,6 +68,7 @@ module Labimotion
83
68
  template_version: record.try(:version),
84
69
  next_doi_version: ::Doi.labimotion_version_segment(record),
85
70
  template_url: Labimotion::TemplateDoiHelpers.template_url(record),
71
+ publication: publication,
86
72
  versions: versions
87
73
  )
88
74
  end
@@ -139,7 +125,7 @@ module Labimotion
139
125
  version: ::Doi.labimotion_doi_version(doi),
140
126
  full_doi: doi.full_doi,
141
127
  minted: doi.minted == true,
142
- xml: Labimotion::BuildTemplateDoiXml.new(record, doi).call
128
+ xml: Labimotion::BuildTemplateDoiXml.new(record, doi, current_user).call
143
129
  }
144
130
  end
145
131
  { versions: versions }
@@ -9,10 +9,6 @@ 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
16
12
  # type => [revision model, foreign key to the klass]
17
13
  REVISION_BY_TYPE = {
18
14
  'element' => [::Labimotion::ElementKlassesRevision, :element_klass_id],
@@ -80,14 +76,7 @@ module Labimotion
80
76
  # The template's DOI publication metadata (shown alongside the DOI).
81
77
  def template_publication(record)
82
78
  pub = (record.properties_template || {})['publication']
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)
79
+ pub.is_a?(Hash) ? pub.slice('title', 'description', 'authors', 'license') : {}
91
80
  end
92
81
 
93
82
  # Released (minted) DOIs of the template, each tagged with its DOI version
@@ -99,7 +88,6 @@ module Labimotion
99
88
  full = doi.full_doi
100
89
  { version: ::Doi.labimotion_doi_version(doi), doi: full,
101
90
  doi_url: "https://dx.doi.org/#{full}", minted_at: doi.minted_at,
102
- publication: released_publication(record, doi),
103
91
  xml: Labimotion::BuildTemplateDoiXml.new(record, doi).call }
104
92
  end
105
93
  end
@@ -215,7 +215,7 @@ module Labimotion
215
215
  layer, field = params[:sort_column].split('.')
216
216
 
217
217
  element_klass = Labimotion::ElementKlass.find_by(name: params[:el_type])
218
- allowed_fields = element_klass.properties_release.dig(Labimotion::Prop::LAYERS, layer, Labimotion::Prop::FIELDS)&.pluck('field') || []
218
+ allowed_fields = element_klass&.properties_release&.dig(Labimotion::Prop::LAYERS, layer, Labimotion::Prop::FIELDS)&.pluck('field') || []
219
219
 
220
220
  if field.in?(allowed_fields)
221
221
  query = ActiveRecord::Base.sanitize_sql(
@@ -247,27 +247,21 @@ module Labimotion
247
247
  end
248
248
 
249
249
  def list_serialized_elements(params, current_user)
250
- collection_id =
251
- if params[:collection_id]
252
- Collection
253
- .belongs_to_or_shared_by(current_user.id, current_user.group_ids)
254
- .find_by(id: params[:collection_id])&.id
255
- elsif params[:sync_collection_id]
256
- current_user
257
- .all_sync_in_collections_users
258
- .find_by(id: params[:sync_collection_id])&.collection&.id
259
- end
250
+ scope = Labimotion::Element.none
260
251
 
261
- scope =
262
- if collection_id
263
- Labimotion::Element
264
- .joins(:element_klass, :collections_elements)
265
- .where(
266
- element_klasses: { name: params[:el_type] },
267
- collections_elements: { collection_id: collection_id },
268
- ).includes(:tag, collections: :sync_collections_users)
252
+ if params[:collection_id]
253
+ begin
254
+ collection = Collection.accessible_for(current_user).find(params[:collection_id])
255
+ scope = collection.elements
256
+ .joins(:element_klass)
257
+ .where(element_klasses: { name: params[:el_type] })
258
+ .includes(:tag)
259
+ rescue ActiveRecord::RecordNotFound
260
+ Labimotion::Element.none
261
+ end
269
262
  else
270
- Labimotion::Element.none
263
+ # All collection of current_user
264
+ scope = Labimotion::Element.for_user(current_user.id)
271
265
  end
272
266
 
273
267
  ## TO DO: refactor labimotion
@@ -278,7 +272,7 @@ module Labimotion
278
272
  layer, field = params[:sort_column].split('.')
279
273
 
280
274
  element_klass = Labimotion::ElementKlass.find_by(name: params[:el_type])
281
- allowed_fields = element_klass.properties_release.dig(Labimotion::Prop::LAYERS, layer, Labimotion::Prop::FIELDS)&.pluck('field') || []
275
+ allowed_fields = element_klass&.properties_release&.dig(Labimotion::Prop::LAYERS, layer, Labimotion::Prop::FIELDS)&.pluck('field') || []
282
276
 
283
277
  if field.in?(allowed_fields)
284
278
  query = ActiveRecord::Base.sanitize_sql(
@@ -26,9 +26,9 @@ module Labimotion
26
26
  joins(collections: :user).where(collections: { user_id: user_id })
27
27
  )
28
28
 
29
- # Shared (synced) records
29
+ # Shared records
30
30
  shared = apply_filters.call(
31
- joins(collections: :sync_collections_users).where(sync_collections_users: { user_id: user_id })
31
+ left_joins(:collection_shares).where(collection_shares: { shared_with_id: user_id })
32
32
  )
33
33
 
34
34
  # Combine (remove duplicates), order, and limit
@@ -141,9 +141,9 @@ module Labimotion
141
141
  joins(collections: :user).where(collections: { user_id: user_id })
142
142
  )
143
143
 
144
- # Shared (synced) elements
144
+ # Shared elements
145
145
  shared = apply_filters.call(
146
- joins(collections: :sync_collections_users).where(sync_collections_users: { user_id: user_id })
146
+ left_joins(:collection_shares).where(collection_shares: { shared_with_id: user_id })
147
147
  )
148
148
 
149
149
  # Combine (remove duplicates), order, and limit
@@ -11,11 +11,7 @@ module Labimotion
11
11
  class BuildTemplateDoiXml
12
12
  include ::DataCitePublisher
13
13
 
14
- # The template ships with the gem. A host app may still override it by
15
- # placing its own copy at HOST_TEMPLATE_PATH, which is how it used to be
16
- # sourced — hence the lookup order below.
17
- GEM_TEMPLATE_PATH = File.expand_path('../libs/data/datacite/labimotion_template.html.erb', __dir__)
18
- HOST_TEMPLATE_PATH = 'app/publish/datacite_metadata_labimotion_template.html.erb'
14
+ TEMPLATE_PATH = Rails.root.join('app/publish/datacite_metadata_labimotion_template.html.erb')
19
15
 
20
16
  RESOURCE_LABEL = {
21
17
  'element' => 'LabIMotion Element Template',
@@ -23,28 +19,21 @@ module Labimotion
23
19
  'dataset' => 'LabIMotion Dataset Template'
24
20
  }.freeze
25
21
 
26
- def initialize(record, doi = nil)
22
+ def initialize(record, doi = nil, current_user = nil)
27
23
  @record = record
28
24
  @doi = doi || ::Doi.labimotion_latest_doi(record)
25
+ @current_user = current_user
29
26
  end
30
27
 
31
28
  # Returns the XML string, or nil when no DOI has been reserved yet.
32
29
  def call
33
30
  return nil if @doi.nil?
34
31
 
35
- ERB.new(File.read(template_path), trim_mode: '-').result(binding)
32
+ ERB.new(File.read(TEMPLATE_PATH), trim_mode: '-').result(binding)
36
33
  end
37
34
 
38
35
  private
39
36
 
40
- # Resolved per call rather than at load time, so the class no longer needs
41
- # Rails to be booted just to be autoloaded.
42
- def template_path
43
- # `Rails.root` is still nil if Rails is loaded but the app has not booted.
44
- host = defined?(Rails) && Rails.respond_to?(:root) ? Rails.root&.join(HOST_TEMPLATE_PATH) : nil
45
- host && File.exist?(host) ? host : GEM_TEMPLATE_PATH
46
- end
47
-
48
37
  def full_doi
49
38
  @doi.full_doi
50
39
  end
@@ -76,70 +65,35 @@ module Labimotion
76
65
  end
77
66
 
78
67
  def creators
79
- Array(publication['authors']).filter_map { |author| person(author) }
80
- end
68
+ Array(publication['authors']).filter_map do |author|
69
+ next unless author.is_a?(Hash)
81
70
 
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)))
92
- end
93
- end
71
+ given = author['givenName'].to_s.strip
72
+ family = author['familyName'].to_s.strip
73
+ next if given.empty? && family.empty?
94
74
 
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
75
+ { 'givenName' => h(given), 'familyName' => h(family),
76
+ 'orcid' => h(author['orcid'].to_s.strip), 'affiliation' => h(author['affiliation'].to_s.strip) }
77
+ end
99
78
  end
100
79
 
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)
80
+ # The acting user, rendered as a DataCite "Researcher" contributor. Nil
81
+ # (no contributors block) when there is no user or they have no name.
82
+ def contributor
83
+ user = @current_user
84
+ return nil unless user
107
85
 
108
- given = entry['givenName'].to_s.strip
109
- family = entry['familyName'].to_s.strip
86
+ given = user.try(:first_name).to_s.strip
87
+ family = user.try(:last_name).to_s.strip
110
88
  return nil if given.empty? && family.empty?
111
89
 
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}") }
90
+ orcid = user.respond_to?(:orcid) ? user.orcid.to_s.strip : ''
91
+ { 'givenName' => h(given), 'familyName' => h(family), 'orcid' => h(orcid) }
116
92
  end
117
93
 
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.
137
94
  def publication
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) || {}
95
+ @publication ||=
96
+ (@record.properties_template.is_a?(Hash) ? @record.properties_template['publication'] : nil) || {}
143
97
  end
144
98
 
145
99
  def h(value)
@@ -24,8 +24,7 @@ module Labimotion
24
24
  return failure('doi not reserved') if @doi.nil?
25
25
  return self if @doi.minted
26
26
 
27
- missing = missing_metadata
28
- return failure("publication metadata is incomplete: #{missing.join(', ')}") if missing.any?
27
+ return failure('publication metadata is incomplete') unless metadata_ready?
29
28
 
30
29
  publish_at_datacite
31
30
  self
@@ -38,33 +37,22 @@ module Labimotion
38
37
  private
39
38
 
40
39
  def publish_at_datacite
41
- datacite_failure = send_to_datacite? ? mint_at_datacite : nil
42
- return failure(datacite_failure) if datacite_failure
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
43
49
 
44
50
  ActiveRecord::Base.transaction do
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))
51
+ doi.update!(minted: true, minted_at: Time.current)
50
52
  stamp_release_on_record
51
53
  end
52
54
  end
53
55
 
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
-
68
56
  def datacite_error(step, response)
69
57
  status = response.respond_to?(:code) ? response.code : 'unknown'
70
58
  "datacite #{step} failed (status #{status})"
@@ -85,26 +73,13 @@ module Labimotion
85
73
  record.update!(attrs) if attrs.any?
86
74
  end
87
75
 
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
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)
101
79
 
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
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? }
108
83
  end
109
84
 
110
85
  def failure(message)
@@ -20,24 +20,6 @@ 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
-
41
23
  module_function
42
24
 
43
25
  # True once the template's latest DOI has been released (minted) and the
@@ -67,17 +49,13 @@ module Labimotion
67
49
  # Used as the DOI's DataCite landing page and surfaced in the UI.
68
50
  def template_url(record)
69
51
  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'
70
54
  record_id = record.try(:identifier).presence || record.try(:uuid).presence || record.id
71
55
  query = "type=finder&kind=#{type}&id=#{record_id}"
72
56
  version = record.try(:version)
73
57
  query += "&version=#{version}" if version.present?
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('/')
58
+ "#{host.chomp('/')}/home/genericHub?#{query}"
81
59
  end
82
60
 
83
61
  # A user may manage a template's DOI iff they hold the LabIMotion
@@ -7,12 +7,7 @@ module Labimotion
7
7
  class UpdateTemplatePublicationMetadata
8
8
  include TemplateDoiHelpers
9
9
 
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
10
+ ALLOWED_KEYS = %w[title description authors license].freeze
16
11
 
17
12
  attr_reader :record, :current_user, :publication, :error
18
13
 
@@ -44,82 +39,18 @@ module Labimotion
44
39
  def normalized_publication
45
40
  slice = publication.stringify_keys.slice(*ALLOWED_KEYS)
46
41
  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) }
52
42
  slice
53
43
  end
54
44
 
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
-
94
45
  def normalize_author(author)
95
46
  return nil unless author.is_a?(Hash)
96
47
 
97
- h = author.stringify_keys.slice(*PERSON_KEYS)
48
+ h = author.stringify_keys.slice('givenName', 'familyName', 'orcid', 'affiliation', 'affiliation_id')
98
49
  return nil if h.values_at('givenName', 'familyName').all? { |v| v.to_s.strip.empty? }
99
50
 
100
- ror = normalize_ror(h['ror_id']) || ror_for(h['affiliation_id'])
101
- ror ? h['ror_id'] = ror : h.delete('ror_id')
102
51
  h
103
52
  end
104
53
 
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
-
123
54
  def failure(message)
124
55
  @error = message
125
56
  self
@@ -12,7 +12,7 @@ module Labimotion
12
12
  end
13
13
 
14
14
  def self.elements_search(params, current_user, c_id, dl)
15
- collection = Collection.belongs_to_or_shared_by(current_user.id, current_user.group_ids).find(c_id)
15
+ collection = Collection.accessible_for(current_user).find(c_id)
16
16
  element_scope = Labimotion::Element.joins(:collections_elements).where('collections_elements.collection_id = ?', collection.id).joins(:element_klass).where('element_klasses.id = elements.element_klass_id AND element_klasses.name = ?', params[:selection][:genericElName])
17
17
  element_scope = element_scope.where('elements.name like (?)', "%#{params[:selection][:searchName]}%") if params[:selection][:searchName].present?
18
18
  element_scope = element_scope.where('elements.short_label like (?)', "%#{params[:selection][:searchShowLabel]}%") if params[:selection][:searchShowLabel].present?
@@ -97,7 +97,7 @@ module Labimotion
97
97
  def self.samples_search(c_id = @c_id)
98
98
  sqls = []
99
99
  sps = params[:selection][:searchProperties]
100
- collection = Collection.belongs_to_or_shared_by(current_user.id, current_user.group_ids).find(c_id)
100
+ collection = Collection.accessible_for(current_user).find(c_id)
101
101
  element_scope = Sample.joins(:collections_samples).where('collections_samples.collection_id = ?', collection.id)
102
102
  return element_scope if sps.empty?
103
103
 
@@ -51,6 +51,20 @@ module Labimotion
51
51
  { "key": "pmol", "label": "pmol", "nm": 1.0e12 }
52
52
  ]
53
53
  },
54
+ {
55
+ "type": "numeric",
56
+ "field": "areal_density",
57
+ "label": "Areal density",
58
+ "default": "",
59
+ "position": 38,
60
+ "placeholder": "areal density",
61
+ "units": [
62
+ { "key": "kg_m2", "label": "kg m<sup>-2</sup>", "nm": 1 },
63
+ { "key": "t_m2", "label": "t m<sup>-2</sup>", "nm": 0.001 },
64
+ { "key": "kg_cm2", "label": "kg cm<sup>-2</sup>", "nm": 1e-4 },
65
+ { "key": "g_m2", "label": "g m<sup>-2</sup>", "nm": 1000 }
66
+ ]
67
+ },
54
68
  {
55
69
  "type": "numeric",
56
70
  "field": "molarity",
@@ -93,16 +107,34 @@ module Labimotion
93
107
  "field": "conductivity",
94
108
  "label": "Conductivity",
95
109
  "default": "",
96
- "position": 66,
110
+ "position": 62,
97
111
  "placeholder": "conductivity",
98
- "units": [{ "key": "s_m", "label": "S/m", "nm": 1 }]
112
+ "units": [
113
+ { "key": "s_m", "label": "S/m", "nm": 1 },
114
+ { "key": "ms_cm", "label": "mS/cm", "nm": 10 }
115
+ ]
116
+ },
117
+ {
118
+ "type": "numeric",
119
+ "field": "currency",
120
+ "label": "Currency",
121
+ "default": "",
122
+ "position": 64,
123
+ "placeholder": "Currency",
124
+ "units": [
125
+ { "key": "eur", "label": "EUR", "nm": 1, "unit_type": "eur" },
126
+ { "key": "chf", "label": "CHF", "nm": 1, "unit_type": "chf" },
127
+ { "key": "gbp", "label": "GBP", "nm": 1, "unit_type": "gbp" },
128
+ { "key": "usd", "label": "USD", "nm": 1, "unit_type": "usd" },
129
+ { "key": "jpy", "label": "JPY", "nm": 1, "unit_type": "jpy" }
130
+ ]
99
131
  },
100
132
  {
101
133
  "type": "numeric",
102
134
  "field": "current",
103
135
  "label": "Current",
104
136
  "default": "",
105
- "position": 60,
137
+ "position": 67,
106
138
  "placeholder": "Current",
107
139
  "units": [
108
140
  { "key": "A", "label": "A", "nm": 1 },
@@ -251,6 +283,17 @@ module Labimotion
251
283
  { "key": "k_j", "label": "kJ", "nm": 1 }
252
284
  ]
253
285
  },
286
+ {
287
+ "type": "numeric",
288
+ "field": "free_energy",
289
+ "label": "Free Energy/enthalpy",
290
+ "default": "",
291
+ "position": 89,
292
+ "placeholder": "Free Energy/enthalpy",
293
+ "units": [
294
+ { "key": "kj_mol", "label": "kJ/mol", "nm": 1 }
295
+ ]
296
+ },
254
297
  {
255
298
  "type": "numeric",
256
299
  "field": "enzyme_activity",
@@ -260,9 +303,9 @@ module Labimotion
260
303
  "placeholder": "Enzyme activity",
261
304
  "units": [
262
305
  { "key": "u_l", "label": "U/L", "nm": 1 },
263
- { "key": "u_ml", "label": "U/mL", "nm": 10e-3 },
264
- { "key": "u_g", "label": "U/g", "nm": 10e-3 },
265
- { "key": "u_mg", "label": "U/mg", "nm": 10e-6 }
306
+ { "key": "u_ml", "label": "U/mL", "nm": 1e-3 },
307
+ { "key": "u_g", "label": "U/g", "nm": 1e-3 },
308
+ { "key": "u_mg", "label": "U/mg", "nm": 1e-6 }
266
309
  ]
267
310
  },
268
311
  {
@@ -284,9 +327,10 @@ module Labimotion
284
327
  "position": 100,
285
328
  "placeholder": "Flow rate",
286
329
  "units": [
287
- { "key": "ul_min", "label": "µl/min", "nm": 1000000 },
288
- { "key": "ml_min", "label": "ml/min", "nm": 1000 },
289
- { "key": "l_m", "label": "l/m", "nm": 1 }
330
+ { "key": "l_m", "label": "L/min", "nm": 1 },
331
+ { "key": "ml_min", "label": "mL/min", "nm": 1000 },
332
+ { "key": "ul_min", "label": "µL/min", "nm": 1000000 },
333
+ { "key": "ml_h", "label": "mL/h", "nm": 60000 }
290
334
  ]
291
335
  },
292
336
  {
@@ -297,19 +341,26 @@ module Labimotion
297
341
  "position": 103,
298
342
  "placeholder": "frequency",
299
343
  "units": [
300
- { "key": "mhz", "label": "MHz", "nm": 1000000 },
301
- { "key": "hz", "label": "Hz", "nm": 1000 },
302
- { "key": "khz", "label": "kHz", "nm": 1 }
344
+ { "key": "hz", "label": "Hz", "nm": 1 },
345
+ { "key": "khz", "label": "kHz", "nm": 1e-3 },
346
+ { "key": "mhz", "label": "MHz", "nm": 1e-6 }
303
347
  ]
304
348
  },
305
349
  {
306
350
  "type": "numeric",
307
351
  "field": "heating_rate",
308
- "label": "Heating rate",
352
+ "label": "Heating rate (Temperature ramp)",
309
353
  "default": "",
310
354
  "position": 106,
311
355
  "placeholder": "heating rate",
312
- "units": [{ "key": "k_min", "label": "K/min", "nm": 1 }]
356
+ "units": [
357
+ { "key": "k_min", "label": "K/min", "nm": 1 },
358
+ { "key": "k_s", "label": "K/s", "nm": 0.016666666666667 },
359
+ { "key": "k_h", "label": "K/h", "nm": 60 },
360
+ { "key": "degc_min", "label": "°C min<sup>-1</sup>", "nm": 1 },
361
+ { "key": "degc_s", "label": "°C s<sup>-1</sup>", "nm": 0.016666666666667 },
362
+ { "key": "degc_h", "label": "°C h<sup>-1</sup>", "nm": 60 }
363
+ ]
313
364
  },
314
365
  {
315
366
  "type": "numeric",
@@ -346,6 +397,29 @@ module Labimotion
346
397
  { "key": "ug", "label": "µg", "nm": 1000000 }
347
398
  ]
348
399
  },
400
+ {
401
+ "type": "numeric",
402
+ "field": "mass_fraction",
403
+ "label": "Mass fraction",
404
+ "default": "",
405
+ "position": 123,
406
+ "placeholder": "mass fraction",
407
+ "units": [
408
+ { "key": "wt_p", "label": "wt.%", "nm": 1 }
409
+ ]
410
+ },
411
+ {
412
+ "type": "numeric",
413
+ "field": "mass_loading",
414
+ "label": "Mass loading",
415
+ "default": "",
416
+ "position": 125,
417
+ "placeholder": "mass loading",
418
+ "units": [
419
+ { "key": "g_cm2", "label": "g cm<sup>-2</sup>", "nm": 1 },
420
+ { "key": "mg_cm2", "label": "mg cm<sup>-2</sup>", "nm": 1000 }
421
+ ]
422
+ },
349
423
  {
350
424
  "type": "numeric",
351
425
  "field": "mass_molecule",
@@ -358,6 +432,28 @@ module Labimotion
358
432
  { "key": "kilo_dalton", "label": "kD", "nm": 1 }
359
433
  ]
360
434
  },
435
+ {
436
+ "type": "numeric",
437
+ "field": "molar_conductivity",
438
+ "label": "Molar conductivity",
439
+ "default": "",
440
+ "position": 127,
441
+ "placeholder": "molar conductivity",
442
+ "units": [
443
+ { "key": "s_cm2_mol", "label": "S cm<sup>2</sup> mol<sup>-1</sup>", "nm": 1 }
444
+ ]
445
+ },
446
+ {
447
+ "type": "numeric",
448
+ "field": "molar_entropy",
449
+ "label": "Molar Entropy",
450
+ "default": "",
451
+ "position": 128,
452
+ "placeholder": "Molar Entropy",
453
+ "units": [
454
+ { "key": "j_mol_k", "label": "J/(mol*K)", "nm": 1 }
455
+ ]
456
+ },
361
457
  {
362
458
  "type": "numeric",
363
459
  "field": "molecular_weight",
@@ -402,6 +498,30 @@ module Labimotion
402
498
  { "key": "mbar", "label": "mbar", "nm": 1013.25 }
403
499
  ]
404
500
  },
501
+ {
502
+ "type": "numeric",
503
+ "field": "process_rate",
504
+ "label": "Process rate",
505
+ "default": "",
506
+ "position": 143,
507
+ "placeholder": "Process rate",
508
+ "units": [
509
+ { "key": "1_s", "label": "1/s", "nm": 1 },
510
+ { "key": "1_min", "label": "1/min", "nm": 60 },
511
+ { "key": "1_h", "label": "1/h", "nm": 3600 }
512
+ ]
513
+ },
514
+ {
515
+ "type": "numeric",
516
+ "field": "rate_constant",
517
+ "label": "Rate constant",
518
+ "default": "",
519
+ "position": 146,
520
+ "placeholder": "Rate constant",
521
+ "units": [
522
+ { "key": "1_m_s", "label": "1/(M*s)", "nm": 1 }
523
+ ]
524
+ },
405
525
  {
406
526
  "type": "numeric",
407
527
  "field": "reaction_rate",
@@ -410,8 +530,8 @@ module Labimotion
410
530
  "position": 150,
411
531
  "placeholder": "Reaction rate",
412
532
  "units": [
413
- { "key": "mol_lmin", "label": "mol/Lmin", "nm": 1 },
414
- { "key": "mol_lsec", "label": "mol/Ls", "nm": 60 }
533
+ { "key": "mol_lmin", "label": "mol L<sup>-1</sup> min<sup>-1</sup>", "nm": 1 },
534
+ { "key": "mol_lsec", "label": "mol L<sup>-1</sup> s<sup>-1</sup>", "nm": 0.016666666666667 }
415
535
  ]
416
536
  },
417
537
  {
@@ -448,8 +568,9 @@ module Labimotion
448
568
  "units": [
449
569
  { "key": "ma_g", "label": "mA/g", "nm": 1000, "unit_type": "mass" },
450
570
  { "key": "a_g", "label": "A/g", "nm": 1, "unit_type": "mass" },
571
+ { "key": "ma_cm2", "label": "mA/cm<sup>2</sup>", "nm": 1000, "unit_type": "area" },
451
572
  { "key": "a_cm2", "label": "A/cm<sup>2</sup>", "nm": 1, "unit_type": "area" },
452
- { "key": "ma_cm2", "label": "mA/cm<sup>2</sup>", "nm": 1000, "unit_type": "area" }
573
+ { "key": "a_mm2", "label": "A/mm<sup>2</sup>", "nm": 0.01, "unit_type": "area" }
453
574
  ]
454
575
  },
455
576
  {
@@ -495,7 +616,7 @@ module Labimotion
495
616
  { "key": "cm_s", "label": "cm/s", "nm": 1 },
496
617
  { "key": "mm_s", "label": "mm/s", "nm": 10 },
497
618
  { "key": "um_m", "label": "µm/min", "nm": 600000 },
498
- { "key": "nm_m", "label": "nm/min", "nm": 60000000 },
619
+ { "key": "nm_m", "label": "nm/min", "nm": 600000000 },
499
620
  { "key": "cm_h", "label": "cm/h", "nm": 3600 },
500
621
  { "key": "mm_h", "label": "mm/h", "nm": 36000 }
501
622
  ]
@@ -540,6 +661,31 @@ module Labimotion
540
661
  { "key": "K", "label": "K" }
541
662
  ]
542
663
  },
664
+ {
665
+ "type": "numeric",
666
+ "field": "tensile_force",
667
+ "label": "Tensile force",
668
+ "default": "",
669
+ "position": 185,
670
+ "placeholder": "tensile force",
671
+ "units": [
672
+ { "key": "n", "label": "N", "nm": 1 },
673
+ { "key": "kn", "label": "kN", "nm": 0.001 },
674
+ { "key": "mn", "label": "mN", "nm": 1000 }
675
+ ]
676
+ },
677
+ {
678
+ "type": "numeric",
679
+ "field": "tensile_stress",
680
+ "label": "Tensile stress",
681
+ "default": "",
682
+ "position": 186,
683
+ "placeholder": "tensile stress",
684
+ "units": [
685
+ { "key": "n_cm2", "label": "N cm<sup>-2</sup>", "nm": 1 },
686
+ { "key": "n_mm2", "label": "N mm<sup>-2</sup>", "nm": 0.01 }
687
+ ]
688
+ },
543
689
  {
544
690
  "type": "numeric",
545
691
  "field": "turnover_number",
@@ -575,7 +721,7 @@ module Labimotion
575
721
  "field": "voltage",
576
722
  "label": "Voltage",
577
723
  "default": "",
578
- "position": 200,
724
+ "position": 206,
579
725
  "placeholder": "voltage",
580
726
  "units": [
581
727
  { "key": "mv", "label": "mV", "nm": 1000 },
@@ -597,6 +743,17 @@ module Labimotion
597
743
  { "key": "nl", "label": "nl", "nm": 1000000000 }
598
744
  ]
599
745
  },
746
+ {
747
+ "type": "numeric",
748
+ "field": "volume_fraction",
749
+ "label": "Volume fraction",
750
+ "default": "",
751
+ "position": 218,
752
+ "placeholder": "volume fraction",
753
+ "units": [
754
+ { "key": "vol_p", "label": "vol.%", "nm": 1 }
755
+ ]
756
+ },
600
757
  {
601
758
  "type": "numeric",
602
759
  "field": "volumes_metric",
@@ -610,6 +767,6 @@ module Labimotion
610
767
  { "key": "m3", "label": "m³", "nm": 0.000001 }
611
768
  ]
612
769
  }
613
- ]
770
+ ]
614
771
  end
615
772
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  ## Labimotion Version
4
4
  module Labimotion
5
- VERSION = '2.2.0.rc22'
5
+ VERSION = '2.3.0.rc1'
6
6
  end
data/lib/labimotion.rb CHANGED
@@ -148,5 +148,4 @@ module Labimotion
148
148
  autoload :Datasetable, 'labimotion/models/concerns/datasetable'
149
149
  autoload :AttachmentConverter, 'labimotion/models/concerns/attachment_converter'
150
150
  autoload :LinkedProperties, 'labimotion/models/concerns/linked_properties'
151
- autoload :TemplateDoi, 'labimotion/models/concerns/template_doi'
152
151
  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.rc22
4
+ version: 2.3.0.rc1
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-29 00:00:00.000000000 Z
12
+ date: 2026-07-03 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: caxlsx
@@ -111,7 +111,6 @@ files:
111
111
  - lib/labimotion/helpers/vocabulary_helpers.rb
112
112
  - lib/labimotion/libs/attachment_handler.rb
113
113
  - lib/labimotion/libs/converter.rb
114
- - lib/labimotion/libs/data/datacite/labimotion_template.html.erb
115
114
  - lib/labimotion/libs/data/mapper/Chemwiki.json
116
115
  - lib/labimotion/libs/data/mapper/Source.json
117
116
  - lib/labimotion/libs/data/vocab/Standard.json
@@ -137,7 +136,6 @@ files:
137
136
  - lib/labimotion/models/concerns/linked_properties.rb
138
137
  - lib/labimotion/models/concerns/metadata_validation.rb
139
138
  - lib/labimotion/models/concerns/segmentable.rb
140
- - lib/labimotion/models/concerns/template_doi.rb
141
139
  - lib/labimotion/models/concerns/workflow.rb
142
140
  - lib/labimotion/models/dataset.rb
143
141
  - lib/labimotion/models/dataset_klass.rb
@@ -1,67 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <resource xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://datacite.org/schema/kernel-4" xsi:schemaLocation="http://datacite.org/schema/kernel-4 https://schema.datacite.org/meta/kernel-4/metadata.xsd">
3
- <identifier identifierType="DOI">https://dx.doi.org/<%= full_doi %></identifier>
4
- <creators>
5
- <% if creators.empty? %>
6
- <creator>
7
- <creatorName>(:unav)</creatorName>
8
- </creator>
9
- <% end %>
10
- <% creators.each do |creator| %>
11
- <creator>
12
- <creatorName nameType="Personal"><%= creator['familyName'] %>, <%= creator['givenName'] %></creatorName>
13
- <givenName><%= creator['givenName'] %></givenName>
14
- <familyName><%= creator['familyName'] %></familyName>
15
- <% if creator['orcid'].present? %>
16
- <nameIdentifier schemeURI="https://orcid.org/" nameIdentifierScheme="ORCID"><%= creator['orcid'] %></nameIdentifier>
17
- <% end %>
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
- <% end %>
21
- </creator>
22
- <% end %>
23
- </creators>
24
- <titles>
25
- <title xml:lang="en-US"><%= title %></title>
26
- </titles>
27
- <%= render_publisher(publisher_config) %>
28
- <publicationYear><%= publication_year %></publicationYear>
29
- <subjects>
30
- <subject>Chemistry</subject>
31
- </subjects>
32
- <% if contributors.any? %>
33
- <contributors>
34
- <% contributors.each do |contributor| %>
35
- <contributor contributorType="<%= contributor['contributorType'] %>">
36
- <contributorName nameType="Personal"><%= contributor['familyName'] %>, <%= contributor['givenName'] %></contributorName>
37
- <givenName><%= contributor['givenName'] %></givenName>
38
- <familyName><%= contributor['familyName'] %></familyName>
39
- <% if contributor['orcid'].present? %>
40
- <nameIdentifier schemeURI="https://orcid.org/" nameIdentifierScheme="ORCID"><%= contributor['orcid'] %></nameIdentifier>
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 %>
45
- </contributor>
46
- <% end %>
47
- </contributors>
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 %>
56
- <language>en</language>
57
- <resourceType resourceTypeGeneral="Model"><%= resource_label %></resourceType>
58
- <version><%= version %></version>
59
- <rightsList>
60
- <rights><%= license %></rights>
61
- </rightsList>
62
- <descriptions>
63
- <% if description.present? %>
64
- <description xml:lang="en-US" descriptionType="Abstract"><%= description %></description>
65
- <% end %>
66
- </descriptions>
67
- </resource>
@@ -1,133 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # TemplateDoi concern
4
- #
5
- # LabIMotion template DOI logic for the host's Doi model. The host owns the DOI
6
- # record and the generic DataCite plumbing (build_suffix/align_suffix, the MDS
7
- # client, the DataCitePublisher concern); this adds only the LabIMotion-specific
8
- # class methods that the gem's template DOI usecases and APIs call on ::Doi.
9
- #
10
- # The host wires it up with a single line:
11
- # class Doi < ApplicationRecord
12
- # include Labimotion::TemplateDoi
13
- # end
14
- #
15
- # Assumes the host `dois` table has a jsonb `metadata` column (the DOI version
16
- # is stamped there) and that the deterministic suffix survives Doi#align_suffix
17
- # unchanged (reserve sets inchikey == suffix and version_count: 0 for that).
18
- module Labimotion
19
- module TemplateDoi
20
- extend ActiveSupport::Concern
21
-
22
- # Literal namespace shared by every LabIMotion template DOI suffix.
23
- LABIMOTION_DOI_NAMESPACE = 'labimotion'
24
-
25
- # ActiveSupport::Concern extends this into the including class, so every
26
- # method below becomes a class method on the host's Doi.
27
- module ClassMethods
28
- # Deterministic DOI suffix for a LabIMotion template klass. The same suffix
29
- # is used at reserve and at release time, so both share one DOI:
30
- # element -> labimotion/element/<name>/<identifier>/<doi_version>
31
- # segment -> labimotion/segment/<element>/<identifier>/<doi_version>
32
- # dataset -> labimotion/dataset/<ols_term_id>/<identifier>/<doi_version>
33
- # `<identifier>` is the first UUID group (8 hex chars) of the template's
34
- # identifier/uuid, falling back to the record id. `<doi_version>` is the DOI
35
- # version (see .labimotion_version_segment). Callers may pass an explicit
36
- # template version; otherwise the record's current version is used.
37
- def build_labimotion_suffix(record, version = nil)
38
- parts = labimotion_suffix_parts(record)
39
- raise ArgumentError, "unsupported template: #{record.class}" if parts.nil?
40
-
41
- segment = labimotion_version_segment(record, version)
42
- return nil if segment.blank?
43
-
44
- segments = parts.map { |part| sanitize_doi_part(part) }.reject(&:empty?)
45
- "#{segments.join('/')}/#{segment}"
46
- end
47
-
48
- # The DOI version segment for a template version. A DOI is published per major
49
- # release: the .0 release opens that version (X.0 -> "X") and later minor
50
- # revisions roll into the NEXT DOI (X.y, y>=1 -> "X+1"). Sub-1.0 and
51
- # unversioned/blank templates map to the first DOI (v1).
52
- # 0.x -> "1" 1.0 -> "1" 1.1..2.0 -> "2" 2.1..3.0 -> "3"
53
- def labimotion_version_segment(record, version = nil)
54
- raw = (version.presence || record.try(:version)).to_s.strip
55
- parts = raw.split('.')
56
- major = parts[0].to_i
57
- minor = parts[1].to_i
58
- return '1' if major.zero?
59
- return major.to_s if minor.zero?
60
-
61
- (major + 1).to_s
62
- end
63
-
64
- # The DOIs reserved for a LabIMotion template, in reservation order.
65
- def labimotion_dois(record)
66
- where(doiable_id: record.id, doiable_type: record.class.name).order(:id).to_a
67
- end
68
-
69
- # The current (most recently reserved) DOI for a template, or nil.
70
- def labimotion_latest_doi(record)
71
- labimotion_dois(record).last
72
- end
73
-
74
- # The DOI version (next-major integer) recorded on a reserved template DOI.
75
- def labimotion_doi_version(doi)
76
- doi.metadata.to_h.dig('labimotion', 'doi_version')
77
- end
78
-
79
- # Metadata stamped on a reserved template DOI so the version it represents
80
- # survives on the record (used to gate reserving the next version).
81
- def labimotion_doi_metadata(record, version = nil)
82
- { 'labimotion' => { 'doi_version' => labimotion_version_segment(record, version) } }
83
- end
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
-
105
- def labimotion_suffix_parts(record)
106
- # identifier/uuid may both be blank for a draft, which would collapse the
107
- # scope (colliding across templates). Fall back to the stable record id so
108
- # every template gets a unique, deterministic suffix. Resolved lazily so an
109
- # unsupported record (no #id) still falls through to the ArgumentError in the
110
- # caller. The identifier is shortened to its first UUID group (8 hex chars).
111
- # A segment's <element> is the name of the ElementKlass it belongs to.
112
- identifier = lambda do
113
- raw = record.try(:identifier).presence || record.try(:uuid).presence || record.id
114
- raw.to_s.split('-').first
115
- end
116
- case record
117
- when ::Labimotion::ElementKlass
118
- [LABIMOTION_DOI_NAMESPACE, 'element', record.name, identifier.call]
119
- when ::Labimotion::SegmentKlass
120
- [LABIMOTION_DOI_NAMESPACE, 'segment', record.element_klass&.name, identifier.call]
121
- when ::Labimotion::DatasetKlass
122
- [LABIMOTION_DOI_NAMESPACE, 'dataset', record.ols_term_id, identifier.call]
123
- end
124
- end
125
-
126
- # Keeps a suffix segment DOI-safe: trims, collapses whitespace to hyphens and
127
- # drops anything outside [A-Za-z0-9._-] (colons stripped, as elsewhere here).
128
- def sanitize_doi_part(value)
129
- value.to_s.strip.gsub(/\s+/, '-').gsub(/[^A-Za-z0-9._-]/, '')
130
- end
131
- end
132
- end
133
- end