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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/lib/labimotion/apis/element_variation_api.rb +58 -3
- data/lib/labimotion/libs/converter.rb +43 -5
- 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/models/concerns/element_fetchable.rb +3 -2
- data/lib/labimotion/models/element.rb +3 -2
- data/lib/labimotion/version.rb +1 -1
- data/lib/labimotion.rb +4 -0
- metadata +20 -2
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'caxlsx'
|
|
4
|
+
|
|
5
|
+
module Labimotion
|
|
6
|
+
## ElementVariationHeader
|
|
7
|
+
# Turns an ordered column set into the banded header the React grid draws:
|
|
8
|
+
#
|
|
9
|
+
# row 1 group Properties | Metadata | Segments
|
|
10
|
+
# row 2 sub-group Conditions | Notes | Group | My Segment
|
|
11
|
+
# row 3 leaf Temperature [C] | Solvent | | | Mass [g]
|
|
12
|
+
#
|
|
13
|
+
# Adjacent columns sharing a header are merged across, and a header with no
|
|
14
|
+
# level beneath it -- the identity columns, and the metadata leaves, which hang
|
|
15
|
+
# straight off their group -- is merged down to the leaf row. That is exactly
|
|
16
|
+
# what ag-grid renders for the same columns.
|
|
17
|
+
#
|
|
18
|
+
# `rows` are the three cell arrays; `merges` the ranges to hand to Axlsx.
|
|
19
|
+
class ElementVariationHeader
|
|
20
|
+
ROWS = 3
|
|
21
|
+
|
|
22
|
+
def initialize(columns)
|
|
23
|
+
@columns = columns
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
attr_reader :columns
|
|
27
|
+
|
|
28
|
+
def rows
|
|
29
|
+
plan[:rows]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def merges
|
|
33
|
+
plan[:merges]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def plan
|
|
39
|
+
@plan ||= build
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def build
|
|
43
|
+
@rows = Array.new(ROWS) { Array.new(columns.length, '') }
|
|
44
|
+
@merges = []
|
|
45
|
+
|
|
46
|
+
runs(columns.map(&:group)).each { |run| place_group(run) }
|
|
47
|
+
runs(columns.map { |column| sub_group_key(column) }).each { |run| place_sub_group(run) }
|
|
48
|
+
|
|
49
|
+
{ rows: @rows, merges: @merges }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def place_group(run)
|
|
53
|
+
# No group at all: an identity column, spanning the whole header.
|
|
54
|
+
return span_down(run, 0) if blank?(run[:key])
|
|
55
|
+
|
|
56
|
+
span_across(run, 0, run[:key])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def place_sub_group(run)
|
|
60
|
+
return if run[:key].nil?
|
|
61
|
+
|
|
62
|
+
sub_group = columns[run[:first]].sub_group
|
|
63
|
+
# No sub-group: a metadata leaf, spanning the rows below its group.
|
|
64
|
+
return span_down(run, 1) if blank?(sub_group)
|
|
65
|
+
|
|
66
|
+
span_across(run, 1, sub_group)
|
|
67
|
+
fill(run, 2)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# One cell per column, each merged down to the leaf row.
|
|
71
|
+
def span_down(run, row)
|
|
72
|
+
fill(run, row)
|
|
73
|
+
column_range(run).each { |index| merge(index, row, index, ROWS - 1) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A single cell carrying `text` across the whole run.
|
|
77
|
+
def span_across(run, row, text)
|
|
78
|
+
@rows[row][run[:first]] = text
|
|
79
|
+
merge(run[:first], row, run[:last], row) unless run[:last] == run[:first]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def fill(run, row)
|
|
83
|
+
column_range(run).each { |index| @rows[row][index] = columns[index].label }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def column_range(run)
|
|
87
|
+
(run[:first]..run[:last])
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def merge(from_column, from_row, to_column, to_row)
|
|
91
|
+
@merges << [cell_ref(from_column, from_row), cell_ref(to_column, to_row)]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def cell_ref(column_index, row_index)
|
|
95
|
+
"#{Axlsx.col_ref(column_index)}#{row_index + 1}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Keyed on the sub-group *key*, not its label: two layers may share a label.
|
|
99
|
+
def sub_group_key(column)
|
|
100
|
+
blank?(column.group) ? nil : [column.group, column.sub_group_key]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Contiguous columns sharing a header key.
|
|
104
|
+
def runs(keys)
|
|
105
|
+
keys.each_with_index.with_object([]) do |(key, index), acc|
|
|
106
|
+
last = acc.last
|
|
107
|
+
if last && last[:key] == key
|
|
108
|
+
last[:last] = index
|
|
109
|
+
else
|
|
110
|
+
acc << { key: key, first: index, last: index }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def blank?(value)
|
|
116
|
+
value.to_s.empty?
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -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
|