labimotion 2.1.0.rc3 → 2.1.0.rc4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1d084fef830351ae2182073c7c42a50abd49bc5bc3f20d256860da683ca6c5c8
4
- data.tar.gz: 24b58889fdc9daeb36267aef35e736c81b56c3fc73dec3a1af06cd1148708875
3
+ metadata.gz: e0c3346ede1f92148d84551b0c4052848803e1b6969d14263a771b0fdfe8f2f1
4
+ data.tar.gz: df2597e134e8adf3e4ff48775578ae507d6c4b022c4abfd368cf01bd53ff1569
5
5
  SHA512:
6
- metadata.gz: 1bb720347508e464f7f4a0786a7daaeeb0a9fa185efab31543adc28cb2addf0e57877e36128147755772097d46f8103a45275cc00134c1cff4af3f5b774f0e31
7
- data.tar.gz: 0b9e694ebe9934be89a32147005c314614c3e26ad8f0f1fd79bd7c75207c936fbdc9bd0a594282a0a8c0434d4a4e9e73cf1f4b3d1418a807fc8cfb15c5fecda8
6
+ metadata.gz: 80d8a6c8d32136057e1cbf43a8bfe0526665cc303bb976492f886454561f39ee9bf7d2bc9be6502a3648ab52420e1b37c171a463776ee97d72470e7a6c192555
7
+ data.tar.gz: 3e4bc196612beac1ad2f8c62bbb608fd1b646385135c0ba3612cd7dc762467a3e0bd73d242b53046cb9a781f8b6214424d5a0a2dee357929fb0eb92712778174
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labimotion
4
+ # ExporterAPI handles exporting data to various formats (e.g., XLSX)
5
+ # The class length is justified due to the comprehensive helper methods needed for export functionality
6
+ class ExporterAPI < Grape::API
7
+ include Grape::Kaminari
8
+
9
+ helpers Labimotion::ParamHelpers
10
+ helpers Labimotion::ExporterHelpers
11
+
12
+ resource :exporter do
13
+ resource :table_xlsx do
14
+ desc 'Export single table field to XLSX format'
15
+ params do
16
+ use :table_xlsx_params
17
+ end
18
+ route_param :id do
19
+ before do
20
+ validate_and_authorize_request!
21
+ end
22
+
23
+ get do
24
+ layer = fetch_layer
25
+ field = fetch_field(layer)
26
+ validate_table_field!(field)
27
+
28
+ headers, expanded_sub_fields = build_table_headers(field)
29
+ rows = build_table_rows(field, expanded_sub_fields)
30
+
31
+ export_to_xlsx(layer, field, headers, rows)
32
+ rescue StandardError => e
33
+ Labimotion.log_exception(e, current_user)
34
+ error!("500 Internal Server Error: #{e.message}", 500)
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ # rubocop:disable Metrics/BlockLength
41
+ # The helpers block is necessarily large due to the many specialized helper methods
42
+ # for data fetching, validation, formatting, and export functionality
43
+ helpers do
44
+ # Validate and authorize the request, sets @element instance variable
45
+ def validate_and_authorize_request!
46
+ klass = fetch_klass
47
+ @element = klass.find_by(id: params[:id])
48
+
49
+ error!('404 Not Found', 404) if @element.nil?
50
+
51
+ authorize_element_access!
52
+ rescue ActiveRecord::RecordNotFound
53
+ error!('404 Not Found', 404)
54
+ rescue NameError => e
55
+ Labimotion.log_exception(e, current_user)
56
+ error!('400 Bad Request - Invalid type', 400)
57
+ end
58
+
59
+ # Fetch the element or segment class based on params
60
+ def fetch_klass
61
+ Labimotion::Utils.resolve_class(params[:klass])
62
+ end
63
+
64
+ # Authorize element access based on user permissions
65
+ def authorize_element_access!
66
+ element_policy = if params[:klass] == 'Element'
67
+ ElementPolicy.new(current_user,
68
+ @element)
69
+ else
70
+ ElementPolicy.new(current_user,
71
+ @element.element)
72
+ end
73
+ matrix_name = params[:klass] == 'Element' ? 'genericElement' : 'segment'
74
+
75
+ return if current_user.matrix_check_by_name(matrix_name) && element_policy.read?
76
+
77
+ error!('401 Unauthorized', 401)
78
+ end
79
+
80
+ # Fetch layer from element properties
81
+ def fetch_layer
82
+ properties = @element.properties
83
+ layers = properties[Labimotion::Prop::LAYERS] || {}
84
+ layer_key = params[:layer_id].to_s
85
+ layer = layers[layer_key]
86
+
87
+ error!('404 Layer Not Found', 404) if layer.nil?
88
+ layer
89
+ end
90
+
91
+ # Fetch field from layer
92
+ def fetch_field(layer)
93
+ fields = layer[Labimotion::Prop::FIELDS] || []
94
+ field = fields.find { |f| f['field'] == params[:field_id].to_s }
95
+
96
+ error!('404 Field Not Found', 404) if field.nil?
97
+ field
98
+ end
99
+
100
+ # Validate that the field is a table type
101
+ def validate_table_field!(field)
102
+ return if field['type'] == Labimotion::FieldType::TABLE
103
+
104
+ error!('400 Bad Request - Field is not a table type', 400)
105
+ end
106
+
107
+ # Build table headers from field definition, expands DRAG_SAMPLE and DRAG_MOLECULE columns
108
+ def build_table_headers(field)
109
+ sub_fields = field.fetch('sub_fields', [])
110
+ headers = []
111
+ expanded_sub_fields = []
112
+
113
+ sub_fields.each do |sf|
114
+ base_col_name = normalize_column_name(sf['col_name'])
115
+
116
+ if expandable_field?(sf['type'])
117
+ headers, expanded_sub_fields = expand_field_columns(sf, base_col_name, headers, expanded_sub_fields)
118
+ else
119
+ headers << base_col_name
120
+ expanded_sub_fields << sf
121
+ end
122
+ end
123
+
124
+ [headers, expanded_sub_fields]
125
+ end
126
+
127
+ # Expand field columns for DRAG_SAMPLE and DRAG_MOLECULE with sub-headers
128
+ def expand_field_columns(sub_field, base_col_name, headers, expanded_sub_fields)
129
+ sub_headers = sub_field['value'].to_s.split(';').reject(&:empty?)
130
+
131
+ # Always add the base column first (for SVG image link)
132
+ headers << base_col_name
133
+ expanded_sub_fields << sub_field
134
+
135
+ # For DRAG_SAMPLE, add a second base column for short_label
136
+ if sub_field['type'] == Labimotion::FieldType::DRAG_SAMPLE
137
+ headers << 'short_label'
138
+ expanded_sub_fields << {
139
+ 'id' => sub_field['id'],
140
+ 'type' => sub_field['type'],
141
+ 'sub_header' => 'short_label',
142
+ 'original' => sub_field,
143
+ 'is_base_column' => true
144
+ }
145
+ end
146
+
147
+ # Add extra columns for each sub-header
148
+ sub_headers.each do |sub_header|
149
+ headers << sub_header
150
+ expanded_sub_fields << {
151
+ 'id' => sub_field['id'],
152
+ 'type' => sub_field['type'],
153
+ 'sub_header' => sub_header,
154
+ 'original' => sub_field
155
+ }
156
+ end
157
+
158
+ [headers, expanded_sub_fields]
159
+ end
160
+
161
+ # Build table rows from field data
162
+ def build_table_rows(field, expanded_sub_fields)
163
+ sub_values = field.fetch('sub_values', [])
164
+
165
+ sub_values.map do |sub_val|
166
+ expanded_sub_fields.map do |exp_field|
167
+ if exp_field.is_a?(Hash) && exp_field['sub_header']
168
+ format_expanded_cell(sub_val, exp_field)
169
+ else
170
+ format_table_cell(sub_val, exp_field)
171
+ end
172
+ end
173
+ end
174
+ end
175
+
176
+ # Export data to XLSX format
177
+ def export_to_xlsx(layer, field, headers, rows)
178
+ element_name = @element.is_a?(Labimotion::Segment) ? @element.element.name : @element.name
179
+ segment_name = @element.is_a?(Labimotion::Segment) ? @element.segment_klass.label : ''
180
+ selected_layer = "#{layer['label'] || ExporterHelpers::CONST_UNNAMED} (#{layer['key']})"
181
+ selected_field = field['label'] || ExporterHelpers::CONST_UNNAMED
182
+
183
+ filename = generate_filename(element_name, segment_name, selected_layer, selected_field)
184
+ exporter = Labimotion::XlsxExporter.new(filename)
185
+
186
+ worksheet_params = {
187
+ exporter: exporter,
188
+ element_name: element_name,
189
+ layer_name: selected_layer,
190
+ field_name: selected_field,
191
+ headers: headers,
192
+ rows: rows
193
+ }
194
+ worksheet_params[:segment_name] = segment_name unless segment_name.empty?
195
+ populate_worksheet(worksheet_params)
196
+
197
+ # Set response headers and format
198
+ configure_xlsx_response(exporter)
199
+
200
+ exporter.read
201
+ end
202
+
203
+ # Build metadata array for worksheet
204
+ def build_metadata(params)
205
+ metadata = [['Element:', params[:element_name]]]
206
+ metadata << ['Segment:', params[:segment_name]] if params[:segment_name].present?
207
+ metadata.push(
208
+ ['Layer:', params[:layer_name]],
209
+ ['Field:', params[:field_name]],
210
+ ['Exported at:', Time.zone.now.strftime('%Y-%m-%d %H:%M:%S')],
211
+ ['Exported by:', current_user.name]
212
+ )
213
+ metadata
214
+ end
215
+
216
+ # Populate worksheet with data
217
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
218
+ def populate_worksheet(params)
219
+ # Store params in local variables for use in the instance_eval block
220
+ table_headers = params[:headers]
221
+ table_rows = params[:rows]
222
+ metadata = build_metadata(params)
223
+
224
+ params[:exporter].add_worksheet('Table Data') do |sheet|
225
+ # Add metadata section
226
+ sheet.add_section('Table Information', metadata)
227
+
228
+ # Add table data with headers
229
+ header_row_index = sheet.add_header(table_headers)
230
+
231
+ # Add data rows with hyperlink support
232
+ table_rows.each do |row_data|
233
+ # Process row data to handle hyperlinks
234
+ processed_row = row_data.map do |cell_value|
235
+ cell_value.is_a?(Hash) && cell_value[:hyperlink] ? cell_value[:text] : cell_value
236
+ end
237
+
238
+ # Add the row
239
+ row_index = sheet.add_row(processed_row)
240
+
241
+ # Add hyperlinks to cells that need them
242
+ row_data.each_with_index do |cell_value, col_index|
243
+ next unless cell_value.is_a?(Hash) && cell_value[:hyperlink]
244
+
245
+ sheet.add_hyperlink(row_index, col_index, cell_value[:hyperlink])
246
+ end
247
+ end
248
+
249
+ # Auto-fit columns and freeze panes
250
+ sheet.auto_fit_columns
251
+ sheet.freeze_panes(header_row_index + 1, 0) if table_headers.any?
252
+ end
253
+ end
254
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
255
+
256
+ # Set XLSX response headers with proper encoding
257
+ def configure_xlsx_response(exporter)
258
+ env['api.format'] = :binary
259
+ content_type 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
260
+
261
+ # Use both filename and filename* for better browser compatibility
262
+ filename = exporter.filename
263
+ encoded_filename = URI.encode_www_form_component(filename)
264
+ header('Content-Disposition', "attachment; filename=\"#{filename}\"; filename*=UTF-8''#{encoded_filename}")
265
+ end
266
+ end
267
+ # rubocop:enable Metrics/BlockLength
268
+ end
269
+ end
@@ -4,6 +4,7 @@ module Labimotion
4
4
  ## Generic Dataset API
