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
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'erb'
5
+
6
+ module Labimotion
7
+ # Resolve the selectable vocabulary behind an "ontology-select" field.
8
+ #
9
+ # A template field pins ONE ontology root (its `ontology_term`); the picker in
10
+ # chem-generic-ui then offers that root and everything below it. The AI data-fill
11
+ # needs the same list — otherwise the model invents a plausible-sounding term that
12
+ # is not in the ontology and the field renders a value the user could never have
13
+ # chosen themselves.
14
+ #
15
+ # The whole subtree arrives in ONE request via `?hierarchicalAncestor=`, rather
16
+ # than the dozens of round trips a /children walk costs; the result set is the
17
+ # same terms, de-duplicated (a term with two parents appears twice in a walk).
18
+ #
19
+ # What comes back is handed to Labimotion::OntologyStore, which is what actually
20
+ # keeps it — so the picker and the auto-fill read one list instead of resolving
21
+ # the same subtree separately.
22
+ #
23
+ # Every failure path returns [] — no vocabulary simply means the field falls back
24
+ # to the free-text behaviour it had before, never a failed fill.
25
+ class OntologyTerms
26
+ DEFAULT_BASE_URL = 'https://api.terminology.tib.eu/api/v2'
27
+ # One page, sized past the largest root anyone points a field at (the biggest
28
+ # CHMO branch is ~650 terms).
29
+ PAGE_SIZE = 1000
30
+ REQUEST_TIMEOUT = 20
31
+ # Per-field and per-request budgets. A field aimed at a very general root would
32
+ # otherwise push tens of thousands of labels into the prompt and crowd out the
33
+ # document the model is supposed to be reading. Both limits SKIP a field rather
34
+ # than truncate its list: a partial vocabulary is the worst of both worlds — the
35
+ # missing terms become unproposable AND a correct answer naming one gets thrown
36
+ # away by validation. Sized well past any real root (the largest CHMO branch is
37
+ # ~650 terms).
38
+ MAX_TERMS_PER_FIELD = 1500
39
+ MAX_TERMS_TOTAL = 3000
40
+
41
+ class << self
42
+ # Selectable labels for one field's `ontology_term`, root included, sorted and
43
+ # de-duplicated. Accepts the shapes the designer writes: a { 'label', 'value' }
44
+ # hash, a bare string, or an ARRAY of either (a field may pin several roots).
45
+ #
46
+ # @return [Array<String>] [] when nothing is configured, the subtree is beyond
47
+ # MAX_TERMS_PER_FIELD, or the service fails
48
+ def labels_for(ontology_term)
49
+ roots = parse(ontology_term)
50
+ return [] if roots.empty?
51
+
52
+ labels = roots.flat_map { |root| Labimotion::OntologyStore.labels(root) }
53
+ .uniq(&:downcase).sort_by(&:downcase)
54
+ return labels if labels.size <= MAX_TERMS_PER_FIELD
55
+
56
+ log("subtree of #{roots.map { |r| r[:iri] }.join(', ')} has #{labels.size} terms " \
57
+ "(over #{MAX_TERMS_PER_FIELD}); leaving the field unconstrained")
58
+ []
59
+ end
60
+
61
+ # Every node of one root's subtree, the root included, as the store keeps
62
+ # them: { 'iri', 'label', 'description', 'parent' }. The root is selectable
63
+ # in the picker, so it has to be in the list the model is offered too.
64
+ #
65
+ # Two requests: the root is not in its own descendant query.
66
+ def subtree(root)
67
+ node = root_node(root)
68
+ return [] if node.nil?
69
+
70
+ nodes = ([node] + descendant_nodes(root)).uniq { |n| n['iri'] }
71
+ resolve_parents(nodes, node['iri'])
72
+ end
73
+
74
+ # Split a configured ontology_term into [{ ontology:, iri: }, ...]. Mirrors
75
+ # parseOntologyTerm in chem-generic-ui/src/utils/ontologyApi.js, which is what
76
+ # actually wrote these strings.
77
+ def parse(ontology_term)
78
+ wrap_terms(ontology_term).filter_map { |term| parse_one(term) }
79
+ end
80
+
81
+ # Public because the store records which service a file came from, so a
82
+ # subtree stored against one host can be told apart from another's.
83
+ def base_url
84
+ cfg = (Rails.configuration.labimotion_ai if defined?(Rails) && Rails.respond_to?(:configuration))
85
+ (cfg.is_a?(Hash) && cfg[:ontology_base_url].presence) ||
86
+ ENV['LABIMOTION_ONTOLOGY_BASE_URL'].presence ||
87
+ DEFAULT_BASE_URL
88
+ rescue StandardError
89
+ DEFAULT_BASE_URL
90
+ end
91
+
92
+ private
93
+
94
+ # A field may pin one term or a list of them. `Array()` would tear the
95
+ # { 'label', 'value' } hash apart into pairs, so branch explicitly.
96
+ def wrap_terms(value)
97
+ return [] if value.nil?
98
+ return value if value.is_a?(Array)
99
+
100
+ [value]
101
+ end
102
+
103
+ def parse_one(term)
104
+ str = term_to_s(term)
105
+ return nil if str.empty?
106
+
107
+ ontology, iri = split_term(str)
108
+ return nil if ontology.to_s.empty? || iri.to_s.empty?
109
+
110
+ { ontology: ontology.downcase, iri: iri }
111
+ end
112
+
113
+ # A term is stored either as the { 'label' => .., 'value' => .. } hash the
114
+ # designer's picker writes, or as a bare string.
115
+ def term_to_s(term)
116
+ raw = term.is_a?(Hash) ? (term['value'] || term[:value]) : term
117
+ raw.to_s.strip
118
+ end
119
+
120
+ # "bao:class:<iri>" (what the designer writes) | "ont|<iri>" | a bare OBO IRI.
121
+ def split_term(str)
122
+ if (m = str.match(/\A([^:]+):class:(.+)\z/))
123
+ [m[1], m[2]]
124
+ elsif (parts = str.split('|')).size == 2
125
+ [parts[0].strip, parts[1].strip]
126
+ elsif (m = str.match(%r{obo/([A-Za-z]+)_}))
127
+ [m[1], str]
128
+ end
129
+ end
130
+
131
+ def root_node(root)
132
+ body = get("#{base_url}/ontologies/#{root[:ontology]}/classes/#{double_encode(root[:iri])}")
133
+ return nil unless body.is_a?(Hash)
134
+
135
+ node_from(body, root[:iri])
136
+ end
137
+
138
+ def descendant_nodes(root)
139
+ url = "#{base_url}/ontologies/#{root[:ontology]}/classes" \
140
+ "?hierarchicalAncestor=#{encode(root[:iri])}&size=#{PAGE_SIZE}"
141
+ body = get(url)
142
+ elements = body.is_a?(Hash) ? body['elements'] : nil
143
+ return [] unless elements.is_a?(Array)
144
+
145
+ elements.filter_map do |entry|
146
+ next unless entry.is_a?(Hash)
147
+ # An obsolete term is still served but must not be proposed: the picker
148
+ # does not offer it either.
149
+ next if entry['isObsolete']
150
+
151
+ node_from(entry)
152
+ end
153
+ end
154
+
155
+ # `parents` is carried only as far as resolve_parents, which turns the
156
+ # graph edges into the single one the picker can draw.
157
+ def node_from(entry, fallback_iri = nil)
158
+ label = normalize_label(entry['label'])
159
+ iri = entry['iri'].presence || fallback_iri
160
+ return nil if label.empty? || iri.blank?
161
+
162
+ {
163
+ 'iri' => iri.to_s,
164
+ 'label' => label,
165
+ 'description' => normalize_label(entry['definition']),
166
+ 'parents' => Array(entry['hierarchicalParent']).map(&:to_s)
167
+ }
168
+ end
169
+
170
+ # An ontology is a graph, not a tree: a term can have several parents, and
171
+ # only some of them are inside the subtree being stored. Keep the first one
172
+ # that IS inside so the picker has something to draw, and hang anything
173
+ # else off the root rather than dropping it — a term reachable from the
174
+ # root is selectable, whichever way it is reached.
175
+ def resolve_parents(nodes, root_iri)
176
+ inside = nodes.to_h { |n| [n['iri'], true] }
177
+ nodes.map do |node|
178
+ parents = node.delete('parents')
179
+ parent =
180
+ if node['iri'] == root_iri
181
+ nil
182
+ else
183
+ Array(parents).find { |p| p != node['iri'] && inside[p] } || root_iri
184
+ end
185
+ node.merge('parent' => parent)
186
+ end
187
+ end
188
+
189
+ # v2 returns a label as a string, a one-element array, or a language-tagged
190
+ # object — the same three shapes normalizeLabel handles on the client.
191
+ def normalize_label(raw)
192
+ first = raw.is_a?(Array) ? raw.first : raw
193
+ return '' if first.nil?
194
+ return first.to_s if first.is_a?(String) || first.is_a?(Numeric)
195
+
196
+ first.is_a?(Hash) ? (first['value'] || first[:value]).to_s : ''
197
+ end
198
+
199
+ # Best-effort note in log/labimotion.log; never raises, never blocks a fill.
200
+ def log(message)
201
+ Labimotion.logger.warn("OntologyTerms #{message}")
202
+ rescue StandardError
203
+ nil
204
+ end
205
+
206
+ def get(url)
207
+ response = HTTParty.get(url, timeout: REQUEST_TIMEOUT, follow_redirects: false)
208
+ return nil unless response.code == 200
209
+
210
+ JSON.parse(response.body)
211
+ rescue StandardError => e
212
+ Labimotion.log_exception(e)
213
+ nil
214
+ end
215
+
216
+ # A path segment carries the IRI double-encoded (TIB decodes once itself); a
217
+ # query parameter is encoded exactly once.
218
+ def double_encode(iri)
219
+ encode(encode(iri))
220
+ end
221
+
222
+ def encode(str)
223
+ ERB::Util.url_encode(str.to_s)
224
+ end
225
+ end
226
+ end
227
+ 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
@@ -70,7 +70,50 @@ module Labimotion
70
70
  []
