labimotion 2.4.0.rc4 → 2.4.0.rc5

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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +3 -0
  3. data/lib/labimotion/apis/generic_dataset_api.rb +0 -29
  4. data/lib/labimotion/apis/generic_element_api.rb +80 -37
  5. data/lib/labimotion/apis/segment_api.rb +0 -19
  6. data/lib/labimotion/apis/user_klass_settings_api.rb +28 -5
  7. data/lib/labimotion/constants.rb +22 -0
  8. data/lib/labimotion/entities/eln_element_entity.rb +6 -0
  9. data/lib/labimotion/entities/properties_entity.rb +55 -7
  10. data/lib/labimotion/helpers/cover_image_helpers.rb +181 -0
  11. data/lib/labimotion/helpers/dataset_helpers.rb +0 -113
  12. data/lib/labimotion/helpers/element_helpers.rb +11 -180
  13. data/lib/labimotion/helpers/exporter_helpers.rb +17 -2
  14. data/lib/labimotion/helpers/generic_helpers.rb +0 -62
  15. data/lib/labimotion/helpers/param_helpers.rb +9 -69
  16. data/lib/labimotion/helpers/sample_association_helpers.rb +7 -0
  17. data/lib/labimotion/helpers/segment_helpers.rb +0 -62
  18. data/lib/labimotion/libs/export_element.rb +128 -13
  19. data/lib/labimotion/libs/linked_element.rb +113 -0
  20. data/lib/labimotion/libs/sample_association.rb +47 -1
  21. data/lib/labimotion/models/concerns/datasetable.rb +3 -0
  22. data/lib/labimotion/models/user_klass_setting.rb +47 -1
  23. data/lib/labimotion/utils/export_utils.rb +1 -0
  24. data/lib/labimotion/utils/import_utils.rb +20 -3
  25. data/lib/labimotion/utils/serializer.rb +27 -0
  26. data/lib/labimotion/version.rb +1 -1
  27. data/lib/labimotion.rb +1 -3
  28. metadata +3 -19
  29. data/lib/labimotion/libs/ai_egress_guard.rb +0 -84
  30. data/lib/labimotion/libs/ai_template.rb +0 -1482
  31. data/lib/labimotion/libs/file_extractor.rb +0 -210
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1babeffe4e43eb088d5ab781101aa48574f67aab9abf0d32df4d883742e63788
4
- data.tar.gz: 8c063d368a6af316e5397804951da314d8fc65035a852f56b066097091b0b2b3
3
+ metadata.gz: 3e6b4cffa98971ea0ef93e54a537710c7fdc587f2433f038a54f42be1ac63f21
4
+ data.tar.gz: b57ba99244c3b9ec975fef1acb2c1b588b8d9bee5a5d6c356d336bc247c08738
5
5
  SHA512:
6
- metadata.gz: 5d92879758e133fbf40fd433882967c8731ebf18e1e2586d8026a65b2d085359a530333cd740d00b7b0359981f155c034eee5687ffe326bb842c2dbc1ee52ad8
7
- data.tar.gz: 3db447e733afbe176b1d7adb80078ef85899b473336e882d41bb9f7da4b66b51ca91d1f4084d30a0f644401e95c4312669bd1f9a6d7bc861af42dc8ad89d1abd
6
+ metadata.gz: c727c0b2409e463b0e6130986ebdcc528cf6473696b2124889061557f365ec163cc1393d08662b258801ddadafd16f7134e82786d759121b915e47b499814b55
7
+ data.tar.gz: 84ac6b69a8326012b61eae9aefef2f248c85ac22fa45cf266cf03114378a0cde2d1839f0798d3d460d35223000283db2a7ee4dcb3bdcfa4e4faac414ad01460b
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).
@@ -7,7 +7,6 @@ module Labimotion
7
7
 
8
8
  helpers Labimotion::GenericHelpers
9
9
  helpers Labimotion::DatasetHelpers
10
- helpers Labimotion::ParamHelpers
11
10
 
12
11
  resource :generic_dataset do
13
12
  namespace :klasses do
@@ -79,34 +78,6 @@ module Labimotion
79
78
  end
80
79
  end
81
80
 
82
- namespace :create_ai_klass do
83
- desc 'create a Generic Dataset Klass from an AI-generated template'
84
- params do
85
- use :create_ai_dataset_klass_params
86
- end
87
- post do
88
- msg = create_ai_dataset_klass(params, current_user)
89
- klass = Labimotion::DatasetKlassEntity.represent(Labimotion::DatasetKlass.all)
90
- { status: msg[:status], message: msg[:message], klass: klass }
91
- rescue StandardError => e
92
- Labimotion.log_exception(e, current_user)
93
- { error: e.message }
94
- end
95
- end
96
-
97
- namespace :refine_ai_klass do
98
- desc 'refine a Generic Dataset Klass template with AI (returns the revised template; not persisted)'
99
- params do
100
- use :refine_ai_dataset_klass_params
101
- end
102
- post do
103
- refine_ai_dataset_klass(params, current_user)
104
- rescue StandardError => e
105
- Labimotion.log_exception(e, current_user)
106
- { status: 'error', message: e.message }
107
- end
108
- end
109
-
110
81
  namespace :find_template do
111
82
  desc 'Find best matching template for given OLS term ID'
112
83
  params do
@@ -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
@@ -186,43 +212,6 @@ module Labimotion
186
212
  end
187
213
  end
188
214
 
