labimotion 2.3.0 → 2.4.0.rc11

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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -1
  3. data/lib/labimotion/apis/generic_dataset_api.rb +93 -3
  4. data/lib/labimotion/apis/generic_element_api.rb +198 -8
  5. data/lib/labimotion/apis/generic_klass_api.rb +74 -8
  6. data/lib/labimotion/apis/klass_share_api.rb +648 -0
  7. data/lib/labimotion/apis/labimotion_ai_api.rb +252 -0
  8. data/lib/labimotion/apis/labimotion_api.rb +4 -0
  9. data/lib/labimotion/apis/labimotion_doi_api.rb +24 -10
  10. data/lib/labimotion/apis/labimotion_template_browse_api.rb +13 -1
  11. data/lib/labimotion/apis/ontology_root_api.rb +74 -0
  12. data/lib/labimotion/apis/segment_api.rb +77 -10
  13. data/lib/labimotion/apis/user_klass_settings_api.rb +93 -0
  14. data/lib/labimotion/conf.rb +5 -0
  15. data/lib/labimotion/constants.rb +64 -0
  16. data/lib/labimotion/entities/application_entity.rb +8 -0
  17. data/lib/labimotion/entities/eln_element_entity.rb +6 -0
  18. data/lib/labimotion/entities/generic_klass_entity.rb +126 -0
  19. data/lib/labimotion/entities/klass_share_entity.rb +48 -0
  20. data/lib/labimotion/entities/properties_entity.rb +78 -2
  21. data/lib/labimotion/entities/segment_entity.rb +8 -0
  22. data/lib/labimotion/entities/user_klass_setting_entity.rb +10 -0
  23. data/lib/labimotion/helpers/cover_image_helpers.rb +181 -0
  24. data/lib/labimotion/helpers/dataset_helpers.rb +187 -1
  25. data/lib/labimotion/helpers/element_helpers.rb +282 -6
  26. data/lib/labimotion/helpers/exporter_helpers.rb +17 -2
  27. data/lib/labimotion/helpers/generic_helpers.rb +293 -4
  28. data/lib/labimotion/helpers/param_helpers.rb +105 -0
  29. data/lib/labimotion/helpers/sample_association_helpers.rb +7 -0
  30. data/lib/labimotion/helpers/segment_helpers.rb +102 -4
  31. data/lib/labimotion/libs/ai_egress_guard.rb +99 -0
  32. data/lib/labimotion/libs/ai_klass_queue.rb +88 -0
  33. data/lib/labimotion/libs/ai_klass_validator.rb +74 -0
  34. data/lib/labimotion/libs/ai_models.rb +201 -0
  35. data/lib/labimotion/libs/ai_template.rb +2285 -0
  36. data/lib/labimotion/libs/converter.rb +5 -43
  37. data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +67 -0
  38. data/lib/labimotion/libs/export_element.rb +128 -13
  39. data/lib/labimotion/libs/file_extractor.rb +210 -0
  40. data/lib/labimotion/libs/linked_element.rb +313 -0
  41. data/lib/labimotion/libs/ontology_store.rb +226 -0
  42. data/lib/labimotion/libs/ontology_terms.rb +227 -0
  43. data/lib/labimotion/libs/owner_resolver.rb +50 -0
  44. data/lib/labimotion/libs/ownership_audit.rb +73 -0
  45. data/lib/labimotion/libs/sample_association.rb +52 -1
  46. data/lib/labimotion/libs/share_notifier.rb +114 -0
  47. data/lib/labimotion/libs/share_resolver.rb +373 -0
  48. data/lib/labimotion/libs/user_ai_settings.rb +129 -0
  49. data/lib/labimotion/models/cellline.rb +47 -0
  50. data/lib/labimotion/models/concerns/datasetable.rb +3 -0
  51. data/lib/labimotion/models/concerns/matrice_labimotion.rb +124 -0
  52. data/lib/labimotion/models/concerns/segmentable.rb +2 -0
  53. data/lib/labimotion/models/concerns/template_doi.rb +133 -0
  54. data/lib/labimotion/models/dataset_klass.rb +1 -1
  55. data/lib/labimotion/models/element_klass.rb +1 -1
  56. data/lib/labimotion/models/klass_share.rb +129 -0
  57. data/lib/labimotion/models/segment_klass.rb +1 -1
  58. data/lib/labimotion/models/user_klass_setting.rb +58 -0
  59. data/lib/labimotion/models/user_setting.rb +60 -0
  60. data/lib/labimotion/usecases/build_template_doi_xml.rb +69 -23
  61. data/lib/labimotion/usecases/release_template_doi.rb +42 -17
  62. data/lib/labimotion/usecases/template_doi_helpers.rb +28 -6
  63. data/lib/labimotion/usecases/update_template_publication_metadata.rb +71 -2
  64. data/lib/labimotion/utils/export_utils.rb +1 -0
  65. data/lib/labimotion/utils/import_utils.rb +20 -3
  66. data/lib/labimotion/utils/serializer.rb +27 -0
  67. data/lib/labimotion/utils/units.rb +32 -67
  68. data/lib/labimotion/version.rb +1 -1
  69. data/lib/labimotion.rb +27 -0
  70. metadata +45 -3