5
5
  class GenericDatasetAPI < Grape::API
6
6
  include Grape::Kaminari
7
+
7
8
  helpers Labimotion::GenericHelpers
8
9
  helpers Labimotion::DatasetHelpers
9
10
 
@@ -24,7 +25,8 @@ module Labimotion
24
25
  end
25
26
  get do
26
27
  list = klass_list(params[:is_active], params[:displayed_in_list])
27
- serialized_data = Labimotion::DatasetKlassEntity.represent(list, displayed_in_list: params[:displayed_in_list])
28
+ serialized_data = Labimotion::DatasetKlassEntity.represent(list,
29
+ displayed_in_list: params[:displayed_in_list])
28
30
  { mc: 'ss00', data: serialized_data }
29
31
  rescue StandardError => e
30
32
  Labimotion.log_exception(e, current_user)
@@ -32,7 +34,8 @@ module Labimotion
32
34
  end
33
35
  end
34
36
 
35
- # Deprecated: This namespace is no longer used, but kept for backward compatibility. It is replaced by `list_klass`.
37
+ # Deprecated: This namespace is no longer used, but kept for backward compatibility.
38
+ # It is replaced by `list_klass`.
36
39
  namespace :list_dataset_klass do
37
40
  desc 'list Generic Dataset Klass'
