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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +3 -0
- data/lib/labimotion/apis/generic_dataset_api.rb +0 -29
- data/lib/labimotion/apis/generic_element_api.rb +80 -37
- data/lib/labimotion/apis/segment_api.rb +0 -19
- data/lib/labimotion/apis/user_klass_settings_api.rb +28 -5
- data/lib/labimotion/constants.rb +22 -0
- data/lib/labimotion/entities/eln_element_entity.rb +6 -0
- data/lib/labimotion/entities/properties_entity.rb +55 -7
- data/lib/labimotion/helpers/cover_image_helpers.rb +181 -0
- data/lib/labimotion/helpers/dataset_helpers.rb +0 -113
- data/lib/labimotion/helpers/element_helpers.rb +11 -180
- data/lib/labimotion/helpers/exporter_helpers.rb +17 -2
- data/lib/labimotion/helpers/generic_helpers.rb +0 -62
- data/lib/labimotion/helpers/param_helpers.rb +9 -69
- data/lib/labimotion/helpers/sample_association_helpers.rb +7 -0
- data/lib/labimotion/helpers/segment_helpers.rb +0 -62
- data/lib/labimotion/libs/export_element.rb +128 -13
- data/lib/labimotion/libs/linked_element.rb +113 -0
- data/lib/labimotion/libs/sample_association.rb +47 -1
- data/lib/labimotion/models/concerns/datasetable.rb +3 -0
- data/lib/labimotion/models/user_klass_setting.rb +47 -1
- data/lib/labimotion/utils/export_utils.rb +1 -0
- data/lib/labimotion/utils/import_utils.rb +20 -3
- data/lib/labimotion/utils/serializer.rb +27 -0
- data/lib/labimotion/version.rb +1 -1
- data/lib/labimotion.rb +1 -3
- metadata +3 -19
- data/lib/labimotion/libs/ai_egress_guard.rb +0 -84
- data/lib/labimotion/libs/ai_template.rb +0 -1482
- data/lib/labimotion/libs/file_extractor.rb +0 -210
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3e6b4cffa98971ea0ef93e54a537710c7fdc587f2433f038a54f42be1ac63f21
|
|
4
|
+
data.tar.gz: b57ba99244c3b9ec975fef1acb2c1b588b8d9bee5a5d6c356d336bc247c08738
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
18
|
-
|
|
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:
|
|
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(
|
|
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
|
data/lib/labimotion/constants.rb
CHANGED
|
@@ -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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|