labimotion 2.4.0.rc1 → 2.4.0.rc2
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 +21 -0
- data/lib/labimotion/apis/element_variation_api.rb +58 -3
- data/lib/labimotion/apis/generic_klass_api.rb +25 -3
- data/lib/labimotion/apis/labimotion_api.rb +1 -0
- data/lib/labimotion/apis/user_klass_settings_api.rb +70 -0
- data/lib/labimotion/entities/generic_klass_entity.rb +1 -0
- data/lib/labimotion/entities/segment_entity.rb +8 -0
- data/lib/labimotion/entities/user_klass_setting_entity.rb +10 -0
- data/lib/labimotion/helpers/generic_helpers.rb +9 -0
- data/lib/labimotion/helpers/param_helpers.rb +14 -0
- data/lib/labimotion/libs/element_variation_column_set.rb +426 -0
- data/lib/labimotion/libs/element_variation_header.rb +119 -0
- data/lib/labimotion/libs/export_element_variations.rb +245 -0
- data/lib/labimotion/libs/import_element_variations.rb +411 -0
- data/lib/labimotion/libs/linked_element.rb +10 -3
- data/lib/labimotion/libs/sample_association.rb +5 -0
- data/lib/labimotion/models/cellline.rb +47 -0
- data/lib/labimotion/models/concerns/element_fetchable.rb +3 -2
- data/lib/labimotion/models/concerns/segmentable.rb +2 -0
- data/lib/labimotion/models/element.rb +3 -2
- data/lib/labimotion/models/user_klass_setting.rb +12 -0
- data/lib/labimotion/version.rb +1 -1
- data/lib/labimotion.rb +8 -0
- metadata +24 -2
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'labimotion/libs/xlsx_exporter'
|
|
4
|
+
require 'labimotion/libs/element_variation_column_set'
|
|
5
|
+
require 'labimotion/libs/element_variation_header'
|
|
6
|
+
|
|
7
|
+
module Labimotion
|
|
8
|
+
## ExportElementVariations
|
|
9
|
+
# Writes a generic element's variations to an .xlsx workbook that
|
|
10
|
+
# ImportElementVariations can read back. See ImportElementVariations for the
|
|
11
|
+
# inverse.
|
|
12
|
+
#
|
|
13
|
+
# Sheet `Variations`:
|
|
14
|
+
# row 1 group header (Properties / Metadata / Segments)
|
|
15
|
+
# row 2 sub-group header (layer or segment name)
|
|
16
|
+
# row 3 leaf header (field label, with its unit token)
|
|
17
|
+
# row 4 machine column keys (hidden)
|
|
18
|
+
# row 5 unit token per column (hidden)
|
|
19
|
+
# row 6+ one row per variation
|
|
20
|
+
#
|
|
21
|
+
# Rows 1-3 mirror the banded header the React grid draws, merged the same way:
|
|
22
|
+
# a header with no level below it spans down to row 3. Rows 4 and 5 are what
|
|
23
|
+
# make the export re-importable; they are hidden so a user only ever sees the
|
|
24
|
+
# header and the data.
|
|
25
|
+
class ExportElementVariations
|
|
26
|
+
SHEET_NAME = 'Variations'
|
|
27
|
+
INFO_SHEET_NAME = 'Info'
|
|
28
|
+
SCHEMA_VERSION = 2
|
|
29
|
+
HEADER_ROWS = Labimotion::ElementVariationHeader::ROWS
|
|
30
|
+
KEY_ROW = HEADER_ROWS + 1
|
|
31
|
+
UNIT_ROW = HEADER_ROWS + 2
|
|
32
|
+
MULTI_VALUE_SEPARATOR = '; '
|
|
33
|
+
GROUP_HEADER_BG = 'C5D9F1'
|
|
34
|
+
SUB_GROUP_HEADER_BG = 'DCE6F1'
|
|
35
|
+
|
|
36
|
+
# `system-defined` is the unit-bearing numeric field type.
|
|
37
|
+
NUMERIC_FIELD_TYPES = %w[integer number system-defined].freeze
|
|
38
|
+
MULTI_VALUE_FIELD_TYPES = %w[select-multi].freeze
|
|
39
|
+
|
|
40
|
+
def initialize(element, variation: nil, user: nil, segment_klasses: nil)
|
|
41
|
+
@element = element
|
|
42
|
+
@variation = variation || element.element_variation
|
|
43
|
+
@user = user
|
|
44
|
+
@column_set = Labimotion::ElementVariationColumnSet.new(
|
|
45
|
+
element, @variation, segment_klasses: segment_klasses
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
attr_reader :element, :variation, :user, :column_set
|
|
50
|
+
|
|
51
|
+
def read
|
|
52
|
+
exporter.read
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def to_stream
|
|
56
|
+
exporter.to_stream
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def filename
|
|
60
|
+
exporter.filename
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def exporter
|
|
66
|
+
@exporter ||= begin
|
|
67
|
+
xlsx = Labimotion::XlsxExporter.new(base_filename)
|
|
68
|
+
build_variations_sheet(xlsx)
|
|
69
|
+
build_info_sheet(xlsx)
|
|
70
|
+
xlsx
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def base_filename
|
|
75
|
+
short_label = element.respond_to?(:short_label) ? element.short_label.to_s : ''
|
|
76
|
+
short_label = element.name.to_s if short_label.empty?
|
|
77
|
+
short_label = "element_#{element.id}" if short_label.empty?
|
|
78
|
+
"#{short_label}_variations_#{Time.now.strftime('%Y%m%d_%H%M%S')}".gsub(/\s+/, '_')
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def columns
|
|
82
|
+
@columns ||= column_set.columns
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def header
|
|
86
|
+
@header ||= Labimotion::ElementVariationHeader.new(columns)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def build_variations_sheet(xlsx)
|
|
90
|
+
# Without a block, add_worksheet just hands back the SheetBuilder; taking
|
|
91
|
+
# it that way avoids the block's instance_eval and its locals-only rule.
|
|
92
|
+
sheet = xlsx.add_worksheet(SHEET_NAME)
|
|
93
|
+
write_header(sheet)
|
|
94
|
+
write_machine_rows(sheet)
|
|
95
|
+
write_data_rows(sheet)
|
|
96
|
+
sheet.freeze_panes(UNIT_ROW, 2)
|
|
97
|
+
header.merges.each { |from, to| sheet.merge_cells(from, to) }
|
|
98
|
+
hide_machine_rows(sheet.sheet)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def write_header(sheet)
|
|
102
|
+
group_row, sub_group_row, leaf_row = header.rows
|
|
103
|
+
sheet.add_header(group_row, bg_color: GROUP_HEADER_BG, alignment: :center)
|
|
104
|
+
sheet.add_header(sub_group_row, bg_color: SUB_GROUP_HEADER_BG, alignment: :center)
|
|
105
|
+
sheet.add_header(leaf_row)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def write_machine_rows(sheet)
|
|
109
|
+
text_types = columns.map { :string }
|
|
110
|
+
sheet.add_row(columns.map(&:key), types: text_types)
|
|
111
|
+
sheet.add_row(columns.map(&:unit), types: text_types)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def write_data_rows(sheet)
|
|
115
|
+
column_set.rows.each do |row|
|
|
116
|
+
values = serialize_row(row)
|
|
117
|
+
sheet.add_row(values, types: cell_types(values))
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Axlsx infers a cell's type from its value, which would turn the group
|
|
122
|
+
# "1.10" into the number 1.1 and an analysis id list "101" into 101. Pin
|
|
123
|
+
# everything the serializer produced as a String to :string; leave the
|
|
124
|
+
# genuinely numeric cells on auto-detection.
|
|
125
|
+
def cell_types(data_row)
|
|
126
|
+
data_row.map { |value| value.is_a?(Numeric) ? nil : :string }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# SheetBuilder exposes no row-hiding helper; reach through to the Axlsx row.
|
|
130
|
+
def hide_machine_rows(worksheet)
|
|
131
|
+
[KEY_ROW - 1, UNIT_ROW - 1].each { |index| worksheet.rows[index].hidden = true }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def build_info_sheet(xlsx)
|
|
135
|
+
info = info_rows
|
|
136
|
+
analyses = analyses_rows
|
|
137
|
+
|
|
138
|
+
xlsx.add_worksheet(INFO_SHEET_NAME) do |sheet|
|
|
139
|
+
sheet.add_section('Element', info)
|
|
140
|
+
sheet.add_header(['Analysis ID', 'Name', 'Kind'])
|
|
141
|
+
analyses.each { |analysis_row| sheet.add_row(analysis_row) }
|
|
142
|
+
sheet.auto_fit_columns
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def info_rows
|
|
147
|
+
rows = [
|
|
148
|
+
['Element ID:', element.id],
|
|
149
|
+
['Short label:', element.respond_to?(:short_label) ? element.short_label : nil],
|
|
150
|
+
['Element class:', element_klass_label],
|
|
151
|
+
['Exported at:', Time.now.strftime('%Y-%m-%d %H:%M:%S')]
|
|
152
|
+
]
|
|
153
|
+
rows << ['Exported by:', user.name] if user.respond_to?(:name)
|
|
154
|
+
rows << ['Schema version:', SCHEMA_VERSION]
|
|
155
|
+
rows
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def element_klass_label
|
|
159
|
+
klass = element.respond_to?(:element_klass) ? element.element_klass : nil
|
|
160
|
+
return nil if klass.nil?
|
|
161
|
+
|
|
162
|
+
klass.respond_to?(:label) && !klass.label.to_s.empty? ? klass.label : klass.name
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def analyses_rows
|
|
166
|
+
element_analyses.map do |analysis|
|
|
167
|
+
kind = analysis.respond_to?(:extended_metadata) ? (analysis.extended_metadata || {})['kind'] : nil
|
|
168
|
+
[analysis.id, analysis.name, kind]
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def element_analyses
|
|
173
|
+
return [] unless element.respond_to?(:analyses)
|
|
174
|
+
|
|
175
|
+
Array(element.analyses)
|
|
176
|
+
rescue StandardError
|
|
177
|
+
[]
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# ---- cell serialization ----------------------------------------------
|
|
181
|
+
|
|
182
|
+
def serialize_row(row)
|
|
183
|
+
columns.map { |column| serialize_cell(column, row) }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def serialize_cell(column, row)
|
|
187
|
+
case column.kind
|
|
188
|
+
when :uuid then row['uuid'].to_s
|
|
189
|
+
when :name then row['name'].to_s
|
|
190
|
+
when :property then serialize_property(column, row)
|
|
191
|
+
when :metadata then serialize_metadata(column, row)
|
|
192
|
+
when :segment then serialize_segment(column, row)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def serialize_property(column, row)
|
|
197
|
+
key = column.key.sub(/\Aprop:/, '')
|
|
198
|
+
cell = (row['properties'] || {})[key]
|
|
199
|
+
return nil unless cell.is_a?(Hash)
|
|
200
|
+
|
|
201
|
+
coerce_out(cell['value'], column.field_type)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def serialize_metadata(column, row)
|
|
205
|
+
field = column.key.sub(/\Ameta:/, '')
|
|
206
|
+
value = (row['metadata'] || {})[field]
|
|
207
|
+
field == 'analyses' ? join_multi(value) : coerce_out(value, field)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def serialize_segment(column, row)
|
|
211
|
+
_, klass_id, field_key = column.key.split(':', 3)
|
|
212
|
+
value = row.dig('segments', klass_id.to_s, field_key)
|
|
213
|
+
coerce_out(value, column.field_type)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def coerce_out(value, field_type)
|
|
217
|
+
return nil if value.nil? || value == ''
|
|
218
|
+
return join_multi(value) if value.is_a?(Array)
|
|
219
|
+
return numeric_or_raw(value) if NUMERIC_FIELD_TYPES.include?(field_type.to_s)
|
|
220
|
+
return join_multi(value) if MULTI_VALUE_FIELD_TYPES.include?(field_type.to_s)
|
|
221
|
+
|
|
222
|
+
value.is_a?(String) ? value : value.to_s
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def join_multi(value)
|
|
226
|
+
return nil if value.nil?
|
|
227
|
+
return nil if value.is_a?(Array) && value.empty?
|
|
228
|
+
return value.join(MULTI_VALUE_SEPARATOR) if value.is_a?(Array)
|
|
229
|
+
|
|
230
|
+
value.to_s
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Write numbers as numbers so a spreadsheet app treats them as such; fall
|
|
234
|
+
# back to the raw string when the stored value is not actually numeric.
|
|
235
|
+
def numeric_or_raw(value)
|
|
236
|
+
return value if value.is_a?(Numeric)
|
|
237
|
+
|
|
238
|
+
text = value.to_s.strip
|
|
239
|
+
float = Float(text)
|
|
240
|
+
(float % 1).zero? && !text.include?('.') ? float.to_i : float
|
|
241
|
+
rescue ArgumentError, TypeError
|
|
242
|
+
value.to_s
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'caxlsx'
|
|
4
|
+
require 'roo'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require 'labimotion/libs/element_variation_column_set'
|
|
7
|
+
|
|
8
|
+
module Labimotion
|
|
9
|
+
## ImportElementVariations
|
|
10
|
+
# Reads an .xlsx workbook produced by ExportElementVariations back into an
|
|
11
|
+
# element's variations.
|
|
12
|
+
#
|
|
13
|
+
# Rows are merged by uuid: a sheet row updates only the columns the sheet
|
|
14
|
+
# actually carries, variations absent from the sheet are left untouched, and a
|
|
15
|
+
# row with a blank `__uuid` becomes a new variation. A wholesale replace would
|
|
16
|
+
# silently drop data held in columns the user had not selected when exporting.
|
|
17
|
+
class ImportElementVariations
|
|
18
|
+
SHEET_NAME = 'Variations'
|
|
19
|
+
# The machine key row is found by its `__uuid` cell rather than fixed at a
|
|
20
|
+
# row number, so a workbook from an older export -- whose header was one row
|
|
21
|
+
# instead of three -- still imports.
|
|
22
|
+
KEY_ROW_SEARCH_LIMIT = 8
|
|
23
|
+
MULTI_VALUE_SEPARATOR = ';'
|
|
24
|
+
GROUP_DISALLOWED = /[^a-zA-Z0-9_\-.,\s]/
|
|
25
|
+
GROUP_MAX_LENGTH = 64
|
|
26
|
+
# A cell whose value a column cannot hold. Treated like a blank cell: it
|
|
27
|
+
# clears what the column held, but never adds a value that was not there.
|
|
28
|
+
REJECTED = Object.new.freeze
|
|
29
|
+
|
|
30
|
+
class InvalidWorkbook < StandardError; end
|
|
31
|
+
|
|
32
|
+
def initialize(element, file_path, segment_klasses: nil, record: nil)
|
|
33
|
+
@element = element
|
|
34
|
+
@file_path = file_path
|
|
35
|
+
@segment_klasses = segment_klasses
|
|
36
|
+
@record = record
|
|
37
|
+
@warnings = []
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
attr_reader :element, :file_path, :warnings
|
|
41
|
+
|
|
42
|
+
def execute!
|
|
43
|
+
sheet = open_sheet
|
|
44
|
+
mapping = build_column_mapping(sheet)
|
|
45
|
+
parsed_rows = parse_rows(sheet, mapping)
|
|
46
|
+
persist(parsed_rows)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def record
|
|
52
|
+
@record ||= Labimotion::ElementVariation.find_or_initialize_by(element_id: element.id)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def column_set
|
|
56
|
+
@column_set ||= Labimotion::ElementVariationColumnSet.new(
|
|
57
|
+
element, record, segment_klasses: @segment_klasses
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# ---- workbook reading -------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def open_sheet
|
|
64
|
+
workbook = Roo::Spreadsheet.open(file_path, extension: :xlsx)
|
|
65
|
+
unless workbook.sheets.include?(SHEET_NAME)
|
|
66
|
+
raise InvalidWorkbook, "Sheet '#{SHEET_NAME}' not found. Use a file produced by the variations export."
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
workbook.sheet(SHEET_NAME)
|
|
70
|
+
rescue Roo::Error, Zip::Error, IOError => e
|
|
71
|
+
raise InvalidWorkbook, "Cannot read the file as .xlsx: #{e.message}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The machine key row is immediately followed by the unit row, and the data
|
|
75
|
+
# starts after it. Both are hidden in the exported workbook.
|
|
76
|
+
def key_row(sheet)
|
|
77
|
+
@key_row ||= begin
|
|
78
|
+
limit = [sheet.last_row.to_i, KEY_ROW_SEARCH_LIMIT].min
|
|
79
|
+
found = (1..limit).find do |number|
|
|
80
|
+
Array(sheet.row(number)).any? do |cell|
|
|
81
|
+
cell.to_s.strip == Labimotion::ElementVariationColumnSet::UUID_KEY
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
found || raise(
|
|
85
|
+
InvalidWorkbook,
|
|
86
|
+
"No column key row found: none of the first #{KEY_ROW_SEARCH_LIMIT} rows carries a " \
|
|
87
|
+
"'#{Labimotion::ElementVariationColumnSet::UUID_KEY}' cell. " \
|
|
88
|
+
'Use a file produced by the variations export.'
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Column index -> { key:, unit: }.
|
|
94
|
+
def build_column_mapping(sheet)
|
|
95
|
+
keys = safe_row(sheet, key_row(sheet))
|
|
96
|
+
units = safe_row(sheet, key_row(sheet) + 1)
|
|
97
|
+
|
|
98
|
+
mapping = {}
|
|
99
|
+
keys.each_with_index do |key, index|
|
|
100
|
+
key = key.to_s.strip
|
|
101
|
+
next if key.empty?
|
|
102
|
+
|
|
103
|
+
mapping[index] = { key: key, unit: units[index].to_s.strip }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
validate_mapping!(mapping, key_row(sheet))
|
|
107
|
+
warn_unknown_keys(mapping)
|
|
108
|
+
mapping
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def warn_unknown_keys(mapping)
|
|
112
|
+
unknown = mapping.reject { |_index, column| known_key?(column[:key]) }
|
|
113
|
+
return if unknown.empty?
|
|
114
|
+
|
|
115
|
+
named = unknown.map { |index, column| "#{column_ref(index)} (#{column[:key]})" }
|
|
116
|
+
@warnings << "Ignored unknown column(s) #{named.join(', ')} - the element schema may have " \
|
|
117
|
+
'changed since this file was exported.'
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def known_key?(key)
|
|
121
|
+
key == Labimotion::ElementVariationColumnSet::UUID_KEY || !column_set.lookup(key).nil?
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def validate_mapping!(mapping, key_row_number)
|
|
125
|
+
keys = mapping.values.map { |column| column[:key] }
|
|
126
|
+
missing = [
|
|
127
|
+
Labimotion::ElementVariationColumnSet::UUID_KEY,
|
|
128
|
+
Labimotion::ElementVariationColumnSet::NAME_KEY
|
|
129
|
+
] - keys
|
|
130
|
+
return if missing.empty?
|
|
131
|
+
|
|
132
|
+
raise InvalidWorkbook,
|
|
133
|
+
"Row #{key_row_number} is missing the column keys #{missing.join(', ')}. " \
|
|
134
|
+
'Use a file produced by the variations export.'
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def safe_row(sheet, number)
|
|
138
|
+
return [] if sheet.last_row.nil? || sheet.last_row < number
|
|
139
|
+
|
|
140
|
+
Array(sheet.row(number))
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def parse_rows(sheet, mapping)
|
|
144
|
+
last_row = sheet.last_row.to_i
|
|
145
|
+
first_data_row = key_row(sheet) + 2
|
|
146
|
+
return [] if last_row < first_data_row
|
|
147
|
+
|
|
148
|
+
(first_data_row..last_row).filter_map do |number|
|
|
149
|
+
cells = Array(sheet.row(number))
|
|
150
|
+
next if cells.all? { |cell| blank_cell?(cell) }
|
|
151
|
+
|
|
152
|
+
parse_row(cells, mapping, number)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Each cell carries the spreadsheet reference it came from, so a warning can
|
|
157
|
+
# point the user at the cell to fix rather than just naming the bad value.
|
|
158
|
+
def parse_row(cells, mapping, row_number)
|
|
159
|
+
parsed = { uuid: nil, cells: {} }
|
|
160
|
+
mapping.each do |index, column|
|
|
161
|
+
value = cells[index]
|
|
162
|
+
if column[:key] == Labimotion::ElementVariationColumnSet::UUID_KEY
|
|
163
|
+
parsed[:uuid] = blank_cell?(value) ? nil : cell_to_s(value)
|
|
164
|
+
else
|
|
165
|
+
parsed[:cells][column[:key]] =
|
|
166
|
+
{ value: value, unit: column[:unit], ref: cell_ref(index, row_number) }
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
parsed
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# 0-based column index to the A1 notation a spreadsheet app shows.
|
|
173
|
+
def column_ref(column_index)
|
|
174
|
+
Axlsx.col_ref(column_index)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def cell_ref(column_index, row_number)
|
|
178
|
+
"#{column_ref(column_index)}#{row_number}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# ---- merging ----------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def persist(parsed_rows)
|
|
184
|
+
variations = deep_dup(record.variations_hash)
|
|
185
|
+
row_order = []
|
|
186
|
+
|
|
187
|
+
parsed_rows.each do |parsed|
|
|
188
|
+
uuid = parsed[:uuid] || SecureRandom.uuid
|
|
189
|
+
base = variations[uuid] || empty_row(uuid)
|
|
190
|
+
variations[uuid] = merge_row(base, parsed[:cells], uuid)
|
|
191
|
+
row_order << uuid
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
record.variations = variations
|
|
195
|
+
apply_row_order(row_order, variations.keys)
|
|
196
|
+
record.save!
|
|
197
|
+
record
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def apply_row_order(row_order, all_uuids)
|
|
201
|
+
return unless record.respond_to?(:layout=)
|
|
202
|
+
return unless record.class.respond_to?(:column_names) && record.class.column_names.include?('layout')
|
|
203
|
+
|
|
204
|
+
layout = deep_dup(record.layout_hash)
|
|
205
|
+
layout['rowOrder'] = row_order + (all_uuids - row_order)
|
|
206
|
+
record.layout = layout
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def empty_row(uuid)
|
|
210
|
+
{
|
|
211
|
+
'uuid' => uuid,
|
|
212
|
+
'name' => 'Variation',
|
|
213
|
+
'properties' => {},
|
|
214
|
+
'metadata' => { 'notes' => '', 'analyses' => [], 'group' => '' },
|
|
215
|
+
'segments' => {}
|
|
216
|
+
}
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def merge_row(base, cells, uuid)
|
|
220
|
+
row = deep_dup(base)
|
|
221
|
+
row['uuid'] = uuid
|
|
222
|
+
row['properties'] ||= {}
|
|
223
|
+
row['metadata'] ||= {}
|
|
224
|
+
row['segments'] ||= {}
|
|
225
|
+
|
|
226
|
+
cells.each do |key, cell|
|
|
227
|
+
column = column_set.lookup(key)
|
|
228
|
+
next if column.nil?
|
|
229
|
+
|
|
230
|
+
apply_cell(row, column, cell)
|
|
231
|
+
end
|
|
232
|
+
row
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def apply_cell(row, column, cell)
|
|
236
|
+
case column.kind
|
|
237
|
+
when :name then row['name'] = blank_cell?(cell[:value]) ? row['name'] : cell_to_s(cell[:value])
|
|
238
|
+
when :property then apply_property(row, column, cell)
|
|
239
|
+
when :metadata then apply_metadata(row, column, cell)
|
|
240
|
+
when :segment then apply_segment(row, column, cell)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def apply_property(row, column, cell)
|
|
245
|
+
key = column.key.sub(/\Aprop:/, '')
|
|
246
|
+
existing = row['properties'][key]
|
|
247
|
+
|
|
248
|
+
# A blank cell for a property the row never had is not a deletion.
|
|
249
|
+
return if existing.nil? && blank_cell?(cell[:value])
|
|
250
|
+
|
|
251
|
+
value = if analyses_field?(column.field_type)
|
|
252
|
+
analysis_ids(cell[:value], cell[:ref])
|
|
253
|
+
else
|
|
254
|
+
coerce_in(cell, column)
|
|
255
|
+
end
|
|
256
|
+
if value.equal?(REJECTED)
|
|
257
|
+
return if existing.nil? # never stored, so there is nothing to clear
|
|
258
|
+
|
|
259
|
+
value = nil
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
base_cell = existing.is_a?(Hash) ? existing : {}
|
|
263
|
+
row['properties'][key] = base_cell.merge('value' => value, 'unit' => cell[:unit])
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def apply_metadata(row, column, cell)
|
|
267
|
+
field = column.key.sub(/\Ameta:/, '')
|
|
268
|
+
row['metadata'][field] =
|
|
269
|
+
case field
|
|
270
|
+
when 'analyses' then analysis_ids(cell[:value], cell[:ref])
|
|
271
|
+
when 'group' then sanitize_group(cell[:value])
|
|
272
|
+
else blank_cell?(cell[:value]) ? '' : cell_to_s(cell[:value])
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def apply_segment(row, column, cell)
|
|
277
|
+
_, klass_id, field_key = column.key.split(':', 3)
|
|
278
|
+
snapshot = row['segments'][klass_id.to_s]
|
|
279
|
+
return if snapshot.nil? && blank_cell?(cell[:value])
|
|
280
|
+
|
|
281
|
+
value = coerce_in(cell, column)
|
|
282
|
+
if value.equal?(REJECTED)
|
|
283
|
+
return if snapshot.nil? # never stored, so there is nothing to clear
|
|
284
|
+
|
|
285
|
+
value = nil
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
snapshot ||= {}
|
|
289
|
+
snapshot[field_key] = value
|
|
290
|
+
row['segments'][klass_id.to_s] = snapshot
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# ---- value coercion ---------------------------------------------------
|
|
294
|
+
|
|
295
|
+
def coerce_in(cell, column)
|
|
296
|
+
value = cell[:value]
|
|
297
|
+
return nil if blank_cell?(value)
|
|
298
|
+
|
|
299
|
+
case column.field_type.to_s
|
|
300
|
+
when 'integer' then to_integer(cell, column)
|
|
301
|
+
when 'number', 'system-defined' then to_number(cell, column)
|
|
302
|
+
when 'select-multi' then split_multi(value)
|
|
303
|
+
else cell_to_s(value)
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def to_integer(cell, column)
|
|
308
|
+
float = numeric(cell[:value])
|
|
309
|
+
return reject(cell, column, 'takes numbers only') if float.nil?
|
|
310
|
+
return reject(cell, column, 'takes whole numbers only') unless (float % 1).zero?
|
|
311
|
+
|
|
312
|
+
float.to_i
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# xlsx has a single numeric type, so a stored 1.0 comes back as 1. Normalise
|
|
316
|
+
# integral values to Integer rather than leave the result depending on how
|
|
317
|
+
# the spreadsheet happened to round-trip the cell.
|
|
318
|
+
def to_number(cell, column)
|
|
319
|
+
float = numeric(cell[:value])
|
|
320
|
+
return reject(cell, column, 'takes numbers only') if float.nil?
|
|
321
|
+
|
|
322
|
+
(float % 1).zero? ? float.to_i : float
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Strict: "12abc" is not a number. A single comma reads as the decimal
|
|
326
|
+
# separator. Kept in step with coerceFieldValue in the React grid, so a cell
|
|
327
|
+
# cannot mean one thing there and another here.
|
|
328
|
+
def numeric(value)
|
|
329
|
+
return value.to_f if value.is_a?(Numeric)
|
|
330
|
+
|
|
331
|
+
Float(value.to_s.strip.sub(',', '.'))
|
|
332
|
+
rescue ArgumentError, TypeError
|
|
333
|
+
nil
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# A numeric column holds a number or nothing at all. The text is not stored,
|
|
337
|
+
# and whatever the column held before is cleared: keeping it would leave the
|
|
338
|
+
# grid showing the very value the warning says was not accepted -- and that
|
|
339
|
+
# value may itself be text, from before these columns were checked.
|
|
340
|
+
def reject(cell, column, reason)
|
|
341
|
+
@warnings << "Cell #{cell[:ref]}: cleared '#{cell_to_s(cell[:value])}' - " \
|
|
342
|
+
"#{column.label} #{reason}."
|
|
343
|
+
REJECTED
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def split_multi(value)
|
|
347
|
+
return value if value.is_a?(Array)
|
|
348
|
+
|
|
349
|
+
cell_to_s(value).split(MULTI_VALUE_SEPARATOR).map(&:strip).reject(&:empty?)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def analyses_field?(field_type)
|
|
353
|
+
field_type.to_s == Labimotion::ElementVariationColumnSet::ANALYSES_FIELD
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def analysis_ids(value, ref)
|
|
357
|
+
return [] if blank_cell?(value)
|
|
358
|
+
|
|
359
|
+
ids = split_multi(value).filter_map do |token|
|
|
360
|
+
Integer(token)
|
|
361
|
+
rescue ArgumentError, TypeError
|
|
362
|
+
@warnings << "Cell #{ref}: ignored non-numeric analysis reference '#{token}'."
|
|
363
|
+
nil
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
known, unknown = ids.partition { |id| element_analysis_ids.include?(id) }
|
|
367
|
+
unless unknown.empty?
|
|
368
|
+
@warnings << "Cell #{ref}: ignored analysis id(s) #{unknown.join(', ')} - " \
|
|
369
|
+
'they do not belong to this element.'
|
|
370
|
+
end
|
|
371
|
+
known
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def element_analysis_ids
|
|
375
|
+
@element_analysis_ids ||= begin
|
|
376
|
+
element.respond_to?(:analyses) ? Array(element.analyses).map(&:id) : []
|
|
377
|
+
rescue StandardError
|
|
378
|
+
[]
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def sanitize_group(value)
|
|
383
|
+
return '' if blank_cell?(value)
|
|
384
|
+
|
|
385
|
+
cell_to_s(value).gsub(GROUP_DISALLOWED, '')[0, GROUP_MAX_LENGTH].to_s
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# ---- cell helpers -----------------------------------------------------
|
|
389
|
+
|
|
390
|
+
def blank_cell?(value)
|
|
391
|
+
value.nil? || (value.is_a?(String) && value.strip.empty?)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Roo hands back Floats for anything numeric-looking, so an integral value
|
|
395
|
+
# from a text column would otherwise stringify as "12.0".
|
|
396
|
+
def cell_to_s(value)
|
|
397
|
+
return value if value.is_a?(String)
|
|
398
|
+
return format('%d', value) if value.is_a?(Float) && (value % 1).zero?
|
|
399
|
+
|
|
400
|
+
value.to_s
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def deep_dup(value)
|
|
404
|
+
case value
|
|
405
|
+
when Hash then value.each_with_object({}) { |(key, val), acc| acc[key] = deep_dup(val) }
|
|
406
|
+
when Array then value.map { |item| deep_dup(item) }
|
|
407
|
+
else value
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
@@ -8,8 +8,8 @@ module Labimotion
|
|
|
8
8
|
# * a generic element klass name -> the target is a Labimotion::Element and
|
|
9
9
|
# its selectable attributes are its own property fields (layer + field);
|
|
10
10
|
# * a permit-target key (reaction, sample, molecule, wellplate, screen,
|
|
11
|
-
# research_plan, device_description) -> the target is a standard
|
|
12
|
-
# and its selectable attributes are a curated set of database columns.
|
|
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
13
|
#
|
|
14
14
|
# Used both to list the attributes a user can pick (#available) and to refresh
|
|
15
15
|
# the current values of a previously stored selection (#resolve).
|
|
@@ -25,7 +25,8 @@ module Labimotion
|
|
|
25
25
|
'wellplate' => '::Wellplate',
|
|
26
26
|
'screen' => '::Screen',
|
|
27
27
|
'research_plan' => '::ResearchPlan',
|
|
28
|
-
'device_description' => '::DeviceDescription'
|
|
28
|
+
'device_description' => '::DeviceDescription',
|
|
29
|
+
'cell_line' => '::CelllineSample'
|
|
29
30
|
}.freeze
|
|
30
31
|
|
|
31
32
|
# Curated, scalar columns exposed per type: [column/method, label, unit_column?]
|
|
@@ -67,6 +68,12 @@ module Labimotion
|
|
|
67
68
|
['device_class', 'Device Class'], ['operation_mode', 'Operation Mode'],
|
|
68
69
|
['application_name', 'Application Name'], ['vendor_url', 'Vendor URL'],
|
|
69
70
|
%w[institute Institute], %w[building Building], %w[room Room]
|
|
71
|
+
],
|
|
72
|
+
'cell_line' => [
|
|
73
|
+
['material_name', 'Cell line name'], ['name', 'Name of specific sample'],
|
|
74
|
+
%w[short_label], %w[description Description],
|
|
75
|
+
%w[amount Amount unit], %w[passage Passage],
|
|
76
|
+
%w[contamination Contamination]
|
|
70
77
|
]
|
|
71
78
|
}.freeze
|
|
72
79
|
|
|
@@ -109,6 +109,11 @@ module Labimotion
|
|
|
109
109
|
sid = field.dig('value', 'el_id')
|
|
110
110
|
next if element.nil? || sid.blank? || sid == element.id
|
|
111
111
|
|
|
112
|
+
# drag_element may link a standard element (reaction, cell_line, ...);
|
|
113
|
+
# el_id is then NOT a Labimotion::Element id, so an ElementsElement row
|
|
114
|
+
# would associate an unrelated generic element that shares the id.
|
|
115
|
+
next if Labimotion::LinkedElement::MODELS.key?(field.dig('value', 'el_klass').to_s)
|
|
116
|
+
|
|
112
117
|
el = Labimotion::Element.find_by(id: sid)
|
|
113
118
|
next if el.nil?
|
|
114
119
|
|