labimotion 2.3.0 → 2.4.0.rc11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -1
- data/lib/labimotion/apis/generic_dataset_api.rb +93 -3
- data/lib/labimotion/apis/generic_element_api.rb +198 -8
- data/lib/labimotion/apis/generic_klass_api.rb +74 -8
- data/lib/labimotion/apis/klass_share_api.rb +648 -0
- data/lib/labimotion/apis/labimotion_ai_api.rb +252 -0
- data/lib/labimotion/apis/labimotion_api.rb +4 -0
- data/lib/labimotion/apis/labimotion_doi_api.rb +24 -10
- data/lib/labimotion/apis/labimotion_template_browse_api.rb +13 -1
- data/lib/labimotion/apis/ontology_root_api.rb +74 -0
- data/lib/labimotion/apis/segment_api.rb +77 -10
- data/lib/labimotion/apis/user_klass_settings_api.rb +93 -0
- data/lib/labimotion/conf.rb +5 -0
- data/lib/labimotion/constants.rb +64 -0
- data/lib/labimotion/entities/application_entity.rb +8 -0
- data/lib/labimotion/entities/eln_element_entity.rb +6 -0
- data/lib/labimotion/entities/generic_klass_entity.rb +126 -0
- data/lib/labimotion/entities/klass_share_entity.rb +48 -0
- data/lib/labimotion/entities/properties_entity.rb +78 -2
- data/lib/labimotion/entities/segment_entity.rb +8 -0
- data/lib/labimotion/entities/user_klass_setting_entity.rb +10 -0
- data/lib/labimotion/helpers/cover_image_helpers.rb +181 -0
- data/lib/labimotion/helpers/dataset_helpers.rb +187 -1
- data/lib/labimotion/helpers/element_helpers.rb +282 -6
- data/lib/labimotion/helpers/exporter_helpers.rb +17 -2
- data/lib/labimotion/helpers/generic_helpers.rb +293 -4
- data/lib/labimotion/helpers/param_helpers.rb +105 -0
- data/lib/labimotion/helpers/sample_association_helpers.rb +7 -0
- data/lib/labimotion/helpers/segment_helpers.rb +102 -4
- data/lib/labimotion/libs/ai_egress_guard.rb +99 -0
- data/lib/labimotion/libs/ai_klass_queue.rb +88 -0
- data/lib/labimotion/libs/ai_klass_validator.rb +74 -0
- data/lib/labimotion/libs/ai_models.rb +201 -0
- data/lib/labimotion/libs/ai_template.rb +2285 -0
- data/lib/labimotion/libs/converter.rb +5 -43
- data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +67 -0
- data/lib/labimotion/libs/export_element.rb +128 -13
- data/lib/labimotion/libs/file_extractor.rb +210 -0
- data/lib/labimotion/libs/linked_element.rb +313 -0
- data/lib/labimotion/libs/ontology_store.rb +226 -0
- data/lib/labimotion/libs/ontology_terms.rb +227 -0
- data/lib/labimotion/libs/owner_resolver.rb +50 -0
- data/lib/labimotion/libs/ownership_audit.rb +73 -0
- data/lib/labimotion/libs/sample_association.rb +52 -1
- data/lib/labimotion/libs/share_notifier.rb +114 -0
- data/lib/labimotion/libs/share_resolver.rb +373 -0
- data/lib/labimotion/libs/user_ai_settings.rb +129 -0
- data/lib/labimotion/models/cellline.rb +47 -0
- data/lib/labimotion/models/concerns/datasetable.rb +3 -0
- data/lib/labimotion/models/concerns/matrice_labimotion.rb +124 -0
- data/lib/labimotion/models/concerns/segmentable.rb +2 -0
- data/lib/labimotion/models/concerns/template_doi.rb +133 -0
- data/lib/labimotion/models/dataset_klass.rb +1 -1
- data/lib/labimotion/models/element_klass.rb +1 -1
- data/lib/labimotion/models/klass_share.rb +129 -0
- data/lib/labimotion/models/segment_klass.rb +1 -1
- data/lib/labimotion/models/user_klass_setting.rb +58 -0
- data/lib/labimotion/models/user_setting.rb +60 -0
- data/lib/labimotion/usecases/build_template_doi_xml.rb +69 -23
- data/lib/labimotion/usecases/release_template_doi.rb +42 -17
- data/lib/labimotion/usecases/template_doi_helpers.rb +28 -6
- data/lib/labimotion/usecases/update_template_publication_metadata.rb +71 -2
- data/lib/labimotion/utils/export_utils.rb +1 -0
- data/lib/labimotion/utils/import_utils.rb +20 -3
- data/lib/labimotion/utils/serializer.rb +27 -0
- data/lib/labimotion/utils/units.rb +32 -67
- data/lib/labimotion/version.rb +1 -1
- data/lib/labimotion.rb +27 -0
- metadata +45 -3
|
@@ -125,6 +125,7 @@ module Labimotion
|
|
|
125
125
|
tmp_file.rewind
|
|
126
126
|
|
|
127
127
|
filename = oat.filename
|
|
128
|
+
# name = "#{File.basename(filename, '.*')}.zip"
|
|
128
129
|
name = "#{File.basename(filename, '.*')}#{File.extname(filename) == '.zip' ? '.bagit.zip' : '.zip'}"
|
|
129
130
|
att = Attachment.new(
|
|
130
131
|
filename: name,
|
|
@@ -246,22 +247,14 @@ module Labimotion
|
|
|
246
247
|
dsr.each do |ds|
|
|
247
248
|
layer = layers[ds[:layer]]
|
|
248
249
|
next if layer.blank? || layer[Labimotion::Prop::FIELDS].blank?
|
|
249
|
-
is_unit = ds[:field].start_with?(Prop::CONVERTER_FIELD_UINT_PREFIX)
|
|
250
|
-
next if is_unit && ds[:value].nil?
|
|
251
250
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
fields = layer[Labimotion::Prop::FIELDS].select{ |f| f['field'] == field_name }
|
|
251
|
+
fields = layer[Labimotion::Prop::FIELDS].select{ |f| f['field'] == ds[:field] }
|
|
255
252
|
fi = fields&.first
|
|
256
253
|
next if fi.blank?
|
|
257
254
|
|
|
258
255
|
idx = layer[Labimotion::Prop::FIELDS].find_index(fi)
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
else
|
|
262
|
-
fi['value'] = ds[:value]
|
|
263
|
-
fi['device'] = ds[:device] || ds[:value]
|
|
264
|
-
end
|
|
256
|
+
fi['value'] = ds[:value]
|
|
257
|
+
fi['device'] = ds[:device] || ds[:value]
|
|
265
258
|
new_prop[Labimotion::Prop::LAYERS][ds[:layer]][Labimotion::Prop::FIELDS][idx] = fi
|
|
266
259
|
end
|
|
267
260
|
element = Container.find(dataset.element_id)&.root_element
|
|
@@ -327,37 +320,6 @@ module Labimotion
|
|
|
327
320
|
res
|
|
328
321
|
end
|
|
329
322
|
|
|
330
|
-
def self.test_conversions(tmpfile, format)
|
|
331
|
-
res = {}
|
|
332
|
-
File.open(tmpfile.path, 'r') do |file|
|
|
333
|
-
body = { file: file, format: format }
|
|
334
|
-
response = HTTParty.post(
|
|
335
|
-
uri('conversions'),
|
|
336
|
-
basic_auth: auth,
|
|
337
|
-
body: body,
|
|
338
|
-
timeout: timeout,
|
|
339
|
-
)
|
|
340
|
-
res = response.parsed_response
|
|
341
|
-
end
|
|
342
|
-
res
|
|
343
|
-
end
|
|
344
|
-
|
|
345
|
-
def self.restore(profile_id, version, hard)
|
|
346
|
-
body = { hard: hard }
|
|
347
|
-
response = HTTParty.post(
|
|
348
|
-
uri("profiles/restore/#{profile_id}/#{version}"),
|
|
349
|
-
headers: {
|
|
350
|
-
"Content-Type" => "application/json"
|
|
351
|
-
},
|
|
352
|
-
basic_auth: auth,
|
|
353
|
-
body: body.to_json,
|
|
354
|
-
timeout: timeout,
|
|
355
|
-
)
|
|
356
|
-
res = response.parsed_response
|
|
357
|
-
|
|
358
|
-
res
|
|
359
|
-
end
|
|
360
|
-
|
|
361
323
|
def self.metadata(id, current_user)
|
|
362
324
|
att = Attachment.find(id)
|
|
363
325
|
return if att.nil? || att.attachable_id.nil? || att.attachable_type != Labimotion::Prop::CONTAINER
|
|
@@ -372,4 +334,4 @@ end
|
|
|
372
334
|
# rubocop: enable Metrics/AbcSize
|
|
373
335
|
# rubocop: enable Metrics/MethodLength
|
|
374
336
|
# rubocop: enable Metrics/ClassLength
|
|
375
|
-
# rubocop: enable Metrics/CyclomaticComplexity
|
|
337
|
+
# rubocop: enable Metrics/CyclomaticComplexity
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<resource xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://datacite.org/schema/kernel-4" xsi:schemaLocation="http://datacite.org/schema/kernel-4 https://schema.datacite.org/meta/kernel-4/metadata.xsd">
|
|
3
|
+
<identifier identifierType="DOI">https://dx.doi.org/<%= full_doi %></identifier>
|
|
4
|
+
<creators>
|
|
5
|
+
<% if creators.empty? %>
|
|
6
|
+
<creator>
|
|
7
|
+
<creatorName>(:unav)</creatorName>
|
|
8
|
+
</creator>
|
|
9
|
+
<% end %>
|
|
10
|
+
<% creators.each do |creator| %>
|
|
11
|
+
<creator>
|
|
12
|
+
<creatorName nameType="Personal"><%= creator['familyName'] %>, <%= creator['givenName'] %></creatorName>
|
|
13
|
+
<givenName><%= creator['givenName'] %></givenName>
|
|
14
|
+
<familyName><%= creator['familyName'] %></familyName>
|
|
15
|
+
<% if creator['orcid'].present? %>
|
|
16
|
+
<nameIdentifier schemeURI="https://orcid.org/" nameIdentifierScheme="ORCID"><%= creator['orcid'] %></nameIdentifier>
|
|
17
|
+
<% end %>
|
|
18
|
+
<% if creator['affiliation'].present? || creator['ror'].present? %>
|
|
19
|
+
<affiliation<% if creator['ror'].present? %> affiliationIdentifier="<%= creator['ror'] %>" affiliationIdentifierScheme="ROR" schemeURI="https://ror.org"<% end %>><%= creator['affiliation'] %></affiliation>
|
|
20
|
+
<% end %>
|
|
21
|
+
</creator>
|
|
22
|
+
<% end %>
|
|
23
|
+
</creators>
|
|
24
|
+
<titles>
|
|
25
|
+
<title xml:lang="en-US"><%= title %></title>
|
|
26
|
+
</titles>
|
|
27
|
+
<%= render_publisher(publisher_config) %>
|
|
28
|
+
<publicationYear><%= publication_year %></publicationYear>
|
|
29
|
+
<subjects>
|
|
30
|
+
<subject>Chemistry</subject>
|
|
31
|
+
</subjects>
|
|
32
|
+
<% if contributors.any? %>
|
|
33
|
+
<contributors>
|
|
34
|
+
<% contributors.each do |contributor| %>
|
|
35
|
+
<contributor contributorType="<%= contributor['contributorType'] %>">
|
|
36
|
+
<contributorName nameType="Personal"><%= contributor['familyName'] %>, <%= contributor['givenName'] %></contributorName>
|
|
37
|
+
<givenName><%= contributor['givenName'] %></givenName>
|
|
38
|
+
<familyName><%= contributor['familyName'] %></familyName>
|
|
39
|
+
<% if contributor['orcid'].present? %>
|
|
40
|
+
<nameIdentifier schemeURI="https://orcid.org/" nameIdentifierScheme="ORCID"><%= contributor['orcid'] %></nameIdentifier>
|
|
41
|
+
<% end %>
|
|
42
|
+
<% if contributor['affiliation'].present? || contributor['ror'].present? %>
|
|
43
|
+
<affiliation<% if contributor['ror'].present? %> affiliationIdentifier="<%= contributor['ror'] %>" affiliationIdentifierScheme="ROR" schemeURI="https://ror.org"<% end %>><%= contributor['affiliation'] %></affiliation>
|
|
44
|
+
<% end %>
|
|
45
|
+
</contributor>
|
|
46
|
+
<% end %>
|
|
47
|
+
</contributors>
|
|
48
|
+
<% end %>
|
|
49
|
+
<% if references.any? %>
|
|
50
|
+
<relatedIdentifiers>
|
|
51
|
+
<% references.each do |reference| %>
|
|
52
|
+
<relatedIdentifier relatedIdentifierType="DOI" relationType="References">https://dx.doi.org/<%= reference['doi'] %></relatedIdentifier>
|
|
53
|
+
<% end %>
|
|
54
|
+
</relatedIdentifiers>
|
|
55
|
+
<% end %>
|
|
56
|
+
<language>en</language>
|
|
57
|
+
<resourceType resourceTypeGeneral="Model"><%= resource_label %></resourceType>
|
|
58
|
+
<version><%= version %></version>
|
|
59
|
+
<rightsList>
|
|
60
|
+
<rights><%= license %></rights>
|
|
61
|
+
</rightsList>
|
|
62
|
+
<descriptions>
|
|
63
|
+
<% if description.present? %>
|
|
64
|
+
<description xml:lang="en-US" descriptionType="Abstract"><%= description %></description>
|
|
65
|
+
<% end %>
|
|
66
|
+
</descriptions>
|
|
67
|
+
</resource>
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require 'date'
|
|
3
3
|
require 'ostruct'
|
|
4
|
+
require 'tempfile'
|
|
4
5
|
require 'export_table'
|
|
5
6
|
require 'labimotion/version'
|
|
6
7
|
require 'labimotion/utils/units'
|
|
8
|
+
require 'labimotion/helpers/cover_image_helpers'
|
|
7
9
|
require 'sablon'
|
|
8
10
|
|
|
9
11
|
module Labimotion
|
|
@@ -33,6 +35,7 @@ module Labimotion
|
|
|
33
35
|
@properties = element.properties
|
|
34
36
|
@options = element.properties_release[Labimotion::Prop::SEL_OPTIONS]
|
|
35
37
|
@export_format = export_format
|
|
38
|
+
@cover_tempfiles = []
|
|
36
39
|
rescue StandardError => e
|
|
37
40
|
Labimotion.log_exception(e)
|
|
38
41
|
end
|
|
@@ -396,6 +399,7 @@ module Labimotion
|
|
|
396
399
|
TABLE_CELL_TEXT_RENDERERS = {
|
|
397
400
|
Labimotion::FieldType::DRAG_SAMPLE => ->(_sf, c, ctx) { ctx.table_cell_sample_text(c['value'] || {}) },
|
|
398
401
|
Labimotion::FieldType::DRAG_MOLECULE => ->(_sf, c, ctx) { ctx.table_cell_molecule_text(c['value'] || {}) },
|
|
402
|
+
Labimotion::FieldType::DRAG_ELEMENT => ->(_sf, c, ctx) { ctx.table_cell_element_text(c['value'] || {}) },
|
|
399
403
|
Labimotion::FieldType::SELECT => ->(_sf, c, _ctx) { c['value'].to_s },
|
|
400
404
|
Labimotion::FieldType::SYSTEM_DEFINED => ->(sf, c, ctx) { ctx.table_cell_sysdef_text(sf, c) }
|
|
401
405
|
}.freeze
|
|
@@ -430,6 +434,13 @@ module Labimotion
|
|
|
430
434
|
parts.join("\n")
|
|
431
435
|
end
|
|
432
436
|
|
|
437
|
+
def table_cell_element_text(val)
|
|
438
|
+
parts = []
|
|
439
|
+
parts << "Short Label: [#{val['el_label']}]" if val['el_label'].present?
|
|
440
|
+
parts << "Name: [#{val['el_name']}]" if val['el_name'].present?
|
|
441
|
+
parts.join("\n")
|
|
442
|
+
end
|
|
443
|
+
|
|
433
444
|
def table_cell_sysdef_text(sub_field, cell)
|
|
434
445
|
fdef = Labimotion::Units::FIELDS.find { |o| o[:field] == sub_field['option_layers'] }
|
|
435
446
|
unit = fdef&.fetch(:units, [])&.find { |u| u[:key] == cell['value_system'] }&.fetch(:label, '')
|
|
@@ -454,10 +465,21 @@ module Labimotion
|
|
|
454
465
|
# location = Rails.root.join('lib', 'template', 'Labimotion.docx')
|
|
455
466
|
location = Rails.root.join('lib', 'template', 'Labimotion_lines.docx')
|
|
456
467
|
# location = Rails.root.join('lib', 'template', 'Labimotion_img.docx')
|
|
457
|
-
File.exist?(location)
|
|
458
468
|
template = Sablon.template(location)
|
|
459
|
-
|
|
460
|
-
|
|
469
|
+
tempfile = Tempfile.new('labimotion.docx')
|
|
470
|
+
template.render_to_file File.expand_path(tempfile), docx_context
|
|
471
|
+
File.read(tempfile)
|
|
472
|
+
rescue StandardError => e
|
|
473
|
+
Labimotion.log_exception(e)
|
|
474
|
+
ensure
|
|
475
|
+
# Close and delete the temporary files
|
|
476
|
+
tempfile&.close
|
|
477
|
+
tempfile&.unlink
|
|
478
|
+
close_cover_tempfiles
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def docx_context
|
|
482
|
+
{
|
|
461
483
|
label: @element_klass.label,
|
|
462
484
|
desc: @element_klass.desc,
|
|
463
485
|
name: @name,
|
|
@@ -465,18 +487,111 @@ module Labimotion
|
|
|
465
487
|
short_label: @short_label,
|
|
466
488
|
date: Time.now.strftime('%d/%m/%Y'),
|
|
467
489
|
author: @current_user.name,
|
|
468
|
-
layers:
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
490
|
+
layers: build_layers
|
|
491
|
+
}.merge(cover_context)
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def close_cover_tempfiles
|
|
495
|
+
Array(@cover_tempfiles).each(&:close!)
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
## The element's cover selection (chem-generic-ui#1050) rendered into the
|
|
499
|
+
## export: the stored `{source, id}` references are resolved through the
|
|
500
|
+
## same helpers as the cover_image API, so the docx shows exactly what the
|
|
501
|
+
## element page shows. Only rasters Word renders reliably are embedded as
|
|
502
|
+
## originals; everything else falls back to the PNG thumbnail.
|
|
503
|
+
DOCX_COVER_MIMES = %w[image/png image/jpeg image/gif image/bmp].freeze
|
|
504
|
+
DOCX_COVER_EXT = {
|
|
505
|
+
'image/png' => '.png',
|
|
506
|
+
'image/jpeg' => '.jpg',
|
|
507
|
+
'image/gif' => '.gif',
|
|
508
|
+
'image/bmp' => '.bmp'
|
|
509
|
+
}.freeze
|
|
510
|
+
# px-unit bounds of the rendered image (Sablon::Image::PX_TO_EMU scale):
|
|
511
|
+
# 1600 ≈ 13.3cm wide, 1100 ≈ 9.2cm tall
|
|
512
|
+
DOCX_COVER_MAX_W = 1600
|
|
513
|
+
DOCX_COVER_MAX_H = 1100
|
|
514
|
+
|
|
515
|
+
def cover_context
|
|
516
|
+
covers = build_cover_images
|
|
517
|
+
{ has_cover: covers.present?, cover_images: covers }
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def build_cover_images
|
|
521
|
+
return [] unless @element.instance_of?(Labimotion::Element)
|
|
522
|
+
|
|
523
|
+
refs = @element.metadata&.fetch('cover_images', nil) || []
|
|
524
|
+
refs.filter_map { |ref| build_cover_image(ref) }
|
|
474
525
|
rescue StandardError => e
|
|
475
526
|
Labimotion.log_exception(e)
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
527
|
+
[]
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def build_cover_image(ref)
|
|
531
|
+
att = cover_helpers.cover_image_attachment(@element, ref['source'], ref['id'])
|
|
532
|
+
data, mime = att && docx_cover_payload(att)
|
|
533
|
+
return nil if data.nil?
|
|
534
|
+
|
|
535
|
+
{ image: docx_cover_sablon_image(data, mime), caption: docx_cover_caption(ref, att) }
|
|
536
|
+
rescue StandardError => e
|
|
537
|
+
Labimotion.log_exception(e)
|
|
538
|
+
nil
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def docx_cover_payload(att)
|
|
542
|
+
original = docx_cover_original(att)
|
|
543
|
+
return original if original
|
|
544
|
+
|
|
545
|
+
thumb = att.read_thumbnail
|
|
546
|
+
[thumb, 'image/png'] if thumb
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def docx_cover_original(att)
|
|
550
|
+
return nil unless att.type_image? && !att.type_image_tiff?
|
|
551
|
+
|
|
552
|
+
mime = att.content_type.to_s.downcase
|
|
553
|
+
data = att.read_file
|
|
554
|
+
return nil if data.nil?
|
|
555
|
+
return [data, mime] if DOCX_COVER_MIMES.include?(mime)
|
|
556
|
+
|
|
557
|
+
# the template's blip placeholder takes bitmaps only, so svg & friends
|
|
558
|
+
# are rasterized onto white through the shared cover helper
|
|
559
|
+
converted = cover_helpers.cover_rasterize(data, mime)
|
|
560
|
+
[converted, 'image/png'] if converted
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
def docx_cover_sablon_image(data, mime)
|
|
564
|
+
tmp = Tempfile.new(['labimotion-cover', DOCX_COVER_EXT.fetch(mime, '.png')])
|
|
565
|
+
tmp.binmode
|
|
566
|
+
tmp.write(data)
|
|
567
|
+
tmp.flush
|
|
568
|
+
@cover_tempfiles << tmp
|
|
569
|
+
image = Sablon::Image.create_by_path(tmp.path)
|
|
570
|
+
apply_docx_cover_dimensions(image, data)
|
|
571
|
+
image
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
## Without dimensions the placeholder geometry of the template is kept,
|
|
575
|
+
## which distorts the aspect ratio — best effort, never fatal.
|
|
576
|
+
def apply_docx_cover_dimensions(image, data)
|
|
577
|
+
require 'mini_magick'
|
|
578
|
+
width, height = MiniMagick::Image.read(data).dimensions
|
|
579
|
+
return unless width.to_i.positive? && height.to_i.positive?
|
|
580
|
+
|
|
581
|
+
scale = [1.0, DOCX_COVER_MAX_W.to_f / width, DOCX_COVER_MAX_H.to_f / height].min
|
|
582
|
+
image.px_width = (width * scale).round
|
|
583
|
+
image.px_height = (height * scale).round
|
|
584
|
+
rescue LoadError, StandardError => e
|
|
585
|
+
Labimotion.log_exception(e)
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def docx_cover_caption(ref, att)
|
|
589
|
+
label = ref['source'] == 'analysis' ? @element.analyses.find_by(id: ref['id'])&.name : att.filename
|
|
590
|
+
"ref: #{ref['source']}: #{label}"
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
def cover_helpers
|
|
594
|
+
@cover_helpers ||= Object.new.extend(Labimotion::CoverImageHelpers)
|
|
480
595
|
end
|
|
481
596
|
|
|
482
597
|
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'stringio'
|
|
5
|
+
require 'timeout'
|
|
6
|
+
require 'tempfile'
|
|
7
|
+
require 'date'
|
|
8
|
+
|
|
9
|
+
# Optional parsers — the feature degrades to filename-only if a gem is absent.
|
|
10
|
+
begin
|
|
11
|
+
require 'pdf/reader'
|
|
12
|
+
rescue LoadError
|
|
13
|
+
nil
|
|
14
|
+
end
|
|
15
|
+
begin
|
|
16
|
+
require 'roo'
|
|
17
|
+
rescue LoadError
|
|
18
|
+
nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
module Labimotion
|
|
22
|
+
# FileExtractor turns an uploaded file (base64-encoded raw bytes + filename)
|
|
23
|
+
# into plain text usable as LLM context for the AI dataset-template feature.
|
|
24
|
+
#
|
|
25
|
+
# It NEVER raises into the caller. Any failure — missing gem, unsupported or
|
|
26
|
+
# oversized or corrupt file, parse error, timeout, out-of-memory — returns
|
|
27
|
+
# either '' or a short bracketed marker describing why, so the request keeps
|
|
28
|
+
# working with filename-only context.
|
|
29
|
+
module FileExtractor
|
|
30
|
+
TEXT_EXTS = %w[txt csv tsv md markdown json xml yml yaml].freeze
|
|
31
|
+
BINARY_EXTS = %w[pdf xlsx xlsm xls].freeze
|
|
32
|
+
ALLOWED_EXTS = (TEXT_EXTS + BINARY_EXTS).freeze
|
|
33
|
+
|
|
34
|
+
MAX_DECODED_BYTES = 15 * 1024 * 1024 # reject decoded payloads larger than this
|
|
35
|
+
MAX_UNCOMPRESSED = 80 * 1024 * 1024 # xlsx zip-bomb guard (sum of entry sizes)
|
|
36
|
+
MAX_OUT_CHARS = 8000 # per-file extracted-text cap
|
|
37
|
+
MAX_PDF_PAGES = 50
|
|
38
|
+
MAX_SHEETS = 5
|
|
39
|
+
MAX_ROWS = 100
|
|
40
|
+
MAX_COLS = 30
|
|
41
|
+
MAX_CELL = 200
|
|
42
|
+
EXTRACT_TIMEOUT = 20 # best-effort (cannot interrupt C-ext parsing)
|
|
43
|
+
|
|
44
|
+
# Magic bytes — forced to binary so start_with? never hits an
|
|
45
|
+
# Encoding::CompatibilityError against the ASCII-8BIT decoded bytes.
|
|
46
|
+
ZIP_MAGIC = "PK\x03\x04".b
|
|
47
|
+
OLE2_MAGIC = "\xD0\xCF\x11\xE0".b
|
|
48
|
+
PDF_MAGIC = '%PDF'.b
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
# @param filename [String] used for the extension whitelist + dispatch
|
|
52
|
+
# @param content_base64 [String] base64 of the raw file bytes (data-URL prefix tolerated)
|
|
53
|
+
# @return [String] extracted text, a '[...]' marker, or ''
|
|
54
|
+
def extract(filename, content_base64)
|
|
55
|
+
extract_bytes(filename, decode(content_base64))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Same as #extract but for already-decoded RAW bytes (e.g. Attachment#read_file).
|
|
59
|
+
# Keeps every guard #extract has: extension whitelist, empty check,
|
|
60
|
+
# MAX_DECODED_BYTES cap, the extraction Timeout, output truncation and the
|
|
61
|
+
# four rescue clauses. NEVER raises into the caller.
|
|
62
|
+
#
|
|
63
|
+
# @param filename [String] used for the extension whitelist + dispatch
|
|
64
|
+
# @param bytes [String, nil] raw file bytes (any encoding; treated as binary)
|
|
65
|
+
# @return [String] extracted text, a '[...]' marker, or ''
|
|
66
|
+
def extract_bytes(filename, bytes)
|
|
67
|
+
ext = File.extname(filename.to_s).downcase.delete('.')
|
|
68
|
+
return '' unless ALLOWED_EXTS.include?(ext)
|
|
69
|
+
|
|
70
|
+
return '' if bytes.nil? || bytes.empty?
|
|
71
|
+
return '[file too large to extract]' if bytes.bytesize > MAX_DECODED_BYTES
|
|
72
|
+
|
|
73
|
+
truncate(Timeout.timeout(EXTRACT_TIMEOUT) { dispatch(ext, bytes) })
|
|
74
|
+
rescue Timeout::Error
|
|
75
|
+
'[extraction timed out]'
|
|
76
|
+
rescue NoMemoryError, SystemStackError => e
|
|
77
|
+
log(e)
|
|
78
|
+
'[file too complex to extract]'
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
log(e)
|
|
81
|
+
''
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def decode(content_base64)
|
|
87
|
+
str = content_base64.to_s.sub(/\Adata:[^;,]*;base64,/, '') # tolerate data-URL prefixes
|
|
88
|
+
return nil if str.strip.empty?
|
|
89
|
+
|
|
90
|
+
Base64.decode64(str)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Dispatch by magic bytes first (robust against spoofed/missing extensions),
|
|
94
|
+
# falling back to the extension.
|
|
95
|
+
def dispatch(ext, bytes)
|
|
96
|
+
head = bytes[0, 8].to_s.b
|
|
97
|
+
if head.start_with?(ZIP_MAGIC) || %w[xlsx xlsm].include?(ext)
|
|
98
|
+
extract_xlsx(bytes)
|
|
99
|
+
elsif head.start_with?(OLE2_MAGIC) || ext == 'xls'
|
|
100
|
+
extract_xls(bytes)
|
|
101
|
+
elsif head.start_with?(PDF_MAGIC) || ext == 'pdf'
|
|
102
|
+
extract_pdf(bytes)
|
|
103
|
+
else
|
|
104
|
+
extract_text(bytes)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def extract_pdf(bytes)
|
|
109
|
+
return '[PDF support unavailable on this server]' unless defined?(PDF::Reader)
|
|
110
|
+
|
|
111
|
+
reader = PDF::Reader.new(StringIO.new(bytes))
|
|
112
|
+
buf = +''
|
|
113
|
+
reader.pages.first(MAX_PDF_PAGES).each do |page|
|
|
114
|
+
buf << page.text.to_s << "\n\n"
|
|
115
|
+
break if buf.length >= MAX_OUT_CHARS
|
|
116
|
+
end
|
|
117
|
+
text = buf.scrub('').strip
|
|
118
|
+
text.empty? ? '[no extractable text - likely a scanned/image PDF]' : text
|
|
119
|
+
rescue StandardError => e
|
|
120
|
+
log(e)
|
|
121
|
+
e.class.name.to_s.include?('Encrypted') ? '[encrypted PDF - no text extracted]' : ''
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def extract_xlsx(bytes)
|
|
125
|
+
return '[Excel support unavailable on this server]' unless defined?(Roo::Excelx)
|
|
126
|
+
return '[Excel file too large to extract]' if xlsx_uncompressed_too_big?(bytes)
|
|
127
|
+
|
|
128
|
+
render_workbook(Roo::Excelx.new(StringIO.new(bytes)))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Legacy OLE2 .xls needs the roo-xls gem (not bundled here) and a real path.
|
|
132
|
+
def extract_xls(bytes)
|
|
133
|
+
return '[legacy .xls not supported on this server]' unless defined?(Roo::Excel)
|
|
134
|
+
|
|
135
|
+
Tempfile.create(['labimotion-upload', '.xls']) do |f|
|
|
136
|
+
f.binmode
|
|
137
|
+
f.write(bytes)
|
|
138
|
+
f.flush
|
|
139
|
+
render_workbook(Roo::Spreadsheet.open(f.path))
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def extract_text(bytes)
|
|
144
|
+
bytes.dup.force_encoding('UTF-8').scrub('')[0, MAX_OUT_CHARS].to_s
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Zip-bomb guard: sum the uncompressed entry sizes before roo inflates the
|
|
148
|
+
# archive. roo bundles rubyzip, so Zip is available when roo loaded.
|
|
149
|
+
def xlsx_uncompressed_too_big?(bytes)
|
|
150
|
+
return false unless defined?(Zip)
|
|
151
|
+
|
|
152
|
+
total = 0
|
|
153
|
+
Zip::File.open_buffer(StringIO.new(bytes)) do |zip|
|
|
154
|
+
zip.each do |entry|
|
|
155
|
+
total += entry.size.to_i
|
|
156
|
+
return true if total > MAX_UNCOMPRESSED
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
false
|
|
160
|
+
rescue StandardError
|
|
161
|
+
false # if we can't pre-inspect, let roo try (still bounded by caps + timeout)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Render up to MAX_SHEETS sheets, each with a "## <name>" header and a
|
|
165
|
+
# capped, pipe-delimited row dump, under a shared output-char budget.
|
|
166
|
+
def render_workbook(book)
|
|
167
|
+
buf = +''
|
|
168
|
+
names = Array(book.sheets)
|
|
169
|
+
names.first(MAX_SHEETS).each do |name|
|
|
170
|
+
sheet = book.sheet(name)
|
|
171
|
+
next unless sheet.last_row # skip empty sheets
|
|
172
|
+
|
|
173
|
+
buf << "## #{name}\n"
|
|
174
|
+
last = [sheet.last_row, sheet.first_row + MAX_ROWS - 1].min
|
|
175
|
+
(sheet.first_row..last).each do |i|
|
|
176
|
+
line = +''
|
|
177
|
+
line << sheet.row(i).first(MAX_COLS).map { |c| fmt_cell(c) }.join(' | ') << "\n"
|
|
178
|
+
break if buf.length + line.length > MAX_OUT_CHARS
|
|
179
|
+
|
|
180
|
+
buf << line
|
|
181
|
+
end
|
|
182
|
+
buf << "[+#{sheet.last_row - last} more rows]\n" if sheet.last_row > last
|
|
183
|
+
break if buf.length >= MAX_OUT_CHARS
|
|
184
|
+
end
|
|
185
|
+
buf << "[+#{names.size - MAX_SHEETS} more sheets omitted]\n" if names.size > MAX_SHEETS
|
|
186
|
+
buf
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# roo returns Date/Time/DateTime objects and nil for empty cells.
|
|
190
|
+
def fmt_cell(cell)
|
|
191
|
+
case cell
|
|
192
|
+
when nil then ''
|
|
193
|
+
when Date, Time, DateTime then cell.iso8601
|
|
194
|
+
else cell.to_s[0, MAX_CELL]
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def truncate(str)
|
|
199
|
+
s = str.to_s
|
|
200
|
+
s.length > MAX_OUT_CHARS ? "#{s[0, MAX_OUT_CHARS]}\n…[truncated]" : s
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def log(err)
|
|
204
|
+
Labimotion.log_exception(err) if Labimotion.respond_to?(:log_exception)
|
|
205
|
+
rescue StandardError
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|