geoblacklight_sidecar_images 0.1.0 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/Gemfile +5 -5
  4. data/README.md +136 -11
  5. data/db/migrate/20180118203155_create_solr_document_sidecars.rb +4 -2
  6. data/db/migrate/20180118203519_create_sidecar_image_transitions.rb +29 -0
  7. data/geoblacklight_sidecar_images.gemspec +5 -3
  8. data/lib/generators/geoblacklight_sidecar_images/{uploaders_generator.rb → config_generator.rb} +4 -5
  9. data/lib/generators/geoblacklight_sidecar_images/install_generator.rb +8 -14
  10. data/lib/generators/geoblacklight_sidecar_images/models_generator.rb +24 -2
  11. data/lib/generators/geoblacklight_sidecar_images/templates/config/initializers/statesman.rb +5 -0
  12. data/lib/generators/geoblacklight_sidecar_images/templates/jobs/store_image_job.rb +10 -3
  13. data/lib/generators/geoblacklight_sidecar_images/templates/models/sidecar_image_state_machine.rb +19 -0
  14. data/lib/generators/geoblacklight_sidecar_images/templates/models/sidecar_image_transition.rb +7 -0
  15. data/lib/generators/geoblacklight_sidecar_images/templates/models/solr_document_sidecar.rb +34 -2
  16. data/lib/generators/geoblacklight_sidecar_images/templates/services/image_service.rb +103 -96
  17. data/lib/generators/geoblacklight_sidecar_images/templates/views/catalog/_index_split_default.html.erb +11 -1
  18. data/lib/geoblacklight_sidecar_images/version.rb +1 -1
  19. data/lib/tasks/geoblacklight_sidecar_images_tasks.rake +165 -39
  20. data/spec/jobs/store_image_job_spec.rb +16 -0
  21. data/spec/services/image_service_spec.rb +5 -8
  22. data/template.rb +3 -2
  23. metadata +48 -24
  24. data/lib/generators/geoblacklight_sidecar_images/assets_generator.rb +0 -31
  25. data/lib/generators/geoblacklight_sidecar_images/templates/assets/images/thumbnail-image.png +0 -0
  26. data/lib/generators/geoblacklight_sidecar_images/templates/assets/images/thumbnail-line.png +0 -0
  27. data/lib/generators/geoblacklight_sidecar_images/templates/assets/images/thumbnail-mixed.png +0 -0
  28. data/lib/generators/geoblacklight_sidecar_images/templates/assets/images/thumbnail-multipoint.png +0 -0
  29. data/lib/generators/geoblacklight_sidecar_images/templates/assets/images/thumbnail-paper-map.png +0 -0
  30. data/lib/generators/geoblacklight_sidecar_images/templates/assets/images/thumbnail-point.png +0 -0
  31. data/lib/generators/geoblacklight_sidecar_images/templates/assets/images/thumbnail-polygon.png +0 -0
  32. data/lib/generators/geoblacklight_sidecar_images/templates/assets/images/thumbnail-raster.png +0 -0
  33. data/lib/generators/geoblacklight_sidecar_images/templates/uploaders/image_uploader.rb +0 -55
@@ -0,0 +1,7 @@
1
+ class SidecarImageTransition < ActiveRecord::Base
2
+ include Statesman::Adapters::ActiveRecordTransition
3
+
4
+ validates :to_state, inclusion: { in: SidecarImageStateMachine.states }
5
+
6
+ belongs_to :solr_document_sidecar, inverse_of: :sidecar_image_transitions
7
+ end
@@ -3,11 +3,15 @@
3
3
  ##
4
4
  # Metadata for indexed documents
5
5
  class SolrDocumentSidecar < ApplicationRecord
6
- mount_uploader :image, ImageUploader
6
+ include Statesman::Adapters::ActiveRecordQueries
7
7
 
8
8
  belongs_to :document, required: true, polymorphic: true
9
+ has_many :sidecar_image_transitions, autosave: false
10
+ has_one_attached :image
11
+
12
+ # If the sidecar solr document is updated, re-fetch thumbnail image
13
+ after_update :reimage, if: :saved_change_to_version?
9
14
 
10
- # Roll our own polymorphism because our documents are not AREL-able
11
15
  def document
12
16
  document_type.new document_type.unique_key => document_id
