Repository: ComPlat/chemotion_ELN
Pull request #2474: fix: research plan export with image attachment
Author: login: TasnimMehzabin
Created: 2025-04-30T12:56:51Z | Merged: 2025-05-06T14:41:50Z

when an attachment image is referenced in the research plan body but has been removed from the attachment list
it is not exported. and the export file cannot be processed.

With this PR the missing images are fetch and the association to the research plan is rebuilt in the export.


Diff:
diff --git a/lib/export/export_collections.rb b/lib/export/export_collections.rb
index cd113b4427..978631cf5f 100644
--- a/lib/export/export_collections.rb
+++ b/lib/export/export_collections.rb
@@ -336,12 +336,16 @@ def fetch_research_plans(collection)
       # loop over research plans and fetch research plan properties
       collection.research_plans.each do |research_plan|
         # fetch attachments
-        # attachments are directrly related to research plans so we don't need fetch_containers
+        # attachments are directly related to research plans so we don't need fetch_containers
         fetch_many(research_plan.attachments, {
                      'attachable_id' => 'ResearchPlan',
                      'created_by' => 'User',
                      'created_for' => 'User',
                    })
+
+        # Fetch attachments referenced in the body by public_name
+        fetch_research_plan_body_attachments(research_plan)
+
         upload_att = Labimotion::Export.fetch_segments(research_plan, @uuids, nil, &method(:fetch_one))
         @attachments += upload_att if upload_att&.length&.positive?
 
@@ -358,6 +362,45 @@ def fetch_research_plans(collection)
       end
     end
 
+    def fetch_research_plan_body_attachments(research_plan)
+      return if research_plan.body.blank?
+
+      image_fields = extract_image_fields(research_plan.body)
+      return if image_fields.empty?
+
+      attachments = Attachment.where(identifier: image_fields.keys,
+                                     attachable_id: nil,
+                                     attachable_type: 'ResearchPlan')
+
+      filter_missing_attachments(research_plan, attachments)
+      process_attachments(attachments, research_plan)
+    end
+
+    def extract_image_fields(body)
+      body.select { |field| field['type'] == 'image' }
+          .each_with_object({}) do |field, map|
+            public_name = field['value']['public_name']
+            map[public_name] = field if public_name.present?
+          end
+    end
+
+    def filter_missing_attachments(research_plan, attachments)
+      found_public_names = attachments.map(&:identifier)
+      research_plan.body = research_plan.body.reject do |field|
+        field['type'] == 'image' && found_public_names.exclude?(field['value']['public_name'])
+      end
+    end
+
+    def process_attachments(attachments, research_plan)
+      attachments.each { |attachment| attachment['attachable_id'] = research_plan.id }
+      fetch_many(attachments, {
+                   'attachable_id' => 'ResearchPlan',
+                   'created_by' => 'User',
+                   'created_for' => 'User',
+                 })
+      @attachments += attachments
+    end
+
     # rubocop:disable Metrics/MethodLength
     def fetch_containers(containable)
       containable_type = containable.class.name
diff --git a/spec/lib/export/export_collections_spec.rb b/spec/lib/export/export_collections_spec.rb
index a82cba98ea..cbff37bb3b 100644
--- a/spec/lib/export/export_collections_spec.rb
+++ b/spec/lib/export/export_collections_spec.rb
@@ -88,9 +88,66 @@
       expect(file_names.length).to be 4
       expect(file_names).to include expected_attachment_filename
     end
