labimotion 2.4.0.rc2 → 2.4.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/lib/labimotion/apis/generic_dataset_api.rb +41 -3
  4. data/lib/labimotion/apis/generic_element_api.rb +60 -8
  5. data/lib/labimotion/apis/generic_klass_api.rb +50 -6
  6. data/lib/labimotion/apis/klass_share_api.rb +646 -0
  7. data/lib/labimotion/apis/labimotion_api.rb +1 -0
  8. data/lib/labimotion/apis/labimotion_doi_api.rb +24 -10
  9. data/lib/labimotion/apis/labimotion_template_browse_api.rb +13 -1
  10. data/lib/labimotion/apis/segment_api.rb +61 -10
  11. data/lib/labimotion/constants.rb +14 -0
  12. data/lib/labimotion/entities/application_entity.rb +13 -1
  13. data/lib/labimotion/entities/generic_klass_entity.rb +125 -0
  14. data/lib/labimotion/entities/klass_share_entity.rb +48 -0
  15. data/lib/labimotion/helpers/dataset_helpers.rb +123 -1
  16. data/lib/labimotion/helpers/element_helpers.rb +206 -4
  17. data/lib/labimotion/helpers/generic_helpers.rb +276 -4
  18. data/lib/labimotion/helpers/param_helpers.rb +68 -0
  19. data/lib/labimotion/helpers/segment_helpers.rb +87 -4
  20. data/lib/labimotion/libs/ai_egress_guard.rb +84 -0
  21. data/lib/labimotion/libs/ai_template.rb +1482 -0
  22. data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +67 -0
  23. data/lib/labimotion/libs/file_extractor.rb +210 -0
  24. data/lib/labimotion/libs/owner_resolver.rb +50 -0
  25. data/lib/labimotion/libs/ownership_audit.rb +73 -0
  26. data/lib/labimotion/libs/share_notifier.rb +114 -0
  27. data/lib/labimotion/libs/share_resolver.rb +369 -0
  28. data/lib/labimotion/models/concerns/template_doi.rb +133 -0
  29. data/lib/labimotion/models/dataset_klass.rb +1 -1
  30. data/lib/labimotion/models/element_klass.rb +1 -1
  31. data/lib/labimotion/models/klass_share.rb +126 -0
  32. data/lib/labimotion/models/segment_klass.rb +1 -1
  33. data/lib/labimotion/usecases/build_template_doi_xml.rb +69 -23
  34. data/lib/labimotion/usecases/release_template_doi.rb +42 -17
  35. data/lib/labimotion/usecases/template_doi_helpers.rb +28 -6
  36. data/lib/labimotion/usecases/update_template_publication_metadata.rb +71 -2
  37. data/lib/labimotion/utils/prop.rb +1 -0
  38. data/lib/labimotion/version.rb +1 -1
  39. data/lib/labimotion.rb +11 -0
  40. metadata +28 -2
