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 +4 -4
- data/lib/labimotion/apis/exporter_api.rb +269 -0
- data/lib/labimotion/apis/generic_dataset_api.rb +19 -2
- data/lib/labimotion/apis/generic_element_api.rb +45 -0
- data/lib/labimotion/apis/labimotion_api.rb +1 -0
- data/lib/labimotion/helpers/dataset_helpers.rb +25 -2
- data/lib/labimotion/helpers/exporter_helpers.rb +139 -0
- data/lib/labimotion/helpers/param_helpers.rb +6 -0
- data/lib/labimotion/libs/dataset_builder.rb +2 -1
- data/lib/labimotion/libs/template_matcher.rb +39 -0
- data/lib/labimotion/libs/xlsx_exporter.rb +285 -0
- data/lib/labimotion/models/dataset_klass.rb +18 -1
- data/lib/labimotion/models/element.rb +30 -0
- data/lib/labimotion/utils/utils.rb +42 -0
- data/lib/labimotion/version.rb +1 -1
- data/lib/labimotion.rb +4 -0
- metadata +30 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e0c3346ede1f92148d84551b0c4052848803e1b6969d14263a771b0fdfe8f2f1
|
4
|
+
data.tar.gz: df2597e134e8adf3e4ff48775578ae507d6c4b022c4abfd368cf01bd53ff1569
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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,
|
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.
|
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
|
@@ -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',
|
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',
|
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::
|
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']).
|
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
|
data/lib/labimotion/version.rb
CHANGED
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.
|
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:
|
15
|
+
name: caxlsx
|
16
16
|
requirement: !ruby/object:Gem::Requirement
|
17
17
|
requirements:
|
18
18
|
- - "~>"
|
19
19
|
- !ruby/object:Gem::Version
|
20
|
-
version:
|
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:
|
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: '
|
170
|
+
version: '2.7'
|
146
171
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
147
172
|
requirements:
|
148
173
|
- - ">"
|