+
+    context 'with multiple image fields' do
+      let(:attachment1) do
+        create(:attachment, :with_png_image,
+               identifier: '3367b5a0-24e9-11f0-ac68-bde43cb79548',
+               filename: 'Screenshot from 2025-04-29 12-01-22.png',
+               created_by: user.id)
+      end
+      let(:attachment2) do
+        create(:attachment, :with_png_image,
+               identifier: '3d3f3da0-24e9-11f0-ac68-bde43cb79548',
+               filename: 'Screenshot from 2025-04-23 10-59-42.png',
+               created_by: user.id)
+      end
+      let(:expected_attachment_filenames) do
+        %W[attachments/#{attachment1.identifier}.png attachments/#{attachment2.identifier}.png]
+      end
+
+      before do
+        research_plan.attachments = [attachment1, attachment2]
+        research_plan.save!
+
+        research_plan.update(
+          body: [
+            {
+              id: 'a39d554e-6822-43e6-9fb5-7a1ce7cc0267',
+              type: 'image',
+              value: {
+                file_name: 'Screenshot from 2025-04-29 12-01-22.png',
+                public_name: '3367b5a0-24e9-11f0-ac68-bde43cb79548',
+              },
+            },
+            {
+              id: 'ab9740db-11b0-4b26-8c1d-58db11c58a32',
+              type: 'image',
+              value: {
+                file_name: 'Screenshot from 2025-04-23 10-59-42.png',
+                public_name: '3d3f3da0-24e9-11f0-ac68-bde43cb79548',
+              },
+            },
+          ],
+        )
+
+        export = Export::ExportCollections.new(job_id, [collection.id], 'zip', nested, gate)
+        export.prepare_data
+        export.to_file
+      end
+
+      it 'exports both attachments' do
+        expect(file_names).to include(*expected_attachment_filenames)
+      end
+
+      it 'has correct number of files in zip' do
+        # 3 base files (export.json, schema.json, description.txt) + 2 attachments
+        expect(file_names.length).to be 5
+      end
+    end
   end
 
-  context 'with a reaction' do # rubocop:disable RSpecq/MultipleMemoizedHelpers
+  context 'with a reaction' do # rubocop:disable RSpec/MultipleMemoizedHelpers
     let(:sample1) { create(:sample) }
     let(:sample2) { create(:sample) }
     let(:reaction_in_json) { elements_in_json['Reaction'].first.second }
@@ -115,7 +172,7 @@
 
     it 'export.json has one reaction entry' do
       expect(elements_in_json['Reaction'].length).to be 1
-      # TO DO - find an elegant way to check all properties json <-> raction, maybe with an grape entity??
+      # TO DO - find an elegant way to check all properties json <-> reaction, maybe with an grape entity??
       expect(reaction_in_json['name']).to eq reaction.name
     end
   end
@@ -186,7 +243,7 @@
       expect(elements_in_json[sample_material_join].values.second['cellline_sample_id']).to eq sample2_uuid
     end
   end
-  
+
   context 'with a chemical' do
     let(:chemical) { create(:chemical, sample_id: sample.id) }
 
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index d4b22be0dc..ae748fe738 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -31,7 +31,7 @@ def stub_pubchem_request(status, body, end_point)
 Capybara.register_driver :selenium do |app|
   http_client = Selenium::WebDriver::Remote::Http::Default.new(
     open_timeout: nil,
-    read_timeout: 500
+    read_timeout: 500,
   )
 
   options = Selenium::WebDriver::Chrome::Options.new
@@ -46,22 +46,23 @@ def stub_pubchem_request(status, body, end_point)
       browser: 'ALL',
       client: 'ALL',
       driver: 'ALL',
-      server: 'ALL'
-    }
+      server: 'ALL',
+    },
   )
 
   Capybara::Selenium::Driver.new(
     app,
     browser: :chrome,
     http_client: http_client,
-    options: options
-    #desired_capabilities: capabilities
+    options: options,
+    # desired_capabilities: capabilities
   )
 end
 
 Capybara.default_max_wait_time = 5
 
 hostname = 'http://pubchem.ncbi.nlm.nih.gov'
+hostname_with_https = 'https://pubchem.ncbi.nlm.nih.gov'
 inchi_path = '/rest/pug/compound/inchikey/'
 
 RSpec.configure do |config|
@@ -78,31 +79,30 @@ def stub_pubchem_request(status, body, end_point)
   config.include ActiveJob::TestHelper, :active_job
 
   config.before do