@@ -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>
@@ -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
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ # `index_by` below is an ActiveSupport core extension, not plain Ruby. A booted host always
4
+ # has it, so the omission was invisible in production, but relying on the host to have loaded
5
+ # it is luck rather than contract -- and in the gem's own spec process it made this file
6
+ # depend on whether some other spec had required grape first.
7
+ require 'active_support/core_ext/enumerable'
8
+
9
+ module Labimotion
10
+ ## OwnerResolver
11
+ # Builds the `owners:` option the klass entities read, so rendering a grid of N templates
12
+ # costs one user query instead of N. Deleted users are included on purpose: a template whose
13
+ # author has left still has to name somebody, otherwise the grid can only say "unknown" and
14
+ # an administrator has nothing to act on.
15
+ class OwnerResolver
16
+ def self.map_for(klasses)
17
+ map_for_ids(Array(klasses).filter_map { |klass| klass.try(:created_by) })
18
+ end
19
+
20
+ # Same map, from bare user ids — what ShareResolver feeds it, where the ids come from
21
+ # owner share rows rather than a created_by column.
22
+ def self.map_for_ids(ids)
23
+ ids = Array(ids).compact.uniq
24
+ return {} if ids.empty?
25
+
26
+ user_scope.where(id: ids).index_by(&:id)
27
+ rescue StandardError => e
28
+ # Ownership is decoration on a list that must still render.
29
+ Labimotion.log_exception(e)
30
+ {}
31
+ end
32
+
33
+ # Single-user fallback for the call sites that ask for ownership without preloading a map.
34
+ # Same soft-delete scope and same "never break the caller" contract as `map_for`.
35
+ def self.find(user_id)
36
+ return nil if user_id.blank?
37
+
38
+ user_scope.find_by(id: user_id)
39
+ rescue StandardError => e
40
+ Labimotion.log_exception(e)
41
+ nil
42
+ end
43
+
44
+ # `with_deleted` comes from acts_as_paranoid, which the host's User model may not use.
45
+ def self.user_scope
46
+ ::User.respond_to?(:with_deleted) ? ::User.with_deleted : ::User
47
+ end
48
+ private_class_method :user_scope
49
+ end
50
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ # `constantize` and `underscore` below are ActiveSupport core extensions, not plain Ruby.
4
+ # A booted host always has them, so the omission is invisible in production, but the gem's
5
+ # own spec process does not load them on its own.
6
+ require 'active_support/core_ext/string/inflections'
7
+ require 'labimotion/constants'
8
+
9
+ module Labimotion
10
+ ## OwnershipAudit
11
+ # Reports templates whose `created_by` may no longer name the person who wrote them.
12
+ #
13
+ # Why this exists: until the fix in this same change, `upload_klass` merged
14
+ # `created_by: current_user.id` into the attributes it handed to `validate_klass`, whose
15
+ # *update* branch then wrote them wholesale — so every re-import of an existing template
16
+ # reassigned its authorship to whoever re-imported it. The fix stops new damage; it cannot
17
+ # undo what already happened, and `created_by` is both what the Owner column displays and
18
+ # what per-template ownership will be seeded from.
19
+ #
20
+ # What it can and cannot tell you: a klass revision records `created_by` as the user who
21
+ # *released* that revision, not the one who created the klass. So a mismatch between a
22
+ # klass's `created_by` and its earliest revision's is a **signal** that authorship moved,
23
+ # not proof — a template legitimately first released by a colleague looks the same. This is
24
+ # a report for a human to read. It gates nothing, changes nothing, and writes nothing.
25
+ #
26
+ # Run it from the host:
27
+ # bundle exec rails runner 'Labimotion::OwnershipAudit.report'
28
+ class OwnershipAudit
29
+ def self.report(io = $stdout)
30
+ findings = Labimotion::Constants::Klass::ALL.flat_map { |klass_name| findings_for(klass_name) }
31
+ io.puts(format_report(findings))
32
+ findings
33
+ end
34
+
35
+ def self.findings_for(klass_name)
36
+ model = "Labimotion::#{klass_name}".constantize
37
+ revision_model = "Labimotion::#{klass_name}esRevision".constantize
38
+ foreign_key = "#{klass_name.underscore}_id"
39
+
40
+ model.all.filter_map do |klass|
41
+ first_revision = revision_model.where(foreign_key => klass.id).order(:created_at).first
42
+ next if first_revision.nil?
43
+ next if first_revision.created_by.blank? || klass.created_by == first_revision.created_by
44
+
45
+ {
46
+ klass: klass_name,
47
+ id: klass.id,
48
+ label: klass.label,
49
+ created_by: klass.created_by,
50
+ first_release_by: first_revision.created_by,
51
+ first_released_at: first_revision.created_at
52
+ }
53
+ end
54
+ rescue StandardError => e
55
+ Labimotion.log_exception(e)
56
+ []
57
+ end
58
+
59
+ def self.format_report(findings)
60
+ lines = ["Labimotion ownership audit — #{findings.size} template(s) to look at"]
61
+ lines << 'created_by differs from the user who released the earliest revision. ' \
62
+ 'Expected for templates a colleague first released; suspicious for the rest.'
63
+ lines << ''
64
+ findings.each do |f|
65
+ lines << "#{f[:klass].ljust(13)} ##{f[:id].to_s.ljust(5)} " \
66
+ "#{f[:label].to_s[0, 40].ljust(40)} created_by=#{f[:created_by]} " \
67
+ "first_release_by=#{f[:first_release_by]} (#{f[:first_released_at]})"
68
+ end
69
+ lines.join("\n")
70
+ end
71
+ private_class_method :format_report
72
+ end
73
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labimotion
4
+ # Courtesy notifications for share events (template-sharing.md §7), moved in
5
+ # from the host's Chemotion::TemplateShareAPI (chem-generic-ui#1040): the gem
6
+ # writes the share rows, so the gem announces them, and the two-call dance the
7
+ # Designer used to do (write to the gem, then call the host back to notify)
8
+ # collapses into the write.
9
+ #
10
+ # These messages buy awareness *outside* the Designer only — the toast in the
11
+ # main ELN. klass_shares is the record and the Designer reads the rows; it
12
+ # never reads or acks a Message. Nothing here may therefore fail a share:
13
+ # every public method rescues to nil, and an undeliverable notification is a
14
+ # notification not sent, never an error. Callers fire after the successful
15
+ # write and ignore the return value.
16
+ #
17
+ # The host models are reached by their bare names and only if they exist:
18
+ # a host without Message/Channel machinery (or one that has not run the
19
+ # channel migration) simply sends nothing. The channel is looked up by its
20
+ # literal subject — referencing Channel::TEMPLATE_SHARE_NOTIFICATION would
21
+ # raise on hosts whose Channel model predates the constant.
22
+ module ShareNotifier
23
+ # Must match the subject the host ELN's channel migration seeds
24
+ # (add_template_share_notification_channel; channel_type 8, the targeted
25
+ # per-user delivery branch of generate_notifications).
26
+ CHANNEL_SUBJECT = 'Template Share Notification'
27
+
28
+ # Fired for level changes too — the grant endpoint is an upsert and cannot
29
+ # tell a fresh share from a level change, so "granted you X access" stays
30
+ # honest for both.
31
+ def self.grant(klass:, actor:, user_ids:, level:)
32
+ label = template_label(klass)
33
+ ping(
34
+ actor: actor, user_ids: user_ids,
35
+ text: "#{actor.name} granted you #{level} access to the template '#{label}'."
36
+ )
37
+ rescue StandardError => e
38
+ Labimotion.log_exception(e, actor)
39
+ nil
40
+ end
41
+
42
+ def self.transfer(klass:, actor:, user_id:)
43
+ label = template_label(klass)
44
+ ping(
45
+ actor: actor, user_ids: [user_id],
46
+ text: "#{actor.name} transferred ownership of the template '#{label}' to you."
47
+ )
48
+ rescue StandardError => e
49
+ Labimotion.log_exception(e, actor)
50
+ nil
51
+ end
52
+
53
+ # The owner to ask: the klass_shares owner row, falling back to the
54
+ # immutable created_by for an owner-less template. A blank owner matches no
55
+ # user and channel_type-8 fan-out reaches Persons only, so an Admin owner
56
+ # gets no toast — they learn about the request when they next open the
57
+ # Designer, where the rows are the truth. The caller gates on `created`:
58
+ # the unique index dedupes rows, not messages, so only the ask that wrote
59
+ # the row earns a ping.
60
+ def self.request(klass:, actor:)
61
+ owner_id = Labimotion::KlassShare.owner_row_for(klass)&.shared_with_id ||
62
+ klass.created_by
63
+ return if owner_id.nil?
64
+
65
+ label = template_label(klass)
66
+ ping(
67
+ actor: actor, user_ids: [owner_id],
68
+ text: "#{actor.name} asks for access to your template '#{label}'. " \
69
+ 'You can grant it from the Share dialog in the Designer.'
70
+ )
71
+ rescue StandardError => e
72
+ Labimotion.log_exception(e, actor)
73
+ nil
74
+ end
75
+
76
+ # Plain Ruby, no ActiveSupport (`presence`/`try`): the host models are
77
+ # optional here, so the module must not assume the host's core extensions
78
+ # either — with them absent, a notification would silently never send.
79
+ def self.template_label(klass)
80
+ read_name(klass, :label) || read_name(klass, :name) || "##{klass.id}"
81
+ end
82
+
83
+ def self.read_name(klass, attribute)
84
+ return nil unless klass.respond_to?(attribute)
85
+
86
+ value = klass.public_send(attribute).to_s
87
+ value.strip.empty? ? nil : value
88
+ end
89
+
90
+ def self.ping(actor:, user_ids:, text:)
91
+ return unless defined?(::Message) && defined?(::Channel)
92
+
93
+ # Persons only (the fan-out inserts notifications for Persons alone), and
94
+ # never the actor: granting yourself a toast about your own click is
95
+ # noise, and a self-transfer is a no-op to announce.
96
+ recipients = ::User.where(type: 'Person', id: user_ids).ids - [actor.id]
97
+ return if recipients.empty?
98
+
99
+ channel = ::Channel.find_by(subject: CHANNEL_SUBJECT)
100
+ return if channel.nil?
101
+
102
+ ::Message.create_msg_notification(
103
+ channel_id: channel.id,
104
+ # The level rides inside message_content: create_msg_notification uses
105
+ # the content verbatim on this path, so a bare level: kwarg would be
106
+ # dropped and the toast would fall back to warning styling.
107
+ message_content: { data: text, level: 'info' },
108
+ message_from: actor.id,
109
+ message_to: recipients
110
+ )
111
+ end
112
+ private_class_method :ping, :template_label, :read_name
113
+ end
114
+ end