@@ -0,0 +1,313 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labimotion
4
+ # Resolves the "inline attributes" of an element linked from a generic
5
+ # drag_element field.
6
+ #
7
+ # A drag_element link target is identified by the stored value's +el_klass+:
8
+ # * a generic element klass name -> the target is a Labimotion::Element and
9
+ # its selectable attributes are its own property fields (layer + field);
10
+ # * a permit-target key (reaction, sample, molecule, wellplate, screen,
11
+ # research_plan, device_description, cell_line) -> the target is a standard
12
+ # ELN model and its selectable attributes are a curated set of database columns.
13
+ #
14
+ # Used both to list the attributes a user can pick (#available) and to refresh
15
+ # the current values of a previously stored selection (#resolve).
16
+ class LinkedElement
17
+ SOURCE_PROPERTY = 'property'
18
+ SOURCE_COLUMN = 'column'
19
+
20
+ # Marks an attribute injected from the user's stored default rather than
21
+ # picked on this element. Stripped again before properties are persisted, so
22
+ # a default never silently becomes an explicit per-instance selection.
23
+ IS_DEFAULT = 'is_default'
24
+
25
+ # el_klass (permit-target key) => model class name
26
+ MODELS = {
27
+ 'reaction' => '::Reaction',
28
+ 'sample' => '::Sample',
29
+ 'molecule' => '::Molecule',
30
+ 'wellplate' => '::Wellplate',
31
+ 'screen' => '::Screen',
32
+ 'research_plan' => '::ResearchPlan',
33
+ 'device_description' => '::DeviceDescription',
34
+ 'cell_line' => '::CelllineSample'
35
+ }.freeze
36
+
37
+ # Display names for the above, for screens that list the linkable types.
38
+ PERMIT_TARGET_LABELS = {
39
+ 'reaction' => 'Reaction', 'sample' => 'Sample', 'molecule' => 'Molecule',
40
+ 'wellplate' => 'Wellplate', 'screen' => 'Screen', 'research_plan' => 'Research Plan',
41
+ 'device_description' => 'Device Description', 'cell_line' => 'Cell Line'
42
+ }.freeze
43
+
44
+ # Curated, scalar columns exposed per type: [column/method, label, unit_column?]
45
+ COLUMNS = {
46
+ 'reaction' => [
47
+ %w[name Name], %w[short_label], ['status', 'Status'], ['role', 'Role'],
48
+ ['rxno', 'Reaction Type'], ['conditions', 'Conditions'],
49
+ ['duration', 'Duration'], %w[solvent Solvent]
50
+ ],
51
+ 'sample' => [
52
+ %w[name Name], %w[short_label], ['external_label', 'External Label'],
53
+ ['sum_formula', 'Sum Formula'], ['molecular_mass', 'Molecular Mass'],
54
+ %w[purity Purity], %w[density Density],
55
+ ['real_amount_value', 'Real Amount', 'real_amount_unit'],
56
+ ['target_amount_value', 'Target Amount', 'target_amount_unit'],
57
+ %w[location Location]
58
+ ],
59
+ 'molecule' => [
60
+ ['iupac_name', 'IUPAC Name'], ['sum_formular', 'Sum Formula'],
61
+ ['molecular_weight', 'Molecular Weight'],
62
+ ['exact_molecular_weight', 'Exact Molecular Weight'],
63
+ ['cano_smiles', 'Canonical SMILES'], ['melting_point', 'Melting Point'],
64
+ ['boiling_point', 'Boiling Point'], %w[density Density]
65
+ ],
66
+ 'wellplate' => [
67
+ %w[name Name], %w[short_label], ['description', 'Description'],
68
+ %w[width Width], %w[height Height]
69
+ ],
70
+ 'screen' => [
71
+ %w[name Name], ['description', 'Description'], %w[result Result],
72
+ %w[collaborator Collaborator], ['conditions', 'Conditions'],
73
+ %w[requirements Requirements]
74
+ ],
75
+ 'research_plan' => [
76
+ %w[name Name], %w[short_label]
77
+ ],
78
+ 'device_description' => [
79
+ %w[name Name], %w[short_label], ['serial_number', 'Serial Number'],
80
+ ['device_class', 'Device Class'], ['operation_mode', 'Operation Mode'],
81
+ ['application_name', 'Application Name'], ['vendor_url', 'Vendor URL'],
82
+ %w[institute Institute], %w[building Building], %w[room Room]
83
+ ],
84
+ 'cell_line' => [
85
+ ['material_name', 'Cell line name'], ['name', 'Name of specific sample'],
86
+ %w[short_label], %w[description Description],
87
+ %w[amount Amount unit], %w[passage Passage],
88
+ %w[contamination Contamination]
89
+ ]
90
+ }.freeze
91
+
92
+ # Property field types whose value is a scalar worth surfacing inline.
93
+ PROPERTY_TYPES = %w[
94
+ text textarea number integer select select-multi checkbox
95
+ datetime system-defined formula-field text-formula ontology-select
96
+ ].freeze
97
+
98
+ def initialize(el_klass, el_id, el_type = nil)
99
+ @el_klass = el_klass.to_s
100
+ @el_type = el_type.to_s
101
+ @el_id = el_id
102
+ end
103
+
104
+ # drag_element keeps the linked class in +el_klass+; drag_sample / drag_molecule
105
+ # keep it in +el_type+ (and may omit el_klass). Resolve one effective dispatch key.
106
+ def target_key
107
+ return @target_key if defined?(@target_key)
108
+
109
+ @target_key =
110
+ if MODELS.key?(@el_klass) || generic_klass?(@el_klass)
111
+ @el_klass
112
+ elsif MODELS.key?(@el_type)
113
+ @el_type
114
+ else
115
+ ''
116
+ end
117
+ end
118
+
119
+ def column_based?
120
+ MODELS.key?(target_key)
121
+ end
122
+
123
+ def generic?
124
+ generic_klass?(target_key)
125
+ end
126
+
127
+ def record
128
+ return @record if defined?(@record)
129
+
130
+ @record =
131
+ if column_based?
132
+ MODELS[target_key].safe_constantize&.find_by(id: @el_id)
133
+ elsif generic?
134
+ Labimotion::Element.find_by(id: @el_id)
135
+ end
136
+ end
137
+
138
+ # All attributes the user can pick, each with its current value.
139
+ def available
140
+ return generic_attributes if generic?
141
+ return column_attributes if column_based?
142
+
143
+ []
144
+ end
145
+
146
+ # Refresh the values/labels of a previously stored selection. Selections whose
147
+ # source attribute no longer exists are kept but flagged as missing; for an
148
+ # unknown/unsupported el_klass the selection is returned untouched.
149
+ def resolve(selected)
150
+ return selected unless selected.is_a?(Array)
151
+ return selected unless generic? || column_based?
152
+
153
+ index = available.index_by { |a| [a['source'], a['key']] }
154
+ selected.map do |attr|
155
+ next attr unless attr.is_a?(Hash)
156
+
157
+ index[[attr['source'], attr['key']]] || attr.merge('value' => nil, 'missing' => true)
158
+ end
159
+ end
160
+
161
+ # The attributes a user can pick for a klass with no element instance to read
162
+ # values from — the settings screen picks defaults before any link exists.
163
+ # Same shape as #available, minus 'value'/'value_system'.
164
+ def self.available_for_klass(el_klass)
165
+ el_klass = el_klass.to_s
166
+ return column_attributes_for_klass(el_klass) if MODELS.key?(el_klass)
167
+
168
+ klass = Labimotion::ElementKlass.find_by(name: el_klass)
169
+ klass.present? ? property_attributes_for_klass(klass) : []
170
+ end
171
+
172
+ # Remove attributes injected from a user's default (marked IS_DEFAULT by
173
+ # PropertiesEntity) from a properties hash on its way to the database. They
174
+ # are display-only: persisting them would freeze today's default into the
175
+ # element as an explicit per-instance selection, and a later change to the
176
+ # default would then stop reaching this link.
177
+ def self.strip_defaults(properties)
178
+ layers = properties.is_a?(Hash) ? properties[Labimotion::Prop::LAYERS] : nil
179
+ return properties unless layers.is_a?(Hash)
180
+
181
+ layers.each_value do |layer|
182
+ fields = layer.is_a?(Hash) ? layer[Labimotion::Prop::FIELDS] : nil
183
+ next unless fields.is_a?(Array)
184
+
185
+ fields.each { |field| strip_field_defaults(field) }
186
+ end
187
+ properties
188
+ end
189
+
190
+ def self.strip_field_defaults(field)
191
+ value = field.is_a?(Hash) ? field['value'] : nil
192
+ return unless value.is_a?(Hash)
193
+
194
+ attrs = value['el_attrs']
195
+ return unless attrs.is_a?(Array)
196
+
197
+ kept = attrs.reject { |attr| default_attr?(attr) }
198
+ return if kept.size == attrs.size # nothing injected — an explicit selection stays put
199
+
200
+ if kept.empty?
201
+ # Drop the key entirely so the field reads as "never picked" again and
202
+ # keeps following the user's default.
203
+ value.delete('el_attrs')
204
+ else
205
+ value['el_attrs'] = kept
206
+ end
207
+ end
208
+ private_class_method :strip_field_defaults
209
+
210
+ def self.default_attr?(attr)
211
+ attr.is_a?(Hash) && attr[IS_DEFAULT] == true
212
+ end
213
+ private_class_method :default_attr?
214
+
215
+ # The standard ELN types a drag field can link, for a settings screen that has
216
+ # to enumerate them. MODELS is the authority here, not the wider permit-target
217
+ # list: 'element' and 'generic_grid' are permit targets but not linkable types.
218
+ def self.permit_targets
219
+ MODELS.keys.map { |key| { 'key' => key, 'label' => PERMIT_TARGET_LABELS[key] || key.humanize } }
220
+ end
221
+
222
+ # Read off the released template rather than an instance, so the list matches
223
+ # what every element of the klass actually has.
224
+ def self.property_attributes_for_klass(klass)
225
+ layers = (klass.properties_release || {})[Labimotion::Prop::LAYERS] || {}
226
+ ordered = layers.values.sort_by { |layer| layer['position'] || 0 }
227
+ ordered.flat_map { |layer| layer_property_attributes(layer) }
228
+ end
229
+ private_class_method :property_attributes_for_klass
230
+
231
+ def self.layer_property_attributes(layer)
232
+ (layer[Labimotion::Prop::FIELDS] || []).filter_map do |field|
233
+ klass_property_attribute(layer, field) if PROPERTY_TYPES.include?(field['type'])
234
+ end
235
+ end
236
+ private_class_method :layer_property_attributes
237
+
238
+ def self.klass_property_attribute(layer, field)
239
+ {
240
+ 'source' => SOURCE_PROPERTY,
241
+ 'key' => "#{layer['key']}::#{field['field']}",
242
+ 'label' => field['label'].presence || field['field'],
243
+ 'type' => field['type'],
244
+ 'group' => layer['label'].presence || layer['key']
245
+ }
246
+ end
247
+ private_class_method :klass_property_attribute
248
+
249
+ def self.column_attributes_for_klass(el_klass)
250
+ (COLUMNS[el_klass] || []).map do |col, label, _unit_col|
251
+ {
252
+ 'source' => SOURCE_COLUMN,
253
+ 'key' => col,
254
+ 'label' => label.presence || col.to_s.humanize,
255
+ 'type' => SOURCE_COLUMN,
256
+ 'group' => el_klass
257
+ }
258
+ end
259
+ end
260
+ private_class_method :column_attributes_for_klass
261
+
262
+ private
263
+
264
+ def generic_klass?(name)
265
+ name.present? && !MODELS.key?(name) && Labimotion::ElementKlass.exists?(name: name)
266
+ end
267
+
268
+ def generic_attributes
269
+ el = record
270
+ return [] unless el&.properties.is_a?(Hash)
271
+
272
+ attrs = []
273
+ layers = el.properties[Labimotion::Prop::LAYERS] || {}
274
+ layers.values.sort_by { |layer| layer['position'] || 0 }.each do |layer|
275
+ (layer[Labimotion::Prop::FIELDS] || []).each do |field|
276
+ next unless PROPERTY_TYPES.include?(field['type'])
277
+
278
+ attr = {
279
+ 'source' => SOURCE_PROPERTY,
280
+ 'key' => "#{layer['key']}::#{field['field']}",
281
+ 'label' => field['label'].presence || field['field'],
282
+ 'type' => field['type'],
283
+ 'value' => field['value'],
284
+ 'group' => layer['label'].presence || layer['key']
285
+ }
286
+ attr['value_system'] = field['value_system'] if field['value_system'].present?
287
+ attrs << attr
288
+ end
289
+ end
290
+ attrs
291
+ end
292
+
293
+ def column_attributes
294
+ rec = record
295
+ return [] if rec.blank?
296
+
297
+ (COLUMNS[target_key] || []).filter_map do |col, label, unit_col|
298
+ next unless rec.respond_to?(col)
299
+
300
+ attr = {
301
+ 'source' => SOURCE_COLUMN,
302
+ 'key' => col,
303
+ 'label' => label.presence || col.to_s.humanize,
304
+ 'type' => SOURCE_COLUMN,
305
+ 'value' => rec.public_send(col),
306
+ 'group' => target_key
307
+ }
308
+ attr['value_system'] = rec.public_send(unit_col) if unit_col && rec.respond_to?(unit_col) && rec.public_send(unit_col).present?
309
+ attr
310
+ end
311
+ end
312
+ end
313
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+ require 'digest'
6
+ require 'fileutils'
7
+
8
+ module Labimotion
9
+ # The stored vocabulary behind an "ontology-select" field: one JSON file per
10
+ # ontology root, holding that root's whole subtree.
11
+ #
12
+ # Both consumers read this one file. The picker renders it as a tree, the AI
13
+ # auto-fill takes the labels out of it. Before the store they resolved the
14
+ # same subtree independently — the browser walking /children node by node,
15
+ # the server asking for the transitive closure — so the only thing keeping
16
+ # the two lists in agreement was somebody checking.
17
+ #
18
+ # Nothing is ever written straight onto the live path. A concurrent reader
19
+ # must not see a half-written file, and a failed fetch must not replace a
20
+ # good list with an empty one, so a write takes a lock, validates what came
21
+ # back, writes a sibling temp file and renames it into place.
22
+ class OntologyStore
23
+ # Raised for anything the person who pressed Refresh should be told about.
24
+ class Error < StandardError; end
25
+ # Someone else is already refreshing this root.
26
+ class Busy < Error; end
27
+
28
+ # The last path segment of an IRI is the term's accession (`BAO_0000019`),
29
+ # which is already filename-safe and, unlike a hash, says what the file is
30
+ # when you list the directory. Anything that is not a clean accession —
31
+ # which no real IRI produces — falls back to a digest rather than being
32
+ # trusted into a path.
33
+ ACCESSION = /\A[A-Za-z0-9][A-Za-z0-9_.-]{0,63}\z/
34
+ # `owl_name` is not the IRI's namespace: CHMO serves OBI and BFO terms, so
35
+ # the accession alone is not unique across ontologies and the prefix stays.
36
+ ONTOLOGY = /\A[a-z0-9][a-z0-9_-]{0,31}\z/
37
+
38
+ # A refresh that comes back with a fraction of what is already stored is a
39
+ # service having a bad day, not an ontology that shrank by half. Keep what
40
+ # is on disk and say so.
41
+ KEEP_RATIO = 0.5
42
+ # Below this there is no meaningful proportion to compare against.
43
+ MIN_COMPARABLE = 8
44
+ # Refresh is a user action on shared state; this is politeness toward the
45
+ # ontology service, not protection of our own data.
46
+ MIN_REFRESH_SECONDS = 60
47
+
48
+ class << self
49
+ # The subtree for one parsed root, warming the file when it is missing.
50
+ # Never raises: a root that cannot be stored still answers this request
51
+ # from a live resolve, which is exactly the behaviour it had before.
52
+ #
53
+ # @param root [Hash] { ontology:, iri: } as Labimotion::OntologyTerms.parse returns
54
+ # @return [Hash, nil] the stored document, or nil when nothing could be had
55
+ def document(root, synced_by: nil)
56
+ stored = read(root)
57
+ return stored if stored
58
+
59
+ begin
60
+ refresh!(root, synced_by: synced_by, interval: false)
61
+ rescue StandardError => e
62
+ log("could not store #{root[:iri]}: #{e.message}")
63
+ live_document(root)
64
+ end
65
+ end
66
+
67
+ # Just the labels, for the auto-fill prompt.
68
+ def labels(root)
69
+ doc = document(root)
70
+ terms = doc.is_a?(Hash) ? doc['terms'] : nil
71
+ Array(terms).filter_map { |term| term['label'].to_s.strip.presence }
72
+ end
73
+
74
+ # Re-fetch and rewrite. Raises Error with a message meant to be shown to
75
+ # whoever asked for it.
76
+ #
77
+ # @param interval [Boolean] enforce MIN_REFRESH_SECONDS (user-triggered
78
+ # refresh); false when warming a file that does not exist yet.
79
+ def refresh!(root, synced_by: nil, interval: true)
80
+ existing = read(root)
81
+ guard_interval!(existing) if interval
82
+
83
+ with_lock(root) do
84
+ # Re-read inside the lock: whoever held it may have just done the work.
85
+ fresh = read(root)
86
+ next fresh if interval == false && fresh
87
+
88
+ terms = Labimotion::OntologyTerms.subtree(root)
89
+ validate!(terms, existing)
90
+ doc = build_document(root, terms, synced_by)
91
+ write_atomically(path_for(root), doc)
92
+ doc
93
+ end
94
+ end
95
+
96
+ # The stored document, or nil when there is no readable file.
97
+ def read(root)
98
+ path = path_for(root)
99
+ return nil unless File.exist?(path)
100
+
101
+ doc = JSON.parse(File.read(path))
102
+ doc.is_a?(Hash) && doc['terms'].is_a?(Array) ? doc : nil
103
+ rescue StandardError => e
104
+ log("unreadable store for #{root[:iri]}: #{e.message}")
105
+ nil
106
+ end
107
+
108
+ def stored?(root)
109
+ File.exist?(path_for(root))
110
+ rescue Error
111
+ false
112
+ end
113
+
114
+ # `<ontology>-<accession>.json` under the roots directory.
115
+ def path_for(root)
116
+ path = File.join(dir, filename(root))
117
+ # The root comes from a template a designer controls. Taking the segment
118
+ # after the last separator cannot produce a traversal, but this makes it
119
+ # impossible for a later edit to the derivation to reintroduce one.
120
+ raise Error, 'refusing an ontology path outside the store' unless
121
+ File.expand_path(path).start_with?("#{File.expand_path(dir)}#{File::SEPARATOR}")
122
+
123
+ path
124
+ end
125
+
126
+ def filename(root)
127
+ ontology = root[:ontology].to_s
128
+ raise Error, "unusable ontology name #{ontology.inspect}" unless ONTOLOGY.match?(ontology)
129
+
130
+ iri = root[:iri].to_s
131
+ "#{ontology}-#{accession(iri) || "x#{Digest::SHA256.hexdigest(iri)[0, 16]}"}.json"
132
+ end
133
+
134
+ # Read through a method so a spec can stub the constant.
135
+ def dir
136
+ Labimotion::ONTOLOGY_ROOTS_DIR
137
+ end
138
+
139
+ private
140
+
141
+ def accession(iri)
142
+ tail = iri.split(%r{[#/]}).last.to_s
143
+ ACCESSION.match?(tail) ? tail : nil
144
+ end
145
+
146
+ # Resolved but deliberately not written — used when the file cannot be
147
+ # created (directory unwritable, another process holding the lock) so the
148
+ # caller still gets an answer.
149
+ def live_document(root)
150
+ terms = Labimotion::OntologyTerms.subtree(root)
151
+ return nil if terms.empty?
152
+
153
+ build_document(root, terms, nil)
154
+ rescue StandardError => e
155
+ log("live resolve failed for #{root[:iri]}: #{e.message}")
156
+ nil
157
+ end
158
+
159
+ def build_document(root, terms, synced_by)
160
+ {
161
+ 'ontology' => root[:ontology].to_s,
162
+ 'iri' => root[:iri].to_s,
163
+ 'source' => Labimotion::OntologyTerms.base_url,
164
+ 'synced_at' => Time.now.utc.iso8601,
165
+ 'synced_by' => synced_by.presence,
166
+ 'count' => terms.size,
167
+ 'terms' => terms
168
+ }
169
+ end
170
+
171
+ def validate!(terms, existing)
172
+ raise Error, 'the ontology service returned no terms for this root' if terms.blank?
173
+
174
+ previous = existing.is_a?(Hash) ? existing['terms'] : nil
175
+ return unless previous.is_a?(Array) && previous.size >= MIN_COMPARABLE
176
+ return if terms.size >= (previous.size * KEEP_RATIO)
177
+
178
+ raise Error, "refusing the update: #{terms.size} terms where #{previous.size} were stored"
179
+ end
180
+
181
+ def guard_interval!(existing)
182
+ synced_at = existing.is_a?(Hash) ? existing['synced_at'] : nil
183
+ return if synced_at.blank?
184
+
185
+ age = Time.now.utc - Time.parse(synced_at.to_s).utc
186
+ return if age >= MIN_REFRESH_SECONDS
187
+
188
+ raise Error, "this ontology was refreshed #{age.round} s ago; try again in a moment"
189
+ rescue ArgumentError
190
+ nil # An unparseable stamp is not a reason to refuse a refresh.
191
+ end
192
+
193
+ def with_lock(root)
194
+ FileUtils.mkdir_p(dir)
195
+ File.open("#{path_for(root)}.lock", File::RDWR | File::CREAT, 0o644) do |lock|
196
+ raise Busy, 'a refresh for this ontology is already running' unless
197
+ lock.flock(File::LOCK_EX | File::LOCK_NB)
198
+
199
+ begin
200
+ yield
201
+ ensure
202
+ lock.flock(File::LOCK_UN)
203
+ end
204
+ end
205
+ end
206
+
207
+ # Rename is atomic within a filesystem; writing onto the live path is not,
208
+ # and a reader landing mid-write gets a JSON parse error for their trouble.
209
+ def write_atomically(path, doc)
210
+ tmp = "#{path}.tmp.#{Process.pid}"
211
+ begin
212
+ File.write(tmp, JSON.generate(doc))
213
+ File.rename(tmp, path)
214
+ ensure
215
+ FileUtils.rm_f(tmp)
216
+ end
217
+ end
218
+
219
+ def log(message)
220
+ Labimotion.logger.warn("OntologyStore #{message}")
221
+ rescue StandardError
222
+ nil
223
+ end
224
+ end
225
+ end
226
+ end