38
41
  params do
@@ -65,6 +68,20 @@ module Labimotion
65
68
  { error: e.message }
66
69
  end
67
70
  end
71
+
72
+ namespace :find_template do
73
+ desc 'Find best matching template for given OLS term ID'
74
+ params do
75
+ requires :ols_term_id, type: String, desc: 'OLS Term ID (e.g., CHMO:0000470)'
76
+ end
77
+ get do
78
+ result = find_best_match_template(params[:ols_term_id])
79
+ result
80
+ rescue StandardError => e
81
+ Labimotion.log_exception(e, current_user)
82
+ raise e
83
+ end
84
+ end
68
85
  end
69
86
  end
70
87
  end
@@ -59,6 +59,34 @@ module Labimotion
59
59
  end
60
60
  end
61
61
 
62
+ namespace :search_by_like do
63
+ desc 'Search elements by name (case-insensitive like search)'
64
+ params do
65
+ requires :name, type: String, desc: 'Search query for element name'
66
+ requires :short_label, type: String, desc: 'Search query for element short label'
67
+ requires :klass_id, type: Integer, desc: 'Filter by element klass id'
68
+ optional :limit, type: Integer, desc: 'Maximum number of results', default: 20
69
+ end
70
+ get do
71
+ scope = Labimotion::Element.fetch_for_user(
72
+ current_user.id,
73
+ name: params[:name],
74
+ short_label: params[:short_label],
75
+ klass_id: params[:klass_id],
76
+ limit: params[:limit]
77
+ )
78
+
79
+ results = scope.map do |element|
80
+ Labimotion::ElementLookupEntity.represent(element)
81
+ end
82
+
83
+ { elements: results, total_count: results.count }
84
+ rescue StandardError => e
85
+ Labimotion.log_exception(e, current_user)
86
+ { elements: [], total_count: 0, error: e.message }
87
+ end
88
+ end
89
+
62
90
  namespace :export do
63
91
  desc 'export element'
64
92
  params do
@@ -465,4 +493,21 @@ module Labimotion
465
493
  end
466
494
  end
467
495
  end
496
+
497
+ # Entity for element lookup by name response
498
+ class ElementLookupEntity < Grape::Entity
499
+ expose :id
500
+ expose :name
501
+ expose :short_label
502
+ expose :element_klass_id
503
+ expose :klass_label, as: :klass_label do |element|
504
+ element.element_klass&.label
505
+ end
506
+ expose :klass_name, as: :klass_name do |element|
507
+ element.element_klass&.name
508
+ end
509
+ expose :klass_icon, as: :klass_icon do |element|
510
+ element.element_klass&.icon_name
511
+ end
512
+ end
468
513
  end
@@ -2,6 +2,7 @@
2
2
  module Labimotion
3
3
  class LabimotionAPI < Grape::API
4
4
  mount Labimotion::ConverterAPI
5
+ mount Labimotion::ExporterAPI
5
6
  mount Labimotion::GenericKlassAPI
6
7
  mount Labimotion::GenericElementAPI
7
8
  mount Labimotion::GenericDatasetAPI
@@ -33,18 +33,41 @@ module Labimotion
33
33
  ds = Labimotion::DatasetKlass.find_by(ols_term_id: attributes['ols_term_id'])
34
34
  ds.update!(attributes)
35
35
  ds.create_klasses_revision(current_user)
36
- { status: 'success', message: "This dataset: [#{attributes['label']}] has been upgraded to the version: #{attributes['version']}!" }
36
+ { status: 'success',
37
+ message: "This dataset: [#{attributes['label']}] has been upgraded to the version: #{attributes['version']}!" }
37
38
  end
38
39
  else
39
40
  attributes['created_by'] = current_user.id
40
41
  ds = Labimotion::DatasetKlass.create!(attributes)
41
42
  ds.create_klasses_revision(current_user)
42
- { status: 'success', message: "The dataset: #{attributes['label']} has been created using version: #{attributes['version']}!" }
43
+ { status: 'success',
44
+ message: "The dataset: #{attributes['label']} has been created using version: #{attributes['version']}!" }
43
45
  end
44
46
  rescue StandardError => e
45
47
  Labimotion.log_exception(e, current_user)
46
48
  # { error: e.message }
47
49
  raise e
48
50
  end
51
+
52
+ def find_best_match_template(ols_term_id)
53
+ result = Labimotion::TemplateMatcher.find_best_match(ols_term_id)
54
+ if result[:template]
55
+ build_template_response(result[:template], result[:match_type], result[:info_messages])
56
+ else
57
+ { error: '', data: {}, info: "No matching template found for term id [#{ols_term_id}]" }
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def build_template_response(template, match_type, info_messages)
64
+ response = {
65
+ error: '',
66
+ data: Labimotion::DatasetKlassEntity.represent(template, displayed_in_list: false),
67
+ match_type: match_type
68
+ }
69
+ response[:info] = info_messages.join('; ') if info_messages.any?
70
+ response
71
+ end
49
72
  end
50
73
  end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labimotion
