[open-ils-commits] [GIT] Evergreen ILS branch master updated. 6631e78645e9027c5dcda72e2adeee59556aee98

Evergreen Git git at git.evergreen-ils.org
Tue Sep 3 22:40:42 EDT 2019


This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "Evergreen ILS".

The branch, master has been updated
       via  6631e78645e9027c5dcda72e2adeee59556aee98 (commit)
       via  dc8457ee5c117e48730d737090451b8bdbcbb9de (commit)
       via  94fb41cbdd52f09de09d91db10fcea0b520fed25 (commit)
       via  c33fce5808b2509cceca43db9b13276c866ca073 (commit)
       via  b81a4897de7f58914860f1fb44167565ef50d231 (commit)
       via  b1f14215e18f52fc1f6a2413888badfc9ca8bc1c (commit)
       via  f508a393cd6949d6487b48f5e813504275bb0541 (commit)
       via  d5553bfb17d26ba92f9d121b50d02807b14eceff (commit)
       via  b191a45d7c3c6ed7556f32def7f218f758b571c1 (commit)
       via  e7100d944520f8037dba6274e891783fbadaff63 (commit)
       via  565223aab576af3284e60b6195eeb09bbba880be (commit)
       via  5f54469b5c4e53396ac35c2f4a7afbabe87becc8 (commit)
       via  f9b755bcfeba8b339862d43328db61980b7f9b65 (commit)
       via  4739faf12e0fd9c11278e2839de1181c8facf10a (commit)
       via  e222aa73bfef5d6a083932bca991243e7b741e83 (commit)
       via  924a4dc1853705d54e57924ff8f9ef51be395e92 (commit)
       via  ce06bea197ac69ae5c800171c47323cb9185c4a3 (commit)
      from  d87ad5f21e23f5e28fe7b970baad090d2b59eb63 (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit 6631e78645e9027c5dcda72e2adeee59556aee98
Author: Jane Sandberg <sandbej at linnbenton.edu>
Date:   Tue Sep 3 19:35:55 2019 -0700

    LP1832897: One more carousel local admin link repair
    
    Previously, Evergreen would redirect to the auto-generated server admin
    carousel screen upon successfully creating a carousel from a bucket.
    Now it goes to the local admin one.
    
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/web/js/ui/default/staff/cat/bucket/record/app.js b/Open-ILS/web/js/ui/default/staff/cat/bucket/record/app.js
index 29d64cc9c3..cdaaaa147b 100644
--- a/Open-ILS/web/js/ui/default/staff/cat/bucket/record/app.js
+++ b/Open-ILS/web/js/ui/default/staff/cat/bucket/record/app.js
@@ -425,7 +425,7 @@ function($scope,  $location,  $q,  $timeout,  $uibModal,
             }]
         }).result.then(function(carouselId) {
             // bouncing outside of AngularJS
-            $window.location.href = '/eg2/en-US/staff/admin/server/container/carousel';
+            $window.location.href = '/eg2/en-US/staff/admin/local/container/carousel';
         });
     }
 

