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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -1
- data/lib/labimotion/apis/generic_dataset_api.rb +93 -3
- data/lib/labimotion/apis/generic_element_api.rb +198 -8
- data/lib/labimotion/apis/generic_klass_api.rb +74 -8
- data/lib/labimotion/apis/klass_share_api.rb +648 -0
- data/lib/labimotion/apis/labimotion_ai_api.rb +252 -0
- data/lib/labimotion/apis/labimotion_api.rb +4 -0
- data/lib/labimotion/apis/labimotion_doi_api.rb +24 -10
- data/lib/labimotion/apis/labimotion_template_browse_api.rb +13 -1
- data/lib/labimotion/apis/ontology_root_api.rb +74 -0
- data/lib/labimotion/apis/segment_api.rb +77 -10
- data/lib/labimotion/apis/user_klass_settings_api.rb +93 -0
- data/lib/labimotion/conf.rb +5 -0
- data/lib/labimotion/constants.rb +64 -0
- data/lib/labimotion/entities/application_entity.rb +8 -0
- data/lib/labimotion/entities/eln_element_entity.rb +6 -0
- data/lib/labimotion/entities/generic_klass_entity.rb +126 -0
- data/lib/labimotion/entities/klass_share_entity.rb +48 -0
- data/lib/labimotion/entities/properties_entity.rb +78 -2
- data/lib/labimotion/entities/segment_entity.rb +8 -0
- data/lib/labimotion/entities/user_klass_setting_entity.rb +10 -0
- data/lib/labimotion/helpers/cover_image_helpers.rb +181 -0
- data/lib/labimotion/helpers/dataset_helpers.rb +187 -1
- data/lib/labimotion/helpers/element_helpers.rb +282 -6
- data/lib/labimotion/helpers/exporter_helpers.rb +17 -2
- data/lib/labimotion/helpers/generic_helpers.rb +293 -4
- data/lib/labimotion/helpers/param_helpers.rb +105 -0
- data/lib/labimotion/helpers/sample_association_helpers.rb +7 -0
- data/lib/labimotion/helpers/segment_helpers.rb +102 -4
- data/lib/labimotion/libs/ai_egress_guard.rb +99 -0
- data/lib/labimotion/libs/ai_klass_queue.rb +88 -0
- data/lib/labimotion/libs/ai_klass_validator.rb +74 -0
- data/lib/labimotion/libs/ai_models.rb +201 -0
- data/lib/labimotion/libs/ai_template.rb +2285 -0
- data/lib/labimotion/libs/converter.rb +5 -43
- data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +67 -0
- data/lib/labimotion/libs/export_element.rb +128 -13
- data/lib/labimotion/libs/file_extractor.rb +210 -0
- data/lib/labimotion/libs/linked_element.rb +313 -0
- data/lib/labimotion/libs/ontology_store.rb +226 -0
- data/lib/labimotion/libs/ontology_terms.rb +227 -0
- data/lib/labimotion/libs/owner_resolver.rb +50 -0
- data/lib/labimotion/libs/ownership_audit.rb +73 -0
- data/lib/labimotion/libs/sample_association.rb +52 -1
- data/lib/labimotion/libs/share_notifier.rb +114 -0
- data/lib/labimotion/libs/share_resolver.rb +373 -0
- data/lib/labimotion/libs/user_ai_settings.rb +129 -0
- data/lib/labimotion/models/cellline.rb +47 -0
- data/lib/labimotion/models/concerns/datasetable.rb +3 -0
- data/lib/labimotion/models/concerns/matrice_labimotion.rb +124 -0
- data/lib/labimotion/models/concerns/segmentable.rb +2 -0
- data/lib/labimotion/models/concerns/template_doi.rb +133 -0
- data/lib/labimotion/models/dataset_klass.rb +1 -1
- data/lib/labimotion/models/element_klass.rb +1 -1
- data/lib/labimotion/models/klass_share.rb +129 -0
- data/lib/labimotion/models/segment_klass.rb +1 -1
- data/lib/labimotion/models/user_klass_setting.rb +58 -0
- data/lib/labimotion/models/user_setting.rb +60 -0
- data/lib/labimotion/usecases/build_template_doi_xml.rb +69 -23
- data/lib/labimotion/usecases/release_template_doi.rb +42 -17
- data/lib/labimotion/usecases/template_doi_helpers.rb +28 -6
- data/lib/labimotion/usecases/update_template_publication_metadata.rb +71 -2
- data/lib/labimotion/utils/export_utils.rb +1 -0
- data/lib/labimotion/utils/import_utils.rb +20 -3
- data/lib/labimotion/utils/serializer.rb +27 -0
- data/lib/labimotion/utils/units.rb +32 -67
- data/lib/labimotion/version.rb +1 -1
- data/lib/labimotion.rb +27 -0
- 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
|