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 +4 -4
- data/CHANGELOG.md +3 -0
- data/lib/labimotion/apis/generic_element_api.rb +80 -0
- 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/element_helpers.rb +11 -2
- data/lib/labimotion/helpers/exporter_helpers.rb +17 -2
- data/lib/labimotion/helpers/param_helpers.rb +9 -1
- data/lib/labimotion/helpers/sample_association_helpers.rb +7 -0
- 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 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fa32d7e24804c4f270efdbce75bccf847ae3d30ae1f6b265c17b9c196e9c12a6
|
|
4
|
+
data.tar.gz: b6901471f70ac6874bf68a517ea9d994a3e4054d089a2bc66e042de38e97f4fd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
16
|
+
# Check if field type should be expanded (DRAG_SAMPLE, DRAG_MOLECULE or DRAG_ELEMENT)
|
|
17
17
|
def expandable_field?(field_type)
|
|
18
|
-
[
|
|
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:
|
|
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
|
-
|
|
460
|
-
|
|
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:
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
data/lib/labimotion/version.rb
CHANGED
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.
|
|
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-
|
|
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
|