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.
@@ -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 ELN model
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