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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -1
  3. data/lib/labimotion/apis/generic_dataset_api.rb +93 -3
  4. data/lib/labimotion/apis/generic_element_api.rb +198 -8
  5. data/lib/labimotion/apis/generic_klass_api.rb +74 -8
  6. data/lib/labimotion/apis/klass_share_api.rb +648 -0
  7. data/lib/labimotion/apis/labimotion_ai_api.rb +252 -0
  8. data/lib/labimotion/apis/labimotion_api.rb +4 -0
  9. data/lib/labimotion/apis/labimotion_doi_api.rb +24 -10
  10. data/lib/labimotion/apis/labimotion_template_browse_api.rb +13 -1
  11. data/lib/labimotion/apis/ontology_root_api.rb +74 -0
  12. data/lib/labimotion/apis/segment_api.rb +77 -10
  13. data/lib/labimotion/apis/user_klass_settings_api.rb +93 -0
  14. data/lib/labimotion/conf.rb +5 -0
  15. data/lib/labimotion/constants.rb +64 -0
  16. data/lib/labimotion/entities/application_entity.rb +8 -0
  17. data/lib/labimotion/entities/eln_element_entity.rb +6 -0
  18. data/lib/labimotion/entities/generic_klass_entity.rb +126 -0
  19. data/lib/labimotion/entities/klass_share_entity.rb +48 -0
  20. data/lib/labimotion/entities/properties_entity.rb +78 -2
  21. data/lib/labimotion/entities/segment_entity.rb +8 -0
  22. data/lib/labimotion/entities/user_klass_setting_entity.rb +10 -0
  23. data/lib/labimotion/helpers/cover_image_helpers.rb +181 -0
  24. data/lib/labimotion/helpers/dataset_helpers.rb +187 -1
  25. data/lib/labimotion/helpers/element_helpers.rb +282 -6
  26. data/lib/labimotion/helpers/exporter_helpers.rb +17 -2
  27. data/lib/labimotion/helpers/generic_helpers.rb +293 -4
  28. data/lib/labimotion/helpers/param_helpers.rb +105 -0
  29. data/lib/labimotion/helpers/sample_association_helpers.rb +7 -0
  30. data/lib/labimotion/helpers/segment_helpers.rb +102 -4
  31. data/lib/labimotion/libs/ai_egress_guard.rb +99 -0
  32. data/lib/labimotion/libs/ai_klass_queue.rb +88 -0
  33. data/lib/labimotion/libs/ai_klass_validator.rb +74 -0
  34. data/lib/labimotion/libs/ai_models.rb +201 -0
  35. data/lib/labimotion/libs/ai_template.rb +2285 -0
  36. data/lib/labimotion/libs/converter.rb +5 -43
  37. data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +67 -0
  38. data/lib/labimotion/libs/export_element.rb +128 -13
  39. data/lib/labimotion/libs/file_extractor.rb +210 -0
  40. data/lib/labimotion/libs/linked_element.rb +313 -0
  41. data/lib/labimotion/libs/ontology_store.rb +226 -0
  42. data/lib/labimotion/libs/ontology_terms.rb +227 -0
  43. data/lib/labimotion/libs/owner_resolver.rb +50 -0
  44. data/lib/labimotion/libs/ownership_audit.rb +73 -0
  45. data/lib/labimotion/libs/sample_association.rb +52 -1
  46. data/lib/labimotion/libs/share_notifier.rb +114 -0
  47. data/lib/labimotion/libs/share_resolver.rb +373 -0
  48. data/lib/labimotion/libs/user_ai_settings.rb +129 -0
  49. data/lib/labimotion/models/cellline.rb +47 -0
  50. data/lib/labimotion/models/concerns/datasetable.rb +3 -0
  51. data/lib/labimotion/models/concerns/matrice_labimotion.rb +124 -0
  52. data/lib/labimotion/models/concerns/segmentable.rb +2 -0
  53. data/lib/labimotion/models/concerns/template_doi.rb +133 -0
  54. data/lib/labimotion/models/dataset_klass.rb +1 -1
  55. data/lib/labimotion/models/element_klass.rb +1 -1
  56. data/lib/labimotion/models/klass_share.rb +129 -0
  57. data/lib/labimotion/models/segment_klass.rb +1 -1
  58. data/lib/labimotion/models/user_klass_setting.rb +58 -0
  59. data/lib/labimotion/models/user_setting.rb +60 -0
  60. data/lib/labimotion/usecases/build_template_doi_xml.rb +69 -23
  61. data/lib/labimotion/usecases/release_template_doi.rb +42 -17
  62. data/lib/labimotion/usecases/template_doi_helpers.rb +28 -6
  63. data/lib/labimotion/usecases/update_template_publication_metadata.rb +71 -2
  64. data/lib/labimotion/utils/export_utils.rb +1 -0
  65. data/lib/labimotion/utils/import_utils.rb +20 -3
  66. data/lib/labimotion/utils/serializer.rb +27 -0
  67. data/lib/labimotion/utils/units.rb +32 -67
  68. data/lib/labimotion/version.rb +1 -1
  69. data/lib/labimotion.rb +27 -0
  70. 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
- field_name = ds[:field].delete_prefix(Prop::CONVERTER_FIELD_UINT_PREFIX)
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
- if is_unit
260
- fi['value_system'] = ds[:value]
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
- layers = build_layers
460
- context = {
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: layers
469
- }
470
- tempfile = Tempfile.new('labimotion.docx')
471
- template.render_to_file File.expand_path(tempfile), context
472
- content = File.read(tempfile)
473
- content
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
- ensure
477
- # Close and delete the temporary file
478
- tempfile&.close
479
- tempfile&.unlink
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