71
71
  end
72
72
 
73
+ ## Link the generic elements referenced by drag_element table columns to the
74
+ ## owning element, mirroring what layer-level drag_element fields do below.
75
+ def self.build_table_element(field_tables, current_user, element = nil)
76
+ els = []
77
+ return els if element.nil?
78
+
79
+ field_tables.each do |field|
80
+ next unless field['sub_values'].present? && field[Labimotion::Prop::SUBFIELDS].present?
81
+
82
+ field_table_elements = field[Labimotion::Prop::SUBFIELDS].select { |ss| ss['type'] == Labimotion::FieldType::DRAG_ELEMENT }
83
+ next unless field_table_elements.present?
84
+
85
+ col_ids = field_table_elements.map { |x| x.values[0] }
86
+ col_ids.each do |col_id|
87
+ field['sub_values'].each do |sub_value|
88
+ next unless sub_value[col_id].present? && sub_value[col_id]['value'].present? && sub_value[col_id]['value']['el_id'].present?
89
+
90
+ svalue = sub_value[col_id]['value']
91
+ sid = svalue['el_id']
92
+ next if sid == element.id
93
+
94
+ # drag_element may link a standard element (reaction, cell_line, ...);
95
+ # el_id is then NOT a Labimotion::Element id, so an ElementsElement row
96
+ # would associate an unrelated generic element that shares the id.
97
+ next if Labimotion::LinkedElement::MODELS.key?(svalue['el_klass'].to_s)
98
+
99
+ el = Labimotion::Element.find_by(id: sid)
100
+ next if el.nil?
101
+
102
+ Labimotion::ElementsElement.find_or_create_by(parent_id: element.id, element_id: el.id)
103
+ els << el.id
104
+ end
105
+ end
106
+ end
107
+ els
108
+ rescue StandardError => e
109
+ Labimotion.log_exception(e, current_user)
110
+ []
111
+ end
112
+
73
113
  def self.update_sample_association(properties, current_user, element = nil)