4
+ # ExporterHelpers provides utility methods for formatting and processing export data
5
+ module ExporterHelpers
6
+ # Constants
7
+ CONST_UNNAMED = '(Unnamed)'
8
+
9
+ # Normalize column name, returns CONST_UNNAMED for blank values
10
+ def normalize_column_name(col_name)
11
+ return CONST_UNNAMED if col_name.nil? || col_name.to_s.strip.empty?
12
+
13
+ col_name
14
+ end
15
+
16
+ # Check if field type should be expanded (DRAG_SAMPLE or DRAG_MOLECULE)
17
+ def expandable_field?(field_type)
18
+ [Labimotion::FieldType::DRAG_SAMPLE, Labimotion::FieldType::DRAG_MOLECULE].include?(field_type)
19
+ end
20
+
21
+ # Generate sanitized filename for export
22
+ def generate_filename(element_name, segment_name, layer_name, field_name)
23
+ timestamp = Time.zone.now.strftime('%Y%m%d_%H%M%S')
24
+ parts = [element_name]
25
+ parts << segment_name unless segment_name.empty?
26
+ parts.push(layer_name, field_name, timestamp)
27
+ parts.join('_').gsub(/\s+/, '_').gsub(/[()]/, '(' => '[', ')' => ']')
28
+ end
29
+
30
+ # Format table cell value based on field type
31
+ def format_table_cell(sub_val, sub_field)
32
+ return '' if sub_field.fetch('id', nil).nil? || sub_val[sub_field['id']].nil?
33
+
34
+ case sub_field['type']
35
+ when Labimotion::FieldType::DRAG_SAMPLE, Labimotion::FieldType::DRAG_MOLECULE
36
+ format_drag_field_cell(sub_val, sub_field)
37
+ when Labimotion::FieldType::SELECT
38
+ format_select_cell(sub_val, sub_field)
39
+ when Labimotion::FieldType::SYSTEM_DEFINED
40
+ format_system_defined_cell(sub_val, sub_field)
41
+ else
42
+ format_default_cell(sub_val, sub_field)
43
+ end
44
+ rescue StandardError => e
45
+ Labimotion.log_exception(e)
46
+ ''
47
+ end
48
+
49
+ # Format expanded cell for DRAG_SAMPLE or DRAG_MOLECULE with sub-headers
50
+ def format_expanded_cell(sub_val, exp_field)
51
+ field_id = exp_field['id']
52
+ sub_header = exp_field['sub_header']
53
+
54
+ return '' if field_id.nil? || sub_val[field_id].nil?
55
+
56
+ val = sub_val[field_id]['value'] || {}
57
+ return '' if val.blank? || !val.is_a?(Hash)
58
+
59
+ property_key = map_sub_header_to_property(sub_header)
60
+ val[property_key].to_s
61
+ rescue StandardError => e
62
+ Labimotion.log_exception(e)
63
+ ''
64
+ end
65
+
66
+ # Map sub-header to property key
67
+ def map_sub_header_to_property(sub_header)
68
+ property_map = {
69
+ 'name' => 'el_name',
70
+ 'label' => 'el_label',
71
+ 'short_label' => 'el_short_label',
72
+ 'external_label' => 'el_external_label',
73
+ 'molecular_weight' => 'el_molecular_weight',
74
+ 'smiles' => 'el_smiles',
75
+ 'inchikey' => 'el_inchikey',
76
+ 'iupac' => 'el_iupac',
77
+ 'sum_formula' => 'el_sum_formula',
78
+ 'decoupled' => 'el_decoupled'
79
+ }
80
+
81
+ property_map[sub_header] || "el_#{sub_header}"
82
+ end
83
+
84
+ # Format drag field cell (DRAG_SAMPLE or DRAG_MOLECULE), returns hyperlink or label
85
+ def format_drag_field_cell(sub_val, sub_field)
86
+ val = sub_val[sub_field['id']]['value'] || {}
87
+ return '' if val.blank?
88
+
89
+ svg_url = val['el_svg'].to_s
90
+ if svg_url.present?
91
+ full_url = URI.join(Rails.application.config.root_url, svg_url).to_s
92
+ { hyperlink: full_url, text: '[image link]' }
93
+ else
94
+ val['el_label'].to_s
95
+ end
96
+ end
97
+
98
+ # Format select cell
99
+ def format_select_cell(sub_val, sub_field)
100
+ sub_val[sub_field['id']]['value'].to_s
101
+ end
102
+
103
+ # Format system-defined cell with unit
104
+ def format_system_defined_cell(sub_val, sub_field)
105
+ value = sub_val[sub_field['id']]['value'].to_s
106
+ unit = find_unit_label(sub_val, sub_field)
107
+
108
+ unit.present? ? "#{value} #{unit}" : value
109
+ end
110
+
111
+ # Find unit label for system-defined field
112
+ def find_unit_label(sub_val, sub_field)
113
+ value_system = extract_value_system(sub_val, sub_field)
114
+ find_unit_by_key(sub_field['option_layers'], value_system)
115
+ end
116
+
117
+ # Extract value_system from sub_val or sub_field
118
+ def extract_value_system(sub_val, sub_field)
119
+ field_data = sub_val[sub_field['id']]
120
+ field_data.is_a?(Hash) ? field_data['value_system'] : sub_field['value_system']
121
+ end
122
+
123
+ # Find unit label by field and key
124
+ def find_unit_by_key(option_layers, value_system)
125
+ field_config = Labimotion::Units::FIELDS.find { |o| o[:field] == option_layers }
126
+ return '' unless field_config
127
+
128
+ units = field_config.fetch(:units, [])
129
+ unit = units.find { |u| u[:key] == value_system }
130
+ unit ? unit.fetch(:label, '') : ''
131
+ end
132
+
133
+ # Format default cell
134
+ def format_default_cell(sub_val, sub_field)
135
+ cell_data = sub_val[sub_field['id']]
136
+ cell_data.is_a?(Hash) ? cell_data['value'].to_s : cell_data.to_s
137
+ end
138
+ end
139
+ end
@@ -134,5 +134,11 @@ module Labimotion
134
134
  optional :select_options, type: Hash, desc: 'selections'
135
135
  optional :option_layers, type: String, desc: 'option'
136
136
  end
137
+
138
+ params :table_xlsx_params do
139
+ requires :klass, type: String, desc: 'Generic Type', values: %w[Element Segment]
140
+ requires :layer_id, type: String, desc: 'layer identifier'
141
+ requires :field_id, type: String, desc: 'field identifier'
142
+ end
137
143
  end
138
144
  end
@@ -29,7 +29,8 @@ module Labimotion
29
29
  end