13
17
  end
@@ -15,4 +19,32 @@ class SolrDocumentSidecar < ApplicationRecord
15
19
  def document_type
16
20
  (super.constantize if defined?(super)) || default_document_type
17
21
  end
22
+
23
+ def image_state
24
+ @state_machine ||= SidecarImageStateMachine.new(
25
+ self,
26
+ transition_class: SidecarImageTransition
27
+ )
28
+ end
29
+
30
+ def self.transition_class
31
+ SidecarImageTransition
32
+ end
33
+
34
+ def self.initial_state
35
+ :initialized
36
+ end
37
+
38
+ def self.image_url
39
+ Rails.application.routes.url_helpers.rails_blob_path(self.image, only_path: true)
40
+ end
41
+
42
+ private_class_method :initial_state
43
+
44
+ private
45
+
46
+ def reimage
47
+ self.image.purge if self.image.attached?
48
+ StoreImageJob.perform_later(self.document.id)
49
+ end
18
50
  end
@@ -1,10 +1,21 @@
1
1
  # frozen_string_literal: true
2
-
3
- require 'rack/mime'
2
+ require "addressable/uri"
3
+ require "mimemagic"
4
4
 
5
5
  class ImageService
6
+ attr_reader :document
7
+ attr_writer :metadata, :logger
8
+
6
9
  def initialize(document)
7
10
  @document = document
