labimotion 2.3.0.rc3 → 2.3.0.rc5

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,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
@@ -26,9 +26,10 @@ module Labimotion
26
26
  joins(collections: :user).where(collections: { user_id: user_id })
27
27
  )
28
28
 
29
- # Shared records
29
+ # Shared records — collection_shares hangs off Collection, not off the
30
+ # element models themselves, so join through the collections association.
30
31
  shared = apply_filters.call(
31
- left_joins(:collection_shares).where(collection_shares: { shared_with_id: user_id })
32
+ left_joins(collections: :collection_shares).where(collection_shares: { shared_with_id: user_id })
32
33
  )
33
34
 
34
35
  # Combine (remove duplicates), order, and limit
@@ -141,9 +141,10 @@ module Labimotion
141
141
  joins(collections: :user).where(collections: { user_id: user_id })
142
142
  )
143
143
 
144
- # Shared elements
144
+ # Shared elements — collection_shares hangs off Collection, not off
145
+ # Element itself, so join through the collections association.
145
146
  shared = apply_filters.call(
146
- left_joins(:collection_shares).where(collection_shares: { shared_with_id: user_id })
147
+ left_joins(collections: :collection_shares).where(collection_shares: { shared_with_id: user_id })
147
148
  )
148
149
 
149
150
  # Combine (remove duplicates), order, and limit
@@ -2,5 +2,5 @@
2
2
 
3
3
  ## Labimotion Version
4
4
  module Labimotion
5
- VERSION = '2.3.0.rc3'
5
+ VERSION = '2.3.0.rc5'
6
6
  end
data/lib/labimotion.rb CHANGED
@@ -92,6 +92,10 @@ module Labimotion
92
92
  autoload :AttachmentHandler, 'labimotion/libs/attachment_handler'
93
93
  autoload :VocabularyHandler, 'labimotion/libs/vocabulary_handler'
94
94
  autoload :XlsxExporter, 'labimotion/libs/xlsx_exporter'
95
+ autoload :ElementVariationColumnSet, 'labimotion/libs/element_variation_column_set'
96
+ autoload :ElementVariationHeader, 'labimotion/libs/element_variation_header'
97
+ autoload :ExportElementVariations, 'labimotion/libs/export_element_variations'
98
+ autoload :ImportElementVariations, 'labimotion/libs/import_element_variations'
95
99
 
96
100
  ######## Utils
97
101
  autoload :Prop, 'labimotion/utils/prop'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: labimotion
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.3.0.rc3
4
+ version: 2.3.0.rc5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chia-Lin Lin
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2026-07-07 00:00:00.000000000 Z
12
+ date: 2026-07-10 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: caxlsx
@@ -45,6 +45,20 @@ dependencies:
45
45
  - - "<"
46
46
  - !ruby/object:Gem::Version
47
47
  version: '8.0'
48
+ - !ruby/object:Gem::Dependency
49
+ name: roo
50
+ requirement: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '2.10'
55
+ type: :runtime
56
+ prerelease: false
57
+ version_requirements: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '2.10'
48
62
  description:
49
63
  email:
50
64
  - chia-lin.lin@kit.edu
@@ -116,8 +130,12 @@ files:
116
130
  - lib/labimotion/libs/data/vocab/Standard.json
117
131
  - lib/labimotion/libs/data/vocab/System.json
118
132
  - lib/labimotion/libs/dataset_builder.rb
133
+ - lib/labimotion/libs/element_variation_column_set.rb
134
+ - lib/labimotion/libs/element_variation_header.rb
119
135
  - lib/labimotion/libs/export_dataset.rb
120
136
  - lib/labimotion/libs/export_element.rb
137
+ - lib/labimotion/libs/export_element_variations.rb
138
+ - lib/labimotion/libs/import_element_variations.rb
121
139
  - lib/labimotion/libs/nmr_mapper.rb
122
140
  - lib/labimotion/libs/properties_handler.rb
123
141
  - lib/labimotion/libs/sample_association.rb