-    [
-      'XLYOFNOQVPJJNP-UHFFFAOYSA-N',
-      'YJTKZCDBKVTVBY-UHFFFAOYSA-N',
-      'TXWRERCHRDBNLG-UHFFFAOYSA-N',
-      'QTBSBXVTEAMEQO-UHFFFAOYSA-N',
-      'LFQSCWFLJHTTHZ-UHFFFAOYSA-N',
-      'XEKOWRVHYACXOJ-UHFFFAOYSA-N',
-      'QAOWNCQODCNURD-UHFFFAOYSA-N',
-      'XEGUVFFZWHRVAV-SFOWXEAESA-N',
-      'QHDHNVFIKWGRJR-UHFFFAOYSA-N',
-      'QHDHNVFIKWGRJR-UHFFFAOYSA-N',
-      'XEGUVFFZWHRVAV-PVQJCKRUSA-N',
-      'OKKJLVBELUTLKV-UHFFFAOYSA-N',
-      'XBDQKXXYIPTUBI-UHFFFAOYSA-N',
-      'UHOVQNZJYSORNB-UHFFFAOYSA-N',
-      'RJUFJBKOKNCXHH-UHFFFAOYSA-N',
+    %w[
+      XLYOFNOQVPJJNP-UHFFFAOYSA-N
+      YJTKZCDBKVTVBY-UHFFFAOYSA-N
+      TXWRERCHRDBNLG-UHFFFAOYSA-N
+      QTBSBXVTEAMEQO-UHFFFAOYSA-N
+      LFQSCWFLJHTTHZ-UHFFFAOYSA-N
+      XEKOWRVHYACXOJ-UHFFFAOYSA-N
+      QAOWNCQODCNURD-UHFFFAOYSA-N
+      XEGUVFFZWHRVAV-SFOWXEAESA-N
+      QHDHNVFIKWGRJR-UHFFFAOYSA-N
+      XEGUVFFZWHRVAV-PVQJCKRUSA-N
+      OKKJLVBELUTLKV-UHFFFAOYSA-N
+      XBDQKXXYIPTUBI-UHFFFAOYSA-N
+      UHOVQNZJYSORNB-UHFFFAOYSA-N
+      RJUFJBKOKNCXHH-UHFFFAOYSA-N
     ].each do |target|
       stub_request(:get, "#{hostname}#{inchi_path}#{target}/record/JSON")
         .with(headers: { 'Content-Type' => 'text/json' })
         .to_return(
           status: 200,
           body: File.read(
-            Rails.root + "spec/fixtures/body_#{target}.json"
+            Rails.root + "spec/fixtures/body_#{target}.json",
           ),
-          headers: { 'Content-Type' => 'application/json' }
+          headers: { 'Content-Type' => 'application/json' },
         )
     end
 
@@ -129,37 +129,90 @@ def stub_pubchem_request(status, body, end_point)
       stub_pubchem_request(200, '', inchikey_end_point)
     end
 
-    stub_request(:get, 'http://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/inchikey/UFWIBTONFRDIAS-UHFFFAOYSA-N/record/JSON')
-      .with(headers: { 'Accept' => '*/*', 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Content-Type' => 'text/json' })
-      .to_return(status: 200, body: '', headers: {})
+    record_json_inchikeys = %w[
+      UHOVQNZJYSORNB-UHFFFAOYSA-N
+      YJTKZCDBKVTVBY-UHFFFAOYSA-N
+    ]
 
-    stub_request(:get, 'http://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/inchikey/YMWUJEATGCHHMB-UHFFFAOYSA-N/record/JSON')
-      .with(headers: { 'Accept' => '*/*', 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Content-Type' => 'text/json' })
-      .to_return(status: 200, body: '', headers: {})
+    record_json_inchikeys_http = %w[
+      YMWUJEATGCHHMB-UHFFFAOYSA-N
+      UFWIBTONFRDIAS-UHFFFAOYSA-N
+      PNNRZXFUPQQZSO-UHFFFAOYSA-N
+    ]
 
-    stub_request(:get, 'http://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/inchikey/UHOVQNZJYSORNB-UHFFFAOYSA-N/record/JSON')
-      .with(headers: { 'Accept' => '*/*', 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Content-Type' => 'text/json' })
-      .to_return(status: 200, body: '', headers: {})
+    cids_txt_inchikeys = %w[
+      YJTKZCDBKVTVBY-UHFFFAOYSA-N
+      XLYOFNOQVPJJNP-UHFFFAOYSA-1
+      XLYOFNOQVPJJNP-UHFFFAOYSA-2
+      XLYOFNOQVPJJNP-UHFFFAOYSA-2
+      XLYOFNOQVPJJNP-UHFFFAOYSA-3
+      XLYOFNOQVPJJNP-UHFFFAOYSA-4
+    ]
 
-    stub_request(:get, 'http://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/inchikey/PNNRZXFUPQQZSO-UHFFFAOYSA-N/record/JSON')
-      .with(headers: { 'Accept' => '*/*', 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Content-Type' => 'text/json' })
-      .to_return(status: 200, body: '', headers: {})
+    record_json_inchikeys.each do |target|
+      stub_request(:get, "#{hostname_with_https}#{inchi_path}#{target}/record/JSON")
+        .with(
+          headers: {
+            'Accept' => '*/*',
+            'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
+            'Content-Type' => 'text/json',
+            'User-Agent' => 'Ruby',
+          },
+        )
+        .to_return(status: 200, body: '', headers: {})
+    end
+
+    record_json_inchikeys_http.each do |target|
+      stub_request(:get, "#{hostname}#{inchi_path}#{target}/record/JSON")
+        .with(
+          headers: {
+            'Accept' => '*/*',
+            'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
+            'Content-Type' => 'text/json',
+            'User-Agent' => 'Ruby',
+          },
+        )
+        .to_return(status: 200, body: '', headers: {})
+    end
+
+    cids_txt_inchikeys.each do |target|
+      stub_request(:get, "#{hostname_with_https}#{inchi_path}#{target}/cids/TXT")
+        .with(
+          headers: {
+            'Accept' => '*/*',
+            'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
+            'Content-Type' => 'text/plain',
+            'User-Agent' => 'Ruby',
+          },
+        )
+        .to_return(status: 200, body: '', headers: {})
+    end
 
     stub_request(:post, 'http://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/inchikey/record/JSON')
-      .with(headers: { 'Content-Type' => 'text/json' }, body: { 'inchikey' => 'RDHQFKQIGNGIED-UHFFFAOYSA-N,RDHQFKQIGNGIED-UHFFFAOYSA-O' })
+      .with(
+        headers: { 'Content-Type' => 'text/json' },
+        body: {
+          'inchikey' => 'RDHQFKQIGNGIED-UHFFFAOYSA-N,RDHQFKQIGNGIED-UHFFFAOYSA-O',
+        },
+      )
       .to_return(
         status: 200,
         body: File.read(Rails.root + 'spec/fixtures/body_two_compounds.json'),
-        headers: { 'Content-Type' => 'application/json' }
+        headers: { 'Content-Type' => 'application/json' },
       )
+
     stub_request(:get, %r{http://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/inchikey/\S+/xrefs/RN/JSON})
       .with(headers: { 'Content-Type' => 'text/json' })
       .to_return { |_request| { body: xref_from_inchikey } }
 
     stub_request(:post, 'http://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/inchikey/property/InChIKey/JSON')
-      .with(headers: { 'Accept' => '*/*',
-                       'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
-                       'Content-Type' => 'application/x-www-form-urlencoded' })
+      .with(
+        headers: {
+          'Accept' => '*/*',
+          'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
+          'Content-Type' => 'application/x-www-form-urlencoded',
+        },
+      )
       .to_return { |request| { body: get_cids_from_inchikeys(request.body) } }
 
     stub_request(:get, 'https://pubchem.ncbi.nlm.nih.gov/rest/pug_view/data/compound/123456789/XML?heading=CAS')
@@ -167,11 +220,11 @@ def stub_pubchem_request(status, body, end_point)
       .to_return(
         status: 200,
         body: File.read(Rails.root + 'spec/fixtures/body_123456789_CAS.xml'),
-        headers: { 'Content-Type' => 'application/xml' }
+        headers: { 'Content-Type' => 'application/xml' },
       )
-    stub_request(:get, %r{http://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/inchikey/\S+/cids/TXT}).
-      # with(:headers => {'Content-Type'=>'text/json'}).
-      to_return(status: 200, body: '123456789', headers: {})
+
+    stub_request(:get, %r{http://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/inchikey/\S+/cids/TXT})
+      .to_return(status: 200, body: '123456789', headers: {})
 
     # page = "https://pubchem.ncbi.nlm.nih.gov/rest/pug_view/data/compound/#{cid}/JSON?heading=GHS%20Classification"
     stub_request(:get, 'https://pubchem.ncbi.nlm.nih.gov/rest/pug_view/data/compound/643785/JSON?heading=GHS%20Classification')
@@ -179,7 +232,7 @@ def stub_pubchem_request(status, body, end_point)
       .to_return(
         status: 200,
         body: File.read(Rails.root + 'spec/fixtures/body_643785_LCSS.json'),
-        headers: { 'Content-Type' => 'application/json' }
+        headers: { 'Content-Type' => 'application/json' },
       )
   end
 


Reviews:
user: login: github-actions[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-30T12:59:44Z
user: login: github-actions[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-05T07:43:31Z
user: login: github-actions[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-05T07:45:17Z
user: login: github-actions[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-05T08:15:01Z
user: login: github-actions[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-06T13:14:49Z
user: login: PiTrem
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-06T14:37:36Z

Review comments:
diff_hunk: @@ -88,6 +88,51 @@
       expect(file_names.length).to be 4
       expect(file_names).to include expected_attachment_filename
     end
+
+    context 'with multiple image fields' do
+      let(:attachment1) { create(:attachment, :with_png_image, identifier: '3367b5a0-24e9-11f0-ac68-bde43cb79548', filename: 'Screenshot from 2025-04-29 12-01-22.png', created_by: user.id) }
path: spec/lib/export/export_collections_spec.rb
user: login: github-actions[bot]
body: Layout/LineLength: Line is too long. [190/120]
created_at: 2025-04-30T12:59:44Z
author_association: NONE
diff_hunk: @@ -88,6 +88,51 @@
       expect(file_names.length).to be 4
       expect(file_names).to include expected_attachment_filename
     end
+
+    context 'with multiple image fields' do
+      let(:attachment1) { create(:attachment, :with_png_image, identifier: '3367b5a0-24e9-11f0-ac68-bde43cb79548', filename: 'Screenshot from 2025-04-29 12-01-22.png', created_by: user.id) }
+      let(:attachment2) { create(:attachment, :with_png_image, identifier: '3d3f3da0-24e9-11f0-ac68-bde43cb79548', filename: 'Screenshot from 2025-04-23 10-59-42.png', created_by: user.id) }
path: spec/lib/export/export_collections_spec.rb
user: login: github-actions[bot]
body: Layout/LineLength: Line is too long. [190/120]
created_at: 2025-04-30T12:59:44Z
author_association: NONE
diff_hunk: @@ -88,6 +88,51 @@
       expect(file_names.length).to be 4
       expect(file_names).to include expected_attachment_filename
     end
+
+    context 'with multiple image fields' do
+      let(:attachment1) { create(:attachment, :with_png_image, identifier: '3367b5a0-24e9-11f0-ac68-bde43cb79548', filename: 'Screenshot from 2025-04-29 12-01-22.png', created_by: user.id) }
+      let(:attachment2) { create(:attachment, :with_png_image, identifier: '3d3f3da0-24e9-11f0-ac68-bde43cb79548', filename: 'Screenshot from 2025-04-23 10-59-42.png', created_by: user.id) }
+      let(:expected_attachment_filenames) do
+        %W[attachments/#{attachment1.identifier}.png attachments/#{attachment2.identifier}.png]
+      end
+
+      before do
+        research_plan.attachments = [attachment1, attachment2]
+        research_plan.save!
+
+        research_plan.update(body: [
+          {
path: spec/lib/export/export_collections_spec.rb
user: login: github-actions[bot]
body: Layout/FirstArrayElementIndentation: Use 2 spaces for indentation in an array, relative to the first position after the preceding left parenthesis.
created_at: 2025-04-30T12:59:44Z
author_association: NONE
diff_hunk: @@ -88,6 +88,51 @@
       expect(file_names.length).to be 4
       expect(file_names).to include expected_attachment_filename
     end
+
+    context 'with multiple image fields' do
+      let(:attachment1) { create(:attachment, :with_png_image, identifier: '3367b5a0-24e9-11f0-ac68-bde43cb79548', filename: 'Screenshot from 2025-04-29 12-01-22.png', created_by: user.id) }
+      let(:attachment2) { create(:attachment, :with_png_image, identifier: '3d3f3da0-24e9-11f0-ac68-bde43cb79548', filename: 'Screenshot from 2025-04-23 10-59-42.png', created_by: user.id) }
+      let(:expected_attachment_filenames) do
+        %W[attachments/#{attachment1.identifier}.png attachments/#{attachment2.identifier}.png]
+      end
+
+      before do
+        research_plan.attachments = [attachment1, attachment2]
+        research_plan.save!
+
+        research_plan.update(body: [
+          {
+            id: 'a39d554e-6822-43e6-9fb5-7a1ce7cc0267',
+            type: 'image',
+            value: {
+              file_name: 'Screenshot from 2025-04-29 12-01-22.png',
+              public_name: '3367b5a0-24e9-11f0-ac68-bde43cb79548',
+            },
+          },
+          {
+            id: 'ab9740db-11b0-4b26-8c1d-58db11c58a32',
+            type: 'image',
+            value: {
+              file_name: 'Screenshot from 2025-04-23 10-59-42.png',
+              public_name: '3d3f3da0-24e9-11f0-ac68-bde43cb79548',
+            },
+          }
path: spec/lib/export/export_collections_spec.rb
user: login: github-actions[bot]
body: Style/TrailingCommaInArrayLiteral: Put a comma after the last item of a multiline array.
created_at: 2025-04-30T12:59:44Z
author_association: NONE
diff_hunk: @@ -88,6 +88,51 @@
       expect(file_names.length).to be 4
       expect(file_names).to include expected_attachment_filename
     end
+
+    context 'with multiple image fields' do
+      let(:attachment1) { create(:attachment, :with_png_image, identifier: '3367b5a0-24e9-11f0-ac68-bde43cb79548', filename: 'Screenshot from 2025-04-29 12-01-22.png', created_by: user.id) }
+      let(:attachment2) { create(:attachment, :with_png_image, identifier: '3d3f3da0-24e9-11f0-ac68-bde43cb79548', filename: 'Screenshot from 2025-04-23 10-59-42.png', created_by: user.id) }
+      let(:expected_attachment_filenames) do
+        %W[attachments/#{attachment1.identifier}.png attachments/#{attachment2.identifier}.png]
+      end
+
+      before do
+        research_plan.attachments = [attachment1, attachment2]
+        research_plan.save!
+
+        research_plan.update(body: [
+          {
+            id: 'a39d554e-6822-43e6-9fb5-7a1ce7cc0267',
+            type: 'image',
+            value: {
+              file_name: 'Screenshot from 2025-04-29 12-01-22.png',
+              public_name: '3367b5a0-24e9-11f0-ac68-bde43cb79548',
+            },
+          },
+          {
+            id: 'ab9740db-11b0-4b26-8c1d-58db11c58a32',
+            type: 'image',
+            value: {
+              file_name: 'Screenshot from 2025-04-23 10-59-42.png',
+              public_name: '3d3f3da0-24e9-11f0-ac68-bde43cb79548',
+            },
+          }
+        ])
path: spec/lib/export/export_collections_spec.rb
user: login: github-actions[bot]
body: Layout/FirstArrayElementIndentation: Indent the right bracket the same as the first position after the preceding left parenthesis.
created_at: 2025-04-30T12:59:44Z
author_association: NONE
diff_hunk: @@ -181,6 +181,72 @@ def stub_pubchem_request(status, body, end_point)
         body: File.read(Rails.root + 'spec/fixtures/body_643785_LCSS.json'),
         headers: { 'Content-Type' => 'application/json' }
       )
+
+    stub_request(:get, 'https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/inchikey/XLYOFNOQVPJJNP-UHFFFAOYSA-1/cids/TXT')
+      .with(
+        headers: {
+          'Accept' => '*/*',
+          'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
+          'Content-Type' => 'text/plain',
+          'User-Agent' => 'Ruby',
+        },
+      )
+      .to_return(status: 200, body: "", headers: {})
path: spec/spec_helper.rb
user: login: github-actions[bot]
body: Style/StringLiterals: Prefer single-quoted strings when you don't need string interpolation or special symbols.
created_at: 2025-04-30T12:59:44Z
author_association: NONE
diff_hunk: @@ -358,6 +362,24 @@ def fetch_research_plans(collection)
       end
     end
 
+    def fetch_research_plan_body_attachments(research_plan)
+      return if research_plan.body.blank?
+
+      public_names = research_plan.body.select { |field| field['type'] == 'image' }
+                                  .map { |field| field['value']['public_name'] }
+      return unless public_names.any?
+
+      attachments = Attachment.where(identifier: public_names, attachable_id: nil, attachable_type: 'ResearchPlan')
+      # TODO filter fields for which the attachment is not found
path: lib/export/export_collections.rb
user: login: github-actions[bot]
body: Style/CommentAnnotation: Annotation keywords like `TODO` should be all upper case, followed by a colon, and a space, then a note describing the problem.
created_at: 2025-05-05T07:43:31Z
author_association: NONE
diff_hunk: @@ -88,6 +88,64 @@
       expect(file_names.length).to be 4
       expect(file_names).to include expected_attachment_filename
     end
+
+    context 'with multiple image fields' do
+      let(:attachment1) do
+        create(:attachment, :with_png_image,
+               identifier: '3367b5a0-24e9-11f0-ac68-bde43cb79548',
+               filename: 'Screenshot from 2025-04-29 12-01-22.png',
+               created_by: user.id)
+      end
+      let(:attachment2) do
+        create(:attachment, :with_png_image,
+               identifier: '3d3f3da0-24e9-11f0-ac68-bde43cb79548',
+               filename: 'Screenshot from 2025-04-23 10-59-42.png',
+               created_by: user.id)
+      end
+      let(:expected_attachment_filenames) do
+        %W[attachments/#{attachment1.identifier}.png attachments/#{attachment2.identifier}.png]
+      end
+
+      before do
+        research_plan.attachments = [attachment1, attachment2]
+        research_plan.save!
+
+        research_plan.update(
+          body: [
+            {
+              id: 'a39d554e-6822-43e6-9fb5-7a1ce7cc0267',
+              type: 'image',
+              value: {
+                file_name: 'Screenshot from 2025-04-29 12-01-22.png',
+                public_name: '3367b5a0-24e9-11f0-ac68-bde43cb79548',
+              },
+            },
+            {
+              id: 'ab9740db-11b0-4b26-8c1d-58db11c58a32',
+              type: 'image',
+              value: {
+                file_name: 'Screenshot from 2025-04-23 10-59-42.png',
+                public_name: '3d3f3da0-24e9-11f0-ac68-bde43cb79548',
+              },
+            },
+          ]
path: spec/lib/export/export_collections_spec.rb
user: login: github-actions[bot]
body: Style/TrailingCommaInArguments: Put a comma after the last parameter of a multiline method call.
created_at: 2025-05-05T07:45:17Z
author_association: NONE
diff_hunk: @@ -88,6 +88,64 @@
       expect(file_names.length).to be 4
       expect(file_names).to include expected_attachment_filename
     end
+
+    context 'with multiple image fields' do
+      let(:attachment1) do
+        create(:attachment, :with_png_image,
+               identifier: '3367b5a0-24e9-11f0-ac68-bde43cb79548',
+               filename: 'Screenshot from 2025-04-29 12-01-22.png',
+               created_by: user.id)
+      end
+      let(:attachment2) do
+        create(:attachment, :with_png_image,
+               identifier: '3d3f3da0-24e9-11f0-ac68-bde43cb79548',
+               filename: 'Screenshot from 2025-04-23 10-59-42.png',
+               created_by: user.id)
+      end
+      let(:expected_attachment_filenames) do
+        %W[attachments/#{attachment1.identifier}.png attachments/#{attachment2.identifier}.png]
+      end
+
+      before do
+        research_plan.attachments = [attachment1, attachment2]
+        research_plan.save!
+
+        research_plan.update(
+          body: [
+            {
+              id: 'a39d554e-6822-43e6-9fb5-7a1ce7cc0267',
+              type: 'image',
+              value: {
+                file_name: 'Screenshot from 2025-04-29 12-01-22.png',
+                public_name: '3367b5a0-24e9-11f0-ac68-bde43cb79548',
+              },
+            },
+            {
+              id: 'ab9740db-11b0-4b26-8c1d-58db11c58a32',
+              type: 'image',
+              value: {
+                file_name: 'Screenshot from 2025-04-23 10-59-42.png',
+                public_name: '3d3f3da0-24e9-11f0-ac68-bde43cb79548',
+              },
+            },
+          ]
+        )
+
+
path: spec/lib/export/export_collections_spec.rb
user: login: github-actions[bot]
body: Layout/EmptyLines: Extra blank line detected.
created_at: 2025-05-05T07:45:17Z
author_association: NONE
diff_hunk: @@ -358,6 +362,47 @@ def fetch_research_plans(collection)
       end
     end
 
+    def fetch_research_plan_body_attachments(research_plan)
+      return if research_plan.body.blank?
+
+      image_fields = extract_image_fields(research_plan.body)
+      return if image_fields.empty?
+
+      attachments = Attachment.where(identifier: image_fields.keys,
+                                     attachable_id: nil,
+                                     attachable_type: 'ResearchPlan')
+
+      filter_missing_attachments(research_plan, attachments)
+      process_attachments(attachments, research_plan)
+    end
+
+    private
path: lib/export/export_collections.rb
user: login: github-actions[bot]
body: Lint/UselessAccessModifier: Useless `private` access modifier.
created_at: 2025-05-05T08:15:00Z
author_association: NONE
diff_hunk: @@ -358,6 +362,47 @@ def fetch_research_plans(collection)
       end
     end
 
+    def fetch_research_plan_body_attachments(research_plan)
+      return if research_plan.body.blank?
+
+      image_fields = extract_image_fields(research_plan.body)
+      return if image_fields.empty?
+
+      attachments = Attachment.where(identifier: image_fields.keys,
+                                     attachable_id: nil,
+                                     attachable_type: 'ResearchPlan')
+
+      filter_missing_attachments(research_plan, attachments)
+      process_attachments(attachments, research_plan)
+    end
+
+    private
+
+    def extract_image_fields(body)
+      body.select { |field| field['type'] == 'image' }
+          .each_with_object({}) do |field, map|
+            public_name = field['value']['public_name']
+            map[public_name] = field if public_name.present?
+          end
+    end
+
+    def filter_missing_attachments(research_plan, attachments)
+      found_public_names = attachments.map(&:identifier)
+      research_plan.body = research_plan.body.reject do |field|
+        field['type'] == 'image' && !found_public_names.include?(field['value']['public_name'])
path: lib/export/export_collections.rb
user: login: github-actions[bot]
body: Rails/NegateInclude: Use `.exclude?` and remove the negation part.
created_at: 2025-05-05T08:15:01Z
author_association: NONE
diff_hunk: @@ -129,57 +129,110 @@ def stub_pubchem_request(status, body, end_point)
       stub_pubchem_request(200, '', inchikey_end_point)
     end
 
-    stub_request(:get, 'http://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/inchikey/UFWIBTONFRDIAS-UHFFFAOYSA-N/record/JSON')
-      .with(headers: { 'Accept' => '*/*', 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Content-Type' => 'text/json' })
-      .to_return(status: 200, body: '', headers: {})
+    record_json_inchikeys = %w[
+      YMWUJEATGCHHMB-UHFFFAOYSA-N
+      UHOVQNZJYSORNB-UHFFFAOYSA-N
+      PNNRZXFUPQQZSO-UHFFFAOYSA-N
+      YJTKZCDBKVTVBY-UHFFFAOYSA-N
+    ]
 
-    stub_request(:get, 'http://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/inchikey/YMWUJEATGCHHMB-UHFFFAOYSA-N/record/JSON')
-      .with(headers: { 'Accept' => '*/*', 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Content-Type' => 'text/json' })
-      .to_return(status: 200, body: '', headers: {})
+    record_json_inchikeys_http = %w[
+     UFWIBTONFRDIAS-UHFFFAOYSA-N
path: spec/spec_helper.rb
user: login: github-actions[bot]
body: Layout/FirstArrayElementIndentation: Use 2 spaces for indentation in an array, relative to the start of the line where the left square bracket is.
created_at: 2025-05-06T13:14:49Z
author_association: NONE
diff_hunk: @@ -129,57 +129,110 @@ def stub_pubchem_request(status, body, end_point)
       stub_pubchem_request(200, '', inchikey_end_point)
     end
 
-    stub_request(:get, 'http://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/inchikey/UFWIBTONFRDIAS-UHFFFAOYSA-N/record/JSON')
-      .with(headers: { 'Accept' => '*/*', 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Content-Type' => 'text/json' })
-      .to_return(status: 200, body: '', headers: {})
+    record_json_inchikeys = %w[
+      YMWUJEATGCHHMB-UHFFFAOYSA-N
+      UHOVQNZJYSORNB-UHFFFAOYSA-N
+      PNNRZXFUPQQZSO-UHFFFAOYSA-N
+      YJTKZCDBKVTVBY-UHFFFAOYSA-N
+    ]
 
-    stub_request(:get, 'http://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/inchikey/YMWUJEATGCHHMB-UHFFFAOYSA-N/record/JSON')
-      .with(headers: { 'Accept' => '*/*', 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Content-Type' => 'text/json' })
-      .to_return(status: 200, body: '', headers: {})
+    record_json_inchikeys_http = %w[
+     UFWIBTONFRDIAS-UHFFFAOYSA-N
+    ]
 
-    stub_request(:get, 'http://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/inchikey/UHOVQNZJYSORNB-UHFFFAOYSA-N/record/JSON')
-      .with(headers: { 'Accept' => '*/*', 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Content-Type' => 'text/json' })
-      .to_return(status: 200, body: '', headers: {})
+    cids_txt_inchikeys = %w[
+      YJTKZCDBKVTVBY-UHFFFAOYSA-N
+      XLYOFNOQVPJJNP-UHFFFAOYSA-1
+      XLYOFNOQVPJJNP-UHFFFAOYSA-2
+      XLYOFNOQVPJJNP-UHFFFAOYSA-2
+      XLYOFNOQVPJJNP-UHFFFAOYSA-3
+      XLYOFNOQVPJJNP-UHFFFAOYSA-4
+    ]
 
-    stub_request(:get, 'http://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/inchikey/PNNRZXFUPQQZSO-UHFFFAOYSA-N/record/JSON')
-      .with(headers: { 'Accept' => '*/*', 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Content-Type' => 'text/json' })
-      .to_return(status: 200, body: '', headers: {})
+    record_json_inchikeys.each do |target|
+      stub_request(:get, "#{hostname_with_https}#{inchi_path}#{target}/record/JSON")
+        .with(
+          headers: {
+            'Accept' => '*/*',
+            'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
+            'Content-Type' => 'text/json',
+            'User-Agent' => 'Ruby',
+          },
+        )
+        .to_return(status: 200, body: '', headers: {})
+    end
+
+    record_json_inchikeys_http.each do |target|
+      stub_request(:get, "#{hostname}#{inchi_path}#{target}/record/JSON")
+        .with(
+          headers: {
+            'Accept' => '*/*',
+            'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
+            'Content-Type' => 'text/json',
+            'User-Agent' => 'Ruby',
+          },
+          )
path: spec/spec_helper.rb
user: login: github-actions[bot]
body: Layout/ClosingParenthesisIndentation: Indent `)` to column 8 (not 10)
created_at: 2025-05-06T13:14:49Z
author_association: NONE

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-30T13:20:10Z
body: ### [LCOV](https://github.com/marketplace/actions/report-lcov) of commit [<code>3c2410d</code>](2474/commits/3c2410d618b95b1719ecec563bb571e4f1a08329) during [Continuous Integration #5005](../actions/runs/14755111093)
<pre>Summary coverage rate:
  lines......: 67.9% (15781 of 23258 lines)
  functions..: no data found
  branches...: no data found

Files changed coverage rate: n/a</pre>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-05T08:02:38Z
body: ### [LCOV](https://github.com/marketplace/actions/report-lcov) of commit [<code>42614d7</code>](2474/commits/42614d7535d9ef00dbb5674c1747207cee83046f) during [Continuous Integration #5007](../actions/runs/14831516047)
<pre>Summary coverage rate:
  lines......: 67.8% (15772 of 23247 lines)
  functions..: no data found
  branches...: no data found

Files changed coverage rate: n/a</pre>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-05T08:04:40Z
body: ### [LCOV](https://github.com/marketplace/actions/report-lcov) of commit [<code>223b10c</code>](2474/commits/223b10c351ed8f1f93928720a5335a7d3d8a55c0) during [Continuous Integration #5008](../actions/runs/14831544733)
<pre>Summary coverage rate:
  lines......: 67.9% (15773 of 23246 lines)
  functions..: no data found
  branches...: no data found

Files changed coverage rate: n/a</pre>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-05T08:34:21Z
body: ### [LCOV](https://github.com/marketplace/actions/report-lcov) of commit [<code>e48a0f6</code>](2474/commits/e48a0f68dee60fca080d705f12ce518d2343430f) during [Continuous Integration #5009](../actions/runs/14832024744)
<pre>Summary coverage rate:
  lines......: 67.9% (15783 of 23258 lines)
  functions..: no data found
  branches...: no data found

Files changed coverage rate: n/a</pre>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-05T09:08:35Z
body: ### [LCOV](https://github.com/marketplace/actions/report-lcov) of commit [<code>29326f2</code>](2474/commits/29326f2269f81118c73015438724ec93121aa9bf) during [Continuous Integration #5010](../actions/runs/14832593580)
<pre>Summary coverage rate:
  lines......: 67.9% (15782 of 23257 lines)
  functions..: no data found
  branches...: no data found

Files changed coverage rate: n/a</pre>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-05T14:21:11Z
body: ### [LCOV](https://github.com/marketplace/actions/report-lcov) of commit [<code>29326f2</code>](2474/commits/29326f2269f81118c73015438724ec93121aa9bf) during [Continuous Integration #5018](../actions/runs/14838251703)
<pre>Summary coverage rate:
  lines......: 67.9% (15782 of 23257 lines)
  functions..: no data found
  branches...: no data found

Files changed coverage rate: n/a</pre>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-06T14:04:55Z
body: ### [LCOV](https://github.com/marketplace/actions/report-lcov) of commit [<code>634d5ce</code>](2474/commits/634d5ce14e5c410d72e4f90251bb297292cb32af) during [Continuous Integration #5039](../actions/runs/14861203841)
<pre>Summary coverage rate:
  lines......: 67.9% (15783 of 23261 lines)
  functions..: no data found
  branches...: no data found

Files changed coverage rate: n/a</pre>
author_association: NONE