11
+
12
+ @metadata = Hash.new
13
+ @metadata['solr_doc_id'] = document.id
14
+ @metadata['solr_version'] = @document.sidecar.version
15
+ @metadata['placeheld'] = false
16
+
17
+ @document.sidecar.image_state.transition_to!(:processing, @metadata)
18
+
8
19
  @logger ||= ActiveSupport::TaggedLogging.new(
9
20
  Logger.new(
10
21
  File.join(
@@ -14,46 +25,66 @@ class ImageService
14
25
  )
15
26
  end
16
27
 
17
- # Stores the document's image in SolrDocumentSidecar
18
- # using Carrierwave
28
+ # Stores the document's image in ActiveStorage
19
29
  # @return [Boolean]
20
30
  #
21
- # @TODO: EWL
22
31
  def store
23
- sidecar = @document.sidecar
24
- sidecar.image = image_tempfile(@document.id)
25
- sidecar.save!
26
- @logger.tagged(@document.id, 'STATUS') { @logger.info 'SUCCESS' }
27
- @logger.tagged(@document.id, 'SIDECAR_IMAGE_URL') { @logger.info @document.sidecar.image_url }
28
- rescue ActiveRecord::RecordInvalid, FloatDomainError => invalid
29
- @logger.tagged(@document.id, 'STATUS') { @logger.info 'FAILURE' }
30
- @logger.tagged(@document.id, 'EXCEPTION') { @logger.info invalid.inspect }
31
- end
32
+ # Gentle hands
33
+ sleep(1)
32
34
 
33
- # Returns hash containing placeholder thumbnail for the document.
34
- # @return [Hash]
35
- # * :type [String] image mime type
36
- # * :data [String] image file data
37
- def placeholder
38
- placeholder_data
39
- end
35
+ io_file = image_tempfile(@document.id)
40
36
 
41
- private
37
+ if io_file.nil? || @metadata['placeheld'] == true
38
+ @document.sidecar.image_state.transition_to!(:placeheld, @metadata)
39
+ log_output
40
+ else
41
+ # Remote content-type headers are untrustworthy
42
+ # Pull the mimetype and file extension via MimeMagic
43
+ mm = MimeMagic.by_magic(File.open(io_file))
44
+
45
+ @metadata['MimeMagic_type'] = mm.type
46
+ @metadata['MimeMagic_mediatype'] = mm.mediatype
47
+ @metadata['MimeMagic_subtype'] = mm.subtype
48
+
49
+ if mm.mediatype == "image"
50
+ @document.sidecar.image.attach(
51
+ io: io_file,
52
+ filename: "#{@document.id}.#{mm.subtype}",
53
+ content_type: mm.type
54
+ )
55
+ @document.sidecar.image_state.transition_to!(:succeeded, @metadata)
56
+ else
57
+ @document.sidecar.image_state.transition_to!(:placeheld, @metadata)
58
+ end
42
59
 
43
- def image_tempfile(document_id)
44
- @logger.tagged(@document.id, 'remote_content_type') { @logger.info remote_content_type }
45
- @logger.tagged(@document.id, 'viewer_protocol') { @logger.info @document.viewer_protocol }
46
- @logger.tagged(@document.id, 'service_url') { @logger.info service_url }
47
- @logger.tagged(@document.id, 'image_extension') { @logger.info image_extension }
60
+ log_output
61
+ end
62
+ rescue Exception => invalid
63
+ @metadata['exception'] = invalid.inspect
64
+ @document.sidecar.image_state.transition_to!(:failed, @metadata)
48
65
 
49
- file = Tempfile.new([document_id, image_extension])
50
- file.binmode
51
- file.write(image_data[:data])
52
- file.close
66
+ log_output
67
+ end
53
68
 
54
- @logger.tagged(@document.id, 'IMAGE_TEMPFILE') { @logger.info file.inspect }
69
+ private
55
70
 
56
- file
71
+ def image_tempfile(document_id)
72
+ @metadata['viewer_protocol'] = @document.viewer_protocol
73
+ @metadata['image_url'] = image_url
74
+ @metadata['service_url'] = service_url
75
+ @metadata['gblsi_thumbnail_uri'] = gblsi_thumbnail_uri
76
+
77
+ if image_data && @metadata['placeheld'] == false
78
+ temp_file = Tempfile.new([document_id, ".tmp"])
79
+ temp_file.binmode
80
+ temp_file.write(image_data)
81
+ temp_file.rewind
82
+
83
+ @metadata['image_tempfile'] = temp_file.inspect
84
+ temp_file
85
+ else
86
+ return nil
87
+ end
57
88
  end
58
89
 
59
90
  # Returns geoserver auth credentials if the document is a restriced Local WMS layer.
@@ -80,71 +111,38 @@ class ImageService
80
111
  end
81
112
  end
82
113
 
83
- def placeholder_base_path
84
- Rails.root.join('app', 'assets', 'images')
85
- end
86
-
87
- # Generates hash containing placeholder mime_type and image.
88
- def placeholder_data
89
- { type: 'image/png', data: placeholder_image }
90
- end
91
-
92
- # Gets placeholder image from disk.
93
- def placeholder_image
94
- File.read(placeholder_image_path)
95
- end
96
-
97
- # Path to placeholder image based on the layer geometry.
98
- def placeholder_image_path
99
- geom_type = @document.fetch('layer_geom_type_s', '').tr(' ', '-').downcase
100
- thumb_path = "#{placeholder_base_path}/thumbnail-#{geom_type}.png"
101
- return "#{placeholder_base_path}/thumbnail-paper-map.png" unless File.exist?(thumb_path)
102
- thumb_path
103
- end
104
-
105
114
  # Generates hash containing thumbnail mime_type and image.
106
115
  def image_data
107
- return placeholder_data unless image_url
108
- { type: remote_content_type, data: remote_image }
116
+ return nil unless image_url
117
+ remote_image
109
118
  end
110
119
 
111
- # Gets thumbnail image from URL. On error, returns document's placeholder image.
112
- def remote_content_type
120
+ # Gets thumbnail image from URL. On error, placehold image.
121
+ def remote_image
113
122
  auth = geoserver_credentials
114
123
 
115
- conn = Faraday.new(url: image_url) do |b|
116
- b.use FaradayMiddleware::FollowRedirects
117
- b.adapter :net_http
118
- end
119
-
120
- conn.options.timeout = timeout
121
- conn.options.timeout = timeout
122
- conn.authorization :Basic, auth if auth
123
-
124
- conn.head.headers['content-type']
125
- rescue Faraday::Error::ConnectionFailed
126
- placeholder_data[:type]
127
- rescue Faraday::Error::TimeoutError
128
- placeholder_data[:type]
129
-
130
- # Rescuing Exception intentionally
131
- rescue Exception
132
- placeholder_data[:type]
133
- end
124
+ uri = Addressable::URI.parse(image_url)
134
125
 
135
- # Gets thumbnail image from URL. On error, returns document's placeholder image.
136
- def remote_image
137
- auth = geoserver_credentials
138
- conn = Faraday.new(url: image_url)
139
- conn.options.timeout = timeout
140
- conn.options.timeout = timeout
141
- conn.authorization :Basic, auth if auth
126
+ if uri.scheme.include?("http")
127
+ conn = Faraday.new(url: uri.normalize.to_s) do |b|
128
+ b.use FaradayMiddleware::FollowRedirects
129
+ b.adapter :net_http
130
+ end
142
131
 
143
- conn.get.body
132
+ conn.options.timeout = timeout
133
+ conn.authorization :Basic, auth if auth
134
+ conn.get.body
135
+ else
136
+ return nil
137
+ end
144
138
  rescue Faraday::Error::ConnectionFailed
145
- placeholder_image
139
+ @metadata['error'] = "Faraday::Error::ConnectionFailed"
140
+ @metadata['placeheld'] = true
141
+ return nil
146
142
  rescue Faraday::Error::TimeoutError
147
- placeholder_image
143
+ @metadata['error'] = "Faraday::Error::TimeoutError"
144
+ @metadata['placeheld'] = true
145
+ return nil
148
146
  end
149
147
 
150
148
  # Returns the thumbnail url.
@@ -165,11 +163,6 @@ class ImageService
165
163
  end
166
164
  end
167
165
 
168
- # Determines the image file extension
169
- def image_extension
170
- @image_extension ||= Rack::Mime::MIME_TYPES.rassoc(remote_content_type).try(:first) || '.png'
171
- end
172
-
173
166
  # Checks if the document is Local restriced access and is a scanned map.
174
167
  def restricted_scanned_map?
175
168
  @document.local_restricted? && @document['layer_geom_type_s'] == 'Image'
@@ -189,9 +182,15 @@ class ImageService
189
182
  @service_url ||= begin
190
183
  return unless @document.available?
191
184
  protocol = @document.viewer_protocol
192
- return if protocol == 'map' || protocol.nil?
185
+ if protocol == 'map' || protocol.nil?
186
+ @metadata['error'] = "Unsupported viewer protocol"
187
+ @metadata['placeheld'] = true
188
+ return nil
189
+ end
193
190
  "ImageService::#{protocol.camelcase}".constantize.image_url(@document, image_size)
194
191
  rescue NameError
192
+ @metadata['error'] = "service_url NameError"
193
+ @metadata['placeheld'] = true
195
194
  return nil
196
195
  end
197
196
  end
@@ -202,13 +201,21 @@ class ImageService
202
201
  JSON.parse(@document[@document.references.reference_field])['http://schema.org/thumbnailUrl']
203
202
  end
204
203
 
205
- # Default thumbnail size.
204
+ # Default image size.
206
205
  def image_size
207
- 300
206
+ 1500
208
207
  end
209
208
 
210
209
  # Faraday timeout value.
211
210
  def timeout
212
211
  30
213
212
  end
213
+
214
+ # Capture metadata within image harvest log
215
+ def log_output
216
+ @metadata["state"] = @document.sidecar.image_state.current_state
217
+ @metadata.each do |key,value|
218
+ @logger.tagged(@document.id, key.to_s) { @logger.info value }
219
+ end
220
+ end
214
221
  end
@@ -11,7 +11,17 @@
11
11
  <div class='col-xs-12'>
12
12
  <div class='media'>
13
13
  <div class='media-left'>
14
- <img class='media-object' src="<%=document.sidecar.image_url(:square)%>"/>
14
+ <% if document.sidecar.image.attached? %>
15
+ <% if document.sidecar.image.variable? %>
16
+ <%= image_tag document.sidecar.image.variant(resize: "100x100"), {class: 'media-object'} %>
17
+ <% else %>
18
+ <%= image_tag document.sidecar.image, {class: 'media-object'} %>
19
+ <% end %>
20
+ <% else %>
21
+ <span title="<%=document[Settings.FIELDS.GEOM_TYPE]%>">
22
+ <%= geoblacklight_icon(document[Settings.FIELDS.GEOM_TYPE]) %>
23
+ </span>
24
+ <% end %>
15
25
  </div>
16
26
  <div class='media-body'>
17
27
  <small>
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GeoblacklightSidecarImages
4
- VERSION = '0.1.0'.freeze
4
+ VERSION = '0.5.0'.freeze
5
5
  end
@@ -1,4 +1,6 @@
1
- namespace :geoblacklight_sidecar_images do
1
+ require 'csv'
2
+
3
+ namespace :gblsci do
2
4
  namespace :sample_data do
3
5
  desc 'Ingests a directory of geoblacklight.json files'
4
6
  task :ingest, [:directory] => :environment do |_t, args|
@@ -17,60 +19,184 @@ namespace :geoblacklight_sidecar_images do
17
19
  end
18
20
 
19
21
  namespace :images do
20
- desc 'Pre-cache specific image'
21
- task :precache_id, [:doc_id] => [:environment] do |_t, args|
22
- query = "dc_identifier_s:#{args[:doc_id]}"
23
- layers = %w[
24
- layer_slug_s
25
- layer_id_s
26
- dc_rights_s
27
- dct_provenance_s
28
- layer_geom_type_s
29
- dct_references_s
30
- ]
31
- index = Geoblacklight::SolrDocument.index
32
- results = index.send_and_receive(index.blacklight_config.solr_path,
33
- q: query,
34
- fl: layers.join(','),
35
- rows: 100_000_000)
36
- num_found = results.response[:numFound]
37
- doc_counter = 0
38
- results.docs.each do |document|
39
- begin
40
- StoreImageJob.perform_later(document.to_h)
41
- rescue Blacklight::Exceptions::RecordNotFound
42
- next
43
- end
44
- end
22
+ desc 'Harvest image for specific document'
23
+ task :harvest_doc_id, [:doc_id] => [:environment] do |_t, args|
24
+ StoreImageJob.perform_later(args[:doc_id])
45
25
  end
46
26
 
47
- desc 'Pre-cache all images'
48
- task :precache_all, [:override_existing] => [:environment] do |_t, args|
27
+ desc 'Harvest all images'
28
+ task harvest_all: :environment do
49
29
  begin
50
- query = 'layer_slug_s:*'
51
- layers = %w[
52
- layer_slug_s
53
- layer_id_s
54
- dc_rights_s
55
- dct_provenance_s
56
- layer_geom_type_s
57
- dct_references_s
58
- ]
30
+ query = '*:*'
59
31
  index = Geoblacklight::SolrDocument.index
60
32
  results = index.send_and_receive(index.blacklight_config.solr_path,
61
33
  q: query,
62
- fl: layers.join(','),
34
+ fl: "*",
63
35
  rows: 100_000_000)
64
36
  num_found = results.response[:numFound]
65
37
  doc_counter = 0
66
38
  results.docs.each do |document|
39
+ sleep(1)
67
40
  begin
68
- StoreImageJob.perform_later(document.to_h)
41
+ StoreImageJob.perform_later(document.id)
69
42
  rescue Blacklight::Exceptions::RecordNotFound
70
43
  next
71
44
  end
72
45
  end
73
46
  end
74
47
  end
48
+
49
+ desc 'Hash of SolrDocumentSidecar image state counts'
50
+ task harvest_states: :environment do
51
+ states = [
52
+ :initialized,
53
+ :queued,
54
+ :processing,
55
+ :succeeded,
56
+ :failed,
57
+ :placeheld
58
+ ]
59
+
60
+ col_state = {}
61
+ states.each do |state|
62
+ sidecars = SolrDocumentSidecar.in_state(state)
63
+ col_state[state] = sidecars.size
64
+ end
65
+
66
+ col_state.each do |col,state|
67
+ puts "#{col} - #{state}"
68
+ end
69
+ end
70
+
71
+ desc 'Re-queues incomplete states for harvesting'
72
+ task harvest_retry: :environment do
73
+ states = [
74
+ :initialized,
75
+ :queued,
76
+ :processing,
77
+ :failed,
78
+ :placeheld
79
+ ]
80
+
81
+ states.each do |state|
82
+ sidecars = SolrDocumentSidecar.in_state(state)
83
+
84
+ puts "#{state} - #{sidecars.size}"
85
+
86
+ sidecars.each do |sc|
87
+ cat = CatalogController.new
88
+ begin
89
+ resp, doc = cat.fetch(sc.document_id)
90
+ StoreImageJob.perform_later(doc.id)
91
+ rescue
92
+ puts "orphaned / #{sc.document_id}"
93
+ end
94
+ end
95
+ end
96
+ end
97
+
98
+ desc 'Write harvest state report (CSV)'
99
+ task harvest_report: :environment do
100
+ # Create a CSV Dump of Results
101
+ file = "#{Rails.root}/public/#{Time.now.strftime('%Y-%m-%d_%H-%M-%S')}.sidecar_report.csv"
102
+
103
+ sidecars = SolrDocumentSidecar.all
104
+
105
+ CSV.open(file, 'w') do |writer|
106
+ header = [
107
+ "Sidecar ID",
108
+ "Document ID",
109
+ "Current State",
110
+ "Doc Data Type",
111
+ "Doc Title",
112
+ "Doc Institution",
113
+ "Error",
114
+ "Viewer Protocol",
115
+ "Image URL",
116
+ "GBLSI Thumbnail URL"
117
+ ]
118
+
119
+ writer << header
120
+
121
+ sidecars.each do |sc|
122
+ cat = CatalogController.new
123
+ begin
124
+ resp, doc = cat.fetch(sc.document_id)
125
+ writer << [
126
+ sc.id,
127
+ sc.document_id,
128
+ sc.image_state.current_state,
129
+ doc._source['layer_geom_type_s'],
130
+ doc._source['dc_title_s'],
131
+ doc._source['dct_provenance_s'],
132
+ sc.image_state.last_transition.metadata['exception'],
133
+ sc.image_state.last_transition.metadata['viewer_protocol'],
134
+ sc.image_state.last_transition.metadata['image_url'],
135
+ sc.image_state.last_transition.metadata['gblsi_thumbnail_uri']
136
+ ]
137
+ rescue Exception => e
138
+ puts "Exception: #{e.inspect}"
139
+ puts "orphaned / #{sc.document_id}"
140
+ next
141
+ end
142
+ end
143
+ end
144
+ end
145
+
146
+ desc 'Destroy all harvested images and sidecar AR objects'
147
+ task harvest_purge_all: :environment do
148
+ # Remove all images
149
+ sidecars = SolrDocumentSidecar.all
150
+ sidecars.each do |sc|
151
+ sc.image.purge
152
+ end
153
+
154
+ # Delete all Transitions and Sidecars
155
+ SidecarImageTransition.destroy_all
156
+ SolrDocumentSidecar.destroy_all
157
+ end
158
+
159
+ desc 'Destroy orphaned images and sidecar AR objects'
160
+ # When a SolrDocumentSidecar AR object exists,
161
+ # but it's corresponding SolrDocument is no longer in the Solr index.
162
+ task harvest_purge_orphans: :environment do
163
+ # Remove all images
164
+ sidecars = SolrDocumentSidecar.all
165
+ sidecars.each do |sc|
166
+ cat = CatalogController.new
167
+ begin
168
+ resp, doc = cat.fetch(sc.document_id)
169
+ rescue
170
+ sc.destroy
171
+ puts "orphaned / #{sc.document_id} / destroyed"
172
+ end
173
+ end
174
+ end
175
+
176
+ desc 'Destroy select sidecar AR objects by CSV file'
177
+ task harvest_destroy_batch: :environment do
178
+ # Expects a CSV file in Rails.root/tmp/destroy_batch.csv
179
+ #
180
+ # From your local machine, copy it up to production server like this:
181
+ # scp destroy_batch.csv swadm@geoprod:/swadm/var/www/geoblacklight/current/tmp/
182
+ CSV.foreach("#{Rails.root}/tmp/destroy_batch.csv", headers: true) do |row|
183
+ sc = SolrDocumentSidecar.find_by(:document_id => row[0])
184
+ sc.destroy
185
+ puts "document_id - #{row[0]} - destroyed"
186
+ end
187
+ end
188
+
189
+ desc 'Inspect failed state objects'
190
+ task harvest_failed_state_inspect: :environment do
191
+ states = [
192
+ :failed
193
+ ]
194
+
195
+ states.each do |state|
196
+ sidecars = SolrDocumentSidecar.in_state(state).each do |sc|
197
+ puts "#{state} - #{sc.document_id} - #{sc.image_state.last_transition.metadata.inspect}"
198
+ end
199
+ end
200
+ end
75
201
  end
76
202
  end