30
30
 
31
31
  def self.find_dataset_klass(ols_term_id)
32
- Labimotion::DatasetKlass.find_by(ols_term_id: ols_term_id)
32
+ result = Labimotion::TemplateMatcher.find_best_match(ols_term_id)
33
+ result[:template]
33
34
  end
34
35
 
35
36
  def self.create_dataset(container, klass)
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labimotion
4
+ class TemplateMatcher
5
+ def self.find_best_match(ols_term_id)
6
+ return { template: nil, match_type: nil, info_messages: [] } if ols_term_id.blank?
7
+
8
+ info_messages = []
9
+
10
+ # Step 1: Find "assigned" mode templates
11
+ assigned = Labimotion::DatasetKlass.find_assigned_templates(ols_term_id)
12
+ if assigned.any?
13
+ handle_multiple_results(assigned, :assigned, info_messages)
14
+ return { template: assigned.first, match_type: 'assigned', info_messages: info_messages }
15
+ end
16
+
17
+ # Step 2: Find exact match template
18
+ exact = Labimotion::DatasetKlass.find_by(ols_term_id: ols_term_id)
19
+ return { template: exact, match_type: 'exact', info_messages: info_messages } if exact
20
+
21
+ # Step 3: Find "inferred" mode templates
22
+ info_messages << 'Using parent template'
23
+ inferred = Labimotion::DatasetKlass.find_inferred_templates(ols_term_id)
24
+ if inferred.any?
25
+ handle_multiple_results(inferred, :inferred, info_messages)
26
+ return { template: inferred.first, match_type: 'inferred', info_messages: info_messages }
27
+ end
28
+
29
+ { template: nil, match_type: nil, info_messages: info_messages }
30
+ end
31
+
32
+ def self.handle_multiple_results(templates, mode, info_messages)
33
+ return unless templates.size > 1
34
+
35
+ ols_ids = templates.map(&:ols_term_id).join(', ')
36
+ info_messages << "Multiple #{mode} templates found, ols_term_id(s) are #{ols_ids}"
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'caxlsx'
4
+
5
+ module Labimotion
6
+ ## XlsxExporter
7
+ # A common utility class for exporting data to Excel (.xlsx) format using the caxlsx gem
8
+ #
9
+ # Usage Example:
10
+ # exporter = Labimotion::XlsxExporter.new('MyReport')
11
+ # exporter.add_worksheet('Sheet1') do |sheet|
12
+ # sheet.add_header(['Name', 'Age', 'Email'])
13
+ # sheet.add_row(['Jane Smith', 25, 'jane@example.com'])
14
+ # end
15
+ # exporter.save_to_file('output.xlsx')
16
+ # # or get the stream: exporter.to_stream
17
+ class XlsxExporter
18
+ attr_reader :package, :workbook
19
+
20
+ # Initialize a new XlsxExporter
21
+ # @param filename [String] optional filename (without extension)
22
+ def initialize(filename = nil)
23
+ @filename = filename
24
+ @package = Axlsx::Package.new
25
+ @workbook = @package.workbook
26
+ @workbook.styles.fonts.first.name = 'Calibri'
27
+ @current_sheet = nil
28
+ end
29
+
30
+ # Add a new worksheet to the workbook
31
+ # @param sheet_name [String] name of the worksheet
32
+ # @param options [Hash] additional options for the worksheet
33
+ # @yield [sheet] gives the SheetBuilder to the block
34
+ # @return [SheetBuilder] the created sheet builder
35
+ def add_worksheet(sheet_name, _options = {}, &block)
36
+ sheet = SheetBuilder.new(@workbook.add_worksheet(name: sheet_name), @workbook)
37
+ sheet.instance_eval(&block) if block_given?
38
+ sheet
39
+ end
40
+
41
+ # Get the Excel file as a stream
42
+ # @return [String] the Excel file stream
43
+ def to_stream
44
+ @package.to_stream
45
+ end
46
+
47
+ # Read the Excel file content
48
+ # @return [String] the Excel file binary content
49
+ def read
50
+ @package.to_stream.read
51
+ end
52
+
53
+ # Save the Excel file to disk
54
+ # @param filepath [String] path where to save the file
55
+ # @return [Boolean] true if saved successfully
56
+ def save_to_file(filepath)
57
+ @package.serialize(filepath)
58
+ true
59
+ rescue StandardError => e
60
+ Labimotion.log_exception(e)
61
+ false
62
+ end
63
+
64
+ # Get suggested filename with .xlsx extension
65
+ # @return [String] filename with extension
66
+ def filename
67
+ name = @filename || "export_#{Time.now.strftime('%Y%m%d_%H%M%S')}"
68
+ "#{name}.xlsx"
69
+ end
70
+
71
+ ## SheetBuilder
72
+ # Helper class to build worksheet content with convenient methods
73
+ class SheetBuilder
74
+ attr_reader :sheet, :workbook, :styles
75
+
76
+ def initialize(sheet, workbook)
77
+ @sheet = sheet
78
+ @workbook = workbook
79
+ @styles = StyleManager.new(workbook)
80
+ @current_row = 0
81
+ end
82
+
83
+ # Add a header row with bold styling
84
+ # @param data [Array] array of header values
85
+ # @param options [Hash] style options (color, bg_color, bold, etc.)
86
+ # @return [Integer] row index
87
+ def add_header(data, options = {})
88
+ style_options = {
89
+ bold: true,
90
+ fg_color: '000000', # Black text
91
+ bg_color: 'DDDDDD',
92
+ border: { style: :thin, color: '000000' }
93
+ }.merge(options)
94
+
95
+ style = @styles.create_style(style_options)
96
+ add_row(data, style: style)
97
+ end
98
+
99
+ # Add a data row
100
+ # @param data [Array] array of cell values
101
+ # @param options [Hash] options including :style, :height, :types
102
+ # @return [Integer] row index
103
+ def add_row(data, options = {})
104
+ row_options = {}
105
+ row_options[:style] = options[:style] if options[:style]
106
+ row_options[:height] = options[:height] if options[:height]
107
+ row_options[:types] = options[:types] if options[:types]
108
+
109
+ @sheet.add_row(data, row_options)
110
+ @current_row += 1
111
+ @current_row - 1
112
+ end
113
+
114
+ # Add multiple rows at once
115
+ # @param rows [Array<Array>] array of row data
116
+ # @param options [Hash] options for all rows
117
+ # @return [Integer] number of rows added
118
+ def add_rows(rows, options = {})
119
+ rows.each { |row| add_row(row, options) }
120
+ rows.length
121
+ end
122
+
123
+ # Add an empty row (for spacing)
124
+ # @return [Integer] row index
125
+ def add_blank_row
126
+ add_row([])
127
+ end
128
+
129
+ # Set column widths
130
+ # @param widths [Array<Numeric>] array of column widths
131
+ def set_column_widths(*widths)
132
+ @sheet.column_widths(*widths)
133
+ end
134
+
135
+ # Auto-fit column widths based on content
136
+ # @param columns [Array<Integer>] column indices to auto-fit (nil for all)
137
+ def auto_fit_columns(columns = nil)
138
+ cols = columns || (0...(@sheet.rows.first&.cells&.length || 0)).to_a
139
+ cols.each do |col_index|
140
+ max_width = @sheet.rows.map { |row| row.cells[col_index]&.value.to_s.length || 0 }.max
141
+ @sheet.column_info[col_index].width = [max_width + 2, 100].min if max_width
142
+ end
143
+ end
144
+
145
+ # Merge cells in a range
146
+ # @param start_cell [String] starting cell (e.g., 'A1')
147
+ # @param end_cell [String] ending cell (e.g., 'C1')
148
+ def merge_cells(start_cell, end_cell)
149
+ @sheet.merge_cells("#{start_cell}:#{end_cell}")
150
+ end
151
+
152
+ # Add a titled section (title + data rows)
153
+ # @param title [String] section title
154
+ # @param data [Array<Array>] data rows
155
+ # @param options [Hash] options for the section
156
+ def add_section(title, data, options = {})
157
+ add_blank_row if @current_row.positive?
158
+
159
+ # Add title
160
+ title_style = @styles.create_style(
161
+ bold: true,
162
+ font_size: 14,
163
+ bg_color: options[:title_bg_color] || 'CCCCCC'
164
+ )
165
+ add_row([title], style: title_style)
166
+
167
+ # Add data
168
+ add_rows(data, options)
169
+
170
+ add_blank_row
171
+ end
172
+
173
+ # Freeze panes (typically for freezing header rows)
174
+ # @param row [Integer] row number to freeze at
175
+ # @param column [Integer] column number to freeze at
176
+ def freeze_panes(row = 1, column = 0)
177
+ @sheet.sheet_view.pane do |pane|
178
+ pane.top_left_cell = Axlsx.cell_r(column, row)
179
+ pane.state = :frozen
180
+ pane.y_split = row
181
+ pane.x_split = column
182
+ pane.active_pane = :bottom_right
183
+ end
184
+ end
185
+
186
+ # Apply auto-filter to a range
187
+ # @param range [String] range to apply filter (e.g., 'A1:D1')
188
+ def add_auto_filter(range = nil)
189
+ if range
190
+ @sheet.auto_filter = range
191
+ else
192
+ # Auto-detect range from first row
193
+ last_col = (@sheet.rows.first&.cells&.length || 1) - 1
194
+ @sheet.auto_filter = "A1:#{Axlsx.col_ref(last_col)}1"
195
+ end
196
+ end
197
+
198
+ # Add a hyperlink to a specific cell
199
+ # @param row_index [Integer] 0-based row index
200
+ # @param col_index [Integer] 0-based column index
201
+ # @param url [String] the URL for the hyperlink
202
+ # @param display_text [String] optional display text (defaults to cell value)
203
+ def add_hyperlink(row_index, col_index, url, display_text = nil)
204
+ cell = @sheet.rows[row_index].cells[col_index]
205
+ cell.value = display_text if display_text
206
+ @sheet.add_hyperlink location: url, ref: cell
207
+ end
208
+ end
209
+
210
+ ## StyleManager
211
+ # Helper class to manage and create cell styles
212
+ class StyleManager
213
+ def initialize(workbook)
214
+ @workbook = workbook
215
+ @style_cache = {}
216
+ end
217
+
218
+ # Create or retrieve a cached style
219
+ # @param options [Hash] style options
220
+ # @option options [Boolean] :bold
221
+ # @option options [Boolean] :italic
222
+ # @option options [String] :font_name
223
+ # @option options [Integer] :font_size
224
+ # @option options [String] :fg_color foreground/text color
225
+ # @option options [String] :bg_color background color
226
+ # @option options [Symbol] :alignment (:left, :center, :right)
227
+ # @option options [Hash] :border border options
228
+ # @option options [String] :format_code number format
229
+ # @return [Axlsx::Style] the created or cached style
230
+ def create_style(options = {})
231
+ cache_key = options.hash
232
+ return @style_cache[cache_key] if @style_cache[cache_key]
233
+
234
+ style_options = {}
235
+
236
+ # Font options
237
+ font_options = {}
238
+ font_options[:b] = options[:bold] if options.key?(:bold)
239
+ font_options[:i] = options[:italic] if options.key?(:italic)
240
+ font_options[:name] = options[:font_name] if options[:font_name]
241
+ font_options[:sz] = options[:font_size] if options[:font_size]
242
+ # font_options[:color] = { rgb: options[:fg_color] } if options[:fg_color]
243
+ style_options[:font] = font_options if font_options.any?
244
+
245
+ # Fill/background color
246
+ if options[:bg_color]
247
+ style_options[:bg_color] = options[:bg_color]
248
+ style_options[:fg_color] = options[:fg_color]
249
+ end
250
+
251
+ # Alignment
252
+ style_options[:alignment] = { horizontal: options[:alignment] } if options[:alignment]
253
+
254
+ # Border
255
+ style_options[:border] = options[:border] if options[:border]
256
+
257
+ # Number format
258
+ style_options[:format_code] = options[:format_code] if options[:format_code]
259
+
260
+ @style_cache[cache_key] = @workbook.styles.add_style(style_options)
261
+ end
262
+
263
+ # Common predefined styles
264
+ def header_style
265
+ create_style(bold: true, bg_color: 'DDDDDD', alignment: :center)
266
+ end
267
+
268
+ def currency_style
269
+ create_style(format_code: '$#,##0.00')
270
+ end
271
+
272
+ def percentage_style
273
+ create_style(format_code: '0.00%')
274
+ end
275
+
276
+ def date_style
277
+ create_style(format_code: 'yyyy-mm-dd')
278
+ end
279
+
280
+ def datetime_style
281
+ create_style(format_code: 'yyyy-mm-dd hh:mm:ss')
282
+ end
283
+ end
284
+ end
285
+ end
@@ -9,6 +9,7 @@ module Labimotion
9
9
  self.table_name = :dataset_klasses