114
+ # Every element and segment properties save funnels through here, which
115
+ # makes it the one place guaranteed to see the payload before it is stored.
116
+ Labimotion::LinkedElement.strip_defaults(properties)
74
117
  sds = []
75
118
  els = []
76
119
  properties[Labimotion::Prop::LAYERS].keys.each do |key|
@@ -99,7 +142,10 @@ module Labimotion
99
142
  Labimotion::ElementsSample.find_or_create_by(element_id: element.id, sample_id: subsample.id) if element.present?
100
143
  end
101
144
  field_tables = properties[Labimotion::Prop::LAYERS][key][Labimotion::Prop::FIELDS].select { |ss| ss['type'] == Labimotion::FieldType::TABLE }
102
- sds << Labimotion::SampleAssociation.build_table_sample(field_tables, current_user, element) if element.present?
145
+ if element.present?
146
+ sds << Labimotion::SampleAssociation.build_table_sample(field_tables, current_user, element)
147
+ els << Labimotion::SampleAssociation.build_table_element(field_tables, current_user, element)
148
+ end
103
149
  field_elements = layer[Labimotion::Prop::FIELDS].select { |ss| ss['type'] == Labimotion::FieldType::DRAG_ELEMENT }
104
150
  field_elements.each do |field|
105
151
  idx = properties[Labimotion::Prop::LAYERS][key][Labimotion::Prop::FIELDS].index(field)
@@ -109,6 +155,11 @@ module Labimotion
109
155
  sid = field.dig('value', 'el_id')
110
156
  next if element.nil? || sid.blank? || sid == element.id
111
157
 
158
+ # drag_element may link a standard element (reaction, cell_line, ...);
159
+ # el_id is then NOT a Labimotion::Element id, so an ElementsElement row
160
+ # would associate an unrelated generic element that shares the id.
161
+ next if Labimotion::LinkedElement::MODELS.key?(field.dig('value', 'el_klass').to_s)
162
+
112
163
  el = Labimotion::Element.find_by(id: sid)
113
164
  next if el.nil?
114
165
 
@@ -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