labimotion 2.4.0.rc4 → 2.4.0.rc6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1babeffe4e43eb088d5ab781101aa48574f67aab9abf0d32df4d883742e63788
4
- data.tar.gz: 8c063d368a6af316e5397804951da314d8fc65035a852f56b066097091b0b2b3
3
+ metadata.gz: fa32d7e24804c4f270efdbce75bccf847ae3d30ae1f6b265c17b9c196e9c12a6
4
+ data.tar.gz: b6901471f70ac6874bf68a517ea9d994a3e4054d089a2bc66e042de38e97f4fd
5
5
  SHA512:
6
- metadata.gz: 5d92879758e133fbf40fd433882967c8731ebf18e1e2586d8026a65b2d085359a530333cd740d00b7b0359981f155c034eee5687ffe326bb842c2dbc1ee52ad8
7
- data.tar.gz: 3db447e733afbe176b1d7adb80078ef85899b473336e882d41bb9f7da4b66b51ca91d1f4084d30a0f644401e95c4312669bd1f9a6d7bc861af42dc8ad89d1abd
6
+ metadata.gz: b6612883678081acfa046ea9956424f06b512f5ea941cac2dd219e60809f0ed308026d9e0e8c9a112e067b8718f4a2b55939211b0843100d6329a9502341ddee
7
+ data.tar.gz: c52a79f87e8dffb30d340be92e107e6518c96e3c28d46a6857e6355ff3e30ec399a989cce94553188b4baff83ac548d34d80083b185a523be8c31823bdc7e97c
data/CHANGELOG.md CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  ## [Unreleased]
4
4
  * Features and enhancements:
