blacklight-spotlight 0.33.3 → 0.34.0
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/README.md +6 -9
- data/app/assets/javascripts/spotlight/application.js +2 -1
- data/app/assets/javascripts/spotlight/crop.es6 +189 -111
- data/app/assets/javascripts/spotlight/croppable.js +3 -1
- data/app/assets/stylesheets/spotlight/_browse.scss +10 -2
- data/app/assets/stylesheets/spotlight/_croppable.scss +0 -7
- data/app/jobs/spotlight/add_uploads_from_csv.rb +1 -2
- data/app/views/spotlight/browse/show.html.erb +3 -1
- data/app/views/spotlight/filters/_form.html.erb +2 -1
- data/config/locales/spotlight.en.yml +3 -0
- data/lib/generators/spotlight/templates/spotlight.scss +0 -1
- data/lib/migration/iiif.rb +5 -2
- data/lib/spotlight/version.rb +1 -1
- data/spec/examples.txt +14 -13
- data/spec/jobs/spotlight/add_uploads_from_csv_spec.rb +5 -4
- data/spec/lib/migration/iiif_spec.rb +8 -11
- data/spec/views/spotlight/browse/show.html.erb_spec.rb +8 -1
- data/vendor/assets/javascripts/Leaflet.Editable.js +1906 -0
- data/vendor/assets/javascripts/Path.Drag.js +137 -0
- metadata +4 -3
- data/vendor/assets/javascripts/leaflet-areaselect.js +0 -184
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA1:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f30c2ba602e4df3dcdd51199dfa12107b73f2524
|
|
4
|
+
data.tar.gz: 5637f5556a02098769807495bbdc6263dee9c53f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0d23691bf5614ac53f7b5fe03363623a05b1c1858b9d08f217db8cbafa806a5535a1ac64c8ef02c41dba673a336819f32ce1fafdc62386944e3a3091f3f45148
|
|
7
|
+
data.tar.gz: ffe5a6592ff92e21d5b8e84436e6c131d6293412f395d4830c9bd45561db420e1937e1fd13f7531b8bf16fac49fab71024f76a8c1aa1059be1bc107e0414213c
|
data/README.md
CHANGED
|
@@ -42,22 +42,19 @@ Run the database migrations:
|
|
|
42
42
|
$ rake db:migrate
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
Start Solr
|
|
45
|
+
Start Solr (possibly using `solr_wrapper` in development or testing):
|
|
46
46
|
|
|
47
47
|
```
|
|
48
|
-
$
|
|
48
|
+
$ solr_wrapper
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
## Upgrade notes
|
|
54
|
-
|
|
55
|
-
To convert your images to IIIF run the following command
|
|
51
|
+
and the Rails development server:
|
|
56
52
|
|
|
57
|
-
```
|
|
58
|
-
$
|
|
53
|
+
```
|
|
54
|
+
$ rails server
|
|
59
55
|
```
|
|
60
56
|
|
|
57
|
+
Go to http://localhost:3000 in your browser.
|
|
61
58
|
|
|
62
59
|
## Configuration
|
|
63
60
|
|
|
@@ -12,13 +12,101 @@ export default class Crop {
|
|
|
12
12
|
this.iiifImageField = $('#' + this.formPrefix + '_iiif_image_id');
|
|
13
13
|
|
|
14
14
|
this.form = cropArea.closest('form');
|
|
15
|
-
this.initialCropRegion = [0, 0, cropArea.data('crop-width'), cropArea.data('crop-height')];
|
|
16
15
|
this.tileSource = null;
|
|
16
|
+
}
|
|
17
17
|
|
|
18
|
+
// Render the cropper environment and add hooks into the autocomplete and upload forms
|
|
19
|
+
render() {
|
|
18
20
|
this.setupAutoCompletes();
|
|
19
21
|
this.setupAjaxFileUpload();
|
|
20
22
|
this.setupExistingIiifCropper();
|
|
21
|
-
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Setup the cropper on page load if the field
|
|
26
|
+
// that holds the IIIF url is populated
|
|
27
|
+
setupExistingIiifCropper() {
|
|
28
|
+
if(this.iiifUrlField.val() === '') {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
this.addImageSelectorToExistingCropTool();
|
|
33
|
+
this.setTileSource(this.iiifUrlField.val());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Display the IIIF Cropper map with the current IIIF Layer (and cropbox, once the layer is available)
|
|
37
|
+
setupIiifCropper() {
|
|
38
|
+
this.loaded = false;
|
|
39
|
+
|
|
40
|
+
this.renderCropperMap();
|
|
41
|
+
|
|
42
|
+
if (this.imageLayer) {
|
|
43
|
+
this.cropperMap.removeLayer(this.imageLayer);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
this.imageLayer = L.tileLayer.iiif(this.tileSource).addTo(this.cropperMap);
|
|
47
|
+
|
|
48
|
+
var self = this;
|
|
49
|
+
this.imageLayer.on('load', function() {
|
|
50
|
+
if (!self.loaded) {
|
|
51
|
+
var region = self.getCropRegion();
|
|
52
|
+
self.positionIiifCropBox(region);
|
|
53
|
+
self.loaded = true;
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
this.cropArea.data('initiallyVisible', this.cropArea.is(':visible'));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Get (or initialize) the current crop region from the form data
|
|
61
|
+
getCropRegion() {
|
|
62
|
+
var regionFieldValue = this.iiifRegionField.val();
|
|
63
|
+
if(!regionFieldValue || regionFieldValue === '') {
|
|
64
|
+
var region = this.defaultCropRegion();
|
|
65
|
+
this.iiifRegionField.val(region);
|
|
66
|
+
return region;
|
|
67
|
+
} else {
|
|
68
|
+
return regionFieldValue.split(',');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Calculate a default crop region in the center of the image using the correct aspect ratio
|
|
73
|
+
defaultCropRegion() {
|
|
74
|
+
var imageWidth = this.imageLayer.x;
|
|
75
|
+
var imageHeight = this.imageLayer.y;
|
|
76
|
+
|
|
77
|
+
var boxWidth = Math.floor(imageWidth / 2);
|
|
78
|
+
var boxHeight = Math.floor(boxWidth / this.aspectRatio());
|
|
79
|
+
|
|
80
|
+
return [
|
|
81
|
+
Math.floor((imageWidth - boxWidth) / 2),
|
|
82
|
+
Math.floor((imageHeight - boxHeight) / 2),
|
|
83
|
+
boxWidth,
|
|
84
|
+
boxHeight
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Calculate the required aspect ratio for the crop area
|
|
89
|
+
aspectRatio() {
|
|
90
|
+
var cropWidth = parseInt(this.cropArea.data('crop-width'));
|
|
91
|
+
var cropHeight = parseInt(this.cropArea.data('crop-height'));
|
|
92
|
+
return cropWidth / cropHeight;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Position the IIIF Crop Box at the given IIIF region
|
|
96
|
+
positionIiifCropBox(region) {
|
|
97
|
+
var bounds = this.unprojectIIIFRegionToBounds(region);
|
|
98
|
+
|
|
99
|
+
if (!this.cropBox) {
|
|
100
|
+
this.renderCropBox(bounds);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
this.cropBox.setBounds(bounds);
|
|
104
|
+
this.cropperMap.invalidateSize();
|
|
105
|
+
this.cropperMap.fitBounds(bounds);
|
|
106
|
+
|
|
107
|
+
this.cropBox.editor.editLayer.clearLayers();
|
|
108
|
+
this.cropBox.editor.refresh();
|
|
109
|
+
this.cropBox.editor.initVertexMarkers();
|
|
22
110
|
}
|
|
23
111
|
|
|
24
112
|
// Set all of the various input fields to
|
|
@@ -30,18 +118,19 @@ export default class Crop {
|
|
|
30
118
|
this.iiifImageField.val(iiifObject.imageId);
|
|
31
119
|
}
|
|
32
120
|
|
|
33
|
-
emptyIiifFields() {
|
|
34
|
-
this.iiifManifestField.val('');
|
|
35
|
-
this.iiifCanvasField.val('');
|
|
36
|
-
this.iiifImageField.val('');
|
|
37
|
-
}
|
|
38
|
-
|
|
39
121
|
// Set the Crop tileSource and setup the cropper
|
|
40
122
|
setTileSource(source) {
|
|
41
123
|
if (source == this.tileSource) {
|
|
42
124
|
return;
|
|
43
|
-
}
|
|
44
|
-
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (source === null || source === undefined) {
|
|
128
|
+
console.error('No tilesource provided when setting up IIIF Cropper');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (this.cropBox) {
|
|
133
|
+
this.iiifRegionField.val("");
|
|
45
134
|
}
|
|
46
135
|
|
|
47
136
|
this.tileSource = source;
|
|
@@ -49,6 +138,68 @@ export default class Crop {
|
|
|
49
138
|
this.setupIiifCropper();
|
|
50
139
|
}
|
|
51
140
|
|
|
141
|
+
// Render the Leaflet Map into the crop area
|
|
142
|
+
renderCropperMap() {
|
|
143
|
+
if (this.cropperMap) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
this.cropperMap = L.map(this.cropArea.attr('id'), {
|
|
147
|
+
editable: true,
|
|
148
|
+
center: [0, 0],
|
|
149
|
+
crs: L.CRS.Simple,
|
|
150
|
+
zoom: 0,
|
|
151
|
+
editOptions: {
|
|
152
|
+
rectangleEditorClass: this.aspectRatioPreservingRectangleEditor(this.aspectRatio())
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
this.invalidateMapSizeOnTabToggle();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Render the crop box (a Leaflet editable rectangle) onto the canvas
|
|
159
|
+
renderCropBox(initialBounds) {
|
|
160
|
+
this.cropBox = L.rectangle(initialBounds);
|
|
161
|
+
this.cropBox.addTo(this.cropperMap);
|
|
162
|
+
this.cropBox.enableEdit();
|
|
163
|
+
this.cropBox.on('dblclick', L.DomEvent.stop).on('dblclick', this.cropBox.toggleEdit);
|
|
164
|
+
|
|
165
|
+
var self = this;
|
|
166
|
+
this.cropperMap.on('editable:dragend editable:vertex:dragend', function(e) {
|
|
167
|
+
var bounds = e.layer.getBounds();
|
|
168
|
+
var region = self.projectBoundsToIIIFRegion(bounds);
|
|
169
|
+
|
|
170
|
+
self.iiifRegionField.val(region.join(','));
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Get the maximum zoom level for the IIIF Layer (always 1:1 image pixel to canvas?)
|
|
175
|
+
maxZoom() {
|
|
176
|
+
if(this.imageLayer) {
|
|
177
|
+
return this.imageLayer.maxZoom;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Take a Leaflet LatLngBounds object and transform it into a IIIF [x, y, w, h] region
|
|
182
|
+
projectBoundsToIIIFRegion(bounds) {
|
|
183
|
+
var min = this.cropperMap.project(bounds.getNorthWest(), this.maxZoom());
|
|
184
|
+
var max = this.cropperMap.project(bounds.getSouthEast(), this.maxZoom());
|
|
185
|
+
return [
|
|
186
|
+
Math.max(Math.floor(min.x), 0),
|
|
187
|
+
Math.max(Math.floor(min.y), 0),
|
|
188
|
+
Math.floor(max.x - min.x),
|
|
189
|
+
Math.floor(max.y - min.y)
|
|
190
|
+
];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Take a IIIF [x, y, w, h] region and transform it into a Leaflet LatLngBounds
|
|
194
|
+
unprojectIIIFRegionToBounds(region) {
|
|
195
|
+
var minPoint = L.point(parseInt(region[0]), parseInt(region[1]));
|
|
196
|
+
var maxPoint = L.point(parseInt(region[0]) + parseInt(region[2]), parseInt(region[1]) + parseInt(region[3]));
|
|
197
|
+
|
|
198
|
+
var min = this.cropperMap.unproject(minPoint, this.maxZoom());
|
|
199
|
+
var max = this.cropperMap.unproject(maxPoint, this.maxZoom());
|
|
200
|
+
return L.latLngBounds(min, max);
|
|
201
|
+
}
|
|
202
|
+
|
|
52
203
|
// TODO: Add accessors to update hidden inputs with IIIF uri/ids?
|
|
53
204
|
|
|
54
205
|
// Setup autocomplete inputs to have the iiif_cropper context
|
|
@@ -62,17 +213,6 @@ export default class Crop {
|
|
|
62
213
|
this.fileInput.change(() => this.uploadFile());
|
|
63
214
|
}
|
|
64
215
|
|
|
65
|
-
// Setup the cropper on page load if the field
|
|
66
|
-
// that holds the IIIF url is populated
|
|
67
|
-
setupExistingIiifCropper() {
|
|
68
|
-
if(this.iiifUrlField.val() === '') {
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
this.addImageSelectorToExistingCropTool();
|
|
73
|
-
this.setTileSource(this.iiifUrlField.val());
|
|
74
|
-
}
|
|
75
|
-
|
|
76
216
|
addImageSelectorToExistingCropTool() {
|
|
77
217
|
if(this.iiifManifestField.val() === '') {
|
|
78
218
|
return;
|
|
@@ -84,99 +224,12 @@ export default class Crop {
|
|
|
84
224
|
addImageSelector(input, panel, this.iiifManifestField.val(), !this.iiifImageField.val());
|
|
85
225
|
}
|
|
86
226
|
|
|
87
|
-
setupIiifCropper() {
|
|
88
|
-
if (this.tileSource === null || this.tileSource === undefined) {
|
|
89
|
-
console.error('No tilesource provided when setting up IIIF Cropper');
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if(this.iiifCropper) {
|
|
94
|
-
this.iiifCropper.removeLayer(this.iiifLayer);
|
|
95
|
-
this.iiifLayer = L.tileLayer.iiif(this.tileSource).addTo(this.iiifCropper);
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
this.iiifCropper = L.map(this.cropArea.attr('id'), {
|
|
100
|
-
center: [0, 0],
|
|
101
|
-
crs: L.CRS.Simple,
|
|
102
|
-
zoom: 0
|
|
103
|
-
});
|
|
104
|
-
this.iiifLayer = L.tileLayer.iiif(this.tileSource, {
|
|
105
|
-
tileSize: 512
|
|
106
|
-
}).addTo(this.iiifCropper);
|
|
107
|
-
|
|
108
|
-
this.iiifCropBox = L.areaSelect({
|
|
109
|
-
width: this.cropArea.data('crop-width') / 2,
|
|
110
|
-
height: this.cropArea.data('crop-height') / 2,
|
|
111
|
-
keepAspectRatio: true
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
this.iiifCropBox.addTo(this.iiifCropper);
|
|
115
|
-
|
|
116
|
-
this.positionIiifCropBox();
|
|
117
|
-
|
|
118
|
-
var self = this;
|
|
119
|
-
this.iiifCropBox.on('change', function(){
|
|
120
|
-
var bounds = this.getBounds();
|
|
121
|
-
var zoom = self.iiifCropper.getZoom();
|
|
122
|
-
var min = self.iiifCropper.project(bounds.getSouthWest(), zoom);
|
|
123
|
-
var max = self.iiifCropper.project(bounds.getNorthEast(), zoom);
|
|
124
|
-
var imageSize = self.iiifLayer._imageSizes[zoom];
|
|
125
|
-
var xRatio = self.iiifLayer.x / imageSize.x;
|
|
126
|
-
var yRatio = self.iiifLayer.y / imageSize.y;
|
|
127
|
-
var region = [
|
|
128
|
-
Math.max(Math.floor(min.x * xRatio), 0),
|
|
129
|
-
Math.max(Math.floor(max.y * yRatio), 0),
|
|
130
|
-
Math.min(Math.floor((max.x - min.x) * xRatio), self.iiifLayer.x),
|
|
131
|
-
Math.min(Math.floor((min.y - max.y) * yRatio), self.iiifLayer.y),
|
|
132
|
-
];
|
|
133
|
-
if (self.existingCropBoxSet) {
|
|
134
|
-
self.iiifRegionField.val(region.join(','));
|
|
135
|
-
}
|
|
136
|
-
});
|
|
137
|
-
this.cropArea.data('initiallyVisible', this.cropArea.is(':visible'));
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
positionIiifCropBox() {
|
|
141
|
-
var self = this;
|
|
142
|
-
this.iiifLayer.on('load', function() {
|
|
143
|
-
var regionFieldValue = self.iiifRegionField.val();
|
|
144
|
-
if(!regionFieldValue || regionFieldValue === '' || self.existingCropBoxSet) {
|
|
145
|
-
self.existingCropBoxSet = true;
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
var maxZoom = self.iiifLayer.maxZoom;
|
|
149
|
-
var b = regionFieldValue.split(',');
|
|
150
|
-
var minPoint = L.point(parseInt(b[0]), parseInt(b[1]));
|
|
151
|
-
var maxPoint = L.point(parseInt(b[0]) + parseInt(b[2]), parseInt(b[1]) + parseInt(b[3]));
|
|
152
|
-
|
|
153
|
-
var min = self.iiifCropper.unproject(minPoint, maxZoom);
|
|
154
|
-
var max = self.iiifCropper.unproject(maxPoint, maxZoom);
|
|
155
|
-
|
|
156
|
-
var y = max.lat - min.lat;
|
|
157
|
-
var x = max.lng - min.lng;
|
|
158
|
-
|
|
159
|
-
// Pop a rectangle on there to show where it goes
|
|
160
|
-
var bounds = L.latLngBounds(min, max);
|
|
161
|
-
self.previousCropBox = L.polygon([min, [min.lat, max.lng], max, [max.lat, min.lng]]);
|
|
162
|
-
self.previousCropBox.addTo(self.iiifCropper);
|
|
163
|
-
self.iiifCropper.panTo(bounds.getCenter());
|
|
164
|
-
|
|
165
|
-
self.iiifCropBox.setDimensions({
|
|
166
|
-
width: Math.round(Math.abs(x)),
|
|
167
|
-
height: Math.round(Math.abs(y))
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
self.existingCropBoxSet = true;
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
|
|
174
227
|
invalidateMapSizeOnTabToggle() {
|
|
175
228
|
var tabs = $('[role="tablist"]', this.form);
|
|
176
229
|
var self = this;
|
|
177
230
|
tabs.on('shown.bs.tab', function() {
|
|
178
231
|
if(self.cropArea.data('initiallyVisible') === false && self.cropArea.is(':visible')) {
|
|
179
|
-
self.
|
|
232
|
+
self.cropperMap.invalidateSize();
|
|
180
233
|
self.cropArea.data('initiallyVisible', null);
|
|
181
234
|
}
|
|
182
235
|
});
|
|
@@ -208,7 +261,32 @@ export default class Crop {
|
|
|
208
261
|
}
|
|
209
262
|
|
|
210
263
|
successHandler(data, stat, xhr) {
|
|
211
|
-
this.
|
|
212
|
-
|
|
264
|
+
this.setIiifFields({ tilesource: data.tilesource });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
aspectRatioPreservingRectangleEditor(aspect) {
|
|
268
|
+
return L.Editable.RectangleEditor.extend({
|
|
269
|
+
extendBounds: function (e) {
|
|
270
|
+
var index = e.vertex.getIndex(),
|
|
271
|
+
next = e.vertex.getNext(),
|
|
272
|
+
previous = e.vertex.getPrevious(),
|
|
273
|
+
oppositeIndex = (index + 2) % 4,
|
|
274
|
+
opposite = e.vertex.latlngs[oppositeIndex];
|
|
275
|
+
|
|
276
|
+
if ((index % 2) == 1) {
|
|
277
|
+
// calculate horiz. displacement
|
|
278
|
+
e.latlng.update([opposite.lat + ((1 / aspect) * (opposite.lng - e.latlng.lng)), e.latlng.lng]);
|
|
279
|
+
} else {
|
|
280
|
+
// calculate vert. displacement
|
|
281
|
+
e.latlng.update([e.latlng.lat, (opposite.lng - (aspect * (opposite.lat - e.latlng.lat)))]);
|
|
282
|
+
}
|
|
283
|
+
var bounds = new L.LatLngBounds(e.latlng, opposite);
|
|
284
|
+
// Update latlngs by hand to preserve order.
|
|
285
|
+
previous.latlng.update([e.latlng.lat, opposite.lng]);
|
|
286
|
+
next.latlng.update([opposite.lat, e.latlng.lng]);
|
|
287
|
+
this.updateBounds(bounds);
|
|
288
|
+
this.refreshVertexMarkers();
|
|
289
|
+
}
|
|
290
|
+
});
|
|
213
291
|
}
|
|
214
292
|
}
|
|
@@ -6,9 +6,17 @@ $image-overlay-bottom-margin: $padding-large-vertical * 3;
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
.long-description-text {
|
|
9
|
-
column-width: 20em;
|
|
10
|
-
column-gap: 3em;
|
|
11
9
|
margin: ($padding-base-vertical * 2) 0;
|
|
10
|
+
width: 40em;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.very-long-description-text {
|
|
14
|
+
width: auto;
|
|
15
|
+
|
|
16
|
+
@media (min-width: $screen-sm-max) {
|
|
17
|
+
column-gap: 3em;
|
|
18
|
+
column-width: 20em;
|
|
19
|
+
}
|
|
12
20
|
}
|
|
13
21
|
|
|
14
22
|
.browse-landing {
|
|
@@ -16,11 +16,10 @@ module Spotlight
|
|
|
16
16
|
next unless url.present?
|
|
17
17
|
|
|
18
18
|
resource = Spotlight::Resources::Upload.new(
|
|
19
|
-
remote_url_url: url,
|
|
20
19
|
data: row,
|
|
21
20
|
exhibit: exhibit
|
|
22
21
|
)
|
|
23
|
-
|
|
22
|
+
resource.build_upload(remote_image_url: url)
|
|
24
23
|
resource.save_and_index
|
|
25
24
|
end
|
|
26
25
|
end
|