commit dc8457ee5c117e48730d737090451b8bdbcbb9de
Author: Mike Rylander <mrylander at gmail.com>
Date:   Tue Sep 3 16:03:54 2019 -0400

    LP1832897: Avoid query errors related to optional carousel filters
    
    Here we make sure that the presence or absence of a shelving location
    filter value does not lead to a query error.
    
    Signed-off-by: Mike Rylander <mrylander at gmail.com>
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Publisher/container.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Publisher/container.pm
index a06fd1dcd3..176197a7ab 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Publisher/container.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Publisher/container.pm
@@ -22,6 +22,7 @@ my $new_items_query = q(
     AND acpl.holdable
     AND acpl.circulate
     AND acp.active_date > NOW() - ?::INTERVAL
+    -- LOC AND acp.location IN (LOC_LIST)
     AND (EXISTS (SELECT 1 FROM asset.copy_vis_attr_cache WHERE record = acn.record AND vis_attr_vector @@ c_attr.vis_test))
     AND (NOT EXISTS (SELECT 1 FROM metabib.record_attr_vector_list WHERE source = acn.record AND vlist @@ metabib.compile_composite_attr(' {"1":[{"_val":"s","_attr":"bib_level"}]}')::query_int))
     GROUP BY acn.record
@@ -46,6 +47,7 @@ WITH c_attr AS (SELECT c_attrs::query_int AS vis_test FROM asset.patron_default_
     AND acpl.circulate
     AND circ.checkin_time > NOW() - ?::INTERVAL
     AND circ.checkin_time IS NOT NULL
+    -- LOC AND acp.location IN (LOC_LIST)
     AND (EXISTS (SELECT 1 FROM asset.copy_vis_attr_cache WHERE record = acn.record AND vis_attr_vector @@ c_attr.vis_test))
     AND (NOT EXISTS (SELECT 1 FROM metabib.record_attr_vector_list WHERE source = acn.record AND vlist @@ metabib.compile_composite_attr(' {"1":[{"_val":"s","_attr":"bib_level"}]}')::query_int))
     GROUP BY acn.record
@@ -69,6 +71,7 @@ my $top_circs_query = q(
     AND acpl.holdable
     AND acpl.circulate
     AND circ.xact_start > NOW() - ?::INTERVAL
+    -- LOC AND acp.location IN (LOC_LIST)
     AND (EXISTS (SELECT 1 FROM asset.copy_vis_attr_cache WHERE record = acn.record AND vis_attr_vector @@ c_attr.vis_test))
     AND (NOT EXISTS (SELECT 1 FROM metabib.record_attr_vector_list WHERE source = acn.record AND vlist @@ metabib.compile_composite_attr(' {"1":[{"_val":"s","_attr":"bib_level"}]}')::query_int))
     GROUP BY acn.record
@@ -86,7 +89,7 @@ my $new_by_loc_query = q(
     WHERE acn.owning_lib IN (ORG_LIST)
     AND acp.circ_lib IN (ORG_LIST)
     AND acp.active_date > NOW() - ?::INTERVAL
-    AND acp.location IN (LOC_LIST)
+    -- LOC AND acp.location IN (LOC_LIST)
     AND acp.holdable
     AND acp.circulate
     AND ccs.holdable
@@ -144,6 +147,7 @@ sub refresh_container_from_carousel_definition {
             return 0;
         }
         my $loc_placeholders = join(',', map { '?' } @$locs);
+        $query =~ s/-- LOC //g;
         $query =~ s/LOC_LIST/$loc_placeholders/g;
     } else {
         $locs = []; # we'll ignore any superflous supplied values

commit 94fb41cbdd52f09de09d91db10fcea0b520fed25
Author: Mike Rylander <mrylander at gmail.com>
Date:   Tue Sep 3 16:02:53 2019 -0400

    LP1832897: Belt-and-suspenders for making sure max_items is set
    
    Signed-off-by: Mike Rylander <mrylander at gmail.com>
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Carousel.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Carousel.pm
index 0b64841d4d..9e9fa616fc 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Carousel.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Carousel.pm
@@ -176,12 +176,16 @@ sub add_carousel_from_bucket {
 
     $e->xact_begin;
 
+    # gather old entries to get a count and set max_items appropriately
+    my $entries = $e->search_container_biblio_record_entry_bucket_item({ bucket => $bucket_id });
+
     my $carousel = Fieldmapper::container::carousel->new;
     $carousel->name($carousel_name);
     $carousel->type(1); # manual
     $carousel->owner($e->requestor->ws_ou);
     $carousel->creator($e->requestor->id);
     $carousel->editor($e->requestor->id);
+    $carousel->max_items(scalar(@$entries));
     $e->create_container_carousel($carousel) or return $e->event;
 
     # and the bucket
@@ -199,7 +203,6 @@ sub add_carousel_from_bucket {
     $e->update_container_carousel($carousel) or return $e->event;
 
     # and fill it
-    my $entries = $e->search_container_biblio_record_entry_bucket_item({ bucket => $bucket_id });
     foreach my $entry (@$entries) {
         $entry->clear_id;
         $entry->bucket($bucket->id);

commit c33fce5808b2509cceca43db9b13276c866ca073
Author: Jane Sandberg <sandbej at linnbenton.edu>
Date:   Sat Aug 31 08:38:49 2019 -0700

    LP1832897: Marking some carousel fields required in the IDL
    
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>
    Signed-off-by: Mike Rylander <mrylander at gmail.com>

diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml
index 2fc7aa1b3a..11839cf2fe 100644
--- a/Open-ILS/examples/fm_IDL.xml
+++ b/Open-ILS/examples/fm_IDL.xml
@@ -12881,8 +12881,8 @@ SELECT  usr,
 		reporter:label="Carousels">
 		<fields oils_persist:primary="id" oils_persist:sequence="container.carousel_id_seq">
 			<field reporter:label="Carousel ID" name="id" reporter:datatype="id" reporter:selector="name"/>
-			<field reporter:label="Carousel Type" name="type" reporter:datatype="link"/>
-			<field reporter:label="Owner" name="owner" reporter:datatype="link"/>
+			<field reporter:label="Carousel Type" name="type" reporter:datatype="link" oils_obj:required="true"/>
+			<field reporter:label="Owner" name="owner" reporter:datatype="link" oils_obj:required="true"/>
 			<field reporter:label="Name" name="name" reporter:datatype="text" oils_obj:required="true" oils_obj:i18n="true"/>
 			<field reporter:label="Bucket" name="bucket" reporter:datatype="link"/>
 			<field reporter:label="Creating User" name="creator" reporter:datatype="link"/>
@@ -12894,7 +12894,7 @@ SELECT  usr,
 			<field reporter:label="Shelving Locations" name="copy_location_filter" reporter:datatype="text" /> <!-- ditto -->
 			<field reporter:label="Last Refresh Time" name="last_refresh_time" reporter:datatype="timestamp"/>
 			<field reporter:label="Is Active" name="active" reporter:datatype="bool"/>
-			<field reporter:label="Maximum Items" name="max_items" reporter:datatype="int"/>
+			<field reporter:label="Maximum Items" name="max_items" reporter:datatype="int" oils_obj:required="true"/>
 		</fields>
 		<links>
 			<link field="type" reltype="has_a" key="id" map="" class="cct"/>

commit b81a4897de7f58914860f1fb44167565ef50d231
Author: Bill Erickson <berickxx at gmail.com>
Date:   Thu Aug 29 12:08:16 2019 -0400

    LP1832897 Carousel local admin link repair
    
    Fixes local admin link table entries for carousels and carousel library
    mappings.  Now correctly points to ../admin/local/...
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Mike Rylander <mrylander at gmail.com>
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/admin-local-splash.component.html b/Open-ILS/src/eg2/src/app/staff/admin/local/admin-local-splash.component.html
index 2ec554d11a..870deeb8dc 100644
--- a/Open-ILS/src/eg2/src/app/staff/admin/local/admin-local-splash.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/admin/local/admin-local-splash.component.html
@@ -12,9 +12,9 @@
     <eg-link-table-link i18n-label label="Barcode Completion" 
       routerLink="/staff/admin/local/config/barcode_completion"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Carousel Library Mappings"
-      routerLink="/staff/admin/server/container/carousel_org_unit"></eg-link-table-link>
+      routerLink="/staff/admin/local/container/carousel_org_unit"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Carousels"
-      routerLink="/staff/admin/server/container/carousel"></eg-link-table-link>
+      routerLink="/staff/admin/local/container/carousel"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Cash Reports" 
       url="/eg/staff/admin/local/money/cash_reports"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Circulation Limit Sets" 

commit b1f14215e18f52fc1f6a2413888badfc9ca8bc1c
Author: Bill Erickson <berickxx at gmail.com>
Date:   Thu Aug 29 11:58:20 2019 -0400

    LP1832897 FM Editor fieldOptions sanity check
    
    If the caller sets the fieldOptions property to null/undefined the FM
    editor will throw a JS error (field.isRequired is not a function) and fail
    to render properly.  This adds a sanity check to the fieldOptions value.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Mike Rylander <mrylander at gmail.com>
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts
index 0cd9a6f3d8..b6e2638bbf 100644
--- a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts
+++ b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts
@@ -176,6 +176,10 @@ export class FmRecordEditorComponent
     // Avoid fetching data on init since that may lead to unnecessary
     // data retrieval.
     ngOnInit() {
+
+        // In case the caller sets the value to null / undef.
+        if (!this.fieldOptions) { this.fieldOptions = {}; }
+
         this.listifyInputs();
         this.idlDef = this.idl.classes[this.idlClass];
         this.recordLabel = this.idlDef.label;

commit f508a393cd6949d6487b48f5e813504275bb0541
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Wed Aug 7 18:41:55 2019 +0000

    LP#1832897: add release notes
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/docs/RELEASE_NOTES_NEXT/OPAC/carousels.adoc b/docs/RELEASE_NOTES_NEXT/OPAC/carousels.adoc
new file mode 100644
index 0000000000..e021cdf500
--- /dev/null
+++ b/docs/RELEASE_NOTES_NEXT/OPAC/carousels.adoc
@@ -0,0 +1,71 @@
+Carousels
+^^^^^^^^^
+This feature fully integrates the creation and management of book carousels
+into Evergreen, allowing for the display of book cover images on a library’s
+public catalog home page.  Carousels may be animated or static.  They can be
+manually maintained by staff or automatically maintained by Evergreen.  Titles
+can appear in carousels based on newly cataloged items, recent returns,
+popularity, etc.  Titles must have copies that are visible to the public
+catalog, be circulating, and holdable to appear in a carousel.  Serial titles
+cannot be displayed in carousels.  
+
+Administration
+++++++++++++++
+This feature introduces the concepts of Carousel Types, Carousels, and Carousel
+Library Mappings. The first can be administered in Server Administration
+while the latter two can be administerd in Local Administration.
+
+Carousel Types define the attributes of a carousel, such as whether it is
+automatically managed and how it is filtered.  A carousel must be associated
+with a carousel type to function properly.    
+
+There are five stock Carousel Types:
+
+  * Newly Cataloged Items - titles appear automatically based on the active date of the title’s copies
+  * Recently Returned Items - titles appear automatically based on the mostly recently circulated copy’s check-in scan date and time  
+  * Top Circulated Titles - titles appear automatically based on the most circulated copies in the Item Libraries identified in the carousel definition; titles are chosen based on the number of action.circulation rows created during an interval specified in the carousel definition and includes both circulations and renewals
+  * Newest Items by Shelving Location - titles appear automatically based on the active date and shelving location of the title’s copies 
+  * Manual - titles are added and managed manually by library staff
+
+While additional Carousel Types can be added using the administration
+interface, new automatic types currently require additional Perl code
+to be recognized.
+
+Carousel definitions allow the operator to specify the type, owner,
+name and, for automatically-maintained types, the item libraries and
+shelving locations to look for titles to populate the carousels as
+well as how far back to look for titles.
+
+Carousel Library Mappings specify the libraries that the carousel
+should be displayed out. The visibility of a carousel at a given organizational
+unit is not automatically inherited by the descendants of that unit.  The
+carousel’s owning organizational unit is automatically added to the list of
+display organizational units.
+
+A server-side job, refresh_carousels.srfsh, is available to periodically
+refresh the contents of automatic carousels.
+
+Staff Interface
++++++++++++++++
+Each carousel has a record bucket associated with it. Library staff can
+add titles to a carousel's bucket, and for the manual Carousel Type, that
+is the only way to populate the carousel. Records added to an automatic
+carousel's bucket will be removed whenever the carousel is next
+refreshed.
+
+Public Catalog
+++++++++++++++
+A new Template Toolkit macro called “carousels” allows the Evergreen
+administrator to inject the contents of one or more carousels into any point in
+the OPAC.  The macro will accept the following parameters:
+
+  * carousel_id
+  * dynamic (Boolean, default value false)
+  * image_size (small, medium, or large)
+  * width (number of titles to display on a “pane” of the carousel)
+  * animated (Boolean to specify whether the carousel should automatically cycle through its panes)
+  * animation_interval (the interval (in seconds) to wait before advancing to the next pane)
+
+If the carousel_id parameter is supplied, the carousel with that ID will be
+displayed.  If carousel_id is not supplied, all carousels visible to the public
+catalog’s physical_loc organizational unit is displayed.

commit d5553bfb17d26ba92f9d121b50d02807b14eceff
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Sun Jun 9 21:00:19 2019 -0400

    LP#1832897: add public catalog display of carousels
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm
index 19a3b258a8..105c3bd1af 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm
@@ -358,6 +358,48 @@ sub load_common {
     $self->load_org_util_funcs;
     $self->load_perm_funcs;
 
+    # FIXME - move carousel helpers to a separate file
+    $ctx->{get_visible_carousels} = sub {
+        my $org_unit = $self->ctx->{physical_loc} || $self->cgi->param('loc') || $self->ctx->{aou_tree}->()->id;
+        return $U->simplereq(
+            'open-ils.actor',
+            'open-ils.actor.carousel.retrieve_by_org',
+            $org_unit
+        );
+    };
+    $ctx->{get_carousel} = sub {
+        my $id = shift;
+
+        my $carousel = $e->retrieve_container_carousel($id);
+        my $ret = {
+            id   => $id,
+            name => $carousel->name
+        };
+        my $q = {
+            select => { bre => ['id'], mfde => [{ column => 'value', alias => 'title' }] },
+            from   => {
+                bre => {
+                    cbrebi => {
+                        join => {
+                            cbreb => {
+                                join => { cc => {} }
+                            }
+                        }
+                    },
+                    mfde => {}
+                }
+            },
+            where  => {
+                '+cc' => { id => $id },
+                '+bre' => { deleted => 'f' },
+                '+mfde' => { name => 'title' }
+            }
+        };
+        my $r = $e->json_query($q);
+        $ret->{bibs} = $r;
+        return $ret;
+    };
+
     $ctx->{fetch_display_fields} = sub {
         my $id = shift;
 
diff --git a/Open-ILS/src/templates/opac/css/style.css.tt2 b/Open-ILS/src/templates/opac/css/style.css.tt2
index e5c1b0ba36..72c092fa4f 100644
--- a/Open-ILS/src/templates/opac/css/style.css.tt2
+++ b/Open-ILS/src/templates/opac/css/style.css.tt2
@@ -3394,3 +3394,42 @@ label[for*=expert_]
     font-weight: bolder;
     background-color: #42b0f4;
 }
+
+/*
+  Carousels
+*/
+
+.carousel {
+    width: 40%;
+    margin: 0 auto;
+}
+.carousel-title {
+    font-size: 150%;
+    font-weight: bold;
+    text-align: center;
+}
+.carousel-entry {
+    max-width: 150px;
+}
+.carousel-entry-image {
+    width: 100%;
+    height: auto;
+}
+.carousel .glide__arrow {
+    color: [% css_colors.button_text %];
+    font-weight: bold !important;
+    text-decoration: none !important;
+    cursor: pointer;
+    border-radius: 5px;
+    border: 1px solid [% css_colors.primary %];
+    background-color:  [% css_colors.primary_fade %];
+    margin: 0.5em;
+    padding: 0.3em;
+    display: inline-block;
+}
+.carousel .glide__arrow--right {
+    right: -5em;
+}
+.carousel .glide__arrow--left {
+    left: -5em;
+}
diff --git a/Open-ILS/src/templates/opac/parts/homesearch.tt2 b/Open-ILS/src/templates/opac/parts/homesearch.tt2
index 7fbc6205ee..f0be43135a 100644
--- a/Open-ILS/src/templates/opac/parts/homesearch.tt2
+++ b/Open-ILS/src/templates/opac/parts/homesearch.tt2
@@ -2,3 +2,4 @@
     <img src="[% ctx.media_prefix %]/opac/images/main_logo.png[% ctx.cache_key %]"
         [% img_alt(l('Evergreen Logo')) %]/>
 </div>
+[% PROCESS carousels %]
diff --git a/Open-ILS/src/templates/opac/parts/js.tt2 b/Open-ILS/src/templates/opac/parts/js.tt2
index 19ad6ff2b4..74a9bd49cf 100644
--- a/Open-ILS/src/templates/opac/parts/js.tt2
+++ b/Open-ILS/src/templates/opac/parts/js.tt2
@@ -153,3 +153,7 @@ var aou_hash = {
 [%- IF ctx.max_cart_size; %]
 <script type="text/javascript">var max_cart_size = [% ctx.max_cart_size %];</script>
 [%- END; %]
+
+
+<link rel="stylesheet" href="[% ctx.media_prefix %]/js/ui/default/common/build/js/glide/css/glide.core.min.css[% ctx.cache_key %]">
+<link rel="stylesheet" href="[% ctx.media_prefix %]/js/ui/default/common/build/js/glide/css/glide.theme.min.css[% ctx.cache_key %]">
diff --git a/Open-ILS/src/templates/opac/parts/misc_util.tt2 b/Open-ILS/src/templates/opac/parts/misc_util.tt2
index 6f3d18bf8c..609f22d055 100644
--- a/Open-ILS/src/templates/opac/parts/misc_util.tt2
+++ b/Open-ILS/src/templates/opac/parts/misc_util.tt2
@@ -802,3 +802,67 @@
     END;
 
 -%]
+
+[%- BLOCK carousels;
+    config = {
+        animated => 0,
+        animation_interval => 5,
+        width => 4,
+    };
+    config.import(args);
+    carousels = [];
+    IF config.carousel_id;
+        carousels.push(ctx.get_carousel(config.carousel_id));
+    ELSE;
+        visible_carousels = ctx.get_visible_carousels();
+        FOREACH car IN visible_carousels;
+            carousel = ctx.get_carousel(car.carousel);
+            IF car.override_name.defined;
+                carousel.name = car.override_name;
+            END;
+            carousels.push(carousel);
+        END; 
+    END;
+    IF carousels.size > 0;
+%]
+<script type="text/javascript" src="[% ctx.media_prefix %]/js/ui/default/common/build/js/glide/glide.min.js[% ctx.cache_key %]"></script>
+<div class="carousels">
+[% FOREACH carousel IN carousels;
+   IF carousel.bibs.size > 0;
+%]
+<div class="carousel">
+<div class="carousel-title">[% carousel.name %]</div>
+<div class="carousel-body glide" id="carousel-[% carousel.id %]">
+  <div class="glide__track" data-glide-el="track">
+    <ul class="glide__slides">
+[% FOREACH bib IN carousel.bibs; %]
+      <li class="glide__slide"><div class="carousel-entry">
+        <a href="[% mkurl(ctx.opac_root  _ '/record/' _ bib.id) %]">
+          <img alt="[% l('Book cover') %]" class="carousel-entry-image" src="[% ctx.media_prefix %]/opac/extras/ac/jacket/medium/r/[% bib.id | uri %]"></img>
+        </a><br>
+        <span class="carousel-entry-title"><a href="[% mkurl(ctx.opac_root  _ '/record/' _ bib.id) %]">[% bib.title | html %]</a></span>
+      </div></li>
+[% END; %]
+    </ul>
+  </div>
+  <div class="glide__arrows" data-glide-el="controls">
+    <button class="glide__arrow glide__arrow--left" data-glide-dir="<">prev</button>
+    <button class="glide__arrow glide__arrow--right" data-glide-dir=">">next</button>
+  </div>
+</div>
+</div>
+<script>
+    var glide = new Glide('#carousel-[% carousel.id %]', {
+        type: 'carousel',
+        rewind: false,
+        perView: [% config.width %],
+        startAt: 0,
+        [% IF config.animated %] autoplay: [% config.animation_interval * 1000 %],[% END %]
+    });
+    glide.mount();
+</script>
+[% END; %]
+[% END; %]
+</div>
+[% END -%]
+[% END -%]

commit b191a45d7c3c6ed7556f32def7f218f758b571c1
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Wed Jun 12 17:58:21 2019 -0400

    LP#1832897: business logic for carousels
    
    This patch adds various methods in open-ils.actor and open-ils.storage
    to manipulate carousels. It also adds a server-side script,
    refresh_carousels.srfsh, and an example crontab entry.
    
    The new methods are:
    
    * open-ils.actor.carousel.retrieve_by_org
    * open-ils.actor.carousel.retrieve_manual_by_staff
    * open-ils.actor.carousel.refresh
    * open-ils.actor.carousel.create.from_bucket
    * open-ils.storage.container.refresh_from_carousel
    * open-ils.storage.carousel.refresh_all
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/examples/crontab.example b/Open-ILS/examples/crontab.example
index 35144cd871..30f62a5916 100644
--- a/Open-ILS/examples/crontab.example
+++ b/Open-ILS/examples/crontab.example
@@ -61,6 +61,9 @@ EG_BIN_DIR = /openils/bin
 # Run the hard due date updater
 2  3  * * *   . ~/.bashrc && $EG_BIN_DIR/update_hard_due_dates.srfsh
 
+# Run the carousel updater
+5  3  * * *   . ~/.bashrc && $EG_BIN_DIR/refresh_carousels.srfsh
+
 # Run the credit card number clearing script
 #5  4  * * *   . ~/.bashrc && $EG_BIN_DIR/clear_cc_number.srfsh
 
diff --git a/Open-ILS/src/Makefile.am b/Open-ILS/src/Makefile.am
index 9420028df4..7f7954e1ca 100644
--- a/Open-ILS/src/Makefile.am
+++ b/Open-ILS/src/Makefile.am
@@ -65,6 +65,7 @@ core_scripts =   $(examples)/oils_ctl.sh \
 		 $(supportscr)/reshelving_complete.srfsh \
 		 $(supportscr)/clear_expired_circ_history.srfsh \
 		 $(supportscr)/update_hard_due_dates.srfsh \
+		 $(supportscr)/refresh_carousels.srfsh \
 		 $(supportscr)/juv_to_adult.srfsh \
 		 $(supportscr)/thaw_expired_frozen_holds.srfsh \
 		 $(supportscr)/long-overdue-status-update.pl \
@@ -276,6 +277,7 @@ ilscore-install:
 	sed -i 's|BINDIR|@bindir@|g' '$(DESTDIR)@bindir@/reshelving_complete.srfsh'
 	sed -i 's|BINDIR|@bindir@|g' '$(DESTDIR)@bindir@/clear_expired_circ_history.srfsh'
 	sed -i 's|BINDIR|@bindir@|g' '$(DESTDIR)@bindir@/update_hard_due_dates.srfsh'
+	sed -i 's|BINDIR|@bindir@|g' '$(DESTDIR)@bindir@/refresh_carousels.srfsh'
 	sed -i 's|BINDIR|@bindir@|g' '$(DESTDIR)@bindir@/juv_to_adult.srfsh'
 	sed -i 's|BINDIR|@bindir@|g' '$(DESTDIR)@bindir@/long-overdue-status-update.pl'
 	sed -i 's|SYSCONFDIR|@sysconfdir@|g' '$(DESTDIR)@bindir@/long-overdue-status-update.pl'
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor.pm
index 219f611b6b..d04160fe73 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor.pm
@@ -26,6 +26,7 @@ use DateTime;
 use DateTime::Format::ISO8601;
 use OpenILS::Const qw/:const/;
 
+use OpenILS::Application::Actor::Carousel;
 use OpenILS::Application::Actor::Container;
 use OpenILS::Application::Actor::ClosedDates;
 use OpenILS::Application::Actor::UserGroups;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Carousel.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Carousel.pm
new file mode 100644
index 0000000000..0b64841d4d
--- /dev/null
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Carousel.pm
@@ -0,0 +1,215 @@
+package OpenILS::Application::Actor::Carousel;
+use base 'OpenILS::Application';
+use strict; use warnings;
+use OpenILS::Application::AppUtils;
+use OpenILS::Perm;
+use Data::Dumper;
+use OpenSRF::EX qw(:try);
+use OpenILS::Utils::Fieldmapper;
+use OpenILS::Utils::CStoreEditor qw/:funcs/;
+use OpenSRF::Utils::SettingsClient;
+use OpenSRF::Utils::Cache;
+use Digest::MD5 qw(md5_hex);
+use OpenSRF::Utils::JSON;
+
+my $apputils = "OpenILS::Application::AppUtils";
+my $U = $apputils;
+my $logger = "OpenSRF::Utils::Logger";
+
+sub initialize { return 1; }
+
+__PACKAGE__->register_method(
+    method  => "retrieve_carousels_at_org",
+    api_name    => "open-ils.actor.carousel.retrieve_by_org",
+    authoritative => 1,
+    notes        => <<"    NOTES");
+        Retrieves the IDs and override names of all carousels visible
+        at the specified org unit sorted by their sequence number at
+        that library
+        PARAMS(OrgId)
+    NOTES
+
+sub retrieve_carousels_at_org {
+    my($self, $client, $org_id) = @_;
+    my $e = new_editor();
+
+    my $carousels = $e->json_query({
+        select => { ccou => ['carousel','override_name','seq'] },
+        distinct => 'true',
+        from => { ccou => 'cc' } ,
+        where => {
+            '+ccou' => { org_unit => $org_id },
+            '+cc'   => { active => 't' }
+        },
+        order_by => {
+            'ccou' => ['seq']
+        }
+    });
+
+    return $carousels;
+}
+
+__PACKAGE__->register_method(
+    method  => "retrieve_manual_carousels_for_staff",
+    api_name    => "open-ils.actor.carousel.retrieve_manual_by_staff",
+    authoritative => 1,
+    notes        => <<"    NOTES");
+        Retrieves the IDs, buckets, and names of all manually-maintained
+        carousels visible at any of the staff members working
+        locations.
+        PARAMS(authtoken)
+    NOTES
+
+sub retrieve_manual_carousels_for_staff {
+    my($self, $client, $auth) = @_;
+    my $e = new_editor(authtoken => $auth);
+    return $e->die_event unless $e->checkauth;
+
+    my $orgs = [];
+    if ($e->requestor->super_user eq 't') {
+        # super users can act/see at all OUs
+        my $ous = $e->json_query({
+            select => { aou => ['id'] },
+            from => 'aou'
+        });
+        $orgs = [ map { $_->{id} } @$ous ];
+    } else {
+        my $ous = $e->json_query({
+            select => { puwoum => ['work_ou'] },
+            from => 'puwoum',
+            where => {
+                '+puwoum' => { usr => $e->requestor->id }
+            }
+        });
+        $orgs = [ map { $_->{work_ou} } @$ous ];
+    }
+
+    my $carousels = $e->json_query({
+        select => { cc => ['id','name','bucket'] },
+        distinct => 'true',
+        from => { cc => 'ccou' },
+        where => {
+            '+ccou' => { org_unit => $orgs },
+            '+cc'   => { type => 1, active => 't' }, # FIXME
+        },
+        order_by => {
+            'cc' => ['name']
+        }
+    });
+
+    return $carousels;
+}
+
+__PACKAGE__->register_method(
+    method  => "refresh_carousel",
+    api_name    => "open-ils.actor.carousel.refresh",
+    authoritative => 1,
+    notes        => <<"    NOTES");
+        Refreshes the specified carousel
+        PARAMS(authtoken, carousel_id)
+    NOTES
+
+sub refresh_carousel {
+    my ($self, $client, $auth, $carousel_id) = @_;
+
+    my $e = new_editor(authtoken => $auth);
+    return $e->event unless $e->checkauth;
+    return $e->event unless $e->allowed('REFRESH_CAROUSEL');
+
+    my $carousel;
+    $carousel = $e->retrieve_container_carousel($carousel_id) or return $e->event;
+
+    return $e->event unless $e->allowed('REFRESH_CAROUSEL', $carousel->owner, $carousel);
+
+    my $ctype;
+    $ctype = $e->retrieve_config_carousel_type($carousel->type) or return $e->event;
+    return new OpenILS::Event('CANNOT_REFRESH_MANUAL_CAROUSEL') unless $ctype->automatic eq 't';
+
+    my $orgs = [];
+    my $locs = [];
+    if (defined($carousel->owning_lib_filter)) {
+        my $ou_filter = $carousel->owning_lib_filter;
+        $ou_filter =~ s/[{}]//g;
+        @$orgs = split /,/, $ou_filter;
+    }
+    if (defined($carousel->copy_location_filter)) {
+        my $loc_filter = $carousel->copy_location_filter;
+        $loc_filter =~ s/[{}]//g;
+        @$locs = split /,/, $loc_filter;
+    }
+
+    my $num_updated = $U->simplereq(
+        'open-ils.storage',
+        'open-ils.storage.container.refresh_from_carousel',
+        $carousel->bucket,
+        $carousel->type,
+        $carousel->age_filter,
+        $orgs,
+        $locs,
+        $carousel->max_items,
+    );
+
+    $carousel->last_refresh_time('now');
+    $e->xact_begin;
+    $e->update_container_carousel($carousel) or return $e->event;
+    $e->xact_commit or return $e->event;
+
+    return $num_updated;
+}
+
+__PACKAGE__->register_method(
+    method  => "add_carousel_from_bucket",
+    api_name    => "open-ils.actor.carousel.create.from_bucket",
+    authoritative => 1,
+    notes        => <<"    NOTES");
+        Creates new carousel and its container by copying the
+        contents of an existing bucket.
+        PARAMS(authtoken, carousel_name, bucket_id)
+    NOTES
+
+sub add_carousel_from_bucket {
+    my ($self, $client, $auth, $carousel_name, $bucket_id) = @_;
+
+    my $e = new_editor(authtoken => $auth);
+    return $e->event unless $e->checkauth;
+    return $e->event unless $e->allowed('ADMIN_CAROUSEL');
+
+    $e->xact_begin;
+
+    my $carousel = Fieldmapper::container::carousel->new;
+    $carousel->name($carousel_name);
+    $carousel->type(1); # manual
+    $carousel->owner($e->requestor->ws_ou);
+    $carousel->creator($e->requestor->id);
+    $carousel->editor($e->requestor->id);
+    $e->create_container_carousel($carousel) or return $e->event;
+
+    # and the bucket
+    my $bucket = Fieldmapper::container::biblio_record_entry_bucket->new;
+    $bucket->owner($e->requestor->id);
+    $bucket->name('System-created bucket for carousel ' . $carousel->id . ' copied from bucket ' . $bucket_id);
+    $bucket->btype('carousel');
+    $bucket->pub('t');
+    $bucket->owning_lib($e->requestor->ws_ou);
+    $e->create_container_biblio_record_entry_bucket($bucket) or return $e->event;
+
+    # link it to the container;
+    $carousel = $e->retrieve_container_carousel($carousel->id) or return $e->event;
+    $carousel->bucket($bucket->id);
+    $e->update_container_carousel($carousel) or return $e->event;
+
+    # and fill it
+    my $entries = $e->search_container_biblio_record_entry_bucket_item({ bucket => $bucket_id });
+    foreach my $entry (@$entries) {
+        $entry->clear_id;
+        $entry->bucket($bucket->id);
+        $entry->create_time('now');
+        $e->create_container_biblio_record_entry_bucket_item($entry) or return $e->event;
+    }
+
+    $e->xact_commit or return $e->event;
+
+    return $carousel->id;
+}
+
+1;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/CDBI/container.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/CDBI/container.pm
index c26d7ae5f0..46d20720e1 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/CDBI/container.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/CDBI/container.pm
@@ -50,7 +50,7 @@ use base qw/container/;
 
 container::biblio_record_entry_bucket_item->table( 'container_biblio_record_entry_bucket_item' );
 container::biblio_record_entry_bucket_item->columns( Primary => qw/id/ );
-container::biblio_record_entry_bucket_item->columns( Essential => qw/bucket target_biblio_record_entry/ );
+container::biblio_record_entry_bucket_item->columns( Essential => qw/bucket target_biblio_record_entry pos/ );
 
 #-------------------------------------------------------------------------------
 package container::call_number_bucket;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Publisher/container.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Publisher/container.pm
index fe60ee6336..a06fd1dcd3 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Publisher/container.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Publisher/container.pm
@@ -1,6 +1,228 @@
 package OpenILS::Application::Storage::Publisher::container;
 use base qw/OpenILS::Application::Storage/;
+use vars qw/$VERSION/;
+use OpenSRF::EX qw/:try/;
+use OpenSRF::Utils::Logger qw/:level :logger/;
+use OpenILS::Utils::CStoreEditor;
 #use OpenILS::Application::Storage::CDBI::config;
 
+my $new_items_query = q(
+    WITH c_attr AS (SELECT c_attrs::query_int AS vis_test FROM asset.patron_default_visibility_mask() x)
+    SELECT acn.record AS bib
+    FROM asset.call_number acn
+    JOIN asset.copy acp ON (acp.call_number = acn.id)
+    JOIN asset.copy_location acpl ON (acp.location = acpl.id)
+    JOIN config.copy_status ccs ON (acp.status = ccs.id)
+    , c_attr
+    WHERE acn.owning_lib IN (ORG_LIST)
+    AND acp.circ_lib IN (ORG_LIST)
+    AND acp.holdable
+    AND acp.circulate
+    AND ccs.holdable
+    AND acpl.holdable
+    AND acpl.circulate
+    AND acp.active_date > NOW() - ?::INTERVAL
+    AND (EXISTS (SELECT 1 FROM asset.copy_vis_attr_cache WHERE record = acn.record AND vis_attr_vector @@ c_attr.vis_test))
+    AND (NOT EXISTS (SELECT 1 FROM metabib.record_attr_vector_list WHERE source = acn.record AND vlist @@ metabib.compile_composite_attr(' {"1":[{"_val":"s","_attr":"bib_level"}]}')::query_int))
+    GROUP BY acn.record
+    ORDER BY MIN(AGE(acp.active_date))
+    LIMIT ? 
+);
+my $recently_returned_query = q(
+WITH c_attr AS (SELECT c_attrs::query_int AS vis_test FROM asset.patron_default_visibility_mask() x)
+    SELECT acn.record AS bib
+    FROM asset.call_number acn
+    JOIN asset.copy acp ON (acp.call_number = acn.id)
+    JOIN asset.copy_location acpl ON (acp.location = acpl.id)
+    JOIN config.copy_status ccs ON (acp.status = ccs.id)
+    JOIN action.circulation circ ON (circ.target_copy = acp.id)
+    , c_attr
+    WHERE acn.owning_lib IN (ORG_LIST)
+    AND acp.circ_lib IN (ORG_LIST)
+    AND acp.holdable
+    AND acp.circulate
+    AND ccs.holdable
+    AND acpl.holdable
+    AND acpl.circulate
+    AND circ.checkin_time > NOW() - ?::INTERVAL
+    AND circ.checkin_time IS NOT NULL
+    AND (EXISTS (SELECT 1 FROM asset.copy_vis_attr_cache WHERE record = acn.record AND vis_attr_vector @@ c_attr.vis_test))
+    AND (NOT EXISTS (SELECT 1 FROM metabib.record_attr_vector_list WHERE source = acn.record AND vlist @@ metabib.compile_composite_attr(' {"1":[{"_val":"s","_attr":"bib_level"}]}')::query_int))
+    GROUP BY acn.record
+    ORDER BY MIN(AGE(circ.checkin_time))
+    LIMIT ?
+);
+my $top_circs_query = q(
+    WITH c_attr AS (SELECT c_attrs::query_int AS vis_test FROM asset.patron_default_visibility_mask() x)
+    SELECT acn.record AS bib
+    FROM asset.call_number acn
+    JOIN asset.copy acp ON (acp.call_number = acn.id)
+    JOIN asset.copy_location acpl ON (acp.location = acpl.id)
+    JOIN config.copy_status ccs ON (acp.status = ccs.id)
+    JOIN action.circulation circ ON (circ.target_copy = acp.id)
+    , c_attr
+    WHERE acn.owning_lib IN (ORG_LIST)
+    AND acp.circ_lib IN (ORG_LIST)
+    AND acp.holdable
+    AND acp.circulate
+    AND ccs.holdable
+    AND acpl.holdable
+    AND acpl.circulate
+    AND circ.xact_start > NOW() - ?::INTERVAL
+    AND (EXISTS (SELECT 1 FROM asset.copy_vis_attr_cache WHERE record = acn.record AND vis_attr_vector @@ c_attr.vis_test))
+    AND (NOT EXISTS (SELECT 1 FROM metabib.record_attr_vector_list WHERE source = acn.record AND vlist @@ metabib.compile_composite_attr(' {"1":[{"_val":"s","_attr":"bib_level"}]}')::query_int))
+    GROUP BY acn.record
+    ORDER BY COUNT(circ.id) DESC
+    LIMIT ?
+);
+my $new_by_loc_query = q(
+    WITH c_attr AS (SELECT c_attrs::query_int AS vis_test FROM asset.patron_default_visibility_mask() x)
+    SELECT acn.record AS bib
+    FROM asset.call_number acn
+    JOIN asset.copy acp ON (acp.call_number = acn.id)
+    JOIN asset.copy_location acpl ON (acp.location = acpl.id)
+    JOIN config.copy_status ccs ON (acp.status = ccs.id)
+    , c_attr
+    WHERE acn.owning_lib IN (ORG_LIST)
+    AND acp.circ_lib IN (ORG_LIST)
+    AND acp.active_date > NOW() - ?::INTERVAL
+    AND acp.location IN (LOC_LIST)
+    AND acp.holdable
+    AND acp.circulate
+    AND ccs.holdable
+    AND acpl.holdable
+    AND acpl.circulate
+    AND (EXISTS (SELECT 1 FROM asset.copy_vis_attr_cache WHERE record = acn.record AND vis_attr_vector @@ c_attr.vis_test))
+    AND (NOT EXISTS (SELECT 1 FROM metabib.record_attr_vector_list WHERE source = acn.record AND vlist @@ metabib.compile_composite_attr(' {"1":[{"_val":"s","_attr":"bib_level"}]}')::query_int))
+    GROUP BY acn.record
+    ORDER BY MIN(AGE(acp.active_date))
+    LIMIT ?
+);
+
+my %TYPE_QUERY_MAP = (
+    2 => $new_items_query,
+    3 => $recently_returned_query,
+    4 => $top_circs_query,
+    5 => $new_by_loc_query,
+);
+
+sub refresh_container_from_carousel_definition {
+    my $self = shift;
+    my $client = shift;
+    my $bucket = shift;
+    my $carousel_type = shift;
+    my $age = shift // '15 days';
+    my $libs = shift // [];
+    my $locs = shift // [];
+    my $limit = shift // 50;
+
+    my $e = OpenILS::Utils::CStoreEditor->new;
+    my $ctype = $e->retrieve_config_carousel_type($carousel_type) or return $e->die_event;
+    $e->disconnect;
+
+    unless (exists($TYPE_QUERY_MAP{$carousel_type})) {
+        $logger->error("Carousel for bucket $bucket is misconfigured; type $carousel_type is not recognized");
+        return 0;
+    }
+
+    my $query = $TYPE_QUERY_MAP{$carousel_type};
+
+    if ($ctype->filter_by_copy_owning_lib eq 't') {
+        if (scalar(@$libs) < 1) {
+            $logger->error("Carousel for bucket $bucket is misconfigured; owning library filter expected but none specified");
+            return 0;
+        }
+        my $org_placeholders = join(',', map { '?' } @$libs);
+        $query =~ s/ORG_LIST/$org_placeholders/g;
+    } else {
+        $libs = []; # we'll ignore any superflous supplied values
+    }
+
+    if ($ctype->filter_by_copy_location eq 't') {
+        if (scalar(@$locs) < 1) {
+            $logger->error("Carousel for bucket $bucket is misconfigured; copy location filter expected but none specified");
+            return 0;
+        }
+        my $loc_placeholders = join(',', map { '?' } @$locs);
+        $query =~ s/LOC_LIST/$loc_placeholders/g;
+    } else {
+        $locs = []; # we'll ignore any superflous supplied values
+    }
+
+    my $sth = container::biblio_record_entry_bucket_item->db_Main->prepare_cached($query);
+
+    $sth->execute(@$libs, @$libs, $age, @$locs, $limit);
+    my @bibs = ();
+    while (my $row = $sth->fetchrow_hashref ) {
+        push @bibs, $row->{bib};
+    }
+    container::biblio_record_entry_bucket_item->search( bucket => $bucket )->delete_all;
+    my $i = 0;
+    foreach my $bib (@bibs) {
+        container::biblio_record_entry_bucket_item->create({ bucket => $bucket, target_biblio_record_entry => $bib, pos => $i++ });
+    }
+    return scalar(@bibs);
+}
+
+__PACKAGE__->register_method(
+    api_name    => 'open-ils.storage.container.refresh_from_carousel',
+    method      => 'refresh_container_from_carousel_definition',
+    api_level   => 1,
+    cachable    => 1,
+);
+
+sub refresh_all_carousels {
+    my $self = shift;
+    my $client = shift;
+
+    my $e = OpenILS::Utils::CStoreEditor->new;
+
+    my $automatic_types = $e->search_config_carousel_type({ automatic => 't' });
+    my $carousels = $e->search_container_carousel({ type => [ map { $_->id } @$automatic_types ], active => 't' });
+
+    my $meth = $self->method_lookup('open-ils.storage.container.refresh_from_carousel');
+
+    foreach my $carousel (@$carousels) {
+
+        my $orgs = [];
+        my $locs = [];
+        if (defined($carousel->owning_lib_filter)) {
+            my $ou_filter = $carousel->owning_lib_filter;
+            $ou_filter =~ s/[{}]//g;
+            @$orgs = split /,/, $ou_filter;
+        }
+        if (defined($carousel->copy_location_filter)) {
+            my $loc_filter = $carousel->copy_location_filter;
+            $loc_filter =~ s/[{}]//g;
+            @$locs = split /,/, $loc_filter;
+        }
+
+        my @res = $meth->run($carousel->bucket, $carousel->type, $carousel->age_filter, $orgs, $locs, $carousel->max_items);
+        my $ct = scalar(@res) ? $res[0] : 0;
+
+        $e->xact_begin;
+        $carousel->last_refresh_time('now');
+        $e->update_container_carousel($carousel);
+        $e->xact_commit;
+
+        $client->respond({
+            carousel => $carousel->id,
+            bucket   => $carousel->bucket,
+            updated  => $ct
+        });
+
+    }
+    $e->disconnect;
+    return undef;
+}
+
+__PACKAGE__->register_method(
+    api_name    => 'open-ils.storage.carousel.refresh_all',
+    method      => 'refresh_all_carousels',
+    api_level   => 1,
+    stream      => 1,
+    cachable    => 1,
+);
+
 
 1;
diff --git a/Open-ILS/src/support-scripts/refresh_carousels.srfsh b/Open-ILS/src/support-scripts/refresh_carousels.srfsh
new file mode 100644
index 0000000000..8abc1f2fe7
--- /dev/null
+++ b/Open-ILS/src/support-scripts/refresh_carousels.srfsh
@@ -0,0 +1,2 @@
+#!BINDIR/srfsh
+request open-ils.storage open-ils.storage.carousel.refresh_all

commit e7100d944520f8037dba6274e891783fbadaff63
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Fri Jun 14 16:42:52 2019 -0400

    LP#1832897: add miscellaneous carousels functionality to staff interface
    
    * Add a 'Create Carousel from Bucket' action in the record bucket interface
    * Add an 'Add to Carousel' action to the record details page
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/templates/staff/cat/bucket/record/t_create_carousel.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/t_create_carousel.tt2
new file mode 100644
index 0000000000..72bb761150
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/bucket/record/t_create_carousel.tt2
@@ -0,0 +1,24 @@
+<!-- create carousel dialog -->
+
+<!-- use <form> so we get submit-on-enter for free -->
+<form class="form-validated" novalidate name="form" ng-submit="ok(args)">
+  <div>
+    <div class="modal-header">
+      <button type="button" class="close" 
+        ng-click="cancel()" aria-hidden="true">×</button>
+      <h4 class="modal-title">[% l('Create Carousel From Bucket') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="form-group">
+        <label for="create-carousel-name">[% l('Name') %]</label>
+        <input type="text" class="form-control" focus-me='focusMe' required
+          id="create-carousel-name" ng-model="args.name" placeholder="[% l('Carousel Name..') %]"/>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" ng-disabled="form.$invalid" 
+          class="btn btn-primary" value="[% l('Create Carousel') %]"/>
+      <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+    </div>
+  </div> <!-- modal-content -->
+</form>
diff --git a/Open-ILS/src/templates/staff/cat/bucket/record/t_grid_menu.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/t_grid_menu.tt2
index 47704fb76e..c9af4f5eb2 100644
--- a/Open-ILS/src/templates/staff/cat/bucket/record/t_grid_menu.tt2
+++ b/Open-ILS/src/templates/staff/cat/bucket/record/t_grid_menu.tt2
@@ -12,6 +12,9 @@
 <eg-grid-menu-item label="[% l('Shared Bucket') %]" 
   handler="openSharedBucketDialog"></eg-grid-menu-item>
 
+<eg-grid-menu-item label="[% l('Create Carousel from Bucket') %]"
+  handler="openCreateCarouselDialog"></eg-grid-menu-item>
+
 <eg-grid-menu-item divider="true"></eg-grid-menu-item>
 
 <eg-grid-menu-item ng-repeat="bkt in bucketSvc.allBuckets" 
diff --git a/Open-ILS/src/templates/staff/cat/catalog/t_add_to_carousel.tt2 b/Open-ILS/src/templates/staff/cat/catalog/t_add_to_carousel.tt2
new file mode 100644
index 0000000000..17aa7320a0
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/catalog/t_add_to_carousel.tt2
@@ -0,0 +1,28 @@
+<div class="modal-header">
+  <button type="button" class="close" ng-click="cancel()"
+    aria-hidden="true">×</button>
+  <h4 class="modal-title">[% l('Add to Carousel') %]</h4>
+</div>
+<div class="modal-body">
+  <div class="row">
+    <div class="col-md-4">
+      <label for="select-carousel">[% l('Name of Carousel') %]</label>
+    </div>
+    <div class="col-md-4">
+      <!-- we're taking a short-cut and indexing by the carousel's bucket -->
+      <select id="select-carousel" class="form-control" ng-model="bucket_id"
+              ng-options="carousel.bucket as carousel.name for carousel in allCarousels | orderBy:'name'">
+      </select>
+    </div>
+    <div class="col-md-4">
+      <button class="btn btn-primary" ng-class="{disabled : !bucket_id}" 
+          ng-click="add_to_carousel()">[% l('Add to Selected Carousel') %]</button>
+    </div>
+  </div>
+  </div>
+</div>
+<div class="modal-footer">
+  <div class="col-md-6 pull-right">
+    <button class="btn btn-warning" ng-click="cancel($event)">[% l('Cancel') %]</button>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/cat/catalog/t_catalog.tt2 b/Open-ILS/src/templates/staff/cat/catalog/t_catalog.tt2
index c9b733d733..3f62abb92c 100644
--- a/Open-ILS/src/templates/staff/cat/catalog/t_catalog.tt2
+++ b/Open-ILS/src/templates/staff/cat/catalog/t_catalog.tt2
@@ -86,6 +86,11 @@
                         [% l('Add To Bucket') %]
                     </a>
             </li>
+             <li role="menuitem" ng-class="{disabled : !carousels_available}">
+                    <a ng-click="add_to_carousel()" href="">
+                        [% l('Add To Carousel') %]
+                    </a>
+            </li>
             <li role="menuitem">
                    <a ng-click="view_place_orders()" href="">
                         [% l('View/Place Orders') %]
diff --git a/Open-ILS/web/js/ui/default/staff/cat/bucket/record/app.js b/Open-ILS/web/js/ui/default/staff/cat/bucket/record/app.js
index 14ebe42e76..29d64cc9c3 100644
--- a/Open-ILS/web/js/ui/default/staff/cat/bucket/record/app.js
+++ b/Open-ILS/web/js/ui/default/staff/cat/bucket/record/app.js
@@ -401,6 +401,34 @@ function($scope,  $location,  $q,  $timeout,  $uibModal,
         });
     }
 
+    // allows user to create a carousel from the selected bucket
+    $scope.openCreateCarouselDialog = function() {
+        if (!bucketSvc.currentBucket || !bucketSvc.currentBucket.id()) {
+            return;
+        }
+        $uibModal.open({
+            templateUrl: './cat/bucket/record/t_create_carousel',
+            backdrop: 'static',
+            controller :
+                ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
+                $scope.focusMe = true;
+                $scope.ok = function(args) {
+                    if (args && args.name) {
+                        return egCore.net.request(
+                            'open-ils.actor',
+                            'open-ils.actor.carousel.create.from_bucket',
+                            egCore.auth.token(), args.name, bucketSvc.currentBucket.id()
+                        ).then(function(carouselId) { $uibModalInstance.close(carouselId) });
+                    }
+                }
+                $scope.cancel = function() { $uibModalInstance.dismiss() }
+            }]
+        }).result.then(function(carouselId) {
+            // bouncing outside of AngularJS
+            $window.location.href = '/eg2/en-US/staff/admin/server/container/carousel';
+        });
+    }
+
     // opens the record export dialog
     $scope.openExportBucketDialog = function() {
         $uibModal.open({
diff --git a/Open-ILS/web/js/ui/default/staff/cat/catalog/app.js b/Open-ILS/web/js/ui/default/staff/cat/catalog/app.js
index c853cb8686..c207b9797a 100644
--- a/Open-ILS/web/js/ui/default/staff/cat/catalog/app.js
+++ b/Open-ILS/web/js/ui/default/staff/cat/catalog/app.js
@@ -423,6 +423,58 @@ function($scope , $routeParams , $location , $window , $q , egCore , egHolds , e
         });
     }
 
+    $scope.carousels_available = false;
+    egCore.net.request(
+        'open-ils.actor',
+        'open-ils.actor.carousel.retrieve_manual_by_staff',
+        egCore.auth.token()
+    ).then(function(carousels) { $scope.carousels_available = true; });
+
+    $scope.add_to_carousel = function(recs) {
+        if (!angular.isArray(recs)) {
+            recs = [ $scope.record_id ];
+        }
+        return $uibModal.open({
+            templateUrl: './cat/catalog/t_add_to_carousel',
+            backdrop: 'static',
+            animation: true,
+            size: 'md',
+            controller:
+                   ['$scope','$uibModalInstance',
+            function($scope , $uibModalInstance) {
+                $scope.bucket_id = 0;
+                $scope.allCarousels = [];
+                egCore.net.request(
+                    'open-ils.actor',
+                    'open-ils.actor.carousel.retrieve_manual_by_staff',
+                    egCore.auth.token()
+                ).then(function(carousels) { $scope.allCarousels = carousels; });
+
+                $scope.add_to_carousel = function() {
+                    // or more precisely, the carousel's bucket
+                    var promises = [];
+                    angular.forEach(recs, function(recId) {
+                        var item = new egCore.idl.cbrebi();
+                        item.bucket($scope.bucket_id);
+                        item.target_biblio_record_entry(recId);
+                        promises.push(egCore.net.request(
+                            'open-ils.actor',
+                            'open-ils.actor.container.item.create',
+                            egCore.auth.token(), 'biblio', item
+                        ));
+                    });
+                    $q.all(promises).then(function(resp) {
+                        $uibModalInstance.close();
+                    });
+                }
+
+                $scope.cancel = function() {
+                    $uibModalInstance.dismiss();
+                }
+            }]
+        });
+    }
+
     $scope.current_overlay_target     = egCore.hatch.getLocalItem('eg.cat.marked_overlay_record');
     $scope.current_transfer_target    = egCore.hatch.getLocalItem('eg.cat.transfer_target_record');
     $scope.current_conjoined_target   = egCore.hatch.getLocalItem('eg.cat.marked_conjoined_record');

commit 565223aab576af3284e60b6195eeb09bbba880be
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Sun Jun 9 19:18:46 2019 -0400

    LP#1832897: add administrative interfaces for carousels
    
    This patch adds three Angular administration interfaces:
    
    (Server Admin) Carousel Types
    (Local Admin) Carousel Library Mappings
    (Local Admin) Carousels
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/admin-carousel.component.html b/Open-ILS/src/eg2/src/app/staff/admin/local/admin-carousel.component.html
new file mode 100644
index 0000000000..6c3e7834d1
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/admin/local/admin-carousel.component.html
@@ -0,0 +1,98 @@
+<eg-title i18n-prefix prefix="{{classLabel}} Administration">
+</eg-title>
+<eg-staff-banner bannerText="{{classLabel}} Configuration" i18n-bannerText>
+</eg-staff-banner>
+
+<ng-template #successStrTmpl i18n>{{idlClassDef.label}} Update Succeeded</ng-template>
+<eg-string #successString [template]="successStrTmpl"></eg-string>
+
+<ng-template #updateFailedStrTmpl i18n>Update of {{idlClassDef.label}} failed</ng-template>
+<eg-string #updateFailedString [template]="updateFailedStrTmpl"></eg-string>
+
+<ng-template #deleteFailedStrTmpl i18n>Delete of carousel faiiled or was not allowed</ng-template>
+<eg-string #deleteFailedString [template]="deleteFailedStrTmpl"></eg-string>
+
+<ng-template #deleteSuccessStrTmpl i18n>Carousel Successfully Deleted</ng-template>
+<eg-string #deleteSuccessString [template]="deleteSuccessStrTmpl"></eg-string>
+
+<ng-template #createStrTmpl i18n>{{idlClassDef.label}} Succeessfully Created</ng-template>
+<eg-string #createString [template]="createStrTmpl"></eg-string>
+
+<ng-template #createErrStrTmpl i18n>Failed to create new {{idlClassDef.label}}</ng-template>
+<eg-string #createErrString [template]="createErrStrTmpl"></eg-string>
+
+<ng-template #refreshStrTmpl i18n let-name="name">{{name}} is being refreshed. It may take a couple minutes.</ng-template>
+<eg-string #refreshString [template]="refreshStrTmpl"></eg-string>
+
+<ng-template #refreshErrStrTmpl i18n let-name="name">{{name}} is manual and cannot be refreshed automatically.</ng-template>
+<eg-string #refreshErrString [template]="refreshErrStrTmpl"></eg-string>
+
+<ng-container *ngIf="orgField">
+  <eg-org-family-select
+    [limitPerms]="viewPerms"
+    [selectedOrgId]="contextOrg.id()"
+    [(ngModel)]="searchOrgs"
+    (ngModelChange)="grid.reload()">
+  </eg-org-family-select>
+  <hr/>
+</ng-container>
+
+<!-- idlObject and fieldName applied programmatically -->
+<eg-translate #translator></eg-translate>
+
+<eg-grid #grid idlClass="{{idlClass}}" [dataSource]="dataSource" 
+    [sortable]="true" persistKey="{{persistKey}}" [showLinkSelectors]="true">
+  <eg-grid-toolbar-button [disabled]="!canCreate" 
+    label="New {{idlClassDef.label}}" i18n-label [action]="createNew">
+  </eg-grid-toolbar-button>
+  <eg-grid-toolbar-button [disabled]="translatableFields.length == 0" 
+    label="Apply Translations" i18n-label [action]="translate">
+  </eg-grid-toolbar-button>
+  <eg-grid-toolbar-action label="Edit Selected" i18n-label [action]="editSelected">
+  </eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="Delete Selected" i18n-label [action]="deleteSelected">
+  </eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="Refesh Selected" i18n-label [action]="refreshSelected">
+  </eg-grid-toolbar-action>
+  <eg-grid-column path="bucket" [hidden]="true"></eg-grid-column>
+  <eg-grid-column path="creator" [hidden]="true"></eg-grid-column>
+  <eg-grid-column path="editor" [hidden]="true"></eg-grid-column>
+  <eg-grid-column path="create_time" [hidden]="true"></eg-grid-column>
+  <eg-grid-column path="edit_time" [hidden]="true"></eg-grid-column>
+  <eg-grid-column path="age_filter" [hidden]="true"></eg-grid-column>
+  <eg-grid-column path="owning_lib_filter" [hidden]="true"></eg-grid-column>
+  <eg-grid-column path="copy_location_filter" [hidden]="true"></eg-grid-column>
+</eg-grid>
+
+<ng-template #bucketTemplate
+    let-field="field" let-record="record">
+  <span *ngIf="record[field.name]()" i18n>
+    <a href="/eg/staff/cat/bucket/record/view/{{record[field.name]()}}" target="_blank" i18n>Link to bucket</a>
+    <span *ngIf="record['type']() !== 1" i18n style="font-style: italic"> (Note: changes to bucket contents may be overwritten by the next carousel update.)</span>
+  </span>
+</ng-template>
+
+<ng-template #locationTemplate
+    let-field="field" let-record="record">
+  <eg-multi-select idlClass="acpl" linkedLibraryLabel="owning_lib"
+                   [startValue]="record['copy_location_filter']()"
+                   (onChange)="record['copy_location_filter']($event)">
+  </eg-multi-select>
+</ng-template>
+<ng-template #orgTemplate
+    let-field="field" let-record="record">
+  <eg-multi-select idlClass="aou"
+                   [startValue]="record['owning_lib_filter']()"
+                   (onChange)="record['owning_lib_filter']($event)">
+  </eg-multi-select>
+</ng-template>
+
+<eg-fm-record-editor #editDialog idlClass="{{idlClass}}" 
+    [preloadLinkedValues]="true" readonlyFields="last_refresh_time"
+    hiddenFieldsList="creator,editor,create_time,edit_time"
+    [preSave]="mungeCarousel" (onSave$)="postSave($event)"
+    readonlyFields="last_refresh_time"
+    [fieldOptions]="{bucket:{customTemplate:{template:bucketTemplate}},copy_location_filter:{customTemplate:{template:locationTemplate}},owning_lib_filter:{customTemplate:{template:orgTemplate}}}"
+></eg-fm-record-editor>
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/admin-carousel.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/local/admin-carousel.component.ts
new file mode 100644
index 0000000000..bfbd4204a5
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/admin/local/admin-carousel.component.ts
@@ -0,0 +1,130 @@
+import {Component, Input, ViewChild, OnInit} from '@angular/core';
+import {AdminPageComponent} from '@eg/staff/share/admin-page/admin-page.component';
+import {ActivatedRoute} from '@angular/router';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {OrgService} from '@eg/core/org.service';
+import {PermService} from '@eg/core/perm.service';
+import {AuthService} from '@eg/core/auth.service';
+import {NetService} from '@eg/core/net.service';
+import {StringComponent} from '@eg/share/string/string.component';
+
+ at Component({
+    templateUrl: './admin-carousel.component.html'
+})
+
+export class AdminCarouselComponent extends AdminPageComponent implements OnInit {
+
+    idlClass = 'cc';
+    classLabel: string;
+
+    refreshSelected: (idlThings: IdlObject[]) => void;
+    createNew: () => void;
+    deleteSelected: (idlThings: IdlObject[]) => void;
+
+    @ViewChild('refreshString') refreshString: StringComponent;
+    @ViewChild('refreshErrString') refreshErrString: StringComponent;
+
+    constructor(
+        route: ActivatedRoute,
+        idl: IdlService,
+        org: OrgService,
+        auth: AuthService,
+        pcrud: PcrudService,
+        perm: PermService,
+        toast: ToastService,
+        private net: NetService
+    ) {
+        super(route, idl, org, auth, pcrud, perm, toast);
+    }
+
+    ngOnInit() {
+        super.ngOnInit();
+
+        this.classLabel = this.idlClassDef.label;
+        this.includeOrgDescendants = true;
+
+
+        this.createNew = () => {
+            super.createNew();
+        };
+
+        this.deleteSelected = (idlThings: IdlObject[]) => {
+            super.deleteSelected(idlThings);
+        };
+
+        this.refreshSelected = (idlThings: IdlObject[]) =>  {
+            idlThings.forEach(cc => {
+                if (cc.type().automatic() === 't') {
+                    this.net.request(
+                        'open-ils.actor',
+                        'open-ils.actor.carousel.refresh',
+                        this.auth.token(), cc.id()
+                    ).toPromise(); // fire and forget, as this could take a couple minutes
+                    this.refreshString.current({ name: cc.name() }).then(str => this.toast.success(str));
+                } else {
+                    this.refreshErrString.current({ name: cc.name() }).then(str => this.toast.warning(str));
+                }
+            });
+        };
+    }
+
+    mungeCarousel(editMode: string, rec: IdlObject) {
+        if (editMode === 'create') {
+            rec.creator(this.auth.user().id());
+        }
+        rec.editor(this.auth.user().id());
+        rec.edit_time('now');
+
+        // convert empty string to nulls as needed
+        // for int[] columns
+        if (rec.owning_lib_filter() === '') {
+            rec.owning_lib_filter(null);
+        }
+        if (rec.copy_location_filter() === '') {
+            rec.copy_location_filter(null);
+        }
+    }
+
+    postSave(rec: IdlObject) {
+        if (rec._isfieldmapper) {
+            // if we got an actual IdlObject back, the
+            // record had just been created, not just
+            // edited. therefore, we probably need
+            if (rec.bucket() == null) {
+                const bucket = this.idl.create('cbreb');
+                bucket.owner(this.auth.user().id());
+                bucket.name('System-generated bucket for carousel: ' + rec.id()); // FIXME I18N
+                bucket.btype('carousel');
+                bucket.pub('t');
+                bucket.owning_lib(rec.owner());
+                rec.bucket(bucket);
+                this.net.request(
+                    'open-ils.actor',
+                    'open-ils.actor.container.create',
+                    this.auth.token(), 'biblio', bucket
+                ).toPromise().then(
+                    newBucket => {
+                        const ccou = this.idl.create('ccou');
+                        ccou.carousel(rec.id());
+                        ccou.org_unit(rec.owner());
+                        ccou.seq(0);
+                        rec.bucket(newBucket);
+                        this.pcrud.create(ccou).subscribe(
+                            ok => {
+                                this.pcrud.update(rec).subscribe(
+                                    ok2 => console.debug('updated'),
+                                    err => console.error(err),
+                                    () => { this.grid.reload(); }
+                                );
+                            },
+                            err => console.error(err),
+                            () => { this.grid.reload(); }
+                        );
+                    }
+                );
+            }
+        }
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/admin-local-splash.component.html b/Open-ILS/src/eg2/src/app/staff/admin/local/admin-local-splash.component.html
index edbb68f7ed..2ec554d11a 100644
--- a/Open-ILS/src/eg2/src/app/staff/admin/local/admin-local-splash.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/admin/local/admin-local-splash.component.html
@@ -11,6 +11,10 @@
       url="/eg/staff/admin/local/config/auto_print"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Barcode Completion" 
       routerLink="/staff/admin/local/config/barcode_completion"></eg-link-table-link>
+    <eg-link-table-link i18n-label label="Carousel Library Mappings"
+      routerLink="/staff/admin/server/container/carousel_org_unit"></eg-link-table-link>
+    <eg-link-table-link i18n-label label="Carousels"
+      routerLink="/staff/admin/server/container/carousel"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Cash Reports" 
       url="/eg/staff/admin/local/money/cash_reports"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Circulation Limit Sets" 
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/admin-local.module.ts b/Open-ILS/src/eg2/src/app/staff/admin/local/admin-local.module.ts
index c2b5041962..7c8a6fa7cb 100644
--- a/Open-ILS/src/eg2/src/app/staff/admin/local/admin-local.module.ts
+++ b/Open-ILS/src/eg2/src/app/staff/admin/local/admin-local.module.ts
@@ -5,11 +5,13 @@ import {AdminLocalRoutingModule} from './routing.module';
 import {AdminCommonModule} from '@eg/staff/admin/common.module';
 import {AdminLocalSplashComponent} from './admin-local-splash.component';
 import {AddressAlertComponent} from './address-alert.component';
+import {AdminCarouselComponent} from './admin-carousel.component';
 
 @NgModule({
   declarations: [
       AdminLocalSplashComponent,
-      AddressAlertComponent
+      AddressAlertComponent,
+      AdminCarouselComponent
   ],
   imports: [
     AdminCommonModule,
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/admin/local/routing.module.ts
index 12030dde0f..4eda5855c4 100644
--- a/Open-ILS/src/eg2/src/app/staff/admin/local/routing.module.ts
+++ b/Open-ILS/src/eg2/src/app/staff/admin/local/routing.module.ts
@@ -3,6 +3,7 @@ import {RouterModule, Routes} from '@angular/router';
 import {AdminLocalSplashComponent} from './admin-local-splash.component';
 import {BasicAdminPageComponent} from '@eg/staff/admin/basic-admin-page.component';
 import {AddressAlertComponent} from './address-alert.component';
+import {AdminCarouselComponent} from './admin-carousel.component';
 
 const routes: Routes = [{
     path: 'splash',
@@ -15,6 +16,9 @@ const routes: Routes = [{
     path: 'actor/address_alert',
     component: AddressAlertComponent
 }, {
+    path: 'container/carousel',
+    component: AdminCarouselComponent
+}, {
     path: ':schema/:table',
     component: BasicAdminPageComponent
 }];
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html b/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html
index f71dd2fd04..d3ed6ebf41 100644
--- a/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html
@@ -25,6 +25,8 @@
       routerLink="/staff/admin/server/asset/call_number_prefix"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Call Number Suffixes"  
       routerLink="/staff/admin/server/asset/call_number_suffix"></eg-link-table-link>
+    <eg-link-table-link i18n-label label="Carousel Types"
+      routerLink="/staff/admin/server/config/carousel_type"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Circulation Duration Rules"  
       routerLink="/staff/admin/server/config/rule_circ_duration"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Circulation Limit Groups"  

commit 5f54469b5c4e53396ac35c2f4a7afbabe87becc8
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Sun Jun 9 18:54:04 2019 -0400

    LP#1832897: add tables, IDL, and seed data for carousels
    
    Carousels
    ^^^^^^^^^
    This feature fully integrates the creation and management of book carousels
    into Evergreen, allowing for the display of book cover images on a library’s
    public catalog home page.  Carousels may be animated or static.  They can be
    manually maintained by staff or automatically maintained by Evergreen.  Titles
    can appear in carousels based on newly cataloged items, recent returns,
    popularity, etc.  Titles must have copies that are visible to the public
    catalog, be circulating, and holdable to appear in a carousel.  Serial titles
    cannot be displayed in carousels.
    
    Administration
    ++++++++++++++
    This feature introduces the concepts of Carousel Types, Carousels, and Carousel
    Library Mappings. The first can be administered in Server Administration
    while the latter two can be administerd in Local Administration.
    
    Carousel Types define the attributes of a carousel, such as whether it is
    automatically managed and how it is filtered.  A carousel must be associated
    with a carousel type to function properly.
    
    There are five stock Carousel Types:
    
      * Newly Cataloged Items - titles appear automatically based on the
        active date of the title’s copies
      * Recently Returned Items - titles appear automatically based on the
        mostly recently circulated copy’s check-in scan date and time
      * Top Circulated Titles - titles appear automatically based on the
        most circulated copies in the Item Libraries identified in the
        carousel definition; titles are chosen based on the number of
        action.circulation rows created during an interval specified
        in the carousel definition and includes both circulations and renewals
      * Newest Items by Shelving Location - titles appear automatically
        based on the active date and shelving location of the title’s copies
      * Manual - titles are added and managed manually by library staff
    
    While additional Carousel Types can be added using the administration
    interface, new automatic types currently require additional Perl code
    to be recognized.
    
    Carousel definitions allow the operator to specify the type, owner,
    name and, for automatically-maintained types, the item libraries and
    shelving locations to look for titles to populate the carousels as
    well as how far back to look for titles.
    
    Carousel Library Mappings specify the libraries that the carousel
    should be displayed out. The visibility of a carousel at a given organizational
    unit is not automatically inherited by the descendants of that unit.  The
    carousel’s owning organizational unit is automatically added to the list of
    display organizational units.
    
    A server-side job, refresh_carousels.srfsh, is available to periodically
    refresh the contents of automatic carousels.
    
    Staff Interface
    +++++++++++++++
    Each carousel has a record bucket associated with it. Library staff can
    add titles to a carousel's bucket, and for the manual Carousel Type, that
    is the only way to populate the carousel. Records added to an automatic
    carousel's bucket will be removed whenever the carousel is next
    refreshed.
    
    Public Catalog
    ++++++++++++++
    A new Template Toolkit macro called “carousels” allows the Evergreen
    administrator to inject the contents of one or more carousels into any point in
    the OPAC.  The macro will accept the following parameters:
    
      * carousel_id
      * dynamic (Boolean, default value false)
      * image_size (small, medium, or large)
      * width (number of titles to display on a “pane” of the carousel)
      * animated (Boolean to specify whether the carousel should automatically cycle through its panes)
      * animation_interval (the interval (in seconds) to wait before advancing to the next pane)
    
    If the carousel_id parameter is supplied, the carousel with that ID will be
    displayed.  If carousel_id is not supplied, all carousels visible to the public
    catalog’s physical_loc organizational unit is displayed.
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml
index 544488e3f1..2fc7aa1b3a 100644
--- a/Open-ILS/examples/fm_IDL.xml
+++ b/Open-ILS/examples/fm_IDL.xml
@@ -12851,6 +12851,94 @@ SELECT  usr,
 		</permacrud>
 	</class>
 
+	<class id="cct" 
+		controller="open-ils.cstore open-ils.pcrud"
+		oils_obj:fieldmapper="config::carousel_type" 
+		oils_persist:tablename="config.carousel_type" 
+		reporter:label="Carousel Types">
+		<fields oils_persist:primary="id" oils_persist:sequence="config.carousel_type_id_seq">
+			<field reporter:label="Carousel Type ID" reporter:selector="name" name="id" reporter:datatype="id" />
+			<field reporter:label="Name" name="name" reporter:datatype="text" oils_obj:required="true" oils_obj:i18n="true"/>
+			<field reporter:label="Automatically Managed?" name="automatic" reporter:datatype="bool"/>
+			<field reporter:label="Filter By Age?" name="filter_by_age" reporter:datatype="bool"/>
+			<field reporter:label="Filter By Item Owning Library?" name="filter_by_copy_owning_lib" reporter:datatype="bool"/>
+			<field reporter:label="Filter By Item Location?" name="filter_by_copy_location" reporter:datatype="bool"/>
+		</fields>
+		<permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+			<actions>
+				<create permission="ADMIN_CAROUSEL_TYPE" global_required="true"/>
+				<retrieve/>
+				<update permission="ADMIN_CAROUSEL_TYPE" global_required="true"/>
+				<delete permission="ADMIN_CAROUSEL_TYPE" global_required="true"/>
+			</actions>
+		</permacrud>
+	</class>
+
+	<class id="cc" 
+		controller="open-ils.cstore open-ils.pcrud"
+		oils_obj:fieldmapper="container::carousel" 
+		oils_persist:tablename="container.carousel" 
+		reporter:label="Carousels">
+		<fields oils_persist:primary="id" oils_persist:sequence="container.carousel_id_seq">
+			<field reporter:label="Carousel ID" name="id" reporter:datatype="id" reporter:selector="name"/>
+			<field reporter:label="Carousel Type" name="type" reporter:datatype="link"/>
+			<field reporter:label="Owner" name="owner" reporter:datatype="link"/>
+			<field reporter:label="Name" name="name" reporter:datatype="text" oils_obj:required="true" oils_obj:i18n="true"/>
+			<field reporter:label="Bucket" name="bucket" reporter:datatype="link"/>
+			<field reporter:label="Creating User" name="creator" reporter:datatype="link"/>
+			<field reporter:label="Editing User" name="editor" reporter:datatype="link"/>
+			<field reporter:label="Create Time" name="create_time" reporter:datatype="timestamp"/>
+			<field reporter:label="Edit Time" name="edit_time" reporter:datatype="timestamp"/>
+			<field reporter:label="Age Limit" name="age_filter"  reporter:datatype="interval"/>
+			<field reporter:label="Item Libraries" name="owning_lib_filter" reporter:datatype="text" />      <!-- Actually an int[], but this is the best we can do in fm_IDL.xml -->
+			<field reporter:label="Shelving Locations" name="copy_location_filter" reporter:datatype="text" /> <!-- ditto -->
+			<field reporter:label="Last Refresh Time" name="last_refresh_time" reporter:datatype="timestamp"/>
+			<field reporter:label="Is Active" name="active" reporter:datatype="bool"/>
+			<field reporter:label="Maximum Items" name="max_items" reporter:datatype="int"/>
+		</fields>
+		<links>
+			<link field="type" reltype="has_a" key="id" map="" class="cct"/>
+			<link field="owner" reltype="has_a" key="id" map="" class="aou"/>
+			<link field="bucket" reltype="has_a" key="id" map="" class="cbreb"/>
+			<link field="creator" reltype="has_a" key="id" map="" class="au"/>
+			<link field="editor" reltype="has_a" key="id" map="" class="au"/>
+		</links>
+		<permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+			<actions>
+				<create permission="ADMIN_CAROUSEL" global_required="true"/>
+				<retrieve/>
+				<update permission="ADMIN_CAROUSEL" global_required="true"/>
+				<delete permission="ADMIN_CAROUSEL" global_required="true"/>
+			</actions>
+		</permacrud>
+	</class>
+
+	<class id="ccou" 
+		controller="open-ils.cstore open-ils.pcrud"
+		oils_obj:fieldmapper="container::carousel_org_unit" 
+		oils_persist:tablename="container.carousel_org_unit" 
+		reporter:label="Carousels Visible at Library">
+		<fields oils_persist:primary="id" oils_persist:sequence="container.carousel_org_unit_id_seq">
+			<field reporter:label="ID" name="id" reporter:datatype="id" />
+			<field reporter:label="Carousel" name="carousel" reporter:datatype="link"/>
+			<field reporter:label="Override Name" name="override_name" reporter:datatype="text"/>
+			<field reporter:label="Library" name="org_unit" reporter:datatype="link"/>
+			<field reporter:label="Sequence Number" name="seq" reporter:datatype="int"/>
+		</fields>
+		<links>
+			<link field="carousel" reltype="has_a" key="id" map="" class="cc"/>
+			<link field="org_unit" reltype="has_a" key="id" map="" class="aou"/>
+		</links>
+		<permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+			<actions>
+				<create permission="ADMIN_CAROUSEL" global_required="true"/>
+				<retrieve/>
+				<update permission="ADMIN_CAROUSEL" global_required="true"/>
+				<delete permission="ADMIN_CAROUSEL" global_required="true"/>
+			</actions>
+		</permacrud>
+	</class>
+
 	<!-- ********************************************************************************************************************* -->
 </IDL>
 
diff --git a/Open-ILS/src/sql/Pg/002.schema.config.sql b/Open-ILS/src/sql/Pg/002.schema.config.sql
index 5068463049..8afa76e79f 100644
--- a/Open-ILS/src/sql/Pg/002.schema.config.sql
+++ b/Open-ILS/src/sql/Pg/002.schema.config.sql
@@ -1349,4 +1349,25 @@ CREATE TABLE config.print_template (
     CONSTRAINT   label_once_per_lib UNIQUE (owner, label)
 );
 
+CREATE TABLE config.carousel_type (
+    id                          SERIAL PRIMARY KEY,
+    name                        TEXT NOT NULL,
+    automatic                   BOOLEAN NOT NULL DEFAULT TRUE,
+    filter_by_age               BOOLEAN NOT NULL DEFAULT FALSE,
+    filter_by_copy_owning_lib   BOOLEAN NOT NULL DEFAULT FALSE,
+    filter_by_copy_location     BOOLEAN NOT NULL DEFAULT FALSE
+);
+
+INSERT INTO config.carousel_type
+    (id, name,                               automatic, filter_by_age, filter_by_copy_owning_lib, filter_by_copy_location)
+VALUES
+    (1, 'Manual',                            FALSE,     FALSE,         FALSE,                     FALSE),
+    (2, 'Newly Catalogued Items',            TRUE,      TRUE,          TRUE,                      TRUE),
+    (3, 'Recently Returned Items',           TRUE,      TRUE,          TRUE,                      TRUE),
+    (4, 'Top Circulated Items',              TRUE,      TRUE,          TRUE,                      FALSE),
+    (5, 'Newest Items By Shelving Location', TRUE,      TRUE,          TRUE,                      FALSE)
+;
+
+SELECT SETVAL('config.carousel_type_id_seq'::TEXT, 100);
+
 COMMIT;
diff --git a/Open-ILS/src/sql/Pg/070.schema.container.sql b/Open-ILS/src/sql/Pg/070.schema.container.sql
index 594dfafe58..8b1572f386 100644
--- a/Open-ILS/src/sql/Pg/070.schema.container.sql
+++ b/Open-ILS/src/sql/Pg/070.schema.container.sql
@@ -250,5 +250,30 @@ CREATE TABLE container.user_bucket_item_note (
     note    TEXT        NOT NULL
 );
 
+CREATE TABLE container.carousel (
+    id                      SERIAL PRIMARY KEY,
+    type                    INTEGER NOT NULL REFERENCES config.carousel_type (id),
+    owner                   INTEGER NOT NULL REFERENCES actor.org_unit (id),
+    name                    TEXT NOT NULL,
+    bucket                  INTEGER REFERENCES container.biblio_record_entry_bucket (id),
+    creator                 INTEGER NOT NULL REFERENCES actor.usr (id),
+    editor                  INTEGER NOT NULL REFERENCES actor.usr (id),
+    create_time             TIMESTAMPTZ NOT NULL DEFAULT now(),
+    edit_time               TIMESTAMPTZ NOT NULL DEFAULT now(),
+    age_filter              INTERVAL,
+    owning_lib_filter       INT[],
+    copy_location_filter    INT[],
+    last_refresh_time       TIMESTAMPTZ,
+    active                  BOOLEAN NOT NULL DEFAULT TRUE,
+    max_items               INTEGER NOT NULL
+);
+
+CREATE TABLE container.carousel_org_unit (
+    id              SERIAL PRIMARY KEY,
+    carousel        INTEGER NOT NULL REFERENCES container.carousel (id) ON DELETE CASCADE,
+    override_name   TEXT,
+    org_unit        INTEGER NOT NULL REFERENCES actor.org_unit (id),
+    seq             INTEGER NOT NULL
+);
 
 COMMIT;
diff --git a/Open-ILS/src/sql/Pg/950.data.seed-values.sql b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
index 4d9d37615c..2627a08eb5 100644
--- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql
+++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
@@ -1917,7 +1917,13 @@ INSERT INTO permission.perm_list ( id, code, description ) VALUES
  ( 610, 'CLEAR_PURCHASE_REQUEST', oils_i18n_gettext(610,
     'Clear Completed User Purchase Requests', 'ppl', 'description')),
  ( 611, 'ADMIN_PRINT_TEMPLATE', oils_i18n_gettext(611,
-    'Modify print templates', 'ppl', 'description'))
+    'Modify print templates', 'ppl', 'description')),
+ ( 612, 'ADMIN_CAROUSEL_TYPE', oils_i18n_gettext(612,
+    'Allow a user to manage carousel types', 'ppl', 'description')),
+ ( 613, 'ADMIN_CAROUSEL', oils_i18n_gettext(613,
+    'Allow a user to manage carousels', 'ppl', 'description')),
+ ( 614, 'REFRESH_CAROUSEL', oils_i18n_gettext(614,
+    'Allow a user to refresh carousels', 'ppl', 'description'))
 ;
 
 
@@ -5652,6 +5658,7 @@ INSERT INTO container.biblio_record_entry_bucket_type (code,label) VALUES ('book
 INSERT INTO container.biblio_record_entry_bucket_type (code,label) VALUES ('reading_list', oils_i18n_gettext('reading_list', 'Reading List', 'cbrebt', 'label'));
 INSERT INTO container.biblio_record_entry_bucket_type (code,label) VALUES ('template_merge',oils_i18n_gettext('template_merge','Template Merge Container', 'cbrebt', 'label'));
 INSERT INTO container.biblio_record_entry_bucket_type (code,label) VALUES ('url_verify', oils_i18n_gettext('url_verify', 'URL Verification Queue', 'cbrebt', 'label'));
+INSERT INTO container.biblio_record_entry_bucket_type (code,label) VALUES ('carousel', oils_i18n_gettext('url_verify', 'Carousel', 'cbrebt', 'label'));
 
 INSERT INTO container.user_bucket_type (code,label) VALUES ('misc', oils_i18n_gettext('misc', 'Miscellaneous', 'cubt', 'label'));
 INSERT INTO container.user_bucket_type (code,label) VALUES ('folks', oils_i18n_gettext('folks', 'Friends', 'cubt', 'label'));
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.carousels.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.carousels.sql
new file mode 100644
index 0000000000..9dd366952e
--- /dev/null
+++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.carousels.sql
@@ -0,0 +1,63 @@
+BEGIN;
+
+SELECT evergreen.upgrade_deps_block_check('XXXX', :eg_version);
+
+CREATE TABLE config.carousel_type (
+    id                          SERIAL PRIMARY KEY,
+    name                        TEXT NOT NULL,
+    automatic                   BOOLEAN NOT NULL DEFAULT TRUE,
+    filter_by_age               BOOLEAN NOT NULL DEFAULT FALSE,
+    filter_by_copy_owning_lib   BOOLEAN NOT NULL DEFAULT FALSE,
+    filter_by_copy_location     BOOLEAN NOT NULL DEFAULT FALSE
+);
+
+INSERT INTO config.carousel_type
+    (id, name,                               automatic, filter_by_age, filter_by_copy_owning_lib, filter_by_copy_location)
+VALUES
+    (1, 'Manual',                            FALSE,     FALSE,         FALSE,                     FALSE),
+    (2, 'Newly Catalogued Items',            TRUE,      TRUE,          TRUE,                      TRUE),
+    (3, 'Recently Returned Items',           TRUE,      TRUE,          TRUE,                      TRUE),
+    (4, 'Top Circulated Items',              TRUE,      TRUE,          TRUE,                      FALSE),
+    (5, 'Newest Items By Shelving Location', TRUE,      TRUE,          TRUE,                      FALSE)
+;
+
+SELECT SETVAL('config.carousel_type_id_seq'::TEXT, 100);
+
+CREATE TABLE container.carousel (
+    id                      SERIAL PRIMARY KEY,
+    type                    INTEGER NOT NULL REFERENCES config.carousel_type (id),
+    owner                   INTEGER NOT NULL REFERENCES actor.org_unit (id),
+    name                    TEXT NOT NULL,
+    bucket                  INTEGER REFERENCES container.biblio_record_entry_bucket (id),
+    creator                 INTEGER NOT NULL REFERENCES actor.usr (id),
+    editor                  INTEGER NOT NULL REFERENCES actor.usr (id),
+    create_time             TIMESTAMPTZ NOT NULL DEFAULT now(),
+    edit_time               TIMESTAMPTZ NOT NULL DEFAULT now(),
+    age_filter              INTERVAL,
+    owning_lib_filter       INT[],
+    copy_location_filter    INT[],
+    last_refresh_time       TIMESTAMPTZ,
+    active                  BOOLEAN NOT NULL DEFAULT TRUE,
+    max_items               INTEGER NOT NULL
+);
+
+CREATE TABLE container.carousel_org_unit (
+    id              SERIAL PRIMARY KEY,
+    carousel        INTEGER NOT NULL REFERENCES container.carousel (id) ON DELETE CASCADE,
+    override_name   TEXT,
+    org_unit        INTEGER NOT NULL REFERENCES actor.org_unit (id),
+    seq             INTEGER NOT NULL
+);
+
+INSERT INTO container.biblio_record_entry_bucket_type (code, label) VALUES ('carousel', 'Carousel');
+
+INSERT INTO permission.perm_list ( id, code, description ) VALUES
+ ( 612, 'ADMIN_CAROUSEL_TYPE', oils_i18n_gettext(611,
+    'Allow a user to manage carousel types', 'ppl', 'description')),
+ ( 613, 'ADMIN_CAROUSEL', oils_i18n_gettext(612,
+    'Allow a user to manage carousels', 'ppl', 'description')),
+ ( 614, 'REFRESH_CAROUSEL', oils_i18n_gettext(613,
+    'Allow a user to refresh carousels', 'ppl', 'description'))
+;
+
+COMMIT;

commit f9b755bcfeba8b339862d43328db61980b7f9b65
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Mon Jun 10 16:09:08 2019 -0400

    LP#1832897: add Glide as a Javascript dependency for the public catalog
    
    Glide (https://glidejs.com) is an MIT-licensed library for rendering
    carousels. Similar to how jQuery is installed for the catalog, the
    additional dependency is specified in the AngularJS client's package.json
    and webpack.config.js.
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/web/js/ui/default/staff/package.json b/Open-ILS/web/js/ui/default/staff/package.json
index 499e3945e9..83e9aff063 100644
--- a/Open-ILS/web/js/ui/default/staff/package.json
+++ b/Open-ILS/web/js/ui/default/staff/package.json
@@ -28,6 +28,7 @@
     "build-prod": "webpack --env.prod"
   },
   "dependencies": {
+    "@glidejs/glide": "^3.3.0",
     "angular": "~1.6.7",
     "angular-animate": "~1.6.7",
     "angular-cookies": "~1.6.7",
diff --git a/Open-ILS/web/js/ui/default/staff/webpack.config.js b/Open-ILS/web/js/ui/default/staff/webpack.config.js
index da66461b4a..f57c80fa79 100644
--- a/Open-ILS/web/js/ui/default/staff/webpack.config.js
+++ b/Open-ILS/web/js/ui/default/staff/webpack.config.js
@@ -56,7 +56,11 @@ const directCopyFiles = [
 
   // jquery is copied to the common build location, up one directory.
   {from: './node_modules/jquery/dist/jquery.min.js', 
-     to: __dirname + '/../common/build/js'}
+     to: __dirname + '/../common/build/js'},
+
+  // and likewise for glide
+  {from: './node_modules/@glidejs/glide/dist',
+     to: __dirname + '/../common/build/js/glide'}
 ];
 
 CSS_FILES.forEach(file => directCopyFiles.push({from: file, to: './css'}));

commit 4739faf12e0fd9c11278e2839de1181c8facf10a
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Tue Jun 11 19:08:51 2019 -0400

    LP#1832897: improvements to the Angular admin-page component
    
    * Make some of its services public so that it can be more easily
      subclassed.
    * Show toast on success or failure of record deletion actions.
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.html b/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.html
index bb39f8bbdd..c7479d62db 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.html
@@ -4,6 +4,11 @@
 <ng-template #updateFailedStrTmpl i18n>Update of {{idlClassDef.label}} failed</ng-template>
 <eg-string #updateFailedString [template]="updateFailedStrTmpl"></eg-string>
 
+<ng-template #deleteFailedStrTmpl i18n>Delete of {{idlClassDef.label}} failed or was not allowed</ng-template>
+<eg-string #deleteFailedString [template]="deleteFailedStrTmpl"></eg-string>
+
+<ng-template #deleteSuccessStrTmpl i18n>{{idlClassDef.label}} Successfully Deleted</ng-template>
+<eg-string #deleteSuccessString [template]="deleteSuccessStrTmpl"></eg-string>
 
 <ng-template #createStrTmpl i18n>{{idlClassDef.label}} Succeessfully Created</ng-template>
 <eg-string #createString [template]="createStrTmpl"></eg-string>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.ts b/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.ts
index 11913c8991..b853a6aef5 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.ts
@@ -81,6 +81,8 @@ export class AdminPageComponent implements OnInit {
     @ViewChild('createString') createString: StringComponent;
     @ViewChild('createErrString') createErrString: StringComponent;
     @ViewChild('updateFailedString') updateFailedString: StringComponent;
+    @ViewChild('deleteFailedString') deleteFailedString: StringComponent;
+    @ViewChild('deleteSuccessString') deleteSuccessString: StringComponent;
     @ViewChild('translator') translator: TranslateComponent;
 
     idlClassDef: any;
@@ -103,12 +105,12 @@ export class AdminPageComponent implements OnInit {
 
     constructor(
         private route: ActivatedRoute,
-        private idl: IdlService,
+        public idl: IdlService,
         private org: OrgService,
-        private auth: AuthService,
-        private pcrud: PcrudService,
+        public auth: AuthService,
+        public pcrud: PcrudService,
         private perm: PermService,
-        private toast: ToastService
+        public toast: ToastService
     ) {
         this.translatableFields = [];
     }
@@ -278,8 +280,15 @@ export class AdminPageComponent implements OnInit {
     deleteSelected(idlThings: IdlObject[]) {
         idlThings.forEach(idlThing => idlThing.isdeleted(true));
         this.pcrud.autoApply(idlThings).subscribe(
-            val => console.debug('deleted: ' + val),
-            err => {},
+            val => {
+                console.debug('deleted: ' + val);
+                this.deleteSuccessString.current()
+                    .then(str => this.toast.success(str));
+            },
+            err => {
+                this.deleteFailedString.current()
+                    .then(str => this.toast.danger(str));
+            },
             ()  => this.grid.reload()
         );
     }

commit e222aa73bfef5d6a083932bca991243e7b741e83
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Tue Jun 11 19:07:37 2019 -0400

    LP#1832897: improvements to eg-fm-record-editor
    
    * Add a new optional attribute, preSave, for passing a
      callback to modify modify records (e.g.,
      to provide default values) before they are saved.
    * Ensure that the components current copy of a record is
      discarded when the user closes or dismisses the modal
    
      This addresses an issue where editing multiple instances
      of records on an admin page could display (and/or flash)
      stale data, particularly for the new multi-select widget.
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.html b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.html
index 476c261c34..85c0c65619 100644
--- a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.html
+++ b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.html
@@ -9,7 +9,7 @@
     <h4 class="modal-title" i18n>Record Editor: {{recordLabel}}</h4>
     <ng-container *ngIf="isDialog()">
       <button type="button" class="close" 
-        i18n-aria-label aria-label="Close" (click)="close()">
+        i18n-aria-label aria-label="Close" (click)="closeEditor()">
         <span aria-hidden="true">×</span>
       </button>
     </ng-container>
@@ -162,7 +162,7 @@
   <div class="modal-footer">
     <ng-container *ngIf="isDialog()">
       <button type="button" class="btn btn-success" *ngIf="mode == 'view'"
-        (click)="close()" i18n>Close</button>
+        (click)="closeEditor()" i18n>Close</button>
       <button type="button" class="btn btn-warning ml-2" *ngIf="mode != 'view'"
         (click)="cancel()" i18n>Cancel</button>
     </ng-container>
diff --git a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts
index 6c079b8bba..0cd9a6f3d8 100644
--- a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts
+++ b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts
@@ -8,7 +8,7 @@ import {PcrudService} from '@eg/core/pcrud.service';
 import {DialogComponent} from '@eg/share/dialog/dialog.component';
 import {ToastService} from '@eg/share/toast/toast.service';
 import {StringComponent} from '@eg/share/string/string.component';
-import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
 import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
 import {TranslateComponent} from '@eg/staff/share/translate/translate.component';
 
@@ -160,6 +160,10 @@ export class FmRecordEditorComponent
         if (id) { this.recId = id; }
     }
 
+    // custom function for munging the record before it gets saved;
+    // will get passed mode and the record itself
+    @Input() preSave: Function;
+
     constructor(
       private modal: NgbModal, // required for passing to parent
       private idl: IdlService,
@@ -186,6 +190,19 @@ export class FmRecordEditorComponent
         }
     }
 
+    open(args?: NgbModalOptions): Observable<any> {
+        if (!args) {
+            args = {};
+        }
+        // ensure we don't hang on to our copy of the record
+        // if the user dismisses the dialog
+        args.beforeDismiss = () => {
+            this.record = undefined;
+            return true;
+        };
+        return super.open(args);
+    }
+
     isDialog(): boolean {
         return this.displayMode === 'dialog';
     }
@@ -471,12 +488,15 @@ export class FmRecordEditorComponent
 
     save() {
         const recToSave = this.idl.clone(this.record);
+        if (this.preSave) {
+            this.preSave(this.mode, recToSave);
+        }
         this.convertDatatypesToIdl(recToSave);
         this.pcrud[this.mode]([recToSave]).toPromise().then(
             result => {
                 this.onSave$.emit(result);
                 this.successStr.current().then(msg => this.toast.success(msg));
-                if (this.isDialog()) { this.close(result); }
+                if (this.isDialog()) { this.record = undefined; this.close(result); }
             },
             error => {
                 this.onError$.emit(error);
@@ -488,6 +508,12 @@ export class FmRecordEditorComponent
 
     cancel() {
         this.onCancel$.emit(this.record);
+        this.record = undefined;
+        this.close();
+    }
+
+    closeEditor() {
+        this.record = undefined;
         this.close();
     }
 

commit 924a4dc1853705d54e57924ff8f9ef51be395e92
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Sun Jun 16 21:53:36 2019 -0400

    LP#1832897: add Angular widget to for selecting multiple linked rows
    
    This component provides a widget to allow the user to select
    multiple linked rows. In particularly, it is meant to handle
    IDL fields whose underlying database columns are intarrays that
    refer to records in another IDL class.
    
    The widget's user interface consists of an eg-combobox for selecting
    new values to add to the list and a list of the existing values.
    
    The component has the following attributes:
    
    - idlClass: IDL class of the records being linked to
    - linkedLibraryLabel: if supplied, specifies that the display
      label in the comboox should include the library shortname as
      found in the specified field.
    - startValue: init value to display
    
    This component emits onChange events.
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/share/multi-select/multi-select.component.html b/Open-ILS/src/eg2/src/app/share/multi-select/multi-select.component.html
new file mode 100644
index 0000000000..c4aa9e5299
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/share/multi-select/multi-select.component.html
@@ -0,0 +1,13 @@
+<div>
+  <div class="row">
+    <eg-combobox [idlClass]="idlClass" 
+      [idlIncludeLibraryInLabel]="linkedLibraryLabel" [asyncSupportsEmptyTermClick]="true"
+      (onChange)="valueSelected($event)">
+    </eg-combobox>
+    <button class="btn btn-outline-dark" (click)="addSelectedValue()" [disabled]="!this.selected" i18n>Add</button>
+  </div>
+  <div class="row" *ngFor="let entry of entrylist">
+    <div class="col-lg-4">{{entry.label}}</div>
+    <button class="btn btn-sm btn-warning" (click)="removeValue(entry)" i18n>Remove</button>
+  </div>
+</div>
diff --git a/Open-ILS/src/eg2/src/app/share/multi-select/multi-select.component.ts b/Open-ILS/src/eg2/src/app/share/multi-select/multi-select.component.ts
new file mode 100644
index 0000000000..0c35beb4dc
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/share/multi-select/multi-select.component.ts
@@ -0,0 +1,88 @@
+/**
+ * <eg-multi-select idlClass="acpl" linkedLibraryLabel="owning_lib">
+ * </eg-multi-select>
+ */
+import {Component, OnInit, Input, Output, ViewChild, EventEmitter, ElementRef} from '@angular/core';
+import {map} from 'rxjs/operators';
+import {Observable, of, Subject} from 'rxjs';
+import {StoreService} from '@eg/core/store.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ComboboxComponent, ComboboxEntry} from '@eg/share/combobox/combobox.component';
+
+ at Component({
+  selector: 'eg-multi-select',
+  templateUrl: './multi-select.component.html',
+  styles: [`
+    .icons {margin-left:-18px}
+    .material-icons {font-size: 16px;font-weight:bold}
+  `]
+})
+export class MultiSelectComponent implements OnInit {
+
+    selected: ComboboxEntry;
+    entrylist: ComboboxEntry[];
+
+    @Input() idlClass: string;
+    @Input() linkedLibraryLabel: string;
+    @Input() startValue: string;
+
+    @Output() onChange: EventEmitter<string>;
+
+    constructor(
+      private store: StoreService,
+      private pcrud: PcrudService,
+    ) {
+        this.entrylist = [];
+        this.onChange = new EventEmitter<string>();
+    }
+
+    valueSelected(entry: ComboboxEntry) {
+        if (entry) {
+            this.selected = entry;
+        } else {
+            this.selected = null;
+        }
+    }
+    addSelectedValue() {
+        this.entrylist.push(this.selected);
+        this.onChange.emit(this.compileCurrentValue());
+    }
+    removeValue(entry: ComboboxEntry) {
+        this.entrylist = this.entrylist.filter(ent => ent.id !== entry.id);
+        this.onChange.emit(this.compileCurrentValue());
+    }
+
+    compileCurrentValue(): string {
+        const valstr = this.entrylist.map(entry => entry.id).join(',');
+        return '{' + valstr + '}';
+    }
+
+    ngOnInit() {
+        if (this.startValue && this.startValue !== '{}') {
+            let valstr = this.startValue;
+            valstr = valstr.replace(/^{/, '');
+            valstr = valstr.replace(/}$/, '');
+            const ids = valstr.split(',');
+            const extra_args = {};
+            if (this.linkedLibraryLabel) {
+                const flesh_fields: Object = {};
+                flesh_fields[this.idlClass] = [ this.linkedLibraryLabel ];
+                extra_args['flesh'] = 1;
+                extra_args['flesh_fields'] = flesh_fields;
+                this.pcrud.search(this.idlClass, { 'id' : ids }, extra_args).pipe(map(data => {
+                    this.entrylist.push({
+                        'id' : data.id(),
+                        'label' : data.name() + ' (' + data[this.linkedLibraryLabel]().shortname() + ')'
+                    });
+                })).toPromise();
+            } else {
+                this.pcrud.search(this.idlClass, { 'id' : ids }, extra_args).pipe(map(data => {
+                    this.entrylist.push({ 'id' : data.id(), 'label' : data.name() });
+                })).toPromise();
+            }
+        }
+    }
+
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/common.module.ts b/Open-ILS/src/eg2/src/app/staff/common.module.ts
index 12c0ff164c..e833a347e2 100644
--- a/Open-ILS/src/eg2/src/app/staff/common.module.ts
+++ b/Open-ILS/src/eg2/src/app/staff/common.module.ts
@@ -17,6 +17,8 @@ import {TranslateComponent} from '@eg/staff/share/translate/translate.component'
 import {AdminPageComponent} from '@eg/staff/share/admin-page/admin-page.component';
 import {EgHelpPopoverComponent} from '@eg/share/eg-help-popover/eg-help-popover.component';
 import {DatetimeValidatorDirective} from '@eg/share/validators/datetime_validator.directive';
+import {ReactiveFormsModule} from '@angular/forms';
+import {MultiSelectComponent} from '@eg/share/multi-select/multi-select.component';
 
 /**
  * Imports the EG common modules and adds modules common to all staff UI's.
@@ -37,6 +39,7 @@ import {DatetimeValidatorDirective} from '@eg/share/validators/datetime_validato
     AdminPageComponent,
     EgHelpPopoverComponent,
     DatetimeValidatorDirective,
+    MultiSelectComponent
   ],
   imports: [
     EgCommonModule,
@@ -60,6 +63,7 @@ import {DatetimeValidatorDirective} from '@eg/share/validators/datetime_validato
     AdminPageComponent,
     EgHelpPopoverComponent,
     DatetimeValidatorDirective,
+    MultiSelectComponent
   ]
 })
 
diff --git a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html
index 3e2985606a..88716f30bb 100644
--- a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html
@@ -110,8 +110,8 @@
     </eg-combobox>
   </div>
   <div class="col-lg-3">
-    <eg-combobox placeholder="Combobox with @idlClass = 'acpl'" [idlClass]="'acpl'" idlIncludeLibraryInLabel="owning_lib" [asyncSupportsEmptyTermClick]="true">
-    </eg-combobox>
+    <eg-multi-select idlClass="acpl" linkedLibraryLabel="owning_lib" [startValue]="'{129,130,131}'">
+    </eg-multi-select>
   </div>
 </div>
 <!-- /Progress Dialog Experiments ----------------------------- -->

commit ce06bea197ac69ae5c800171c47323cb9185c4a3
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Sun Jun 16 20:30:10 2019 -0400

    LP#1832897: Angular combobox: add idlIncludeLibraryInLabel option
    
    The new attribute, idlIncludeLibraryInLabel, specifies the
    column containing a linked OU. That OU's shortname will
    then be included in the label.  This attribute takes effect
    only if idlClass is specified.
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts b/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts
index 2664112c78..ce0dc3e2ae 100644
--- a/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts
+++ b/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts
@@ -72,6 +72,7 @@ export class ComboboxComponent implements ControlValueAccessor, OnInit {
 
     @Input() idlClass: string;
     @Input() idlField: string;
+    @Input() idlIncludeLibraryInLabel: string;
     @Input() asyncDataSource: (term: string) => Observable<ComboboxEntry>;
 
     // If true, an async data search is allowed to fetch all
@@ -152,9 +153,22 @@ export class ComboboxComponent implements ControlValueAccessor, OnInit {
                 const extra_args = { order_by : {} };
                 args[field] = {'ilike': `%${term}%`}; // could -or search on label
                 extra_args['order_by'][this.idlClass] = field;
-                return this.pcrud.search(this.idlClass, args, extra_args).pipe(map(data => {
-                    return {id: data[pkeyField](), label: data[field]()};
-                }));
+                if (this.idlIncludeLibraryInLabel) {
+                    extra_args['flesh'] = 1;
+                    const flesh_fields: Object = {};
+                    flesh_fields[this.idlClass] = [ this.idlIncludeLibraryInLabel ];
+                    extra_args['flesh_fields'] = flesh_fields;
+                    return this.pcrud.search(this.idlClass, args, extra_args).pipe(map(data => {
+                        return {
+                            id: data[pkeyField](),
+                            label: data[field]() + ' (' + data[this.idlIncludeLibraryInLabel]().shortname() + ')'
+                        };
+                    }));
+                } else {
+                    return this.pcrud.search(this.idlClass, args, extra_args).pipe(map(data => {
+                        return {id: data[pkeyField](), label: data[field]()};
+                    }));
+                }
             };
         }
     }
diff --git a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html
index 85585f9836..3e2985606a 100644
--- a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html
@@ -110,6 +110,8 @@
     </eg-combobox>
   </div>
   <div class="col-lg-3">
+    <eg-combobox placeholder="Combobox with @idlClass = 'acpl'" [idlClass]="'acpl'" idlIncludeLibraryInLabel="owning_lib" [asyncSupportsEmptyTermClick]="true">
+    </eg-combobox>
   </div>
 </div>
 <!-- /Progress Dialog Experiments ----------------------------- -->

-----------------------------------------------------------------------

Summary of changes:
 Open-ILS/examples/crontab.example                  |   3 +
 Open-ILS/examples/fm_IDL.xml                       |  88 ++++++++
 Open-ILS/src/Makefile.am                           |   2 +
 .../src/app/share/combobox/combobox.component.ts   |  20 +-
 .../app/share/fm-editor/fm-editor.component.html   |   4 +-
 .../src/app/share/fm-editor/fm-editor.component.ts |  34 +++-
 .../share/multi-select/multi-select.component.html |  13 ++
 .../share/multi-select/multi-select.component.ts   |  88 ++++++++
 .../admin/local/admin-carousel.component.html      |  98 +++++++++
 .../staff/admin/local/admin-carousel.component.ts  | 130 ++++++++++++
 .../admin/local/admin-local-splash.component.html  |   4 +
 .../app/staff/admin/local/admin-local.module.ts    |   4 +-
 .../src/app/staff/admin/local/routing.module.ts    |   4 +
 .../server/admin-server-splash.component.html      |   2 +
 Open-ILS/src/eg2/src/app/staff/common.module.ts    |   4 +
 .../src/app/staff/sandbox/sandbox.component.html   |   2 +
 .../share/admin-page/admin-page.component.html     |   5 +
 .../staff/share/admin-page/admin-page.component.ts |  21 +-
 .../src/perlmods/lib/OpenILS/Application/Actor.pm  |   1 +
 .../lib/OpenILS/Application/Actor/Carousel.pm      | 218 ++++++++++++++++++++
 .../OpenILS/Application/Storage/CDBI/container.pm  |   2 +-
 .../Application/Storage/Publisher/container.pm     | 226 +++++++++++++++++++++
 .../src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm    |  42 ++++
 Open-ILS/src/sql/Pg/002.schema.config.sql          |  21 ++
 Open-ILS/src/sql/Pg/070.schema.container.sql       |  25 +++
 Open-ILS/src/sql/Pg/950.data.seed-values.sql       |   9 +-
 .../src/sql/Pg/upgrade/XXXX.schema.carousels.sql   |  63 ++++++
 .../src/support-scripts/refresh_carousels.srfsh    |   2 +
 Open-ILS/src/templates/opac/css/style.css.tt2      |  39 ++++
 Open-ILS/src/templates/opac/parts/homesearch.tt2   |   1 +
 Open-ILS/src/templates/opac/parts/js.tt2           |   4 +
 Open-ILS/src/templates/opac/parts/misc_util.tt2    |  64 ++++++
 .../staff/cat/bucket/record/t_create_carousel.tt2  |  24 +++
 .../staff/cat/bucket/record/t_grid_menu.tt2        |   3 +
 .../staff/cat/catalog/t_add_to_carousel.tt2        |  28 +++
 .../src/templates/staff/cat/catalog/t_catalog.tt2  |   5 +
 .../js/ui/default/staff/cat/bucket/record/app.js   |  28 +++
 .../web/js/ui/default/staff/cat/catalog/app.js     |  52 +++++
 Open-ILS/web/js/ui/default/staff/package.json      |   1 +
 Open-ILS/web/js/ui/default/staff/webpack.config.js |   6 +-
 docs/RELEASE_NOTES_NEXT/OPAC/carousels.adoc        |  71 +++++++
 41 files changed, 1444 insertions(+), 17 deletions(-)
 create mode 100644 Open-ILS/src/eg2/src/app/share/multi-select/multi-select.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/share/multi-select/multi-select.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/admin/local/admin-carousel.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/admin/local/admin-carousel.component.ts
 create mode 100644 Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Carousel.pm
 create mode 100644 Open-ILS/src/sql/Pg/upgrade/XXXX.schema.carousels.sql
 create mode 100644 Open-ILS/src/support-scripts/refresh_carousels.srfsh
 create mode 100644 Open-ILS/src/templates/staff/cat/bucket/record/t_create_carousel.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/catalog/t_add_to_carousel.tt2
 create mode 100644 docs/RELEASE_NOTES_NEXT/OPAC/carousels.adoc


hooks/post-receive
-- 
Evergreen ILS


More information about the open-ils-commits mailing list