5
+ * `Link Element` is now available as a column type inside `Table` fields of generic elements and segments ([chem-generic-ui#1055](https://github.com/LabIMotion/chem-generic-ui/issues/1055), labimotion#261). A `drag_element` cell stores the same payload the layer-level field stores (`el_id`, `el_klass`, `el_label`, `el_name`, `el_tip`, `icon_name`) and deliberately carries no `is_new`/`cr_opt`: a drop links a record that already exists and must never create one on save, unlike the sample column it sits next to. The stored label, name, tooltip and icon are refreshed on every read by the new `Labimotion::Serializer.set_table_element`, called from `PropertiesEntity` and `ElnElementEntity` as well, so all three property loaders agree rather than drifting into three copies. `SampleAssociation.build_table_element` writes the `Labimotion::ElementsElement` join rows and feeds the same `els` list that the stale-link cleanup compares against — feeding it is load-bearing, since the cleanup would otherwise `destroy_all` the rows it had just created. Both paths skip a cell whose `el_klass` is a `Labimotion::LinkedElement::MODELS` key: such a column accepts reactions, wellplates, screens and the rest as readily as generic elements, and for those `el_id` is not a `Labimotion::Element` id, so resolving it would relabel or link a wholly unrelated element — the same guard the layer-level field already applies. Carried through every export path: XLSX (`exporter_helpers`, expandable like the sample and molecule columns, short label as the cell text and `Name` as an optional sub-column), DOCX (`export_element`), and the ZIP round-trip (`export_utils` remaps element ids to uuids, `import_utils` re-links them through the new `proc_table_element`). A linked element has no structure image, so the cell shows the klass icon instead of an SVG.
6
+ * Element cover images, write path ([chem-generic-ui#1050](https://github.com/LabIMotion/chem-generic-ui/issues/1050), labimotion#259): the element create and update saves now validate the `cover_images` key of the optional `metadata` param — an array of at most 3 `{ source: attachment|analysis, id: <integer> }` references, mirroring the chem-generic-ui picker's cap and vocabulary, normalized to exactly those two entry keys — and refuse a malformed selection with a plain 422 before any container/sample-association write runs. All cover-image helpers live in the dedicated `Labimotion::CoverImageHelpers` module, with the contract cap and source vocabulary as `Constants::CoverImage::MAX`/`SOURCES`. The rest of the metadata document keeps its existing contract: a present param replaces the stored hash wholesale, because its other writers (layer groups/restrictions from the details page, host regComps through the registry seam) remove a key by omitting it, while an absent param leaves stored metadata untouched — an old client that does not send `metadata` can never wipe a saved cover selection. References only, no FK: a deleted attachment/analysis leaves a dangling reference the UI skips at render time. No migration (`elements.metadata` and `elements_revisions.metadata` already exist), no entity change (`metadata` was already exposed), no klass change (the per-klass `cover_image` toggle rides the existing `update_settings` shallow merge); revision snapshots already carry `metadata` alongside `properties`, and restoring a revision round-trips the cover selection through this validated save.
7
+ * Element cover images, original serving ([chem-generic-ui#1050](https://github.com/LabIMotion/chem-generic-ui/issues/1050), labimotion#259): new `GET /generic_elements/cover_image?element_id=&source=&id=` answers the original file behind a cover-image reference, so the element page's display area can show originals while thumbnails stay in the picker. Gated by the `genericElement` matrix check plus `ElementPolicy` `read?` **and** `read_dataset?` (analysis previews are dataset attachments, and the ELN's own attachment reads demand both); the reference resolves through the element's own associations only, so a foreign attachment or container id answers 404, never a file. An `analysis` reference resolves the container preview server-side for the first time, mirroring the client's `getContainerImageData`: the container's `preferred_thumbnail` while it still exists, else the newest "combined" image, else the newest thumbnail-bearing dataset attachment. The body is the original bytes under the file's own content type — or the PNG thumbnail when a browser could not render the original (TIFF, raw-data files behind an analysis preview). An optional `variant=thumbnail` always answers the PNG thumbnail through the same gate and resolution, which is what the picker's rows show — for an svg the stored thumbnail (black: ImageMagick reads `transparent` as black) is replaced by a fresh white-background rasterization of the original, through the same shared converter as the docx export — with it, the whole cover-image feature runs on gem-owned API surface and the host wires no fetcher at all. The picker's Save persists directly through the new `POST /generic_elements/cover_images` (`element_id`, `cover_images`; gated by `ElementPolicy` `update?` + the matrix check): the same validation as the element-save path, then a **targeted merge** of exactly the `cover_images` key into the stored metadata — deliberately narrower than the element save's wholesale-replace contract, so this writer can neither wipe nor resurrect `groups`/host keys. Live like klass settings: the write is immediate and unversioned until the next element save snapshots metadata into the revisions. The docx export (`ExportElement`) now renders the cover selection too: the stored references resolve through the same helpers as the `cover_image` endpoint, each image is embedded above the layers (originals for the rasters Word renders reliably — png/jpeg/gif/bmp; other renderable originals such as svg are rasterized onto white via MiniMagick, with the CSS keyword `transparent` normalized to `none` first because ImageMagick’s own SVG renderer reads it as black — the PNG thumbnail only as last resort), captioned "ref: …" and scaled to the page via MiniMagick-read dimensions (best effort — without them the template placeholder geometry is kept), Requires the matching `Labimotion_lines.docx` template change in the host ELN (a `has_cover`-guarded `cover_images:each` block before the layers loop); an old template simply ignores the new context keys.
5
8
  * Share notifications now fire from the gem itself ([chem-generic-ui#1040](https://github.com/LabIMotion/chem-generic-ui/issues/1040), labimotion#255): a successful grant or level change (`POST /generic_klass/shares`), an ownership transfer (`POST /generic_klass/transfer_ownership`) and the ask that creates an access-request row (`POST /generic_klass/request_access`, `created: true` only — repeat asks are not repeat pings) each send the toast the host's `Chemotion::TemplateShareAPI` used to send, through the new `Labimotion::ShareNotifier`. Recipients are Persons only, never the actor; the channel is looked up by the literal subject `'Template Share Notification'` (the host ELN's channel migration stays required for delivery); and the notifier rescues internally — an undeliverable notification is a notification not sent, never a failed share. A host without Message/Channel machinery (or without the channel row) silently sends nothing. The level-change route (`PUT /shares/:id`) stays silent, matching the host layer it replaces. Until the matching ELN change removes `TemplateShareAPI` and its caller, grants on the integration branches notify twice — accepted, dev-only.
6
9
  * Per-template ownership and sharing, backend ([chem-generic-ui#1001](https://github.com/LabIMotion/chem-generic-ui/issues/1001) Phase 2): `klass_shares` table (viewer/editor/owner, one owner per template enforced by a partial unique index), `authorize_klass!` per-template gate replacing the designer-wide check on template mutation endpoints (owner-less templates keep the legacy behaviour permanently), share management endpoints under `/generic_klass/shares` plus `/generic_klass/transfer_ownership`, and capability booleans (`can_write` … `can_manage`) on the Designer list payloads. `download_klass` is now gated (`:read`) and no longer writes an identifier during a GET. Requires host ELN twin migrations `create_klass_shares` and `seed_klass_share_owners` (the latter seeds owner rows from `created_by` in raw SQL, idempotent via `ON CONFLICT DO NOTHING`); deploy order is migrate → **restart** (the stale schema cache otherwise silently drops the running app's writes to the new table). Ships as one upgrade with the matching chem-generic-ui and ELN changes — see the design doc's ADR 0001.
7
10
  * `created_by` and `created_at` are now `NOT NULL` on `element_klasses` and `segment_klasses` ([chem-generic-ui#1018](https://github.com/LabIMotion/chem-generic-ui/issues/1018)); `dataset_klasses` already declared both. Two new migrations, back-dated ahead of `create_klass_shares`: `backfill_klass_created_columns` fills a NULL `created_by` with the first system administrator (falling back to the lowest-id user of any type, and raising if the `users` table is empty) and a NULL `created_at` with `COALESCE(updated_at, NOW())`; `tighten_klass_created_columns` then applies the constraints — data before constraint. The back-dating is what lets `seed_klass_share_owners` stay unchanged: with `created_by` already filled in, its `WHERE created_by IS NOT NULL` filter excludes nothing and every template gets an owner row instead of staying owner-less on the permanent legacy gate. The backfill re-runs that owner seed itself (`ON CONFLICT DO NOTHING`) so a database on which the seed had already run picks up the repaired rows. Requires host ELN twin migrations for both, back-dated the same way; deploy order is migrate → **restart** (the stale schema cache otherwise silently drops the running app's writes).
@@ -16,6 +16,7 @@ module Labimotion
16
16
  helpers Labimotion::VocabularyHelpers
17
17
  helpers Labimotion::GenericHelpers
18
18
  helpers Labimotion::ElementHelpers
19
+ helpers Labimotion::CoverImageHelpers
19
20
  helpers Labimotion::ParamHelpers
20
21
 
21
22
  resource :generic_elements do
@@ -108,6 +109,31 @@ module Labimotion
108
109
  Labimotion.log_exception(e, current_user)
109
110
  { attributes: [], error: e.message }
110
111
  end
112
+
113
+ desc 'List the selectable inline attributes of a klass, with no element instance'
114
+ params do
115
+ requires :el_klass, type: String, desc: 'Generic klass name or permit-target key (reaction, sample, ...)'
116
+ end
117
+ get 'for_klass' do
118
+ error!('401 Unauthorized', 401) unless current_user
119
+
120
+ # Template shape only — no instance is read, so there is nothing
121
+ # collection-scoped to authorize beyond being a signed-in user.
122
+ { attributes: Labimotion::LinkedElement.available_for_klass(params[:el_klass]) }
123
+ rescue StandardError => e
124
+ Labimotion.log_exception(e, current_user)
125
+ { attributes: [], error: e.message }
126
+ end
127
+
128
+ desc 'List the standard ELN element types a drag field can link'
129
+ get 'permit_targets' do
130
+ error!('401 Unauthorized', 401) unless current_user
131
+
132
+ { permit_targets: Labimotion::LinkedElement.permit_targets }
133
+ rescue StandardError => e
134
+ Labimotion.log_exception(e, current_user)
135
+ { permit_targets: [], error: e.message }
136
+ end
111
137
  end
112
138
 
113
139
  namespace :search_basic_by_like do
@@ -530,6 +556,58 @@ module Labimotion
530
556
  { generic_elements: [] }
531
557
  end
532
558
 
559
+ # Declared before `route_param :id` so the literal segment is not
560
+ # swallowed by the id capture.
561
+ namespace :cover_images do
562
+ desc 'Save the cover-image selection directly (live, like klass settings)'
563
+ params do
564
+ requires :element_id, type: Integer, desc: 'Element id'
565
+ requires :cover_images, type: Array, desc: 'Ordered {source, id} references; [] clears'
566
+ end
567
+ post do
568
+ element = Labimotion::Element.find(params[:element_id])
569
+ unless current_user.matrix_check_by_name('genericElement') &&
570
+ ElementPolicy.new(current_user, element).update?
571
+ error!('401 Unauthorized', 401)
572
+ end
573
+
574
+ { mc: 'ss00', cover_images: save_cover_images(element, params[:cover_images]) }
575
+ rescue ActiveRecord::RecordNotFound
576
+ error!('404 Not Found', 404)
577
+ end
578
+ end
579
+
580
+ namespace :cover_image do
581
+ desc 'Serve the image behind a cover-image reference (original or thumbnail)'
582
+ params do
583
+ requires :element_id, type: Integer, desc: 'Element id'
584
+ requires :source, type: String, values: Labimotion::Constants::CoverImage::SOURCES,
585
+ desc: 'Reference source'
586
+ requires :id, type: Integer, desc: 'Attachment or analysis-container id'
587
+ optional :variant, type: String, values: %w[original thumbnail], default: 'original',
588
+ desc: 'original file (display area) or PNG thumbnail (picker rows)'
589
+ end
590
+ get do
591
+ element = Labimotion::Element.find(params[:element_id])
592
+ policy = ElementPolicy.new(current_user, element)
593
+ unless current_user.matrix_check_by_name('genericElement') && policy.read? && policy.read_dataset?
594
+ error!('401 Unauthorized', 401)
595
+ end
596
+
597
+ attachment = cover_image_attachment(element, params[:source], params[:id])
598
+ payload = attachment && cover_image_body(attachment, params[:variant])
599
+ error!('404 Not Found', 404) if payload.nil?
600
+
601
+ data, mime_type = payload
602
+ env['api.format'] = :binary
603
+ content_type(mime_type || 'application/octet-stream')
604
+ header('Content-Disposition', "inline; filename=\"#{CGI.escape(attachment.filename.to_s)}\"")
605
+ data
606
+ rescue ActiveRecord::RecordNotFound
607
+ error!('404 Not Found', 404)
608
+ end
609
+ end
610
+
533
611
  desc 'Return serialized element by id'
534
612
  params do
535
613
  requires :id, type: Integer, desc: 'Element id'
@@ -547,6 +625,7 @@ module Labimotion
547
625
  {
548
626
  element: Labimotion::ElementEntity.represent(
549
627
  element,
628
+ current_user: current_user,
550
629
  detail_levels: ElementDetailLevelCalculator.new(user: current_user, element: element).detail_levels,
551
630
  policy: @element_policy
552
631
  ),
@@ -591,6 +670,7 @@ module Labimotion
591
670
  {
592
671
  element: Labimotion::ElementEntity.represent(
593
672
  element,
673
+ current_user: current_user,
594
674
  detail_levels: ElementDetailLevelCalculator.new(user: current_user, element: element).detail_levels,
595
675
  ),
596
676
  attachments: Entities::AttachmentEntity.represent(element&.attachments),
@@ -3,7 +3,8 @@
3
3
  require 'labimotion/version'
4
4
 
5
5
  module Labimotion
6
- # Per-user, per-klass UI settings API (toolbar Overview/Arrange visibility).
6
+ # Per-user, per-klass UI settings API (toolbar Overview/Arrange visibility, and
7
+ # the default inline attributes of a linked element).
7
8
  # Every route is scoped to the authenticated user via created_by, so a user
8
9
  # only ever reads/writes their own setting; no klass-edit permission needed.
9
10
  class UserKlassSettingsAPI < Grape::API
@@ -14,15 +15,37 @@ module Labimotion
14
15
  Labimotion::UserKlassSetting.where(created_by: current_user.id)
15
16
  end
16
17
 
17
- def klass_toolbar(settings)
18
- (settings || {})['toolbar'] || (settings || {})[:toolbar] || {}
18
+ # Only the sub-settings actually present in the request are written, so a
19
+ # save carrying just one of them leaves the other untouched. Absent reads
20
+ # as "leave alone"; an empty linked_el_attrs array reads as "clear".
21
+ def submitted_settings(settings)
22
+ settings = (settings || {}).transform_keys(&:to_s)
23
+ toolbar = settings[Labimotion::Constants::UserSetting::TOOLBAR]
24
+ attrs = settings[Labimotion::Constants::UserSetting::LINKED_EL_ATTRS]
25
+ {}.tap do |result|
26
+ result[Labimotion::Constants::UserSetting::TOOLBAR] = toolbar if toolbar.is_a?(Hash)
27
+ if attrs.is_a?(Array)
28
+ result[Labimotion::Constants::UserSetting::LINKED_EL_ATTRS] = normalize_linked_el_attrs(attrs)
29
+ end
30
+ end
31
+ end
32
+
33
+ # Persist only the identity of each attribute. The values are resolved live
34
+ # on every load, and the label is refreshed from the template alongside them.
35
+ def normalize_linked_el_attrs(attrs)
36
+ attrs.filter_map do |attr|
37
+ attr = attr.transform_keys(&:to_s) if attr.respond_to?(:transform_keys)
38
+ next unless attr.is_a?(Hash) && attr['key'].present?
39
+
40
+ { 'source' => attr['source'], 'key' => attr['key'], 'label' => attr['label'] }
41
+ end
19
42
  end
20
43
  end
21
44
 
22
45
  resource :user_klass_settings do
23
46
  desc 'Fetch the current user UI setting for a klass'
24
47
  params do
25
- requires :klass, type: String, values: %w[ElementKlass SegmentKlass DatasetKlass], desc: 'Klass type'
48
+ requires :klass, type: String, values: Labimotion::Constants::Klass::USER_SETTINGS, desc: 'Klass type'
26
49
  requires :identifier, type: String, desc: 'Klass identifier'
27
50
  end
28
51
  get 'fetch' do
@@ -48,7 +71,7 @@ module Labimotion
48
71
  )
49
72
  record.klass_label = params[:klass_label] if params[:klass_label]
50
73
  record.created_by ||= current_user.id
51
- record.settings = (record.settings || {}).merge('toolbar' => klass_toolbar(params[:settings]))
74
+ record.settings = (record.settings || {}).merge(submitted_settings(params[:settings]))
52
75
  record.save!
53
76
  { mc: 'ss00', data: Labimotion::UserKlassSettingEntity.represent(record) }
54
77
  rescue StandardError => e
@@ -17,6 +17,14 @@ module Labimotion
17
17
  CV = 'Cyclic Voltammetry'
18
18
  end
19
19
 
20
+ # The element cover selection (labimotion#259): cap and source vocabulary
21
+ # of `metadata.cover_images` entries. Mirrored by the chem-generic-ui
22
+ # cover-image picker (chem-generic-ui#1050) — keep them in sync.
23
+ module CoverImage
24
+ SOURCES = %w[attachment analysis].freeze
25
+ MAX = 3
26
+ end
27
+
20
28
  module Mapper
21
29
  NMR_CONFIG = ::File.join(__dir__, 'libs', 'data', 'mapper', 'Source.json').freeze
22
30
  WIKI_CONFIG = ::File.join(__dir__, 'libs', 'data', 'mapper', 'Chemwiki.json').freeze
@@ -27,6 +35,20 @@ module Labimotion
27
35
  SEGMENT = 'SegmentKlass'
28
36
  DATASET = 'DatasetKlass'
29
37
  ALL = [ELEMENT, SEGMENT, DATASET].freeze
38
+
39
+ # Standard ELN elements (sample, reaction, molecule, ...) have no klass
40
+ # model. Per-user settings that key on one use this pseudo-type, with the
41
+ # permit-target key itself as the klass identifier — see
42
+ # Labimotion::UserKlassSetting.
43
+ PERMIT_TARGET = 'PermitTarget'
44
+ USER_SETTINGS = [ELEMENT, SEGMENT, DATASET, PERMIT_TARGET].freeze
45
+ end
46
+
47
+ # The independent sub-settings held side by side in
48
+ # user_klass_settings.settings. A save writes only the ones it carries.
49
+ module UserSetting
50
+ TOOLBAR = 'toolbar'
51
+ LINKED_EL_ATTRS = 'linked_el_attrs'
30
52
  end
31
53
 
32
54
  # The designer-right families as keyed in `users.generic_admin` (host-owned jsonb) and
@@ -68,6 +68,12 @@ module Labimotion
68
68
  object.properties[Labimotion::Prop::LAYERS][key][Labimotion::Prop::FIELDS][idx] =
69
69
  set_table(field, field_table_samples, Labimotion::Prop::SAMPLE)
70
70
  end
71
+
72
+ field_table_elements = field[Labimotion::Prop::SUBFIELDS].select { |ss| ss['type'] == Labimotion::FieldType::DRAG_ELEMENT }
73
+ if field_table_elements.present?
74
+ object.properties[Labimotion::Prop::LAYERS][key][Labimotion::Prop::FIELDS][idx] =
75
+ Labimotion::Serializer.set_table_element(field, field_table_elements)
76
+ end
71
77
  end
72
78
  end
73
79
  object.properties
@@ -97,15 +97,55 @@ module Labimotion
97
97
  return unless field['value'].is_a?(Hash)
98
98
 
99
99
  attrs = field.dig('value', 'el_attrs')
100
+ el_klass = field.dig('value', 'el_klass')
101
+ el_id = field.dig('value', 'el_id')
102
+ el_type = field.dig('value', 'el_type')
103
+ linked = Labimotion::LinkedElement.new(el_klass, el_id, el_type)
104
+
105
+ # A missing el_attrs means the user never picked anything for this link, so
106
+ # their My LabIMotion default applies. An explicit [] means they picked none
107
+ # — the two must not collapse, or clearing a selection would not stick.
108
+ from_default = attrs.nil?
109
+ attrs = default_linked_attrs(el_klass, el_type) if from_default
100
110
  return unless attrs.is_a?(Array) && attrs.present?
101
111
  return unless object.properties.dig(Labimotion::Prop::LAYERS, key, Labimotion::Prop::FIELDS, idx,
102
112
  'value').present?
103
113
 
104
- el_klass = field.dig('value', 'el_klass')
105
- el_id = field.dig('value', 'el_id')
106
- el_type = field.dig('value', 'el_type')
107
- object.properties[Labimotion::Prop::LAYERS][key][Labimotion::Prop::FIELDS][idx]['value']['el_attrs'] =
108
- Labimotion::LinkedElement.new(el_klass, el_id, el_type).resolve(attrs)
114
+ resolved = linked.resolve(attrs)
115
+ resolved = resolved.map { |attr| mark_default(attr) } if from_default
116
+ object.properties[Labimotion::Prop::LAYERS][key][Labimotion::Prop::FIELDS][idx]['value']['el_attrs'] = resolved
117
+ end
118
+
119
+ def mark_default(attr)
120
+ attr.is_a?(Hash) ? attr.merge(Labimotion::LinkedElement::IS_DEFAULT => true) : attr
121
+ end
122
+
123
+ # Keyed by what the drag field itself stores — el_klass for a generic element
124
+ # or a standard type, el_type for drag_molecule, which omits el_klass. Read
125
+ # straight off the preloaded map rather than via LinkedElement#target_key, so
126
+ # a field with no default costs no query.
127
+ def default_linked_attrs(el_klass, el_type)
128
+ map = default_linked_attrs_map
129
+ return [] if map.empty?
130
+
131
+ map[el_klass.to_s] || map[el_type.to_s] || []
132
+ end
133
+
134
+ # The current user's defaults for every linked target, loaded once per
135
+ # represented object. Skipped when no user is in scope (this entity is also
136
+ # rendered from contexts that pass none, e.g. Hub submissions and exports) and
137
+ # for list views, which don't render the inline attributes and would otherwise
138
+ # pay the lookup once per row.
139
+ def default_linked_attrs_map
140
+ return @default_linked_attrs_map if defined?(@default_linked_attrs_map)
141
+
142
+ user_id = options[:current_user]&.id
143
+ @default_linked_attrs_map =
144
+ if user_id.blank? || displayed_in_list?
145
+ {}
146
+ else
147
+ Labimotion::UserKlassSetting.linked_el_attrs_by_el_klass(user_id)
148
+ end
109
149
  end
110
150
 
111
151
  def update_reaction_field(key, field, idx)
@@ -144,10 +184,18 @@ module Labimotion
144
184
  field_table_samples = field[Labimotion::Prop::SUBFIELDS].select do |ss|
145
185
  ss['type'] == Labimotion::FieldType::DRAG_SAMPLE
146
186
  end
147
- return unless field_table_samples.present?
187
+ if field_table_samples.present?
188
+ object.properties[Labimotion::Prop::LAYERS][key][Labimotion::Prop::FIELDS][idx] =
189
+ set_table(field, field_table_samples, Labimotion::Prop::SAMPLE)
190
+ end
191
+
192
+ field_table_elements = field[Labimotion::Prop::SUBFIELDS].select do |ss|
193
+ ss['type'] == Labimotion::FieldType::DRAG_ELEMENT
194
+ end
195
+ return unless field_table_elements.present?
148
196
 
149
197
  object.properties[Labimotion::Prop::LAYERS][key][Labimotion::Prop::FIELDS][idx] =
150
- set_table(field, field_table_samples, Labimotion::Prop::SAMPLE)
198
+ Labimotion::Serializer.set_table_element(field, field_table_elements)
151
199
  end
152
200
 
153
201
  def update_voc_field(key, field, idx)
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'grape'
4
+ require 'labimotion/constants'
5
+
6
+ module Labimotion
7
+ ## Element cover images (chem-generic-ui#1050, labimotion#259).
8
+ ## `cover_images` is the one element-metadata key whose shape the gem owns:
9
+ ## validation and normalization of the stored references, their direct
10
+ ## save, the resolution of a reference to the attachment holding its
11
+ ## picture, and the bytes served for the display area (originals), the
12
+ ## picker rows (thumbnails) and the docx export.
13
+ module CoverImageHelpers
14
+ extend Grape::API::Helpers
15
+
16
+ # Element metadata is a client-round-tripped document with several writers —
17
+ # layer groups/restrictions from the element details page, host regComps
18
+ # through the registry seam — and those writers remove a key by omitting it.
19
+ # So a present :metadata param replaces the stored hash wholesale; only an
20
+ # absent param leaves the stored metadata untouched (an old client that does
21
+ # not send :metadata must never wipe a saved cover selection). `cover_images`
22
+ # is the one key the gem owns: its entries are validated against the picker's
23
+ # cap and source vocabulary and normalized to exactly source/id, so nothing
24
+ # else reaches the jsonb column through that key.
25
+ def prepare_element_metadata(stored, incoming)
26
+ return stored || {} if incoming.nil?
27
+
28
+ # to_h returns self for a plain Hash — dup so the caller's copy stays intact.
29
+ metadata = incoming.to_h.dup
30
+ return metadata unless metadata.key?('cover_images') || metadata.key?(:cover_images)
31
+
32
+ entries = metadata.key?('cover_images') ? metadata['cover_images'] : metadata[:cover_images]
33
+ metadata.delete(:cover_images)
34
+ metadata['cover_images'] = validate_cover_images!(entries)
35
+ metadata
36
+ end
37
+
38
+ def validate_cover_images!(entries)
39
+ cover_images_error!('must be an array') unless entries.is_a?(Array)
40
+ max = Constants::CoverImage::MAX
41
+ cover_images_error!("holds at most #{max} entries") if entries.size > max
42
+
43
+ entries.map { |entry| normalized_cover_image!(entry) }
44
+ end
45
+
46
+ def normalized_cover_image!(entry)
47
+ cover_image_entry_error! unless entry.is_a?(Hash)
48
+ source = entry['source'] || entry[:source]
49
+ id = entry['id'] || entry[:id]
50
+ cover_image_entry_error! unless Constants::CoverImage::SOURCES.include?(source) && id.is_a?(Integer)
51
+ { 'source' => source, 'id' => id }
52
+ end
53
+
54
+ def cover_image_entry_error!
55
+ cover_images_error!("entries must be { source: #{Constants::CoverImage::SOURCES.join(' | ')}, id: integer }")
56
+ end
57
+
58
+ def cover_images_error!(reason)
59
+ error!("metadata.cover_images #{reason}", 422)
60
+ end
61
+
62
+ # Direct write of the one gem-owned metadata key (the cover picker's
63
+ # Save): a targeted merge, deliberately narrower than the element save's
64
+ # wholesale-replace contract — this writer owns exactly cover_images and
65
+ # can neither wipe nor resurrect any other key. Live like klass settings:
66
+ # not versioned until the next element save snapshots metadata.
67
+ def save_cover_images(element, entries)
68
+ validated = validate_cover_images!(entries)
69
+ element.update!(metadata: (element.metadata || {}).merge('cover_images' => validated))
70
+ validated
71
+ end
72
+
73
+ # Resolves a stored cover-image reference to the Attachment holding its
74
+ # picture, or nil. Ownership is part of the resolution: an attachment is
75
+ # looked up through the element's own association, an analysis must be one
76
+ # of the element's analyses — a foreign id answers nil, never a file.
77
+ def cover_image_attachment(element, source, sid)
78
+ case source
79
+ when 'attachment'
80
+ element.attachments.find_by(id: sid)
81
+ when 'analysis'
82
+ container = element.analyses.find_by(id: sid)
83
+ container && analysis_preview_attachment(container)
84
+ end
85
+ end
86
+
87
+ # Server twin of the ELN client's getContainerImageData: among the
88
+ # thumbnail-bearing attachments of the analysis' dataset children, the
89
+ # container's preferred_thumbnail wins while it still exists, then the
90
+ # newest "combined" image, then the newest overall.
91
+ def analysis_preview_attachment(container)
92
+ dataset_ids = container.children.where(container_type: 'dataset').pluck(:id)
93
+ return nil if dataset_ids.empty?
94
+
95
+ candidates = Attachment.where(attachable_type: 'Container', attachable_id: dataset_ids, thumb: true).to_a
96
+ pick_preview_attachment(container, candidates)
97
+ end
98
+
99
+ def pick_preview_attachment(container, candidates)
100
+ return nil if candidates.empty?
101
+
102
+ preferred = preferred_candidate(container, candidates)
103
+ return preferred if preferred
104
+
105
+ combined = candidates.select { |att| att.filename.to_s.downcase.include?('combined') }
106
+ (combined.empty? ? candidates : combined).max_by(&:updated_at)
107
+ end
108
+
109
+ # hstore values are strings — the stored preferred_thumbnail id arrives as '1'.
110
+ def preferred_candidate(container, candidates)
111
+ preferred_id = (container.extended_metadata || {})['preferred_thumbnail'].to_i
112
+ candidates.find { |att| att.id == preferred_id }
113
+ end
114
+
115
+ # The picker's rows ask for variant=thumbnail — the small uniform PNG;
116
+ # everything else means the original (display area). An svg's STORED
117
+ # thumbnail is black (see cover_rasterize), so it is re-rasterized from
118
+ # the original instead, falling back to the stored one.
119
+ def cover_image_body(att, variant)
120
+ return cover_image_payload(att) unless variant == 'thumbnail'
121
+
122
+ thumb = svg_cover_thumbnail(att) || att.read_thumbnail
123
+ [thumb, 'image/png'] if thumb
124
+ end
125
+
126
+ COVER_THUMB_RESIZE = '256x256>'
127
+
128
+ def svg_cover_thumbnail(att)
129
+ mime = att.content_type.to_s.downcase
130
+ return nil unless mime.include?('svg')
131
+
132
+ data = att.read_file
133
+ data && cover_rasterize(data, mime, resize: COVER_THUMB_RESIZE)
134
+ end
135
+
136
+ ## ImageMagick's own SVG renderer reads the CSS keyword `transparent` as
137
+ ## black — the defect behind the ELN thumbnails' black background — so
138
+ ## paint properties are normalized to `none` first and the result is a
139
+ ## PNG flattened onto white. Nil when conversion is unavailable or
140
+ ## fails; callers fall back.
141
+ SVG_TRANSPARENT_RE =
142
+ /((?:fill|stroke|stop-color|flood-color|background(?:-color)?)\s*[:=]\s*["']?)transparent\b/i
143
+
144
+ def cover_rasterize(data, mime, resize: nil)
145
+ # Deferred on purpose: mini_magick is a host-bundle gem, not a
146
+ # dependency of this gem — at the top of the file it would break gem
147
+ # load (and the spec suite) on a host without it, while here a miss is
148
+ # rescued below into the nil fallback.
149
+ require 'mini_magick'
150
+ data = data.gsub(SVG_TRANSPARENT_RE, '\1none') if mime.include?('svg')
151
+ image = MiniMagick::Image.read(data, cover_read_ext(mime))
152
+ image.format('png') do |convert|
153
+ convert.background('white')
154
+ convert.flatten
155
+ convert.resize(resize) if resize
156
+ end
157
+ image.to_blob
158
+ rescue LoadError, StandardError => e
159
+ Labimotion.log_exception(e)
160
+ nil
161
+ end
162
+
163
+ def cover_read_ext(mime)
164
+ subtype = mime.split('/').last.to_s.sub('+xml', '')
165
+ subtype.empty? ? '.img' : ".#{subtype}"
166
+ end
167
+
168
+ # The bytes + content type to serve for a cover attachment: the original
169
+ # file whenever a browser can render it, otherwise the PNG thumbnail
170
+ # (TIFF originals, raw-data files behind an analysis preview). Nil when
171
+ # neither is readable.
172
+ def cover_image_payload(att)
173
+ if att.type_image? && !att.type_image_tiff?
174
+ data = att.read_file
175
+ return [data, att.content_type] if data
176
+ end
177
+ thumb = att.read_thumbnail
178
+ [thumb, 'image/png'] if thumb
179
+ end
180
+ end
181
+ end
@@ -3,11 +3,16 @@
3
3
  require 'grape'
4
4
  require 'labimotion/conf'
5
5
  require 'labimotion/utils/utils'
6
+ require 'labimotion/helpers/cover_image_helpers'
6
7
  # require 'labimotion/models/element_klass'
7
8
  module Labimotion
8
9
  ## ElementHelpers
9
10
  module ElementHelpers
10
11
  extend Grape::API::Helpers
12
+ # the element save validates metadata.cover_images up front through
13
+ # prepare_element_metadata — carry the dependency instead of relying on
14
+ # both modules being mounted on the same Grape endpoint
15
+ include Labimotion::CoverImageHelpers
11
16
 
12
17
  def klass_list(is_generic_only)
13
18
  if is_generic_only == true
@@ -267,7 +272,7 @@ module Labimotion
267
272
  klass_uuid: klass[:uuid],
268
273
  properties: properties,
269
274
  properties_release: params[:properties_release],
270
- metadata: params[:metadata] || {},
275
+ metadata: prepare_element_metadata({}, params[:metadata]),
271
276
  created_by: current_user.id
272
277
  }
273
278
  element = Labimotion::Element.new(attributes)
@@ -295,6 +300,11 @@ module Labimotion
295
300
 
296
301
  def update_element_by_id(current_user, params)
297
302
  element = Labimotion::Element.find(params[:id])
303
+ # Validated up front, before the container/sample-association writes run:
304
+ # a rejected cover selection must fail the request whole, not leave a
305
+ # half-applied datamodel behind. error! throws rather than raises, so it
306
+ # escapes this method's rescue-and-log and reaches the client as a 422.
307
+ metadata = prepare_element_metadata(element.metadata, params[:metadata])
298
308
  update_datamodel(params[:container], current_user)
299
309
  properties = update_sample_association(params[:properties], current_user, element)
300
310
  params.delete(:container)
@@ -303,7 +313,6 @@ module Labimotion
303
313
  params.delete(:user_labels)
304
314
  attributes = declared(params.except(:segments), include_missing: false)
305
315
  properties['pkg'] = Labimotion::Utils.pkg(properties['pkg'])
306
- metadata = params[:metadata] || element.metadata || {}
307
316
  if element.klass_uuid != properties['klass_uuid'] || element.properties != properties || element.name != params[:name] || element.metadata != metadata
308
317
  properties['klass'] = 'Element'
309
318
  uuid = SecureRandom.uuid
@@ -13,9 +13,13 @@ module Labimotion
13
13
  col_name
14
14
  end
15
15
 
16
- # Check if field type should be expanded (DRAG_SAMPLE or DRAG_MOLECULE)
16
+ # Check if field type should be expanded (DRAG_SAMPLE, DRAG_MOLECULE or DRAG_ELEMENT)
17
17
  def expandable_field?(field_type)
18
- [Labimotion::FieldType::DRAG_SAMPLE, Labimotion::FieldType::DRAG_MOLECULE].include?(field_type)
18
+ [
19
+ Labimotion::FieldType::DRAG_SAMPLE,
20
+ Labimotion::FieldType::DRAG_MOLECULE,
21
+ Labimotion::FieldType::DRAG_ELEMENT
22
+ ].include?(field_type)
19
23
  end
20
24
 
21
25
  # Generate sanitized filename for export
@@ -34,6 +38,8 @@ module Labimotion
34
38
  case sub_field['type']
35
39
  when Labimotion::FieldType::DRAG_SAMPLE, Labimotion::FieldType::DRAG_MOLECULE
36
40
  format_drag_field_cell(sub_val, sub_field)
41
+ when Labimotion::FieldType::DRAG_ELEMENT
42
+ format_element_cell(sub_val, sub_field)
37
43
  when Labimotion::FieldType::SELECT
38
44
  format_select_cell(sub_val, sub_field)
39
45
  when Labimotion::FieldType::SYSTEM_DEFINED
@@ -95,6 +101,15 @@ module Labimotion
95
101
  end
96
102
  end
97
103
 
104
+ # Format drag_element cell — a linked element has no structure image,
105
+ # so the short label is the cell text
106
+ def format_element_cell(sub_val, sub_field)
107
+ val = sub_val[sub_field['id']]['value'] || {}
108
+ return '' if val.blank?
109
+
110
+ val['el_label'].present? ? val['el_label'].to_s : val['el_name'].to_s
111
+ end
112
+
98
113
  # Format select cell
99
114
  def format_select_cell(sub_val, sub_field)
100
115
  sub_val[sub_field['id']]['value'].to_s
@@ -225,7 +225,7 @@ module Labimotion
225
225
  end
226
226
 
227
227
  params :user_klass_settings_save do
228
- requires :klass, type: String, values: %w[ElementKlass SegmentKlass DatasetKlass], desc: 'Klass type'
228
+ requires :klass, type: String, values: Labimotion::Constants::Klass::USER_SETTINGS, desc: 'Klass type'
229
229
  requires :klass_identifier, type: String, desc: 'Stable klass identifier'
230
230
  optional :klass_label, type: String, desc: 'Klass label (display only)'
231
231
  requires :settings, type: Hash do
@@ -233,6 +233,14 @@ module Labimotion
233
233
  optional :overview, type: Boolean, default: true
234
234
  optional :arrange, type: Boolean, default: true
235
235
  end
236
+ # The user's default inline attributes for elements linked from a
237
+ # drag field. Only the identity of each attribute is stored; the values
238
+ # are resolved live on every load. An empty array clears the default.
239
+ optional :linked_el_attrs, type: Array do
240
+ requires :key, type: String, desc: 'Attribute key (layer::field, or column name)'
241
+ optional :source, type: String, desc: 'property or column'
242
+ optional :label, type: String, desc: 'Attribute label (display only)'
243
+ end
236
244
  end
237
245
  end
238
246
  end
@@ -19,6 +19,13 @@ module Labimotion
19
19
  []
20
20
  end
21
21
 
22
+ def build_table_element(field_tables, current_user, element)
23
+ Labimotion::SampleAssociation.build_table_element(field_tables, current_user, element)
24
+ rescue StandardError => e
25
+ Labimotion.log_exception(e, current_user)
26
+ []
27
+ end
28
+
22
29
  def update_sample_association(properties, current_user, element)
23
30
  Labimotion::SampleAssociation.update_sample_association(properties, current_user, element)
24
31
  rescue StandardError => e
@@ -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
 
@@ -17,6 +17,11 @@ module Labimotion
17
17
  SOURCE_PROPERTY = 'property'
18
18
  SOURCE_COLUMN = 'column'
19
19
 
20
+ # Marks an attribute injected from the user's stored default rather than
21
+ # picked on this element. Stripped again before properties are persisted, so
22
+ # a default never silently becomes an explicit per-instance selection.
23
+ IS_DEFAULT = 'is_default'
24
+
20
25
  # el_klass (permit-target key) => model class name
21
26
  MODELS = {
22
27
  'reaction' => '::Reaction',
@@ -29,6 +34,13 @@ module Labimotion
29
34
  'cell_line' => '::CelllineSample'
30
35
  }.freeze
31
36
 
37
+ # Display names for the above, for screens that list the linkable types.
38
+ PERMIT_TARGET_LABELS = {
39
+ 'reaction' => 'Reaction', 'sample' => 'Sample', 'molecule' => 'Molecule',
40
+ 'wellplate' => 'Wellplate', 'screen' => 'Screen', 'research_plan' => 'Research Plan',
41
+ 'device_description' => 'Device Description', 'cell_line' => 'Cell Line'
42
+ }.freeze
43
+
32
44
  # Curated, scalar columns exposed per type: [column/method, label, unit_column?]
33
45
  COLUMNS = {
34
46
  'reaction' => [
@@ -146,6 +158,107 @@ module Labimotion
146
158
  end
147
159
  end
148
160
 
161
+ # The attributes a user can pick for a klass with no element instance to read
162
+ # values from — the settings screen picks defaults before any link exists.
163
+ # Same shape as #available, minus 'value'/'value_system'.
164
+ def self.available_for_klass(el_klass)
165
+ el_klass = el_klass.to_s
166
+ return column_attributes_for_klass(el_klass) if MODELS.key?(el_klass)
167
+
168
+ klass = Labimotion::ElementKlass.find_by(name: el_klass)
169
+ klass.present? ? property_attributes_for_klass(klass) : []
170
+ end
171
+
172
+ # Remove attributes injected from a user's default (marked IS_DEFAULT by
173
+ # PropertiesEntity) from a properties hash on its way to the database. They
174
+ # are display-only: persisting them would freeze today's default into the
175
+ # element as an explicit per-instance selection, and a later change to the
176
+ # default would then stop reaching this link.
177
+ def self.strip_defaults(properties)
178
+ layers = properties.is_a?(Hash) ? properties[Labimotion::Prop::LAYERS] : nil
179
+ return properties unless layers.is_a?(Hash)
180
+
181
+ layers.each_value do |layer|
182
+ fields = layer.is_a?(Hash) ? layer[Labimotion::Prop::FIELDS] : nil
183
+ next unless fields.is_a?(Array)
184
+
185
+ fields.each { |field| strip_field_defaults(field) }
186
+ end
187
+ properties
188
+ end
189
+
190
+ def self.strip_field_defaults(field)
191
+ value = field.is_a?(Hash) ? field['value'] : nil
192
+ return unless value.is_a?(Hash)
193
+
194
+ attrs = value['el_attrs']
195
+ return unless attrs.is_a?(Array)
196
+
197
+ kept = attrs.reject { |attr| default_attr?(attr) }
198
+ return if kept.size == attrs.size # nothing injected — an explicit selection stays put
199
+
200
+ if kept.empty?
201
+ # Drop the key entirely so the field reads as "never picked" again and
202
+ # keeps following the user's default.
203
+ value.delete('el_attrs')
204
+ else
205
+ value['el_attrs'] = kept
206
+ end
207
+ end
208
+ private_class_method :strip_field_defaults
209
+
210
+ def self.default_attr?(attr)
211
+ attr.is_a?(Hash) && attr[IS_DEFAULT] == true
212
+ end
213
+ private_class_method :default_attr?
214
+
215
+ # The standard ELN types a drag field can link, for a settings screen that has
216
+ # to enumerate them. MODELS is the authority here, not the wider permit-target
217
+ # list: 'element' and 'generic_grid' are permit targets but not linkable types.
218
+ def self.permit_targets
219
+ MODELS.keys.map { |key| { 'key' => key, 'label' => PERMIT_TARGET_LABELS[key] || key.humanize } }
220
+ end
221
+
222
+ # Read off the released template rather than an instance, so the list matches
223
+ # what every element of the klass actually has.
224
+ def self.property_attributes_for_klass(klass)
225
+ layers = (klass.properties_release || {})[Labimotion::Prop::LAYERS] || {}
226
+ ordered = layers.values.sort_by { |layer| layer['position'] || 0 }
227
+ ordered.flat_map { |layer| layer_property_attributes(layer) }
228
+ end
229
+ private_class_method :property_attributes_for_klass
230
+
231
+ def self.layer_property_attributes(layer)
232
+ (layer[Labimotion::Prop::FIELDS] || []).filter_map do |field|
233
+ klass_property_attribute(layer, field) if PROPERTY_TYPES.include?(field['type'])
234
+ end
235
+ end
236
+ private_class_method :layer_property_attributes
237
+
238
+ def self.klass_property_attribute(layer, field)
239
+ {
240
+ 'source' => SOURCE_PROPERTY,
241
+ 'key' => "#{layer['key']}::#{field['field']}",
242
+ 'label' => field['label'].presence || field['field'],
243
+ 'type' => field['type'],
244
+ 'group' => layer['label'].presence || layer['key']
245
+ }
246
+ end
247
+ private_class_method :klass_property_attribute
248
+
249
+ def self.column_attributes_for_klass(el_klass)
250
+ (COLUMNS[el_klass] || []).map do |col, label, _unit_col|
251
+ {
252
+ 'source' => SOURCE_COLUMN,
253
+ 'key' => col,
254
+ 'label' => label.presence || col.to_s.humanize,
255
+ 'type' => SOURCE_COLUMN,
256
+ 'group' => el_klass
257
+ }
258
+ end
259
+ end
260
+ private_class_method :column_attributes_for_klass
261
+
149
262
  private
150
263
 
151
264
  def generic_klass?(name)
@@ -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)
@@ -42,6 +42,9 @@ module Labimotion
42
42
  uuid = SecureRandom.uuid
43
43
  metadata = args[:metadata] || {}
44
44
  props = args[:properties]
45
+ # Datasets do not go through SampleAssociation, so they strip their own
46
+ # default-injected linked attributes before being stored.
47
+ Labimotion::LinkedElement.strip_defaults(props)
45
48
  props['pkg'] = Labimotion::Utils.pkg(props['pkg'])
46
49
  props['identifier'] = klass.identifier if klass.identifier.present?
47
50
  props['uuid'] = uuid
@@ -1,12 +1,58 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Labimotion
4
- # Per-user, per-klass UI settings (e.g. toolbar Overview/Arrange visibility).
4
+ # Per-user, per-klass UI settings. Currently two of them, held side by side in
5
+ # the +settings+ jsonb:
6
+ # * 'toolbar' -> Overview/Arrange button visibility
7
+ # * 'linked_el_attrs' -> the attributes that elements of this klass surface
8
+ # inline by default when linked from a drag field
9
+ #
5
10
  # Scoped to the owning user via created_by; at most one row per
6
11
  # (created_by, klass, klass_identifier) — see the unique index. Keyed on the
7
12
  # stable klass identifier so it applies to every instance of that klass,
8
13
  # across template versions.
9
14
  class UserKlassSetting < ApplicationRecord
15
+ # Generic templates are keyed by their klass model plus the stable klass
16
+ # identifier. Standard ELN elements (sample, reaction, ...) have no klass
17
+ # model, so they share the PERMIT_TARGET pseudo-type and use the permit-target
18
+ # key itself as the identifier — 'sample', 'reaction', ...
19
+ PERMIT_TARGET = Labimotion::Constants::Klass::PERMIT_TARGET
20
+
21
+ TOOLBAR = Labimotion::Constants::UserSetting::TOOLBAR
22
+ LINKED_EL_ATTRS = Labimotion::Constants::UserSetting::LINKED_EL_ATTRS
23
+
10
24
  self.table_name = :user_klass_settings
25
+
26
+ # All of a user's linked-attribute defaults, keyed by what a drag field stores
27
+ # in +el_klass+: a generic element klass *name*, or a permit-target key. Two
28
+ # queries for the whole map, so an element with many drag fields still costs
29
+ # the same as one with a single field.
30
+ def self.linked_el_attrs_by_el_klass(user_id)
31
+ return {} if user_id.blank?
32
+
33
+ rows = where(created_by: user_id, klass: [Labimotion::Constants::Klass::ELEMENT, PERMIT_TARGET]).to_a
34
+ rows.select! { |row| stored_linked_el_attrs(row).is_a?(Array) }
35
+ return {} if rows.empty?
36
+
37
+ names = element_klass_names(rows)
38
+ rows.each_with_object({}) do |row, acc|
39
+ el_klass = row.klass == PERMIT_TARGET ? row.klass_identifier : names[row.klass_identifier]
40
+ acc[el_klass] = stored_linked_el_attrs(row) if el_klass.present?
41
+ end
42
+ end
43
+
44
+ def self.stored_linked_el_attrs(row)
45
+ row.settings.is_a?(Hash) ? row.settings[LINKED_EL_ATTRS] : nil
46
+ end
47
+ private_class_method :stored_linked_el_attrs
48
+
49
+ # identifier => name, for the generic klasses among the given rows.
50
+ def self.element_klass_names(rows)
51
+ identifiers = rows.filter_map { |row| row.klass_identifier if row.klass == Labimotion::Constants::Klass::ELEMENT }
52
+ return {} if identifiers.empty?
53
+
54
+ Labimotion::ElementKlass.where(identifier: identifiers).pluck(:identifier, :name).to_h
55
+ end
56
+ private_class_method :element_klass_names
11
57
  end
12
58
  end
@@ -156,6 +156,7 @@ module Labimotion
156
156
 
157
157
  set_table_prop(field, uuids, Labimotion::FieldType::DRAG_MOLECULE, &fetch_one)
158
158
  set_table_prop(field, uuids, Labimotion::FieldType::DRAG_SAMPLE, &fetch_one)
159
+ set_table_prop(field, uuids, Labimotion::FieldType::DRAG_ELEMENT, &fetch_one)
159
160
  end
160
161
  properties
161
162
  rescue StandardError => e
@@ -120,7 +120,21 @@ module Labimotion
120
120
  properties
121
121
  end
122
122
 
123
- def proc_table_prop(layer, key, data, instance, properties, field, tidx, type)
123
+ def proc_table_element(properties, instances, elements, svalue, key, tidx, vdx, col_id)
124
+ return properties unless id = svalue.fetch('el_id', nil)
125
+
126
+ att_el = (elements && elements[id]) || instances.fetch(Labimotion::Prop::L_ELEMENT, nil)&.fetch(id, nil)
127
+ return properties if att_el.nil?
128
+
129
+ val = properties[Labimotion::Prop::LAYERS][key][Labimotion::Prop::FIELDS][tidx]['sub_values'][vdx][col_id]['value']
130
+ properties[Labimotion::Prop::LAYERS][key][Labimotion::Prop::FIELDS][tidx]['sub_values'][vdx][col_id]['value'] = proc_assign_element(val, att_el)
131
+ properties
132
+ rescue StandardError => e
133
+ Labimotion.log_exception(e)
134
+ properties
135
+ end
136
+
137
+ def proc_table_prop(layer, key, data, instance, properties, field, tidx, type, elements = nil)
124
138
  fields = field[Labimotion::Prop::SUBFIELDS].select { |ss| ss['type'] == type }
125
139
  return properties if fields.nil?
126
140
 
@@ -141,6 +155,8 @@ module Labimotion
141
155
  properties = proc_table_molecule(properties, data, svalue, key, tidx, vdx, col_id)
142
156
  when Labimotion::FieldType::DRAG_SAMPLE
143
157
  properties = proc_table_sample(properties, data, instance, svalue, key, tidx, vdx, col_id)
158
+ when Labimotion::FieldType::DRAG_ELEMENT
159
+ properties = proc_table_element(properties, instance, elements, svalue, key, tidx, vdx, col_id)
144
160
  end
145
161
  end
146
162
  end
@@ -256,7 +272,7 @@ module Labimotion
256
272
  end
257
273
 
258
274
 
259
- def self.proc_table(layer, key, data, instance, properties)
275
+ def self.proc_table(layer, key, data, instance, properties, elements = nil)
260
276
  fields = layer[Labimotion::Prop::FIELDS].select { |ss| ss['type'] == Labimotion::FieldType::TABLE }
261
277
  fields&.each do |field|
262
278
  tidx = layer[Labimotion::Prop::FIELDS].index(field)
@@ -264,6 +280,7 @@ module Labimotion
264
280
 
265
281
  proc_table_prop(layer, key, data, instance, properties, field, tidx, Labimotion::FieldType::DRAG_MOLECULE)
266
282
  proc_table_prop(layer, key, data, instance, properties, field, tidx, Labimotion::FieldType::DRAG_SAMPLE)
283
+ proc_table_prop(layer, key, data, instance, properties, field, tidx, Labimotion::FieldType::DRAG_ELEMENT, elements)
267
284
  end
268
285
  properties
269
286
  rescue StandardError => e
@@ -280,7 +297,7 @@ module Labimotion
280
297
  properties = proc_reaction(layer, key, data, instances, properties)
281
298
  properties = proc_sample(layer, key, data, instances, properties)
282
299
  properties = proc_element(layer, key, data, instances, properties, elements) # unless elements.nil?
283
- properties = proc_table(layer, key, data, instances, properties)
300
+ properties = proc_table(layer, key, data, instances, properties, elements)
284
301
  end
285
302
  properties
286
303
  rescue StandardError => e
@@ -32,6 +32,30 @@ module Labimotion
32
32
  field
33
33
  end
34
34
 
35
+ ## Refresh the stored label/name of every drag_element cell of a table field.
36
+ ## Shared by PropertiesEntity and ElnElementEntity so all three loaders agree.
37
+ def self.set_table_element(field, field_table_elements)
38
+ col_ids = field_table_elements.map { |x| x.values[0] }
39
+ col_ids.each do |col_id|
40
+ field['sub_values'].each do |sub_value|
41
+ cell = sub_value[col_id]
42
+ next unless cell.present? && cell['value'].present? && cell['value']['el_id'].present?
43
+ ## A drag_element cell may link a standard ELN element (reaction, wellplate, ...);
44
+ ## el_id is then not a Labimotion::Element id, so keep what the client stored.
45
+ next if Labimotion::LinkedElement::MODELS.key?(cell['value']['el_klass'].to_s)
46
+
47
+ find_obj = Labimotion::Element.find_by(id: cell['value']['el_id'])
48
+ next if find_obj.blank?
49
+
50
+ cell['value']['el_label'] = find_obj.short_label
51
+ cell['value']['el_name'] = find_obj.name
52
+ cell['value']['el_tip'] = "#{find_obj.element_klass&.label}@@#{find_obj.name}"
53
+ cell['value']['icon_name'] = find_obj.element_klass&.icon_name || ''
54
+ end
55
+ end
56
+ field
57
+ end
58
+
35
59
  def self.element_properties(object)
36
60
  object.properties[Labimotion::Prop::LAYERS]&.keys.each do |key|
37
61
  # layer = object.properties[key]
@@ -72,6 +96,9 @@ module Labimotion
72
96
 
73
97
  field_table_samples = field[Labimotion::Prop::SUBFIELDS].select { |ss| ss['type'] == Labimotion::FieldType::DRAG_SAMPLE }
74
98
  object.properties[Labimotion::Prop::LAYERS][key][Labimotion::Prop::FIELDS][idx] = set_table(field, field_table_samples, Labimotion::Prop::SAMPLE) if field_table_samples.present?
99
+
100
+ field_table_elements = field[Labimotion::Prop::SUBFIELDS].select { |ss| ss['type'] == Labimotion::FieldType::DRAG_ELEMENT }
101
+ object.properties[Labimotion::Prop::LAYERS][key][Labimotion::Prop::FIELDS][idx] = set_table_element(field, field_table_elements) if field_table_elements.present?
75
102
  end
76
103
  end
77
104
  object.properties
@@ -2,5 +2,5 @@
2
2
 
3
3
  ## Labimotion Version
4
4
  module Labimotion
5
- VERSION = '2.4.0.rc4'
5
+ VERSION = '2.4.0.rc6'
6
6
  end
data/lib/labimotion.rb CHANGED
@@ -66,6 +66,7 @@ module Labimotion
66
66
  ######## Helpers
67
67
  autoload :GenericHelpers, 'labimotion/helpers/generic_helpers'
68
68
  autoload :ElementHelpers, 'labimotion/helpers/element_helpers'
69
+ autoload :CoverImageHelpers, 'labimotion/helpers/cover_image_helpers'
69
70
  autoload :SegmentHelpers, 'labimotion/helpers/segment_helpers'
70
71
  autoload :DatasetHelpers, 'labimotion/helpers/dataset_helpers'
71
72
  autoload :SearchHelpers, 'labimotion/helpers/search_helpers'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: labimotion
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.4.0.rc4
4
+ version: 2.4.0.rc6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chia-Lin Lin
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2026-08-05 00:00:00.000000000 Z
12
+ date: 2026-08-07 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: caxlsx
@@ -130,6 +130,7 @@ files:
130
130
  - lib/labimotion/entities/user_klass_setting_entity.rb
131
131
  - lib/labimotion/entities/vocabulary_entity.rb
132
132
  - lib/labimotion/helpers/converter_helpers.rb
133
+ - lib/labimotion/helpers/cover_image_helpers.rb
133
134
  - lib/labimotion/helpers/dataset_helpers.rb
134
135
  - lib/labimotion/helpers/element_helpers.rb
135
136
  - lib/labimotion/helpers/exporter_helpers.rb