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,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