From a26bc80adf4f60615c46861bbd11085d14aa162f Mon Sep 17 00:00:00 2001 From: Christina Cortland Date: Tue, 1 Oct 2024 10:47:46 -0400 Subject: [PATCH] WIP - Curators can upload multiple images for an item --- .../javascripts/openseadragon/jquery.js | 50 +++ .../admin/blocks/solr_documents_base_block.js | 36 +- .../javascripts/spotlight/admin/crop.es6 | 315 +++++++++++++++ .../spotlight/admin/multi_image_selector.js | 165 ++++++++ .../spotlight/admin/search_typeahead.js | 34 ++ app/assets/stylesheets/exhibits/_blocks.scss | 7 +- app/assets/stylesheets/exhibits/_layout.scss | 6 - app/controllers/catalog_controller.rb | 3 +- app/helpers/spotlight_helper.rb | 10 + app/jobs/spotlight/add_uploads_from_csv.rb | 40 +- app/models/concerns/cul/uploaded_resource.rb | 12 + app/models/solr_document.rb | 39 +- .../catalog_controller.rb | 14 + .../exhibits_controller.rb | 2 +- .../upload_controller.rb | 21 + .../prepended_models/featured_image.rb | 20 + app/prepends/prepended_models/upload.rb | 32 ++ .../exhibit_import_export_service.rb | 374 ++++++++++++++++++ .../spotlight/iiif_manifest_presenter.rb | 113 ++++++ ...ml.erb => _openseadragon_default.html.erb} | 9 +- .../spotlight/catalog/_edit_default.html.erb | 28 ++ .../spotlight/resources/upload/_form.html.erb | 22 ++ .../_solr_documents_embed_block.html.erb | 7 +- config/application.rb | 10 + config/environments/development.rb | 2 +- config/initializers/lib_prepends.rb | 5 + config/initializers/spotlight_initializer.rb | 3 + config/locales/spotlight.en.yml | 1 + ...esource_id_to_spotlight_featured_images.rb | 23 ++ lib/prepends/iiif.rb | 19 + scripts/resource_delete_service.rb | 50 +-- 31 files changed, 1397 insertions(+), 75 deletions(-) create mode 100644 app/assets/javascripts/openseadragon/jquery.js create mode 100644 app/assets/javascripts/spotlight/admin/crop.es6 create mode 100644 app/assets/javascripts/spotlight/admin/multi_image_selector.js create mode 100644 app/assets/javascripts/spotlight/admin/search_typeahead.js create mode 100644 app/models/concerns/cul/uploaded_resource.rb create mode 100644 app/prepends/prepended_controllers/catalog_controller.rb create mode 100644 app/prepends/prepended_controllers/upload_controller.rb create mode 100644 app/prepends/prepended_models/featured_image.rb create mode 100644 app/prepends/prepended_models/upload.rb create mode 100644 app/prepends/prepended_services/exhibit_import_export_service.rb create mode 100644 app/presenters/spotlight/iiif_manifest_presenter.rb rename app/views/catalog/{_viewer_default.html.erb => _openseadragon_default.html.erb} (83%) create mode 100644 app/views/spotlight/catalog/_edit_default.html.erb create mode 100644 app/views/spotlight/resources/upload/_form.html.erb create mode 100644 config/initializers/lib_prepends.rb create mode 100644 db/migrate/20240920151533_add_resource_id_to_spotlight_featured_images.rb create mode 100644 lib/prepends/iiif.rb diff --git a/app/assets/javascripts/openseadragon/jquery.js b/app/assets/javascripts/openseadragon/jquery.js new file mode 100644 index 00000000..dc81586e --- /dev/null +++ b/app/assets/javascripts/openseadragon/jquery.js @@ -0,0 +1,50 @@ +// Overrides openseadragon/jquery.js from openseadragon-rails gem to add custom page binding for pagination + +(function($) { + $.fn.openseadragon = function() { + var __osd_counter = 0; + function generateOsdId() { + __osd_counter++; + + return "Openseadragon" + __osd_counter; + } + + $(this).each(function() { + var $picture = $(this); + $picture.addClass('openseadragon-viewer'); + + if (typeof $picture.attr('id') === "undefined") { + $picture.attr('id', generateOsdId()); + } + + var collectionOptions = $picture.data('openseadragon'); + + var sources = $picture.find('source[media="openseadragon"]'); + + var tilesources = $.map(sources, function(e) { + if ($(e).data('openseadragon')) { + return $(e).data('openseadragon'); + } else { + return $(e).attr('src'); + } + }); + + $picture.css('height', $picture.css('height')); + + // BEGIN CUSTOMIZATION + var viewer = OpenSeadragon( + $.extend({ id: $picture.attr('id') }, collectionOptions, { tileSources: tilesources }) + ); + + viewer.addHandler('page', function (data) { + $picture.siblings().find('[id*="currentpage"]')[0].innerHTML = ( data.page + 1 ); + }); + + $picture.data('osdViewer', viewer); + // END CUSTOMIZATION + + }); + + return this; + }; +})(jQuery); \ No newline at end of file diff --git a/app/assets/javascripts/spotlight/admin/blocks/solr_documents_base_block.js b/app/assets/javascripts/spotlight/admin/blocks/solr_documents_base_block.js index dd80847b..7ad08b12 100644 --- a/app/assets/javascripts/spotlight/admin/blocks/solr_documents_base_block.js +++ b/app/assets/javascripts/spotlight/admin/blocks/solr_documents_base_block.js @@ -1,5 +1,7 @@ // Replaces _itemPanel from https://github.com/projectblacklight/spotlight/blob/v3.5.0.2/app/assets/javascripts/spotlight/admin/blocks/resources_block.js -// Adds optional "Alt text" input for resource image display +// Adds optional "Alt text" input for resource image display +// Replaces afterPanelRender from https://github.com/projectblacklight/spotlight/blob/v3.5.0.4/app/assets/javascripts/spotlight/admin/blocks/solr_documents_base_block.js +// Passes iiif tilesource to multiImageSelector instead of imageid SirTrevor.Blocks.SolrDocumentsBase = (function(){ @@ -73,6 +75,38 @@ SirTrevor.Blocks.SolrDocumentsBase = (function(){ this.afterPanelRender(data, panel); return panel; + }, + afterPanelRender: function(data, panel) { + var context = this; + var manifestUrl = data.iiif_manifest || data.iiif_manifest_url; + + if (!manifestUrl) { + $(panel).find('[name$="[thumbnail_image_url]"]').val(data.thumbnail_image_url || data.thumbnail); + $(panel).find('[name$="[full_image_url]"]').val(data.full_image_url); + + return; + } + + $.ajax(manifestUrl).done( + function(manifest) { + var Iiif = spotlightAdminIiif; + var iiifManifest = new Iiif(manifestUrl, manifest); + + var thumbs = iiifManifest.imagesArray(); + + // BEGIN CUSTOMIZATION + if (!data.iiif_tilesource) { + context.setIiifFields(panel, thumbs[0], !!data.iiif_manifest_url); + } + + if(thumbs.length > 1) { + panel.multiImageSelector(thumbs, function(selectorImage) { + context.setIiifFields(panel, selectorImage, false); + }, data.iiif_tilesource); + } + // END CUSTOMIZATION + } + ); } }); diff --git a/app/assets/javascripts/spotlight/admin/crop.es6 b/app/assets/javascripts/spotlight/admin/crop.es6 new file mode 100644 index 00000000..1946d0d9 --- /dev/null +++ b/app/assets/javascripts/spotlight/admin/crop.es6 @@ -0,0 +1,315 @@ +// Overrides Crop addImageSelectorToExistingCropTool from blacklight-spotlight gem to add image based on tilesource +// TODO: How to override addImageSelectorToExistingCropTool function only instead of copying over whole class? + +export default class Crop { + constructor(cropArea) { + this.cropArea = cropArea; + this.cropArea.data('iiifCropper', this); + this.cropSelector = '[data-cropper="' + cropArea.data('cropperKey') + '"]'; + this.cropTool = $(this.cropSelector); + this.formPrefix = this.cropTool.data('form-prefix'); + this.iiifUrlField = $('#' + this.formPrefix + '_iiif_tilesource'); + this.iiifRegionField = $('#' + this.formPrefix + '_iiif_region'); + this.iiifManifestField = $('#' + this.formPrefix + '_iiif_manifest_url'); + this.iiifCanvasField = $('#' + this.formPrefix + '_iiif_canvas_id'); + this.iiifImageField = $('#' + this.formPrefix + '_iiif_image_id'); + + this.form = cropArea.closest('form'); + this.tileSource = null; + } + + // Render the cropper environment and add hooks into the autocomplete and upload forms + render() { + this.setupAutoCompletes(); + this.setupAjaxFileUpload(); + this.setupExistingIiifCropper(); + } + + // Setup the cropper on page load if the field + // that holds the IIIF url is populated + setupExistingIiifCropper() { + if(this.iiifUrlField.val() === '') { + return; + } + + this.addImageSelectorToExistingCropTool(); + this.setTileSource(this.iiifUrlField.val()); + } + + // Display the IIIF Cropper map with the current IIIF Layer (and cropbox, once the layer is available) + setupIiifCropper() { + this.loaded = false; + + this.renderCropperMap(); + + if (this.imageLayer) { + // Force a broken layer's container to be an element before removing. + // Code in leaflet-iiif land calls delete on the image layer's container when removing, + // which errors if there is an issue fetching the info.json and stops further necessary steps to execute. + if(!this.imageLayer._container) { + this.imageLayer._container = $('
'); + } + this.cropperMap.removeLayer(this.imageLayer); + } + + this.imageLayer = L.tileLayer.iiif(this.tileSource).addTo(this.cropperMap); + + var self = this; + this.imageLayer.on('load', function() { + if (!self.loaded) { + var region = self.getCropRegion(); + self.positionIiifCropBox(region); + self.loaded = true; + } + }); + + this.cropArea.data('initiallyVisible', this.cropArea.is(':visible')); + } + + // Get (or initialize) the current crop region from the form data + getCropRegion() { + var regionFieldValue = this.iiifRegionField.val(); + if(!regionFieldValue || regionFieldValue === '') { + var region = this.defaultCropRegion(); + this.iiifRegionField.val(region); + return region; + } else { + return regionFieldValue.split(','); + } + } + + // Calculate a default crop region in the center of the image using the correct aspect ratio + defaultCropRegion() { + var imageWidth = this.imageLayer.x; + var imageHeight = this.imageLayer.y; + + var boxWidth = Math.floor(imageWidth / 2); + var boxHeight = Math.floor(boxWidth / this.aspectRatio()); + + return [ + Math.floor((imageWidth - boxWidth) / 2), + Math.floor((imageHeight - boxHeight) / 2), + boxWidth, + boxHeight + ]; + } + + // Calculate the required aspect ratio for the crop area + aspectRatio() { + var cropWidth = parseInt(this.cropArea.data('crop-width')); + var cropHeight = parseInt(this.cropArea.data('crop-height')); + return cropWidth / cropHeight; + } + + // Position the IIIF Crop Box at the given IIIF region + positionIiifCropBox(region) { + var bounds = this.unprojectIIIFRegionToBounds(region); + + if (!this.cropBox) { + this.renderCropBox(bounds); + } + + this.cropBox.setBounds(bounds); + this.cropperMap.invalidateSize(); + this.cropperMap.fitBounds(bounds); + + this.cropBox.editor.editLayer.clearLayers(); + this.cropBox.editor.refresh(); + this.cropBox.editor.initVertexMarkers(); + } + + // Set all of the various input fields to + // the appropriate IIIF URL or identifier + setIiifFields(iiifObject) { + this.setTileSource(iiifObject.tilesource); + this.iiifManifestField.val(iiifObject.manifest); + this.iiifCanvasField.val(iiifObject.canvasId); + this.iiifImageField.val(iiifObject.imageId); + } + + // Set the Crop tileSource and setup the cropper + setTileSource(source) { + if (source == this.tileSource) { + return; + } + + if (source === null || source === undefined) { + console.error('No tilesource provided when setting up IIIF Cropper'); + return; + } + + if (this.cropBox) { + this.iiifRegionField.val(""); + } + + this.tileSource = source; + this.iiifUrlField.val(source); + this.setupIiifCropper(); + } + + // Render the Leaflet Map into the crop area + renderCropperMap() { + if (this.cropperMap) { + return; + } + this.cropperMap = L.map(this.cropArea.attr('id'), { + editable: true, + center: [0, 0], + crs: L.CRS.Simple, + zoom: 0, + editOptions: { + rectangleEditorClass: this.aspectRatioPreservingRectangleEditor(this.aspectRatio()) + } + }); + this.invalidateMapSizeOnTabToggle(); + } + + // Render the crop box (a Leaflet editable rectangle) onto the canvas + renderCropBox(initialBounds) { + this.cropBox = L.rectangle(initialBounds); + this.cropBox.addTo(this.cropperMap); + this.cropBox.enableEdit(); + this.cropBox.on('dblclick', L.DomEvent.stop).on('dblclick', this.cropBox.toggleEdit); + + var self = this; + this.cropperMap.on('editable:dragend editable:vertex:dragend', function(e) { + var bounds = e.layer.getBounds(); + var region = self.projectBoundsToIIIFRegion(bounds); + + self.iiifRegionField.val(region.join(',')); + }); + } + + // Get the maximum zoom level for the IIIF Layer (always 1:1 image pixel to canvas?) + maxZoom() { + if(this.imageLayer) { + return this.imageLayer.maxZoom; + } + } + + // Take a Leaflet LatLngBounds object and transform it into a IIIF [x, y, w, h] region + projectBoundsToIIIFRegion(bounds) { + var min = this.cropperMap.project(bounds.getNorthWest(), this.maxZoom()); + var max = this.cropperMap.project(bounds.getSouthEast(), this.maxZoom()); + return [ + Math.max(Math.floor(min.x), 0), + Math.max(Math.floor(min.y), 0), + Math.floor(max.x - min.x), + Math.floor(max.y - min.y) + ]; + } + + // Take a IIIF [x, y, w, h] region and transform it into a Leaflet LatLngBounds + unprojectIIIFRegionToBounds(region) { + var minPoint = L.point(parseInt(region[0]), parseInt(region[1])); + var maxPoint = L.point(parseInt(region[0]) + parseInt(region[2]), parseInt(region[1]) + parseInt(region[3])); + + var min = this.cropperMap.unproject(minPoint, this.maxZoom()); + var max = this.cropperMap.unproject(maxPoint, this.maxZoom()); + return L.latLngBounds(min, max); + } + + // TODO: Add accessors to update hidden inputs with IIIF uri/ids? + + // Setup autocomplete inputs to have the iiif_cropper context + setupAutoCompletes() { + var input = $('[data-behavior="autocomplete"]', this.cropTool); + input.data('iiifCropper', this); + } + + setupAjaxFileUpload() { + this.fileInput = $('input[type="file"]', this.cropTool); + this.fileInput.change(() => this.uploadFile()); + } + + addImageSelectorToExistingCropTool() { + if(this.iiifManifestField.val() === '') { + return; + } + + var input = $('[data-behavior="autocomplete"]', this.cropTool); + var panel = $(input.data('target-panel')); + // This is defined in search_typeahead.js + // BEGIN CUSTOMIZATION + addImageSelector(input, panel, this.iiifManifestField.val(), !this.iiifUrlField.val()); + // END CUSTOMIZATION + } + + invalidateMapSizeOnTabToggle() { + var tabs = $('[role="tablist"]', this.form); + var self = this; + tabs.on('shown.bs.tab', function() { + if(self.cropArea.data('initiallyVisible') === false && self.cropArea.is(':visible')) { + self.cropperMap.invalidateSize(); + // Because the map size is 0,0 when image is loading (not visible) we need to refit the bounds of the layer + self.imageLayer._fitBounds(); + self.cropArea.data('initiallyVisible', null); + } + }); + } + + // Get all the form data with the exception of the _method field. + getData() { + var data = new FormData(this.form[0]); + data.append('_method', null); + return data; + } + + uploadFile() { + // Set a ujs adapter to support both rails-ujs and jquery-ujs + var ujs = typeof Rails === 'undefined' ? $.rails : Rails; + var url = this.fileInput.data('endpoint') + // Every post creates a new image/masthead. + // Because they create IIIF urls which are heavily cached. + $.ajax({ + url: url, //Server script to process data + type: 'POST', + success: (data, stat, xhr) => this.successHandler(data, stat, xhr), + // error: errorHandler, + // Form data + data: this.getData(), + headers: { + 'X-CSRF-Token': ujs.csrfToken() || '' + }, + //Options to tell jQuery not to process data or worry about content-type. + cache: false, + contentType: false, + processData: false + }); + } + + successHandler(data, stat, xhr) { + this.setIiifFields({ tilesource: data.tilesource }); + this.setUploadId(data.id); + } + + setUploadId(id) { + $('#' + this.formPrefix + "_upload_id").val(id); + } + + aspectRatioPreservingRectangleEditor(aspect) { + return L.Editable.RectangleEditor.extend({ + extendBounds: function (e) { + var index = e.vertex.getIndex(), + next = e.vertex.getNext(), + previous = e.vertex.getPrevious(), + oppositeIndex = (index + 2) % 4, + opposite = e.vertex.latlngs[oppositeIndex]; + + if ((index % 2) == 1) { + // calculate horiz. displacement + e.latlng.update([opposite.lat + ((1 / aspect) * (opposite.lng - e.latlng.lng)), e.latlng.lng]); + } else { + // calculate vert. displacement + e.latlng.update([e.latlng.lat, (opposite.lng - (aspect * (opposite.lat - e.latlng.lat)))]); + } + var bounds = new L.LatLngBounds(e.latlng, opposite); + // Update latlngs by hand to preserve order. + previous.latlng.update([e.latlng.lat, opposite.lng]); + next.latlng.update([opposite.lat, e.latlng.lng]); + this.updateBounds(bounds); + this.refreshVertexMarkers(); + } + }); + } +} diff --git a/app/assets/javascripts/spotlight/admin/multi_image_selector.js b/app/assets/javascripts/spotlight/admin/multi_image_selector.js new file mode 100644 index 00000000..2e3c7b9c --- /dev/null +++ b/app/assets/javascripts/spotlight/admin/multi_image_selector.js @@ -0,0 +1,165 @@ +// Overrides multi_image_selector.js from blacklight-spotlight to set active image by tilesource instead of image id +// Uploaded images don't have an image id in the iiif manifest +// +// Module to add multi-image selector to widget panels + +(function(){ + $.fn.multiImageSelector = function(image_versions, clickCallback, activeImageId) { + var changeLink = $("Change"), + thumbsListContainer = $(""), + thumbList = $(""), + panel; + + // BEGIN CUSTOMIZATION + var imageIds = $.map(image_versions, function(e) { return e['tilesource']; }); + // END CUSTOMIZATION + + return init(this); + + function init(el) { + panel = el; + + destroyExistingImageSelector(); + if(image_versions && image_versions.length > 1) { + addChangeLink(); + addThumbsList(); + } + } + function addChangeLink() { + $('[data-panel-image-pagination]', panel) + .html("Image " + indexOf(activeImageId) + " of " + image_versions.length) + .show() + .append(" ") + .append(changeLink); + addChangeLinkBehavior(); + } + + function destroyExistingImageSelector() { + var pagination = $('[data-panel-image-pagination]', panel); + pagination.html(''); + pagination.next('.' + thumbsListContainer.attr('class')).remove(); + } + + function indexOf(thumb){ + if( (index = imageIds.indexOf(thumb)) > -1 ){ + return index + 1; + } else { + return 1; + } + } + function addChangeLinkBehavior() { + changeLink.on('click', function(){ + thumbsListContainer.slideToggle(); + updateThumbListWidth(); + addScrollBehavior(); + scrollToActiveThumb(); + loadVisibleThumbs(); + swapChangeLinkText($(this)); + }); + } + function updateThumbListWidth() { + var width = 0; + $('li', thumbList).each(function(){ + width += $(this).outerWidth(); + }); + thumbList.width(width + 5); + } + function loadVisibleThumbs(){ + var viewportWidth = thumbsListContainer.width(); + var width = 0; + $('li', thumbList).each(function(){ + var thisThumb = $(this), + image = $('img', thisThumb), + totalWidth = width += thisThumb.width(); + position = (thumbList.position().left + totalWidth) - thisThumb.width(); + + if(position >= 0 && position < viewportWidth) { + image.prop('src', image.data('src')); + } + }); + } + function addScrollBehavior(){ + thumbsListContainer.scrollStop(function(){ + loadVisibleThumbs(); + }); + } + function scrollToActiveThumb(){ + var halfContainerWidth = (thumbsListContainer.width() / 2), + activeThumbLeftPosition = ($('.active', thumbList).position() || $('li', thumbList).first().position()).left, + halfActiveThumbWidth = ($('.active', thumbList).width() / 2); + thumbsListContainer.scrollLeft( + (activeThumbLeftPosition - halfContainerWidth) + halfActiveThumbWidth + ); + } + function addThumbsList() { + addThumbsToList(); + updateActiveThumb(); + $('.card-header', panel).append( + thumbsListContainer.append( + thumbList + ) + ); + } + function updateActiveThumb(){ + $('li', thumbList).each(function(){ + var item = $(this); + if($('img', item).data('image-id') == activeImageId){ + item.addClass('active'); + } + }); + } + function swapChangeLinkText(link){ + link.text( + link.text() == 'Change' ? 'Close' : 'Change' + ) + } + + function addThumbsToList(){ + $.each(image_versions, function(i){ + // BEGIN CUSTOMIZATION + var listItem = $('
  • '); + // END CUSTOMIZATION + listItem.on('click', function(){ + // get the current image id + var imageid = $('img', $(this)).data('image-id'); + var src = $('img', $(this)).attr('src'); + + if (typeof clickCallback === 'function' ) { + clickCallback(image_versions[i]); + } + + // mark the current selection as active + $('li.active', thumbList).removeClass('active'); + $(this).addClass('active'); + + // update the multi-image selector image + $(".pic img.img-thumbnail", panel).attr("src", src); + + $('[data-panel-image-pagination] [data-current-image]', panel).text( + $('li', thumbList).index($(this)) + 1 + ); + scrollToActiveThumb(); + }); + $("img", listItem).on('load', function() { + updateThumbListWidth(); + }); + thumbList.append(listItem); + }); + } + }; + +})(jQuery); + +// source: http://stackoverflow.com/questions/14035083/jquery-bind-event-on-scroll-stops +jQuery.fn.scrollStop = function(callback) { + $(this).scroll(function() { + var self = this, + $this = $(self); + + if ($this.data('scrollTimeout')) { + clearTimeout($this.data('scrollTimeout')); + } + + $this.data('scrollTimeout', setTimeout(callback, 250, self)); + }); +}; diff --git a/app/assets/javascripts/spotlight/admin/search_typeahead.js b/app/assets/javascripts/spotlight/admin/search_typeahead.js new file mode 100644 index 00000000..edec647e --- /dev/null +++ b/app/assets/javascripts/spotlight/admin/search_typeahead.js @@ -0,0 +1,34 @@ +// Overrides addImageSelector function from https://github.com/projectblacklight/spotlight/blob/v3.5.0.4/app/assets/javascripts/spotlight/admin/search_typeahead.js +// Uses tilesource for multi-image selector instead of image id to handle uploaded images + +function addImageSelector(input, panel, manifestUrl, initialize) { + if (!manifestUrl) { + showNonIiifAlert(input); + return; + } + var cropper = input.data('iiifCropper'); + $.ajax(manifestUrl).done( + function(manifest) { + var Iiif = spotlightAdminIiif; + var iiifManifest = new Iiif(manifestUrl, manifest); + + var thumbs = iiifManifest.imagesArray(); + + hideNonIiifAlert(input); + + if (initialize) { + cropper.setIiifFields(thumbs[0]); + panel.multiImageSelector(); // Clears out existing selector + } + + // BEGIN CUSTOMIZATION + if(thumbs.length > 1) { + panel.show(); + panel.multiImageSelector(thumbs, function(selectorImage) { + cropper.setIiifFields(selectorImage); + }, cropper.iiifUrlField.val()); + } + // END CUSTOMIZATION + } + ); +} diff --git a/app/assets/stylesheets/exhibits/_blocks.scss b/app/assets/stylesheets/exhibits/_blocks.scss index ab39ba6f..a84c6d16 100644 --- a/app/assets/stylesheets/exhibits/_blocks.scss +++ b/app/assets/stylesheets/exhibits/_blocks.scss @@ -72,4 +72,9 @@ color: inherit; } } -} \ No newline at end of file +} + +// Multi-image selection for item widgets +.thumbs-list { + overflow-x: auto; +} diff --git a/app/assets/stylesheets/exhibits/_layout.scss b/app/assets/stylesheets/exhibits/_layout.scss index efb3ae7b..5c843b72 100644 --- a/app/assets/stylesheets/exhibits/_layout.scss +++ b/app/assets/stylesheets/exhibits/_layout.scss @@ -1,9 +1,3 @@ -// Openseadragon viewer -// ----------------------- -// .openseadragon-container { -// min-height: 300px; -// } - #main-container { padding-bottom: 3em; } diff --git a/app/controllers/catalog_controller.rb b/app/controllers/catalog_controller.rb index a848e101..59759716 100644 --- a/app/controllers/catalog_controller.rb +++ b/app/controllers/catalog_controller.rb @@ -12,8 +12,7 @@ class CatalogController < ApplicationController config.view.masonry(document_component: Blacklight::Gallery::DocumentComponent) config.view.slideshow(document_component: Blacklight::Gallery::SlideshowComponent) config.show.tile_source_field = :content_metadata_image_iiif_info_ssm - # config.show.partials.insert(1, :openseadragon) - config.show.partials.insert(1, :viewer) + config.show.partials.insert(1, :openseadragon) ## Default parameters to send to solr for all search-like requests. See also SolrHelper#solr_search_params config.default_solr_params = { qt: 'search', diff --git a/app/helpers/spotlight_helper.rb b/app/helpers/spotlight_helper.rb index f0d31fd5..8baa34a1 100644 --- a/app/helpers/spotlight_helper.rb +++ b/app/helpers/spotlight_helper.rb @@ -26,4 +26,14 @@ def render_markdown_links(options = {}) def osd_container_class '' end + + # Gets initialPage for OpenSeadragon viewer + # Document tilesources are in the format: /images/{TILESOURCE ID}/info.json + # Selected tilesource from block is in the format: {DOMAIN}/images/{TILESOURCE ID}/info.json + def initial_page(document, block_options = {}) + selected_image_tile_source = block_options.fetch(:iiif_tilesource, '') + selected_image_tile_source = URI::parse(block_options.fetch(:iiif_tilesource, '')).path + doc_tile_source = document.fetch(blacklight_config.show.tile_source_field, default: []) + doc_tile_source.find_index(selected_image_tile_source) || 0 + end end diff --git a/app/jobs/spotlight/add_uploads_from_csv.rb b/app/jobs/spotlight/add_uploads_from_csv.rb index 03c77d0c..7f998a26 100644 --- a/app/jobs/spotlight/add_uploads_from_csv.rb +++ b/app/jobs/spotlight/add_uploads_from_csv.rb @@ -5,24 +5,28 @@ module Spotlight # Process a CSV upload into new Spotlight::Resource::Upload objects class AddUploadsFromCsv < Spotlight::ApplicationJob include Spotlight::JobTracking + with_job_tracking(resource: ->(job) { job.arguments[1] }) + attr_reader :count attr_reader :errors after_perform do |job| csv_data, exhibit, user = job.arguments - Spotlight::IndexingCompleteMailer.documents_indexed( - csv_data, - exhibit, - user, - indexed_count: job.count, - errors: job.errors - ).deliver_now - ### BEGIN CUSTOMIZATION elr37 - catch exceptions from notification to prevent job from repeating if email fails - # NOTE: Cannot use prepend to override after_perform - rescue RuntimeError => e - Rails.application.config.debug_logger.warn("********************** EMAIL FAILURE => exception #{e.class.name} : #{e.message}") + ### BEGIN CUSTOMIZATION - catch exceptions from notification to prevent job from repeating if email fails + # NOTE: Cannot use prepend to override after_perform + begin + Spotlight::IndexingCompleteMailer.documents_indexed( + csv_data, + exhibit, + user, + indexed_count: job.count, + errors: job.errors + ).deliver_now + rescue StandardError => e + Rails.logger.error("********************** EMAIL FAILURE => exception #{e.class.name} : #{e.message}") + end + ### END CUSTOMIZATION end - ### END CUSTOMIZATION def perform(csv_data, exhibit, _user) @count = 0 @@ -37,20 +41,22 @@ def perform(csv_data, exhibit, _user) end end - private + private def resources(csv_data, exhibit) return to_enum(:resources, csv_data, exhibit) unless block_given? encoded_csv(csv_data).each do |row| url = row.delete('url') - next if url.blank? + next unless url.present? resource = Spotlight::Resources::Upload.new( data: row, exhibit: exhibit ) - resource.build_upload(remote_image_url: url) unless url == '~' + ### BEGIN CUSTOMIZATION - Updated resource to has_many uploads association + resource.uploads.build(remote_image_url: url) unless url == '~' + ### END CUSTOMIZATION yield resource end @@ -63,9 +69,5 @@ def encoded_csv(csv) end.compact.to_h end.compact end - - def job_tracking_resource - arguments[1] - end end end diff --git a/app/models/concerns/cul/uploaded_resource.rb b/app/models/concerns/cul/uploaded_resource.rb new file mode 100644 index 00000000..ec935a06 --- /dev/null +++ b/app/models/concerns/cul/uploaded_resource.rb @@ -0,0 +1,12 @@ +## +# Mixin for SolrDocuments backed by exhibit-specific resources +# Overrides Spotlight::SolrDocument::UploadedResource to handle multiple uploads +module Cul + module UploadedResource + extend ActiveSupport::Concern + + def to_openseadragon(*_args) + uploaded_resource.uploads.map(&:iiif_tilesource) if uploaded_resource&.uploads.present? + end + end +end diff --git a/app/models/solr_document.rb b/app/models/solr_document.rb index c4693524..0e4465da 100644 --- a/app/models/solr_document.rb +++ b/app/models/solr_document.rb @@ -22,16 +22,39 @@ class SolrDocument # Recommendation: Use field names from Dublin Core use_extension(Blacklight::Document::DublinCore) - # If no Spotlight::FeaturedImage, create one and reindex resource + # Overrides Spotlight::SolrDocument::UploadedResource included in Spotlight::SolrDocument to handle multiple uploads + use_extension(Cul::UploadedResource, &:uploaded_resource?) + + # If no uploads, create an upload for each url + # If more uploads than new urls, delete extra uploads + # Reindex resource to update thumbnail and etc # Called from SolrDocument#update def update_exhibit_resource(resource_attributes) - return unless resource_attributes && resource_attributes['url'] - - if uploaded_resource.upload - uploaded_resource.upload.update image: resource_attributes['url'] - else - uploaded_resource.build_upload image: resource_attributes['url'] - uploaded_resource.save_and_index + return unless resource_attributes && resource_attributes['url'].present? + + urls = resource_attributes['url'] + current_uploads = uploaded_resource.uploads + current_upload_ids = current_uploads.pluck(:id) + upload_updates = Hash[[*0..(urls.count-1)].zip(current_upload_ids)] + new_uploads_attributes = [] + urls.each_with_index do |url, i| + upload_id = upload_updates[i] + if upload_id.nil? + new_uploads_attributes << { image: url } + else + # Update existing uploads + current_uploads.find(upload_id).update(image: url) + end end + + # Build new uploads + uploaded_resource.uploads.build(new_uploads_attributes) + + # Delete extra uploads + extra_upload_ids = current_upload_ids - upload_updates.values + uploaded_resource.uploads.where(id: extra_upload_ids).destroy_all + + # Save uploaded_resource and any new uploads and reindex + uploaded_resource.save_and_index end end diff --git a/app/prepends/prepended_controllers/catalog_controller.rb b/app/prepends/prepended_controllers/catalog_controller.rb new file mode 100644 index 00000000..8866cc92 --- /dev/null +++ b/app/prepends/prepended_controllers/catalog_controller.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# Based on the Module#prepend pattern in ruby. +# Uses the to_prepare Rails hook in application.rb to inject this module to override Spotlight::CatalogController +module PrependedControllers::CatalogController + private + + # Overrides strong params to permit url as an array + def solr_document_params + params.require(:solr_document).permit(:exhibit_tag_list, + uploaded_resource: [url: []], + sidecar: [:public, data: [editable_solr_document_params]]) + end +end diff --git a/app/prepends/prepended_controllers/exhibits_controller.rb b/app/prepends/prepended_controllers/exhibits_controller.rb index 17a90fc2..7971f0c3 100644 --- a/app/prepends/prepended_controllers/exhibits_controller.rb +++ b/app/prepends/prepended_controllers/exhibits_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Based on the Module#prepend pattern in ruby. -# Uses the to_prepare Rails hook in application.rb to inject this module to override Spotlight::Exhibit +# Uses the to_prepare Rails hook in application.rb to inject this module to override Spotlight::ExhibitsController module PrependedControllers::ExhibitsController # Overrides published exhibit order in so that exhibits are ordered first by asc weight, then by most recently published def index diff --git a/app/prepends/prepended_controllers/upload_controller.rb b/app/prepends/prepended_controllers/upload_controller.rb new file mode 100644 index 00000000..a23a1220 --- /dev/null +++ b/app/prepends/prepended_controllers/upload_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# Based on the Module#prepend pattern in ruby. +# Uses the to_prepare Rails hook in application.rb to inject this module to override Spotlight::Resources::UploadController +module PrependedControllers::UploadController + private + + # Overrides build_resource method to handle multiple uploads + def build_resource + @resource ||= begin + resource = Spotlight::Resources::Upload.new exhibit: current_exhibit + resource.attributes = resource_params + if params[:resources_upload][:url].present? + uploads_attributes = params[:resources_upload][:url].map { |url| { image: url } } + resource.uploads.build(uploads_attributes) + end + + resource + end + end +end diff --git a/app/prepends/prepended_models/featured_image.rb b/app/prepends/prepended_models/featured_image.rb new file mode 100644 index 00000000..ba6bd837 --- /dev/null +++ b/app/prepends/prepended_models/featured_image.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# Based on the Module#prepend pattern in ruby. +# Uses the to_prepare Rails hook in application.rb to inject this module to override Spotlight::FeaturedImage +module PrependedModels::FeaturedImage + private + + # Overrides bust_containing_resource_caches to reflect latest schema change supporting multiple uploads + def bust_containing_resource_caches + if Rails.version > '6' + Spotlight::Search.where(thumbnail: self).or(Spotlight::Search.where(masthead: self)).touch_all + Spotlight::Page.where(thumbnail: self).touch_all + Spotlight::Exhibit.where(thumbnail: self).or(Spotlight::Exhibit.where(masthead: self)).touch_all + Spotlight::Contact.where(avatar: self).touch_all + Spotlight::Resources::Upload.where(id: spotlight_resource_id).touch_all + else + bust_containing_resource_caches_rails5 + end + end +end diff --git a/app/prepends/prepended_models/upload.rb b/app/prepends/prepended_models/upload.rb new file mode 100644 index 00000000..89f3b1d0 --- /dev/null +++ b/app/prepends/prepended_models/upload.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Based on the Module#prepend pattern in ruby. +# Uses the to_prepare Rails hook in application.rb to inject this module to override Spotlight::Resources::Upload +module PrependedModels::Upload + # Changes relationship from belongs_to to has_many + # belongs_to relationship still exists (no way to override), but doesn't return anything + Spotlight::Resources::Upload.has_many :uploads, + class_name: 'Spotlight::FeaturedImage', + foreign_key: 'spotlight_resource_id' + + Spotlight::Resources::Upload.accepts_nested_attributes_for :uploads + + # Overrides to_solr method to handle multiple uploads + def to_solr + return {} unless uploads.present? && uploads.all?(&:file_present?) + + spotlight_routes = Spotlight::Engine.routes.url_helpers + riiif = Riiif::Engine.routes.url_helpers + + upload_ids = uploads.pluck(:id) + dimensions = upload_ids.map { |upload_id| Riiif::Image.new(upload_id).info } + + { + spotlight_full_image_width_ssm: dimensions.map(&:width), + spotlight_full_image_height_ssm: dimensions.map(&:height), + Spotlight::Engine.config.thumbnail_field => riiif.image_path(uploads[0], size: '!400,400'), + Spotlight::Engine.config.iiif_manifest_field => spotlight_routes.manifest_exhibit_solr_document_path(exhibit, compound_id), + exhibit.blacklight_config.show.tile_source_field => uploads.map { |upload| riiif.info_path(upload) } + } + end +end diff --git a/app/prepends/prepended_services/exhibit_import_export_service.rb b/app/prepends/prepended_services/exhibit_import_export_service.rb new file mode 100644 index 00000000..ac400363 --- /dev/null +++ b/app/prepends/prepended_services/exhibit_import_export_service.rb @@ -0,0 +1,374 @@ +# frozen_string_literal: true + +# Based on the Module#prepend pattern in ruby. +# Uses the to_prepare Rails hook in application.rb to inject this module to override Spotlight::ExhibitImportExportService +module PrependedServices::ExhibitImportExportService + # Overrides from_hash! method called during exhibit import to handle resource with multiple uploads + def from_hash!(hash) + hash = hash.deep_symbolize_keys.reverse_merge( + main_navigations: {}, + contact_emails: {}, + searches: {}, + about_pages: {}, + feature_pages: {}, + contacts: {}, + custom_fields: {}, + solr_document_sidecars: {}, + resources: {}, + attachments: {}, + languages: {}, + translations: {}, + owned_taggings: {}, + groups: {} + ) + + exhibit_attributes = hash.reject { |_k, v| v.is_a?(Array) || v.is_a?(Hash) } + exhibit.update(exhibit_attributes.except(:theme)) + exhibit.theme = exhibit_attributes[:theme] if exhibit.themes.include? exhibit_attributes[:theme] + + deserialize_featured_image(exhibit, :masthead, hash[:masthead]) if hash[:masthead] + deserialize_featured_image(exhibit, :thumbnail, hash[:thumbnail]) if hash[:thumbnail] + + exhibit.blacklight_configuration.update hash[:blacklight_configuration].with_indifferent_access if hash[:blacklight_configuration] + + hash[:main_navigations].each do |attr| + ar = exhibit.main_navigations.find_or_initialize_by(nav_type: attr[:nav_type]) + ar.update(attr) + end + + hash[:contact_emails].each do |attr| + ar = exhibit.contact_emails.find_or_initialize_by(email: attr[:email]) + ar.update(attr) + end + + hash[:groups].each do |attr| + gr = exhibit.groups.find_or_initialize_by(slug: attr[:slug]) + gr.update(attr) + end + + hash[:searches].each do |attr| + group_slugs = attr.delete(:group_slugs) || [] + masthead = attr.delete(:masthead) + thumbnail = attr.delete(:thumbnail) + + ar = exhibit.searches.find_or_initialize_by(slug: attr[:slug]) + ar.update(attr) + + ar.update(groups: exhibit.groups.select { |x| group_slugs.include? x.slug }) + + deserialize_featured_image(ar, :masthead, masthead) if masthead + deserialize_featured_image(ar, :thumbnail, thumbnail) if thumbnail + end + + hash[:about_pages].each do |attr| + masthead = attr.delete(:masthead) + thumbnail = attr.delete(:thumbnail) + translated_pages = attr.delete(:translated_pages) || [] + + ar = exhibit.about_pages.find_or_initialize_by(slug: attr[:slug]) + ar.update(attr) + + deserialize_featured_image(ar, :masthead, masthead) if masthead + deserialize_featured_image(ar, :thumbnail, thumbnail) if thumbnail + + translated_pages.each do |tattr| + masthead = tattr.delete(:masthead) + thumbnail = tattr.delete(:thumbnail) + + tar = ar.translated_page_for(tattr[:locale]) || ar.clone_for_locale(tattr[:locale]) + tar.update(tattr) + + deserialize_featured_image(ar, :masthead, masthead) if masthead + deserialize_featured_image(ar, :thumbnail, thumbnail) if thumbnail + end + end + + hash[:feature_pages].each do |attr| + masthead = attr.delete(:masthead) + thumbnail = attr.delete(:thumbnail) + + ar = exhibit.feature_pages.find_or_initialize_by(slug: attr[:slug]) + ar.update(attr.except(:parent_page_slug, :translated_pages)) + + deserialize_featured_image(ar, :masthead, masthead) if masthead + deserialize_featured_image(ar, :thumbnail, thumbnail) if thumbnail + end + + feature_pages = exhibit.feature_pages.index_by(&:slug) + hash[:feature_pages].each do |attr| + next unless attr[:parent_page_slug] + + feature_pages[attr[:slug]].parent_page_id = feature_pages[attr[:parent_page_slug]].id + end + + hash[:feature_pages].each do |attr| + ar = exhibit.feature_pages.find_or_initialize_by(slug: attr[:slug]) + + (attr[:translated_pages] || []).each do |tattr| + masthead = tattr.delete(:masthead) + thumbnail = tattr.delete(:thumbnail) + + tar = ar.translated_page_for(tattr[:locale]) || ar.clone_for_locale(tattr[:locale]) + tar.update(tattr) + + deserialize_featured_image(ar, :masthead, masthead) if masthead + deserialize_featured_image(ar, :thumbnail, thumbnail) if thumbnail + end + end + + if hash[:home_page] + translated_pages = hash[:home_page].delete(:translated_pages) || [] + exhibit.home_page.update(hash[:home_page].except(:thumbnail)) + deserialize_featured_image(exhibit.home_page, :thumbnail, hash[:home_page][:thumbnail]) if hash[:home_page][:thumbnail] + + translated_pages.each do |tattr| + masthead = tattr.delete(:masthead) + thumbnail = tattr.delete(:thumbnail) + + tar = exhibit.home_page.translated_page_for(tattr[:locale]) || exhibit.home_page.clone_for_locale(tattr[:locale]) + tar.update(tattr) + + deserialize_featured_image(ar, :masthead, masthead) if masthead + deserialize_featured_image(ar, :thumbnail, thumbnail) if thumbnail + end + end + + hash[:contacts].each do |attr| + avatar = attr.delete(:avatar) + + ar = exhibit.contacts.find_or_initialize_by(slug: attr[:slug]) + ar.update(attr) + + deserialize_featured_image(ar, :avatar, avatar) if avatar + end + + hash[:custom_fields].each do |attr| + ar = exhibit.custom_fields.find_or_initialize_by(slug: attr[:slug]) + ar.update(attr) + end + + hash[:solr_document_sidecars].each do |attr| + ar = exhibit.solr_document_sidecars.find_or_initialize_by(document_id: attr[:document_id]) + ar.update(attr) + end + + hash[:resources].each do |attr| + ### BEGIN CUSTOMIZATION + uploads = attr.delete(:uploads) + + ar = exhibit.resources.find_or_initialize_by(type: attr[:type], url: attr[:url]) + ar.update(attr) + + uploads.each { |upload| deserialize_upload(ar, upload) } if uploads.present? + ### END CUSTOMIZATION + end + + hash[:attachments].each do |attr| + file = attr.delete(:file) + + # dedupe by something?? + ar = exhibit.attachments.build(attr) + ar.file = CarrierWave::SanitizedFile.new tempfile: StringIO.new(Base64.decode64(file[:content])), + filename: file[:filename], + content_type: file[:content_type] + end + + hash[:languages].each do |attr| + ar = exhibit.languages.find_or_initialize_by(locale: attr[:locale]) + ar.update(attr) + end + + hash[:translations].each do |attr| + ar = exhibit.translations.find_or_initialize_by(locale: attr[:locale], key: attr[:key]) + ar.update(attr) + end + + hash[:owned_taggings].each do |attr| + tag = ActsAsTaggableOn::Tag.find_or_create_by(name: attr[:tag][:name]) + exhibit.owned_taggings.build(attr.except(:tag).merge(tag_id: tag.id)) + end + end + + private + + # Distinct from deserialize_featured_image to handle has_many relationship between resources and uploads + def deserialize_upload(resource, upload_attrs) + file = upload_attrs.delete(:image) + image = resource.uploads.build(upload_attrs) + if file + image.image = CarrierWave::SanitizedFile.new tempfile: StringIO.new(Base64.decode64(file[:content])), + filename: file[:filename], + content_type: file[:content_type] + # Unset the iiif_tilesource field as the new image should be different, because + # the source has been reloaded + image.iiif_tilesource = nil + end + image.save! + end + + # Overrides attach_featured_images to handle resource with multiple uploads + def attach_featured_images(json) + json[:masthead] = serialize_featured_image(json[:masthead_id]) if json[:masthead_id] + json.delete(:masthead_id) + json[:thumbnail] = serialize_featured_image(json[:thumbnail_id]) if json[:thumbnail_id] + json.delete(:thumbnail_id) + + (json[:searches] || []).each do |search| + search[:masthead] = serialize_featured_image(search[:masthead_id]) if search[:masthead_id] + search.delete(:masthead_id) + search[:thumbnail] = serialize_featured_image(search[:thumbnail_id]) if search[:thumbnail_id] + search.delete(:thumbnail_id) + end + + (json[:about_pages] || []).each do |page| + page[:masthead] = serialize_featured_image(page[:masthead_id]) if page[:masthead_id] + page.delete(:masthead_id) + page[:thumbnail] = serialize_featured_image(page[:thumbnail_id]) if page[:thumbnail_id] + page.delete(:thumbnail_id) + + (page[:translated_pages] || []).each do |translated_page| + translated_page[:masthead] = serialize_featured_image(translated_page[:masthead_id]) if translated_page[:masthead_id] + translated_page.delete(:masthead_id) + translated_page[:thumbnail] = serialize_featured_image(translated_page[:thumbnail_id]) if translated_page[:thumbnail_id] + translated_page.delete(:thumbnail_id) + end + end + + (json[:feature_pages] || []).each do |page| + page[:masthead] = serialize_featured_image(page[:masthead_id]) if page[:masthead_id] + page.delete(:masthead_id) + page[:thumbnail] = serialize_featured_image(page[:thumbnail_id]) if page[:thumbnail_id] + page.delete(:thumbnail_id) + + (page[:translated_pages] || []).each do |translated_page| + translated_page[:masthead] = serialize_featured_image(translated_page[:masthead_id]) if translated_page[:masthead_id] + translated_page.delete(:masthead_id) + translated_page[:thumbnail] = serialize_featured_image(translated_page[:thumbnail_id]) if translated_page[:thumbnail_id] + translated_page.delete(:thumbnail_id) + end + end + + if json[:home_page] + json[:home_page][:masthead] = serialize_featured_image(json[:home_page][:masthead_id]) if json[:home_page][:masthead_id] + json[:home_page].delete(:masthead_id) + json[:home_page][:thumbnail] = serialize_featured_image(json[:home_page][:thumbnail_id]) if json[:home_page][:thumbnail_id] + json[:home_page].delete(:thumbnail_id) + + (json[:home_page][:translated_pages] || []).each do |translated_page| + translated_page[:masthead] = serialize_featured_image(translated_page[:masthead_id]) if translated_page[:masthead_id] + translated_page.delete(:masthead_id) + translated_page[:thumbnail] = serialize_featured_image(translated_page[:thumbnail_id]) if translated_page[:thumbnail_id] + translated_page.delete(:thumbnail_id) + end + end + + (json[:contacts] || []).each do |page| + page[:avatar] = serialize_featured_image(page[:avatar_id]) if page[:avatar_id] + page.delete(:avatar_id) + end + + ### BEGIN CUSTOMIZATION + (json[:resources] || []).each do |page| + upload_ids = Spotlight::FeaturedImage.where(spotlight_resource_id: page[:id]).pluck(:id) + page[:uploads] = upload_ids.map { |upload_id| serialize_featured_image(upload_id) } + page.delete(:id) + end + ### END CUSTOMIZATION + + json + end + + # Overrides raw_json method to include resource ids + def raw_json(_input = nil) + exhibit.as_json( + { + except: %i[id slug site_id], + include: {}.merge( + if_include?(:config, + main_navigations: { + except: %i[id exhibit_id] + }, + contact_emails: { + except: %i[id exhibit_id confirmation_token] + }, + languages: { + except: %i[id exhibit_id] + }, + translations: { + only: %i[locale key value interpolations is_proc] + }) + ).merge( + if_include?(:pages, + searches: { # thumbnail + except: %i[id scope exhibit_id] + }, + groups: { + except: %i[id exhibit_id] + }, + about_pages: { # thumbnail + except: %i[id scope exhibit_id parent_page_id content], + include: { + translated_pages: { + except: %i[id scope exhibit_id parent_page_id default_locale_page_id content] + } + } + }, + home_page: { # thumbnail + except: %i[id slug scope exhibit_id parent_page_id content], + include: { + translated_pages: { + except: %i[id scope exhibit_id parent_page_id default_locale_page_id content] + } + } + }, + feature_pages: { # thumbnail + except: %i[scope exhibit_id content], + include: { + translated_pages: { + except: %i[id scope exhibit_id parent_page_id default_locale_page_id content] + } + } + }, + contacts: { + except: %i[id exhibit_id] + }) + ).merge( + if_include?(:blacklight_configuration, + blacklight_configuration: { + except: %i[id exhibit_id] + }, + # blacklight_configuration + custom_fields: { + except: %i[id exhibit_id] + }) + ).merge( + if_include?(:resources, + # resources + solr_document_sidecars: { + except: %i[id exhibit_id] + }, + owned_taggings: { + only: %i[taggable_id taggable_type context], + include: { + tag: { + only: [:name] + } + } + }, + ### BEGIN CUSTOMIZATION + resources: { # upload + except: %i[exhibit_id], + methods: :type + }) + ### END CUSTOMIZATION + ).merge( + if_include?(:attachments, + # attachments + attachments: { # file + except: %i[exhibit_id] + }) + ) + }.merge(include[:config] ? {} : { only: %i[does_not_exist] }) + ).deep_symbolize_keys + end +end diff --git a/app/presenters/spotlight/iiif_manifest_presenter.rb b/app/presenters/spotlight/iiif_manifest_presenter.rb new file mode 100644 index 00000000..4930aae9 --- /dev/null +++ b/app/presenters/spotlight/iiif_manifest_presenter.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +# Overrides Spotlight::IiifManifestPresenter in blacklight-spotlight gem to handle resources with multiple uploads +module Spotlight + ## + # A presenter class that provides the methods that IIIFManifest expects, as well as convenience methods + # that will generate a IIIFManifest object, and the actual JSON manifest from the IIIFManifest object. + # Instances of this class represent IIIF leaf nodes. We do not currently generate manifests with interstitial + # nodes. + # + # IIIFManifest expects the following methods: #file_set_presenters, #work_presenters, #manifest_url, #description. + # see: https://github.com/projecthydra-labs/iiif_manifest/blob/main/README.md + class IiifManifestPresenter + require 'iiif_manifest' + + delegate :uploaded_resource, to: :resource + delegate :blacklight_config, to: :controller + + attr_accessor :resource, :controller + + def initialize(resource, controller) + @resource = resource + @controller = controller + end + + # Returns an array of leaf nodes representing a resource's associated uploads. + def file_set_presenters + uploaded_resource.uploads.map.each_with_index do |upload, i| + width = resource[:spotlight_full_image_width_ssm].try(:[], i) + height = resource[:spotlight_full_image_height_ssm].try(:[], i) + Upload.new(upload, width, height, controller, label) + end + end + + # This is an empty array, since we're not building manifests for works at the moment. + def work_presenters + [] + end + + # where this manifest can be found + def manifest_url + controller.spotlight.manifest_exhibit_solr_document_url(uploaded_resource.exhibit, resource) + end + + # a description of the manifest + def description + resource.first(Spotlight::Engine.config.upload_description_field) + end + + def iiif_manifest + IIIFManifest::ManifestFactory.new(self) + end + + def iiif_manifest_json + iiif_manifest.to_h.to_json + end + + private + + def presenter + controller.view_context.document_presenter(resource) + end + + def label + presenter.heading + end + + class Upload + # The class that represents the leaf nodes must implement #id (here implemented + # via delegation to the featured_image). + delegate :id, to: :featured_image + + attr_accessor :upload, :width, :height, :controller, :label + + def initialize(upload, width, height, controller, label) + @upload = upload + @width = width + @height = height + @controller = controller + @label = label + end + + def id + upload.id + end + + # IIIFManifest expects leaf nodes to implement #display_image, which returns an instance of IIIFManifest::DisplayImage. + def display_image + IIIFManifest::DisplayImage.new(id, + width: width&.to_i, + height: height&.to_i, + format: 'image/jpeg', + iiif_endpoint: endpoint) + end + + # IIIFManifest will call #to_s on each leaf node to get its respective label (not called out in README). + def to_s + label + end + + private + + def endpoint + IIIFManifest::IIIFEndpoint.new(iiif_url, profile: 'http://iiif.io/api/image/2/level2.json') + end + + def iiif_url + # yes this is hacky, and we are appropriately ashamed. + controller.riiif.info_url(upload).sub(%r{/info\.json\Z}, '') + end + end + end +end diff --git a/app/views/catalog/_viewer_default.html.erb b/app/views/catalog/_openseadragon_default.html.erb similarity index 83% rename from app/views/catalog/_viewer_default.html.erb rename to app/views/catalog/_openseadragon_default.html.erb index 0ee4bf6f..93d12e8b 100644 --- a/app/views/catalog/_viewer_default.html.erb +++ b/app/views/catalog/_openseadragon_default.html.erb @@ -1,11 +1,13 @@ <%# Overrides _openseadragon_default.html.erb from blacklight-gallery - Adds sequenceMode to showReferenceStrip for images with count > 1 + Sets sequenceMode to true for images with count > 1 + Sets initialPage if block with selected iiif_tilesource is present, defaults to first image %> - <% image = document.to_openseadragon(blacklight_config.view_config(:show)) id_prefix = osd_html_id_prefix + block_options = block_options || {} + initial_page = initial_page(document, block_options) %> <% osd_config = { @@ -21,6 +23,7 @@ osd_config_referencestrip = { sequenceMode: true, showReferenceStrip: true, + initialPage: initial_page, referenceStripPosition: 'OUTSIDE', referenceStripScroll: 'vertical', referenceStripWidth: 100, @@ -36,7 +39,7 @@ <% if count > 1 %> <% osd_config = osd_config_referencestrip.merge(osd_config) %> <%= render Blacklight::Gallery::Icons::ChevronLeftComponent.new %> - 1 of <%= count %> + <%= initial_page + 1%> of <%= count %> <%= render Blacklight::Gallery::Icons::ChevronRightComponent.new %> <% end %> diff --git a/app/views/spotlight/catalog/_edit_default.html.erb b/app/views/spotlight/catalog/_edit_default.html.erb new file mode 100644 index 00000000..1dc5bc55 --- /dev/null +++ b/app/views/spotlight/catalog/_edit_default.html.erb @@ -0,0 +1,28 @@ +<%= bootstrap_form_for document, url: spotlight.polymorphic_path([current_exhibit, document]), html: {:'data-form-observer' => true, multipart: true} do |f| %> +
    + + <%= f.fields_for :sidecar, document.sidecar(current_exhibit) do |c| %> + <%= c.check_box :public %> + <% end %> + + <%= f.fields_for :uploaded_resource do |r| %> + <%# BEGIN CUSTOMIZATION - Allow multiple file uploads %> + <%= r.url_field :url, type: 'file', multiple: true, help: t('.url-field.help', extensions: Spotlight::Engine.config.allowed_upload_extensions.join(' ')), label: "Files" %> + <%# END CUSTOMIZATION %> + <% end if document.uploaded_resource? %> + + <%= render partial: 'edit_sidecar', locals: { document: document, f: f } %> + + <% if can? :tag, current_exhibit %> +
    + <%= f.text_field :exhibit_tag_list, value: f.object.sidecar(current_exhibit).tags_from(current_exhibit).to_s, data: { autocomplete_tag: true, autocomplete_url: exhibit_tags_path(current_exhibit, format: 'json')} %> +
    + <% end %> +
    +
    + <%= cancel_link document, spotlight.polymorphic_path([current_exhibit, document]), class: 'btn-sizing' %> + <%= f.submit nil, class: 'btn btn-primary' %> +
    +
    +
    +<% end %> diff --git a/app/views/spotlight/resources/upload/_form.html.erb b/app/views/spotlight/resources/upload/_form.html.erb new file mode 100644 index 00000000..edb5bd8e --- /dev/null +++ b/app/views/spotlight/resources/upload/_form.html.erb @@ -0,0 +1,22 @@ +<%= bootstrap_form_for([current_exhibit, @resource.becomes(Spotlight::Resources::Upload)], layout: :horizontal, label_col: 'col-md-2', control_col: 'col-sm-6 col-md-6', html: { class: 'item-upload-form', multipart: true } ) do |f| %> + <%# BEGIN CUSTOMIZATION - Allow multiple file uploads %> + <%= f.url_field :url, type: 'file', multiple: true, help: t('.url-field.help', extensions: Spotlight::Engine.config.allowed_upload_extensions.join(' ')), label: "Files" %> + <%# END CUSTOMIZATION %> + <%= f.fields_for :data do |d| %> + <% Spotlight::Resources::Upload.fields(current_exhibit).each do |config| %> + <%= d.send(config.form_field_type, config.field_name, label: uploaded_field_label(config)) %> + <% end %> + + <% current_exhibit.custom_fields.each do |custom_field| %> + <%= render partial: "spotlight/custom_fields/form_group/#{custom_field.field_type}", locals: { inline: true, f: d, field: custom_field, value: nil } %> + <% end %> + <% end %> +
    +
    + <%= hidden_field_tag :tab, 'upload', id: nil %> + <%= cancel_link @resource, :back, class: 'btn-sizing' %> + <%= f.submit t('.add_item_and_continue'), name: 'add-and-continue', class: 'btn btn-secondary' %> + <%= f.submit t('.add_item'), class: 'btn btn-primary' %> +
    +
    +<% end %> diff --git a/app/views/spotlight/sir_trevor/blocks/_solr_documents_embed_block.html.erb b/app/views/spotlight/sir_trevor/blocks/_solr_documents_embed_block.html.erb index 94718f7e..f7c9916d 100644 --- a/app/views/spotlight/sir_trevor/blocks/_solr_documents_embed_block.html.erb +++ b/app/views/spotlight/sir_trevor/blocks/_solr_documents_embed_block.html.erb @@ -13,7 +13,10 @@
    <% solr_documents_embed_block.each_document do |block_options, document| %>
    - <%= render_document_partials document, blacklight_config.view.embed.partials, (blacklight_config.view.embed.locals || {}).reverse_merge(block: solr_documents_embed_block) %> + <%# BEGIN CUSTOMIZATION - Add block_options to view locals %> + <% locals = (blacklight_config.view.embed.locals || {}).reverse_merge(block: solr_documents_embed_block, block_options: block_options) %> + <%= render_document_partials document, blacklight_config.view.embed.partials, locals %> + <%# END CUSTOMIZATION %>
    <% end %>
    @@ -21,7 +24,9 @@ <% end %> <% if solr_documents_embed_block.text? %> + <%# BEGIN CUSTOMIZATION - Add solr-documents-text class for styling %>
    + <%# END CUSTOMIZATION %> <% unless solr_documents_embed_block.title.blank? %>

    <%= solr_documents_embed_block.title %>

    <% end %> diff --git a/config/application.rb b/config/application.rb index 7d320d30..7546d4ac 100644 --- a/config/application.rb +++ b/config/application.rb @@ -24,7 +24,17 @@ class Application < Rails::Application config.to_prepare do # for each prepended class # OriginalApp::OriginalClassName.prepend PrependedModuleName::OriginalClassName + # Controllers Spotlight::ExhibitsController.prepend PrependedControllers::ExhibitsController + Spotlight::Resources::UploadController.prepend PrependedControllers::UploadController + Spotlight::CatalogController.prepend PrependedControllers::CatalogController + + # Models + Spotlight::FeaturedImage.prepend PrependedModels::FeaturedImage + Spotlight::Resources::Upload.prepend PrependedModels::Upload + + # Services + Spotlight::ExhibitImportExportService.prepend PrependedServices::ExhibitImportExportService end end end diff --git a/config/environments/development.rb b/config/environments/development.rb index 848a75de..cccc96a1 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -33,7 +33,7 @@ config.active_storage.service = :local # Configure Email Notifications - config.action_mailer.delivery_method = :letter_opener # Open emails in a web browser + config.action_mailer.delivery_method = :test config.action_mailer.raise_delivery_errors = true config.action_mailer.perform_caching = false diff --git a/config/initializers/lib_prepends.rb b/config/initializers/lib_prepends.rb new file mode 100644 index 00000000..6d34ee26 --- /dev/null +++ b/config/initializers/lib_prepends.rb @@ -0,0 +1,5 @@ +require 'prepends/iiif' + +Rails.application.config.to_prepare do + Migration::IIIF.prepend IIIFPrepend +end diff --git a/config/initializers/spotlight_initializer.rb b/config/initializers/spotlight_initializer.rb index f5c256ce..f5b46c44 100644 --- a/config/initializers/spotlight_initializer.rb +++ b/config/initializers/spotlight_initializer.rb @@ -98,6 +98,9 @@ # Spotlight::Engine.config.ga_page_analytics_options = config.ga_analytics_options.merge(limit: 5) Spotlight::Engine.config.ga_debug_mode = false +# Hide from indexing job list in exhibit dashboard +Spotlight::Engine.config.hidden_job_classes = %w[Spotlight::ReindexJob Spotlight::AddUploadsFromCsv] + # ==> Sir Trevor Widget Configuration # These are set by default by Spotlight's configuration, # but you can customize them here, or in the SirTrevorRails::Block#custom_block_types method diff --git a/config/locales/spotlight.en.yml b/config/locales/spotlight.en.yml index 78f793d8..aed11551 100644 --- a/config/locales/spotlight.en.yml +++ b/config/locales/spotlight.en.yml @@ -20,6 +20,7 @@ en: search: fields: spotlight_copyright_tesim: Copyright + spotlight_upload_description_tesim: Description shared: site_sidebar: documentation: Spotlight curator documentation diff --git a/db/migrate/20240920151533_add_resource_id_to_spotlight_featured_images.rb b/db/migrate/20240920151533_add_resource_id_to_spotlight_featured_images.rb new file mode 100644 index 00000000..8b328f15 --- /dev/null +++ b/db/migrate/20240920151533_add_resource_id_to_spotlight_featured_images.rb @@ -0,0 +1,23 @@ +class AddResourceIdToSpotlightFeaturedImages < ActiveRecord::Migration[7.0] + def change + add_reference :spotlight_featured_images, :spotlight_resource, foreign_key: true + + reversible do |direction| + direction.up do + execute <<-SQL + UPDATE spotlight_featured_images f, spotlight_resources r SET f.spotlight_resource_id = r.id WHERE r.upload_id = f.id; + SQL + end + # Shouldn't really be running this in reverse, but just in case + # This will set the upload_id to the first featured image id + direction.down do + execute <<-SQL + UPDATE spotlight_resources r SET r.upload_id = (SELECT f.id FROM spotlight_featured_images f WHERE f.spotlight_resource_id = r.id LIMIT 1); + SQL + end + end + + remove_index :spotlight_resources, :upload_id + remove_column :spotlight_resources, :upload_id, :integer, after: :index_status + end +end diff --git a/lib/prepends/iiif.rb b/lib/prepends/iiif.rb new file mode 100644 index 00000000..713f02e3 --- /dev/null +++ b/lib/prepends/iiif.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'migration/iiif' + +module IIIFPrepend + def copy_upload_to_featured_image(upload) + return unless upload.exhibit # We need exhibit context to re-index, and you can't find an item not in an exhibit + + filename = upload.read_attribute_before_type_cast('url') + filepath = "public/uploads/spotlight/resources/upload/url/#{upload.id}/#{filename}" + return unless filename.present? && File.exist?(filepath) + + old_file = File.new(filepath) + image = upload.uploads.create { |i| i.image.store!(old_file) } + iiif_tilesource = riiif.info_path(image) + image.update(iiif_tilesource: iiif_tilesource) + upload.save_and_index + end +end diff --git a/scripts/resource_delete_service.rb b/scripts/resource_delete_service.rb index 06b4ffa5..3a4a6e52 100644 --- a/scripts/resource_delete_service.rb +++ b/scripts/resource_delete_service.rb @@ -32,10 +32,9 @@ def services # @param [Integer] limit the number of image ids returned (default: 1000) # @return [Array] resource_ids - list of ids of resources with image=NULL def find_null_images(exhibit: nil, limit: 1000) - puts "Resources where upload_id is NULL" - where_clause = { upload_id: nil } - where_clause[:exhibit_id] = exhibit if exhibit - resource_ids = Spotlight::Resource.where(where_clause).limit(limit).map(&:id) + puts "Resources with no associated Spotlight::FeaturedImage" + where_clause = exhibit ? { exhibit_id: exhibit.id } : {} + resource_ids = Spotlight::Resources::Upload.where(where_clause).missing(:uploads).pluck(:id) delete_resources(resource_ids, pretest: true) # just lists info about resources when pretest: true resource_ids end @@ -49,15 +48,6 @@ def find_resources_for_exhibit(exhibit:, limit: 1000) resource_ids end - # @param [Integer] upload_id - id for an uploaded file - # @return [Array] resource_ids - list of ids of resources with the upload_id (Should be at most 1. More than that is an error.) - def find_resources_with_upload_id(upload_id:, limit: 10) - puts "Resources with upload_id #{upload_id}" - resource_ids = Spotlight::Resource.where(upload_id: upload_id).limit(limit).map(&:id) - delete_resources(resource_ids, pretest: true) # just lists info about resources when pretest: true - resource_ids - end - # @return [Array] resource_ids - list of ids of resources with image=NULL def find_resources_without_an_exhibit puts "Resources without an exhibit" @@ -87,22 +77,22 @@ def delete_resource(resource_id, pretest: false) resource = find_resource(resource_id) return if resource.blank? - featured_image = find_featured_image(resource) - thumbnails = find_thumbnails(featured_image) + featured_images = find_featured_images(resource) + thumbnails = featured_images.each_with_object([]) { |i, arr| arr << find_thumbnails(i) }.flatten solr_document_sidecars = find_solr_document_sidecars(resource) bookmarks = find_bookmarks(solr_document_sidecars) - perform_pretest(resource: resource, featured_image: featured_image, thumbnails: thumbnails, + perform_pretest(resource: resource, featured_images: featured_images, thumbnails: thumbnails, solr_document_sidecars: solr_document_sidecars, bookmarks: bookmarks) return if pretest return unless delete? - perform_deletes(resource: resource, featured_image: featured_image, thumbnails: thumbnails, + perform_deletes(resource: resource, featured_images: featured_images, thumbnails: thumbnails, solr_document_sidecars: solr_document_sidecars, bookmarks: bookmarks) end private - def perform_deletes(resource:, featured_image:, thumbnails: [], solr_document_sidecars: [], bookmarks: []) + def perform_deletes(resource:, featured_images: [], thumbnails: [], solr_document_sidecars: [], bookmarks: []) bookmarks.each { |b| b.delete } solr_document_sidecars.each do |d| Blacklight.default_index.connection.delete_by_id d.document_id @@ -110,21 +100,19 @@ def perform_deletes(resource:, featured_image:, thumbnails: [], solr_document_si d.delete end thumbnails.each { |t| t.delete } - featured_image.remove_image! if featured_image - featured_image.delete if featured_image + featured_images.each do |i| + i.remove_image! + i.delete + end resource.delete puts("DELETE Complete!") end - def perform_pretest(resource:, featured_image:, thumbnails: [], solr_document_sidecars: [], bookmarks: []) + def perform_pretest(resource:, featured_images: [], thumbnails: [], solr_document_sidecars: [], bookmarks: []) puts "-----------------------------------" puts("Will DELETE...") - puts(" resource - id: #{resource.id} type: #{resource.type} upload_id: #{resource.upload_id} exhibit_id: #{resource.exhibit_id}") - puts(" featured image - id: #{featured_image.id} type: #{featured_image.type} image: #{featured_image.image}") if featured_image - Spotlight::Resource.where(upload_id: resource.upload_id).each do |r| - next if r.id == resource.id || resource.upload_id.blank? - puts(" ALT resource with upload_id: #{resource.upload_id} resource - id: #{r.id} exhibit_id: #{r.exhibit_id}") - end unless resource.upload_id.blank? + puts(" resource - id: #{resource.id} type: #{resource.type} exhibit_id: #{resource.exhibit_id}") + featured_images.each { |i| puts(" featured image - id: #{i.id} type: #{i.type} image: #{i.image}") } thumbnails.each { |t| puts(" thumbnail - id: #{t.id} type: #{t.type} image: #{t.image} iiif_tilesource: #{t.iiif_tilesource}") } solr_document_sidecars.each { |d| puts(" solr_document_sidecar - id: #{d.id} document_id: #{d.document_id} document_type: #{d.document_type} resource_id: #{d.resource_id} exhibit_id: #{d.exhibit_id}") } bookmarks.each { |b| puts(" bookmark - id: #{b.id}") } @@ -137,11 +125,9 @@ def find_resource(resource_id) puts("resource NOT FOUND - id: #{resource_id}") end - def find_featured_image(resource) - return nil if resource.blank? - featured_image_id = resource.upload_id - return nil if featured_image_id.blank? - Spotlight::FeaturedImage.find(featured_image_id) + def find_featured_images(resource) + return [] if resource.blank? + Spotlight::FeaturedImage.where(spotlight_resource_id: resource.id) end def find_thumbnails(featured_image)