10
10
  include GenericKlassRevisions
11
11
  include GenericKlass
12
+
12
13
  has_many :datasets, dependent: :destroy, class_name: 'Labimotion::Dataset'
13
14
  has_many :dataset_klasses_revisions, dependent: :destroy, class_name: 'Labimotion::DatasetKlassesRevision'
14
15
 
@@ -25,7 +26,7 @@ module Labimotion
25
26
  seeds = JSON.parse(File.read(seeds_path))
26
27
 
27
28
  seeds['chmo'].each do |term|
28
- next if Labimotion::DatasetKlass.where(ols_term_id: term['id']).count.positive?
29
+ next if Labimotion::DatasetKlass.where(ols_term_id: term['id']).any?
29
30
 
30
31
  attributes = { ols_term_id: term['id'], label: "#{term['label']} (#{term['synonym']})",
31
32
  desc: "#{term['label']} (#{term['synonym']})", place: term['position'],
@@ -34,5 +35,21 @@ module Labimotion
34
35
  end
35
36
  true
36
37
  end
38
+
39
+ def self.find_by_mode(ols_term_id, mode)
40
+ where(
41
+ "super_class_of @> jsonb_build_object(:id, jsonb_build_object('mode', :mode))",
42
+ id: ols_term_id,
43
+ mode: mode.to_s
44
+ )
45
+ end
46
+
47
+ def self.find_assigned_templates(ols_term_id)
48
+ find_by_mode(ols_term_id, 'assigned')
49
+ end
50
+
51
+ def self.find_inferred_templates(ols_term_id)
52
+ find_by_mode(ols_term_id, 'inferred')
53
+ end
37
54
  end
38
55
  end
@@ -110,6 +110,36 @@ module Labimotion
110
110
  pids
111
111
  end
112
112
 
113
+ # Fetch elements for a user (owned + shared)
114
+ def self.fetch_for_user(user_id, name: nil, short_label: nil, klass_id: nil, limit: 20)
115
+ # Prevent abuse by capping and validating limit
116
+ limit = [limit.to_i, 100].min
117
+ limit = 20 if limit <= 0
118
+
119
+ # Build base scope with common filters
120
+ apply_filters = lambda do |scope|
121
+ scope = scope.where('elements.name ILIKE ?', "%#{sanitize_sql_like(name)}%") if name.present?
122
+ scope = scope.where('elements.short_label ILIKE ?', "%#{sanitize_sql_like(short_label)}%") if short_label.present?
123
+ scope = scope.by_klass_id(klass_id) if klass_id.present?
124
+ scope
125
+ end
126
+
127
+ # Owned elements
128
+ owned = apply_filters.call(
129
+ joins(collections: :user).where(collections: { user_id: user_id })
130
+ )
131
+
132
+ # Shared (synced) elements
133
+ shared = apply_filters.call(
134
+ joins(collections: :sync_collections_users).where(sync_collections_users: { user_id: user_id })
135
+ )
136
+
137
+ # Combine (remove duplicates), order, and limit
138
+ from("(#{owned.to_sql} UNION #{shared.to_sql}) AS elements")
139
+ .order(short_label: :desc)
140
+ .limit(limit)
141
+ end
142
+
113
143
  def thumb_svg
114
144
  image_atts = attachments.select(&:type_image?)
115
145
  attachment = image_atts[0] || attachments[0]
@@ -107,5 +107,47 @@ module Labimotion
107
107
  pkg['labimotion'] = Labimotion::VERSION
108
108
  pkg
109
109
  end
110
+
111
+ # Safely resolve a Labimotion class from a string name
112
+ # @param class_name [String] the class name (e.g., 'Element', 'Segment', 'ElementKlass')
113
+ # @param namespace [Boolean] whether to include the Labimotion namespace (default: true)
114
+ # @return [Class, nil] the resolved class or nil if not found
115
+ # @raise [NameError] if the class cannot be constantized
116
+ #
117
+ # @example
118
+ # Utils.resolve_class('Element') #=> Labimotion::Element
119
+ # Utils.resolve_class('Sample', false) #=> Sample
120
+ def self.resolve_class(class_name, namespace: true)
121
+ return nil if class_name.nil? || class_name.to_s.strip.empty?
122
+
123
+ full_name = namespace ? "Labimotion::#{class_name}" : class_name.to_s
124
+ full_name.constantize
125
+ rescue NameError => e
126
+ Labimotion.log_exception(e)
127
+ raise
128
+ end
129
+
130
+ # Resolve a Labimotion entity class from a string name
131
+ # @param class_name [String] the base class name (e.g., 'Element', 'Segment')
132
+ # @return [Class, nil] the resolved entity class
133
+ #
134
+ # @example
135
+ # Utils.resolve_entity_class('Element') #=> Labimotion::ElementEntity
136
+ def self.resolve_entity_class(class_name)
137
+ resolve_class("#{class_name}Entity")
138
+ end
139
+
140
+ # Resolve a Labimotion revision class from a string name
141
+ # @param class_name [String] the base class name (e.g., 'Element', 'Segment')
142
+ # @param plural [Boolean] whether to use plural form (default: false)
143
+ # @return [Class, nil] the resolved revision class
144
+ #
145
+ # @example
146
+ # Utils.resolve_revision_class('Element') #=> Labimotion::ElementsRevision
147
+ # Utils.resolve_revision_class('ElementKlass', plural: true) #=> Labimotion::ElementKlassesRevision
148
+ def self.resolve_revision_class(class_name, plural: false)
149
+ suffix = plural ? 'esRevision' : 'sRevision'
150
+ resolve_class("#{class_name}#{suffix}")
151
+ end
110
152
  end
111
153
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  ## Labimotion Version
4
4
  module Labimotion
5
- VERSION = '2.1.0.rc3'
5
+ VERSION = '2.1.0.rc4'
6
6
  end
data/lib/labimotion.rb CHANGED
@@ -24,6 +24,7 @@ module Labimotion
24
24
  autoload :SegmentAPI, 'labimotion/apis/segment_api'
25
25
  autoload :LabimotionHubAPI, 'labimotion/apis/labimotion_hub_api'
26
26
  autoload :ConverterAPI, 'labimotion/apis/converter_api'
27
+ autoload :ExporterAPI, 'labimotion/apis/exporter_api'
27
28
  autoload :StandardLayerAPI, 'labimotion/apis/standard_layer_api'
28
29
  autoload :VocabularyAPI, 'labimotion/apis/vocabulary_api'
29
30
 
@@ -57,6 +58,7 @@ module Labimotion
57
58
  autoload :SearchHelpers, 'labimotion/helpers/search_helpers'
58
59
  autoload :ParamHelpers, 'labimotion/helpers/param_helpers'
59
60
  autoload :ConverterHelpers, 'labimotion/helpers/converter_helpers'
61
+ autoload :ExporterHelpers, 'labimotion/helpers/exporter_helpers'
60
62
  autoload :SampleAssociationHelpers, 'labimotion/helpers/sample_association_helpers'
61
63
  autoload :RepositoryHelpers, 'labimotion/helpers/repository_helpers'
62
64
  autoload :VocabularyHelpers, 'labimotion/helpers/vocabulary_helpers'
@@ -67,11 +69,13 @@ module Labimotion
67
69
  autoload :NmrMapper, 'labimotion/libs/nmr_mapper'
68
70
  autoload :NmrMapperRepo, 'labimotion/libs/nmr_mapper_repo' ## for Chemotion Repository
69
71
  autoload :TemplateHub, 'labimotion/libs/template_hub'
72
+ autoload :TemplateMatcher, 'labimotion/libs/template_matcher'
70
73
  autoload :ExportDataset, 'labimotion/libs/export_dataset'
71
74
  autoload :SampleAssociation, 'labimotion/libs/sample_association'
72
75
  autoload :PropertiesHandler, 'labimotion/libs/properties_handler'
73
76
  autoload :AttachmentHandler, 'labimotion/libs/attachment_handler'
74
77
  autoload :VocabularyHandler, 'labimotion/libs/vocabulary_handler'
78
+ autoload :XlsxExporter, 'labimotion/libs/xlsx_exporter'
75
79
 
76
80
  ######## Utils
77
81
  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.1.0.rc3
4
+ version: 2.1.0.rc4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chia-Lin Lin
@@ -12,19 +12,39 @@ cert_chain: []
12
12
  date: 2025-10-20 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
- name: rails
15
+ name: caxlsx
16
16
  requirement: !ruby/object:Gem::Requirement
17
17
  requirements:
18
18
  - - "~>"
19
19
  - !ruby/object:Gem::Version
20
- version: 6.1.7
20
+ version: '3.0'
21
21
  type: :runtime
22
22
  prerelease: false
23
23
  version_requirements: !ruby/object:Gem::Requirement
24
24
  requirements:
25
25
  - - "~>"
26
26
  - !ruby/object:Gem::Version
27
- version: 6.1.7
27
+ version: '3.0'
28
+ - !ruby/object:Gem::Dependency
29
+ name: rails
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: '6.1'
35
+ - - "<"
36
+ - !ruby/object:Gem::Version
37
+ version: '8.0'
38
+ type: :runtime
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: '6.1'
45
+ - - "<"
46
+ - !ruby/object:Gem::Version
47
+ version: '8.0'
28
48
  description:
29
49
  email:
30
50
  - chia-lin.lin@kit.edu
@@ -35,6 +55,7 @@ extra_rdoc_files: []
35
55
  files:
36
56
  - lib/labimotion.rb
37
57
  - lib/labimotion/apis/converter_api.rb
58
+ - lib/labimotion/apis/exporter_api.rb
38
59
  - lib/labimotion/apis/generic_dataset_api.rb
39
60
  - lib/labimotion/apis/generic_element_api.rb
40
61
  - lib/labimotion/apis/generic_klass_api.rb
@@ -67,6 +88,7 @@ files:
67
88
  - lib/labimotion/helpers/converter_helpers.rb
68
89
  - lib/labimotion/helpers/dataset_helpers.rb
69
90
  - lib/labimotion/helpers/element_helpers.rb
91
+ - lib/labimotion/helpers/exporter_helpers.rb
70
92
  - lib/labimotion/helpers/generic_helpers.rb
71
93
  - lib/labimotion/helpers/param_helpers.rb
72
94
  - lib/labimotion/helpers/repository_helpers.rb
@@ -88,7 +110,9 @@ files:
88
110
  - lib/labimotion/libs/properties_handler.rb
89
111
  - lib/labimotion/libs/sample_association.rb
90
112
  - lib/labimotion/libs/template_hub.rb
113
+ - lib/labimotion/libs/template_matcher.rb
91
114
  - lib/labimotion/libs/vocabulary_handler.rb
115
+ - lib/labimotion/libs/xlsx_exporter.rb
92
116
  - lib/labimotion/models/collections_element.rb
93
117
  - lib/labimotion/models/concerns/attachment_converter.rb
94
118
  - lib/labimotion/models/concerns/datasetable.rb
@@ -134,6 +158,7 @@ metadata:
134
158
  homepage_uri: https://github.com/LabIMotion/labimotion
135
159
  source_code_uri: https://github.com/LabIMotion/labimotion
136
160
  bug_tracker_uri: https://github.com/LabIMotion/labimotion/discussions
161
+ rubygems_mfa_required: 'true'
137
162
  post_install_message:
138
163
  rdoc_options: []
139
164
  require_paths:
@@ -142,7 +167,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
142
167
  requirements:
143
168
  - - ">="
144
169
  - !ruby/object:Gem::Version
145
- version: '0'
170
+ version: '2.7'
146
171
  required_rubygems_version: !ruby/object:Gem::Requirement
147
172
  requirements:
148
173
  - - ">"