189
- namespace :create_ai_klass do
190
- desc 'create a Generic Element Klass from an AI-generated template'
191
- params do
192
- use :create_ai_element_klass_params
193
- end
194
- post do
195
- authenticate_admin!('elements')
196
- msg = create_ai_element_klass(params, current_user)
197
- { status: msg[:status], message: msg[:message],
198
- klass: Labimotion::ElementKlassEntity.represent(Labimotion::ElementKlass.where(is_active: true)) }
199
- rescue StandardError => e
200
- Labimotion.log_exception(e, current_user)
201
- { error: e.message }
202
- end
203
- end
204
-
205
- # Sibling namespace of :create_ai_klass — MUST stay above the route_param :id
206
- # blocks below, otherwise POST /ai_fill_data falls through to the element
207
- # instance route and Element.find(nil) is raised.
208
- namespace :ai_fill_data do
209
- desc "Auto-fill a generic element's data values from a document using AI"
210
- params do
211
- use :ai_fill_element_data_params
212
- end
213
- post do
214
- msg = ai_fill_element_data(params, current_user)
215
- if msg[:status] == 'success'
216
- { status: 'success', values: msg[:values], summary: msg[:summary] }
217
- else
218
- { status: 'error', message: msg[:message] }
219
- end
220
- rescue StandardError => e
221
- Labimotion.log_exception(e, current_user)
222
- { status: 'error', message: e.message }
223
- end
224
- end
225
-
226
215
  namespace :update_element_klass do
227
216
  desc 'update Generic Element Klass'
228
217
  params do
@@ -530,6 +519,58 @@ module Labimotion
530
519
  { generic_elements: [] }
531
520
  end
532
521
 
522
+ # Declared before `route_param :id` so the literal segment is not
523
+ # swallowed by the id capture.
524
+ namespace :cover_images do
525
+ desc 'Save the cover-image selection directly (live, like klass settings)'
526
+ params do
527
+ requires :element_id, type: Integer, desc: 'Element id'
528
+ requires :cover_images, type: Array, desc: 'Ordered {source, id} references; [] clears'
529
+ end
530
+ post do
531
+ element = Labimotion::Element.find(params[:element_id])
532
+ unless current_user.matrix_check_by_name('genericElement') &&
533
+ ElementPolicy.new(current_user, element).update?
534
+ error!('401 Unauthorized', 401)
535
+ end
536
+
537
+ { mc: 'ss00', cover_images: save_cover_images(element, params[:cover_images]) }
538
+ rescue ActiveRecord::RecordNotFound
539
+ error!('404 Not Found', 404)
540
+ end
541
+ end
542
+
543
+ namespace :cover_image do
544
+ desc 'Serve the image behind a cover-image reference (original or thumbnail)'
545
+ params do
546
+ requires :element_id, type: Integer, desc: 'Element id'
547
+ requires :source, type: String, values: Labimotion::Constants::CoverImage::SOURCES,
548
+ desc: 'Reference source'
549
+ requires :id, type: Integer, desc: 'Attachment or analysis-container id'
550
+ optional :variant, type: String, values: %w[original thumbnail], default: 'original',
551
+ desc: 'original file (display area) or PNG thumbnail (picker rows)'
552
+ end
553
+ get do
554
+ element = Labimotion::Element.find(params[:element_id])
555
+ policy = ElementPolicy.new(current_user, element)
556
+ unless current_user.matrix_check_by_name('genericElement') && policy.read? && policy.read_dataset?
557
+ error!('401 Unauthorized', 401)
558
+ end
559
+
560
+ attachment = cover_image_attachment(element, params[:source], params[:id])
561
+ payload = attachment && cover_image_body(attachment, params[:variant])
562
+ error!('404 Not Found', 404) if payload.nil?
563
+
564
+ data, mime_type = payload
565
+ env['api.format'] = :binary
566
+ content_type(mime_type || 'application/octet-stream')
567
+ header('Content-Disposition', "inline; filename=\"#{CGI.escape(attachment.filename.to_s)}\"")
568
+ data
569
+ rescue ActiveRecord::RecordNotFound
570
+ error!('404 Not Found', 404)
571
+ end
572
+ end
573
+
533
574
  desc 'Return serialized element by id'
534
575
  params do
535
576
  requires :id, type: Integer, desc: 'Element id'
@@ -547,6 +588,7 @@ module Labimotion
547
588
  {
548
589
  element: Labimotion::ElementEntity.represent(
549
590
  element,
591
+ current_user: current_user,
550
592
  detail_levels: ElementDetailLevelCalculator.new(user: current_user, element: element).detail_levels,
551
593
  policy: @element_policy
552
594
  ),
@@ -591,6 +633,7 @@ module Labimotion
591
633
  {
592
634
  element: Labimotion::ElementEntity.represent(
593
635
  element,
636
+ current_user: current_user,
594
637
  detail_levels: ElementDetailLevelCalculator.new(user: current_user, element: element).detail_levels,
595
638
  ),
596
639
  attachments: Entities::AttachmentEntity.represent(element&.attachments),
@@ -59,25 +59,6 @@ module Labimotion
59
59
  end
60
60
  end
61
61
 
62
- namespace :create_ai_klass do
63
- desc 'create a Generic Segment Klass from an AI-generated template'
64
- params do
65
- use :create_ai_segment_klass_params
66
- end
67
- after_validation do
68
- authenticate_admin!('segments')
69
- @klass = fetch_klass('ElementKlass', params[:element_klass])
70
- end
71
- post do
72
- msg = create_ai_segment_klass(current_user, params)
73
- { status: msg[:status], message: msg[:message],
74
- klass: Labimotion::SegmentKlassEntity.represent(Labimotion::SegmentKlass.all) }
75
- rescue StandardError => e
76
- Labimotion.log_exception(e, current_user)
77
- { error: e.message }
78
- end
79
- end
80
-
81
62
  namespace :update_segment_klass do
82
63
  desc 'update Generic Segment Klass'
83
64
  params do
@@ -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