Merge branch 'master' of git.evergreen-ils.org:Evergreen
authorJane Sandberg <sandbej@linnbenton.edu>
Thu, 23 Feb 2017 23:40:22 +0000 (15:40 -0800)
committerJane Sandberg <sandbej@linnbenton.edu>
Thu, 23 Feb 2017 23:40:22 +0000 (15:40 -0800)
197 files changed:
Open-ILS/examples/opensrf.xml.example
Open-ILS/examples/opensrf_core.xml.example
Open-ILS/src/perlmods/MANIFEST
Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI.pm [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/OneClickdigital.pm [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/OverDrive.pm [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/Test.pm [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/Utils/HTTPClient.pm [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm
Open-ILS/src/perlmods/live_t/20-lp1541559-ebook-api.t [new file with mode: 0644]
Open-ILS/src/perlmods/t/14-OpenILS-Utils.t
Open-ILS/src/perlmods/t/23-OpenILS-Application-EbookAPI.t [new file with mode: 0644]
Open-ILS/src/sql/Pg/002.schema.config.sql
Open-ILS/src/sql/Pg/950.data.seed-values.sql
Open-ILS/src/sql/Pg/t/regress/lp1629108_metarecord_constituent_result_reroute.pg
Open-ILS/src/sql/Pg/upgrade/1017.schema.update_fingerprinting.sql
Open-ILS/src/sql/Pg/upgrade/1026.data.subject_browse.sql [new file with mode: 0644]
Open-ILS/src/sql/Pg/upgrade/1027.data.org-setting.ebook-api-oneclickdigital.sql [new file with mode: 0644]
Open-ILS/src/sql/Pg/upgrade/1028.data.org-setting.ebook-api-overdrive.sql [new file with mode: 0644]
Open-ILS/src/sql/Pg/version-upgrade/2.11.3-2.12-beta-upgrade-db.sql [new file with mode: 0644]
Open-ILS/src/templates/acq/common/li_table.tt2
Open-ILS/src/templates/opac/css/style.css.tt2
Open-ILS/src/templates/opac/myopac/circ_history.tt2
Open-ILS/src/templates/opac/myopac/circs.tt2
Open-ILS/src/templates/opac/myopac/ebook_circs.tt2 [new file with mode: 0644]
Open-ILS/src/templates/opac/myopac/ebook_holds.tt2 [new file with mode: 0644]
Open-ILS/src/templates/opac/myopac/ebook_holds_ready.tt2 [new file with mode: 0644]
Open-ILS/src/templates/opac/myopac/hold_history.tt2
Open-ILS/src/templates/opac/myopac/holds.tt2
Open-ILS/src/templates/opac/parts/config.tt2
Open-ILS/src/templates/opac/parts/ebook_api/avail.tt2 [new file with mode: 0644]
Open-ILS/src/templates/opac/parts/ebook_api/avail_js.tt2 [new file with mode: 0644]
Open-ILS/src/templates/opac/parts/ebook_api/base_js.tt2 [new file with mode: 0644]
Open-ILS/src/templates/opac/parts/ebook_api/login_js.tt2 [new file with mode: 0644]
Open-ILS/src/templates/opac/parts/header.tt2
Open-ILS/src/templates/opac/parts/js.tt2
Open-ILS/src/templates/opac/parts/misc_util.tt2
Open-ILS/src/templates/opac/parts/myopac/main_base.tt2
Open-ILS/src/templates/opac/parts/record/summary.tt2
Open-ILS/src/templates/opac/parts/result/table.tt2
Open-ILS/src/templates/opac/parts/topnav.tt2
Open-ILS/src/templates/staff/acq/index.tt2
Open-ILS/src/templates/staff/acq/t_edit_marc_order_record.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/admin/booking/t_splash.tt2
Open-ILS/src/templates/staff/admin/server/t_splash.tt2
Open-ILS/src/templates/staff/cat/catalog/t_holdings.tt2
Open-ILS/src/templates/staff/cat/item/t_list.tt2
Open-ILS/src/templates/staff/cat/share/t_marcedit.tt2
Open-ILS/tests/datasets/sql/assets_mr.sql [new file with mode: 0644]
Open-ILS/tests/datasets/sql/bibs_ebook_api.sql [new file with mode: 0644]
Open-ILS/tests/datasets/sql/bibs_mr.sql [new file with mode: 0644]
Open-ILS/tests/datasets/sql/load_all.sql
Open-ILS/web/js/dojo/openils/XUL.js
Open-ILS/web/js/dojo/openils/widget/XULTermLoader.js
Open-ILS/web/js/file-saver/FileSaver.js [new file with mode: 0644]
Open-ILS/web/js/file-saver/FileSaver.min.js [new file with mode: 0644]
Open-ILS/web/js/file-saver/LICENSE.md [new file with mode: 0644]
Open-ILS/web/js/file-saver/README.md [new file with mode: 0644]
Open-ILS/web/js/file-saver/bower.json [new file with mode: 0644]
Open-ILS/web/js/file-saver/demo/demo.css [new file with mode: 0644]
Open-ILS/web/js/file-saver/demo/demo.js [new file with mode: 0755]
Open-ILS/web/js/file-saver/demo/demo.min.js [new file with mode: 0755]
Open-ILS/web/js/file-saver/demo/index.xhtml [new file with mode: 0644]
Open-ILS/web/js/file-saver/package.json [new file with mode: 0644]
Open-ILS/web/js/ui/default/acq/common/inv_dialog.js
Open-ILS/web/js/ui/default/acq/common/li_table.js
Open-ILS/web/js/ui/default/acq/invoice/view.js
Open-ILS/web/js/ui/default/acq/picklist/upload.js
Open-ILS/web/js/ui/default/opac/ebook_api/ebook.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/opac/ebook_api/loggedin.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/opac/ebook_api/relation.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/opac/ebook_api/session.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/acq/app.js
Open-ILS/web/js/ui/default/staff/admin/acq/app.js
Open-ILS/web/js/ui/default/staff/admin/booking/app.js
Open-ILS/web/js/ui/default/staff/cat/catalog/app.js
Open-ILS/web/js/ui/default/staff/cat/item/app.js
Open-ILS/web/js/ui/default/staff/cat/volcopy/app.js
Open-ILS/web/js/ui/default/staff/services/eframe.js
build/i18n/po/acq/acq.pot
build/i18n/po/acq/ar-JO.po
build/i18n/po/acq/cs-CZ.po
build/i18n/po/acq/es-ES.po
build/i18n/po/circ.properties/ar-JO.po
build/i18n/po/circ.properties/circ.properties.pot
build/i18n/po/circ.properties/cs-CZ.po
build/i18n/po/circ.properties/de-DE.po
build/i18n/po/circ.properties/en-CA.po
build/i18n/po/circ.properties/en-GB.po
build/i18n/po/circ.properties/es-ES.po
build/i18n/po/circ.properties/fi-FI.po
build/i18n/po/circ.properties/fr-CA.po
build/i18n/po/circ.properties/hy-AM.po
build/i18n/po/circ.properties/oc-FR.po
build/i18n/po/circ.properties/pt-BR.po
build/i18n/po/circ.properties/ru-RU.po
build/i18n/po/circ.properties/tr-TR.po
build/i18n/po/conify/ar-JO.po
build/i18n/po/conify/conify.pot
build/i18n/po/conify/cs-CZ.po
build/i18n/po/conify/es-ES.po
build/i18n/po/db.seed/ar-JO.po
build/i18n/po/db.seed/cs-CZ.po
build/i18n/po/db.seed/db.seed.pot
build/i18n/po/db.seed/de-DE.po
build/i18n/po/db.seed/en-CA.po
build/i18n/po/db.seed/en-GB.po
build/i18n/po/db.seed/es-ES.po
build/i18n/po/db.seed/fi-FI.po
build/i18n/po/db.seed/fr-CA.po
build/i18n/po/db.seed/hy-AM.po
build/i18n/po/db.seed/oc-FR.po
build/i18n/po/db.seed/pt-BR.po
build/i18n/po/db.seed/ru-RU.po
build/i18n/po/db.seed/tr-TR.po
build/i18n/po/fm_IDL.dtd/ar-JO.po
build/i18n/po/fm_IDL.dtd/cs-CZ.po
build/i18n/po/fm_IDL.dtd/de-DE.po
build/i18n/po/fm_IDL.dtd/en-CA.po
build/i18n/po/fm_IDL.dtd/en-GB.po
build/i18n/po/fm_IDL.dtd/es-ES.po
build/i18n/po/fm_IDL.dtd/fi-FI.po
build/i18n/po/fm_IDL.dtd/fm_IDL.dtd.pot
build/i18n/po/fm_IDL.dtd/fr-CA.po
build/i18n/po/fm_IDL.dtd/hy-AM.po
build/i18n/po/fm_IDL.dtd/oc-FR.po
build/i18n/po/fm_IDL.dtd/pt-BR.po
build/i18n/po/fm_IDL.dtd/ru-RU.po
build/i18n/po/fm_IDL.dtd/tr-TR.po
build/i18n/po/ils_events.xml/ar-JO.po
build/i18n/po/ils_events.xml/cs-CZ.po
build/i18n/po/ils_events.xml/de-DE.po
build/i18n/po/ils_events.xml/en-CA.po
build/i18n/po/ils_events.xml/en-GB.po
build/i18n/po/ils_events.xml/es-ES.po
build/i18n/po/ils_events.xml/fi-FI.po
build/i18n/po/ils_events.xml/fr-CA.po
build/i18n/po/ils_events.xml/hy-AM.po
build/i18n/po/ils_events.xml/ils_events.xml.pot
build/i18n/po/ils_events.xml/pt-BR.po
build/i18n/po/ils_events.xml/ru-RU.po
build/i18n/po/lang.dtd/ar-JO.po
build/i18n/po/lang.dtd/cs-CZ.po
build/i18n/po/lang.dtd/de-DE.po
build/i18n/po/lang.dtd/en-CA.po
build/i18n/po/lang.dtd/en-GB.po
build/i18n/po/lang.dtd/es-ES.po
build/i18n/po/lang.dtd/fi-FI.po
build/i18n/po/lang.dtd/fr-CA.po
build/i18n/po/lang.dtd/hy-AM.po
build/i18n/po/lang.dtd/oc-FR.po
build/i18n/po/lang.dtd/pt-BR.po
build/i18n/po/lang.dtd/ru-RU.po
build/i18n/po/lang.dtd/tr-TR.po
build/i18n/po/tpac/ar-JO.po
build/i18n/po/tpac/cs-CZ.po
build/i18n/po/tpac/de-DE.po
build/i18n/po/tpac/en-CA.po
build/i18n/po/tpac/en-GB.po
build/i18n/po/tpac/es-ES.po
build/i18n/po/tpac/fi-FI.po
build/i18n/po/tpac/fr-CA.po
build/i18n/po/tpac/hy-AM.po
build/i18n/po/tpac/oc-FR.po
build/i18n/po/tpac/pt-BR.po
build/i18n/po/tpac/ru-RU.po
build/i18n/po/tpac/tpac.pot
build/i18n/po/tpac/tr-TR.po
build/i18n/po/webstaff/ar-JO.po
build/i18n/po/webstaff/cs-CZ.po
build/i18n/po/webstaff/es-ES.po
build/i18n/po/webstaff/ru-RU.po
build/i18n/po/webstaff/webstaff.pot
docs/RELEASE_NOTES_2_12.adoc [new file with mode: 0644]
docs/RELEASE_NOTES_NEXT/Administration/Additional_SMS_Carriers.adoc [deleted file]
docs/RELEASE_NOTES_NEXT/Administration/add-parts-to-biblio-fingerprint.adoc [deleted file]
docs/RELEASE_NOTES_NEXT/Administration/hold-targeter.adoc [deleted file]
docs/RELEASE_NOTES_NEXT/Administration/i18n-make-target.adoc [deleted file]
docs/RELEASE_NOTES_NEXT/Administration/missing_permissions.adoc [deleted file]
docs/RELEASE_NOTES_NEXT/Administration/new-action-trigger-helper.adoc [deleted file]
docs/RELEASE_NOTES_NEXT/Administration/removed-unused-selfcheck-setting.adoc [deleted file]
docs/RELEASE_NOTES_NEXT/Administration/stripe_settings_permission.adoc [deleted file]
docs/RELEASE_NOTES_NEXT/Administration/ubuntu-xenial-support.adoc [deleted file]
docs/RELEASE_NOTES_NEXT/Cataloging/New_Access_Points_for_MARC_Overlay.adoc [deleted file]
docs/RELEASE_NOTES_NEXT/Circulation/lp1507807_in-house-use_copy_alerts.adoc [deleted file]
docs/RELEASE_NOTES_NEXT/Client/accent_insensitive_patron_search [deleted file]
docs/RELEASE_NOTES_NEXT/Client/active-date-column-picker.adoc [deleted file]
docs/RELEASE_NOTES_NEXT/Client/patron_punctuation [deleted file]
docs/RELEASE_NOTES_NEXT/Client/pay_fines_button.adoc [deleted file]
docs/RELEASE_NOTES_NEXT/Infrastructure/TZ_awareness.adoc [deleted file]
docs/RELEASE_NOTES_NEXT/OPAC/Metarecord_search_by_default.adoc [deleted file]
docs/RELEASE_NOTES_NEXT/OPAC/advanced_search_limiters.adoc [deleted file]
docs/RELEASE_NOTES_NEXT/OPAC/arabic-rtl-support.adoc [deleted file]
docs/RELEASE_NOTES_NEXT/OPAC/metarecord_reroute.adoc [deleted file]
docs/RELEASE_NOTES_NEXT/OPAC/relator_list.adoc [deleted file]
docs/installation/server_installation.txt
docs/installation/server_upgrade.txt

index 9205229..a111c5b 100644 (file)
@@ -232,6 +232,39 @@ vim:et:ts=4:sw=4:
         instructions on mapping the old XML entries to database tables.
         -->
 
+        <http_client>
+            <!--
+            These settings are used by the OpenILS::Utils::HTTPClient module
+            when communicating with external services (e.g. third-party APIs)
+            over HTTP.  Values are passed along to LWP::UserAgent.
+            -->
+
+            <!-- custom useragent for HTTP requests
+            <useragent>Evergreen</useragent>
+            -->
+
+            <!-- default timeout value (in seconds) -->
+            <default_timeout>60</default_timeout>
+
+            <ssl_opts>
+                <!--
+                When using HTTPS, verify that the external server has a valid
+                SSL certificate matching the expected hostname.  (Set to 0 to
+                disable verification, 1 to enable it.)
+                -->
+                <verify_hostname>1</verify_hostname>
+
+                <!--
+                If verify_hostname is enabled, you may need to specify a path
+                for CA certificates installed on your system.  Use ONE of the
+                following settings.  See LWP::UserAgent docs for details.
+                <SSL_ca_path>/etc/ssl/certs</SSL_ca_path>
+                <SSL_ca_file>/etc/ssl/certs/ca-certificates.crt</SSL_ca_file>
+                -->
+            </ssl_opts>
+
+        </http_client>
+
         <added_content>
             <!-- load the OpenLibrary added content module -->
             <module>OpenILS::WWW::AddedContent::OpenLibrary</module>
@@ -1214,7 +1247,27 @@ vim:et:ts=4:sw=4:
                 </app_settings>
             </open-ils.hold-targeter>
 
-
+            <open-ils.ebook_api>
+                <keepalive>5</keepalive>
+                <stateless>1</stateless>
+                <language>perl</language>
+                <implementation>OpenILS::Application::EbookAPI</implementation>
+                <max_requests>100</max_requests>
+                <unix_config>
+                    <unix_sock>ebook_api_unix.sock</unix_sock>
+                    <unix_pid>ebook_api_unix.pid</unix_pid>
+                    <unix_log>ebook_api_unix.log</unix_log>
+                    <max_requests>100</max_requests>
+                    <min_children>1</min_children>
+                    <max_children>15</max_children>
+                    <min_spare_children>1</min_spare_children>
+                    <max_spare_children>5</max_spare_children>
+                </unix_config>
+                <app_settings>
+                  <cache_timeout>300</cache_timeout>
+                  <request_timeout>60</request_timeout>
+                </app_settings>
+            </open-ils.ebook_api>
         </apps>
     </default>
 
@@ -1260,6 +1313,7 @@ vim:et:ts=4:sw=4:
                 <appname>open-ils.vandelay</appname>  
                 <appname>open-ils.serial</appname>  
                 <appname>open-ils.hold-targeter</appname>  
+                <appname>open-ils.ebook_api</appname>
             </activeapps>
         </localhost>
     </hosts>
index d2ec8eb..ba21693 100644 (file)
@@ -37,6 +37,7 @@ Example OpenSRF bootstrap configuration file for Evergreen
           <service>open-ils.url_verify</service>
           <service>open-ils.vandelay</service>
           <service>open-ils.serial</service>
+          <service>open-ils.ebook_api</service>
         </services>
       </router>
 
index f8a77a4..594e944 100644 (file)
@@ -39,6 +39,10 @@ lib/OpenILS/Application/Circ/StatCat.pm
 lib/OpenILS/Application/Circ/Survey.pm
 lib/OpenILS/Application/Circ/Transit.pm
 lib/OpenILS/Application/Collections.pm
+lib/OpenILS/Application/EbookAPI.pm
+lib/OpenILS/Application/EbookAPI/Test.pm
+lib/OpenILS/Application/EbookAPI/OverDrive.pm
+lib/OpenILS/Application/EbookAPI/OneClickdigital.pm
 lib/OpenILS/Application/Fielder.pm
 lib/OpenILS/Application/PermaCrud.pm
 lib/OpenILS/Application/Proxy.pm
@@ -132,6 +136,7 @@ lib/OpenILS/Utils/Cronscript.pm
 lib/OpenILS/Utils/Cronscript.pm.in
 lib/OpenILS/Utils/CStoreEditor.pm
 lib/OpenILS/Utils/Fieldmapper.pm
+lib/OpenILS/Utils/HTTPClient.pm
 lib/OpenILS/Utils/ISBN.pm
 lib/OpenILS/Utils/Lockfile.pm
 lib/OpenILS/Utils/MFHD.pm
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI.pm
new file mode 100644 (file)
index 0000000..1b3c8c6
--- /dev/null
@@ -0,0 +1,811 @@
+#!/usr/bin/perl
+
+# Copyright (C) 2015 BC Libraries Cooperative
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+# 
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+# 
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+# ====================================================================== 
+# We define a handler class for each vendor API (OneClickdigital, OverDrive, etc.).
+# See EbookAPI/Test.pm for a reference implementation with required methods,
+# arguments, and return values.
+# ====================================================================== 
+
+package OpenILS::Application::EbookAPI;
+
+use strict;
+use warnings;
+
+use Time::HiRes qw/gettimeofday/;
+use Digest::MD5 qw/md5_hex/;
+
+use OpenILS::Application;
+use base qw/OpenILS::Application/;
+use OpenSRF::AppSession;
+use OpenILS::Utils::CStoreEditor qw/:funcs/;
+use OpenSRF::EX qw(:try);
+use OpenSRF::Utils::SettingsClient;
+use OpenSRF::Utils::Logger qw($logger);
+use OpenSRF::Utils::Cache;
+use OpenSRF::Utils::JSON;
+use OpenILS::Utils::HTTPClient;
+
+my $handler;
+my $cache;
+my $cache_timeout;
+my $default_request_timeout;
+
+# map EbookAPI vendor codes to corresponding packages
+our %vendor_handlers = (
+    'ebook_test' => 'OpenILS::Application::EbookAPI::Test',
+    'oneclickdigital' => 'OpenILS::Application::EbookAPI::OneClickdigital',
+    'overdrive' => 'OpenILS::Application::EbookAPI::OverDrive'
+);
+
+sub initialize {
+    $cache = OpenSRF::Utils::Cache->new;
+
+    my $sclient = OpenSRF::Utils::SettingsClient->new();
+    $cache_timeout = $sclient->config_value("apps", "open-ils.ebook_api", "app_settings", "cache_timeout" ) || 300;
+    $default_request_timeout = $sclient->config_value("apps", "open-ils.ebook_api", "app_settings", "request_timeout" ) || 60;
+}
+
+# returns the cached object (if successful)
+sub update_cache {
+    my $cache_obj = shift;
+    my $overlay = shift || 0;
+    my $cache_key;
+    if ($cache_obj->{session_id}) {
+        $cache_key = $cache_obj->{session_id};
+    } else {
+        $logger->error("EbookAPI: cannot update cache with unknown cache object");
+        return;
+    }
+
+    # Optionally, keep old cached field values unless a new value for that
+    # field is explicitly provided.  This makes it easier for asynchronous
+    # requests (e.g. for circs and holds) to cache their results.
+    if ($overlay) {
+        if (my $orig_cache = $cache->get_cache($cache_key)) {
+            $logger->info("EbookAPI: overlaying new values on existing cache object");
+            foreach my $k (%$cache_obj) {
+                # Add/overwrite existing cached value if a new value is defined.
+                $orig_cache->{$k} = $cache_obj->{$k} if (defined $cache_obj->{$k});
+            }
+            # The cache object we want to save is the (updated) original one.
+            $cache_obj = $orig_cache;
+        }
+    }
+
+    try { # fail silently if there's no pre-existing cache to delete
+        $cache->delete_cache($cache_key);
+    } catch Error with {};
+    if (my $success_key = $cache->put_cache($cache_key, $cache_obj, $cache_timeout)) {
+        return $cache->get_cache($success_key);
+    } else {
+        $logger->error("EbookAPI: error when updating cache with object");
+        return;
+    }
+}
+
+sub retrieve_session {
+    my $session_id = shift;
+    unless ($session_id) {
+        $logger->info("EbookAPI: no session ID provided");
+        return;
+    }
+    my $cached_session = $cache->get_cache($session_id) || undef;
+    if ($cached_session) {
+        return $cached_session;
+    } else {
+        $logger->info("EbookAPI: could not find cached session with id $session_id");
+        return;
+    }
+}
+
+# prepare new handler from session
+# (will retrieve cached session unless a session object is provided)
+sub new_handler {
+    my $session_id = shift;
+    my $ses = shift || retrieve_session($session_id);
+    if (!$ses) {
+        $logger->error("EbookAPI: could not start handler - no cached session with ID $session_id");
+        return;
+    }
+    my $module = ref($ses);
+    $logger->info("EbookAPI: starting new $module handler from cached session $session_id...");
+    $module->use;
+    my $handler = $module->new($ses);
+    return $handler;
+}
+
+
+sub check_session {
+    my $self = shift;
+    my $conn = shift;
+    my $session_id = shift;
+    my $vendor = shift;
+    my $ou = shift;
+
+    return start_session($self, $conn, $vendor, $ou) unless $session_id;
+
+    my $cached_session = retrieve_session($session_id);
+    if ($cached_session) {
+        # re-authorize cached session, if applicable
+        my $handler = new_handler($session_id, $cached_session);
+        $handler->do_client_auth();
+        if (update_cache($handler)) {
+            return $session_id;
+        } else {
+            $logger->error("EbookAPI: error updating session cache");
+            return;
+        }
+    } else {
+        return start_session($self, $conn, $vendor, $ou);
+    }
+}
+__PACKAGE__->register_method(
+    method => 'check_session',
+    api_name => 'open-ils.ebook_api.check_session',
+    api_level => 1,
+    argc => 2,
+    signature => {
+        desc => "Validate an existing EbookAPI session, or initiate a new one",
+        params => [
+            {
+                name => 'session_id',
+                desc => 'The EbookAPI session ID being checked',
+                type => 'string'
+            },
+            {
+                name => 'vendor',
+                desc => 'The ebook vendor (e.g. "oneclickdigital")',
+                type => 'string'
+            },
+            {
+                name => 'ou',
+                desc => 'The context org unit ID',
+                type => 'number'
+            }
+        ],
+        return => {
+            desc => 'Returns an EbookAPI session ID',
+            type => 'string'
+        }
+    }
+);
+
+sub _start_session {
+    my $vendor = shift;
+    my $ou = shift;
+    $ou = $ou || 1; # default to top-level org unit
+
+    my $module;
+    
+    # determine EbookAPI handler from vendor name
+    # TODO handle API versions?
+    if ($vendor_handlers{$vendor}) {
+        $module = $vendor_handlers{$vendor};
+    } else {
+        $logger->error("EbookAPI: No handler module found for $vendor!");
+        return;
+    }
+
+    # TODO cache session? reuse an existing one if available?
+
+    # generate session ID
+    my ($sec, $usec) = gettimeofday();
+    my $r = rand();
+    my $session_id = "ebook_api.ses." . md5_hex("$sec-$usec-$r");
+    
+    my $args = {
+        vendor => $vendor,
+        ou => $ou,
+        session_id => $session_id
+    };
+
+    $module->use;
+    $handler = $module->new($args);  # create new handler object
+    $handler->initialize();          # set handler attributes
+    $handler->do_client_auth();      # authorize client session against API, if applicable
+
+    # our "session" is actually just our handler object, serialized and cached
+    my $ckey = $handler->{session_id};
+    $cache->put_cache($ckey, $handler, $cache_timeout);
+
+    return $handler->{session_id};
+}
+
+sub start_session {
+    my $self = shift;
+    my $conn = shift;
+    my $vendor = shift;
+    my $ou = shift;
+    return _start_session($vendor, $ou);
+}
+__PACKAGE__->register_method(
+    method => 'start_session',
+    api_name => 'open-ils.ebook_api.start_session',
+    api_level => 1,
+    argc => 1,
+    signature => {
+        desc => "Initiate an EbookAPI session",
+        params => [
+            {
+                name => 'vendor',
+                desc => 'The ebook vendor (e.g. "oneclickdigital")',
+                type => 'string'
+            },
+            {
+                name => 'ou',
+                desc => 'The context org unit ID',
+                type => 'number'
+            }
+        ],
+        return => {
+            desc => 'Returns an EbookAPI session ID',
+            type => 'string'
+        }
+    }
+);
+
+sub cache_patron_password {
+    my $self = shift;
+    my $conn = shift;
+    my $session_id = shift;
+    my $password = shift;
+
+    # We don't need the handler module for this.
+    # Let's just update the cache directly.
+    if (my $ses = $cache->get_cache($session_id)) {
+        $ses->{patron_password} = $password;
+        if (update_cache($ses)) {
+            return $session_id;
+        } else {
+            $logger->error("EbookAPI: there was an error caching patron password");
+            return;
+        }
+    }
+}
+__PACKAGE__->register_method(
+    method => 'cache_patron_password',
+    api_name => 'open-ils.ebook_api.patron.cache_password',
+    api_level => 1,
+    argc => 2,
+    signature => {
+        desc => "Cache patron password on login for use during EbookAPI patron authentication",
+        params => [
+            {
+                name => 'session_id',
+                desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+                type => 'string'
+            },
+            {
+                name => 'patron_password',
+                desc => 'The patron password',
+                type => 'string'
+            }
+        ],
+        return => { desc => 'A session key, or undef' }
+    }
+);
+
+# Submit an HTTP request to a specified API endpoint.
+#
+# Params:
+#
+#   $req - hashref containing the following:
+#       method: HTTP request method (defaults to GET)
+#       uri: API endpoint URI (required)
+#       header: arrayref of HTTP headers (optional, but see below)
+#       content: content of HTTP request (optional)
+#       request_timeout (defaults to value in opensrf.xml)
+#   $session_id - id of cached EbookAPI session
+#
+# A "Content-Type: application/json" header is automatically added to each
+# request.  If no Authorization header is provided via the $req param, the
+# following header will also be automatically added:
+#
+#   Authorization: basic $basic_token
+#
+# ... where $basic_token is derived from the cached session identified by the
+# $session_id param.  If this does not meet the needs of your API, include the
+# correct Authorization header in $req->{header}.
+sub request {
+    my $self = shift;
+    my $req = shift;
+    my $session_id = shift;
+
+    my $uri;
+    if (!defined ($req->{uri})) {
+        $logger->error('EbookAPI: attempted an HTTP request but no URI was provided');
+        return;
+    } else {
+        $uri = $req->{uri};
+    }
+    
+    my $method = defined $req->{method} ? $req->{method} : 'GET';
+    my $headers = defined $req->{headers} ? $req->{headers} : {};
+    my $content = defined $req->{content} ? $req->{content} : undef;
+    my $request_timeout = defined $req->{request_timeout} ? $req->{request_timeout} : $default_request_timeout;
+
+    # JSON as default content type
+    if ( !defined ($headers->{'Content-Type'}) ) {
+        $headers->{'Content-Type'} = 'application/json';
+    }
+
+    # all requests also require an Authorization header;
+    # let's default to using our basic token, if available
+    if ( !defined ($headers->{'Authorization'}) ) {
+        if (!$session_id) {
+            $logger->error("EbookAPI: HTTP request requires session info but no session ID was provided");
+            return;
+        }
+        my $ses = retrieve_session($session_id);
+        if ($ses) {
+            my $basic_token = $ses->{basic_token};
+            $headers->{'Authorization'} = "basic $basic_token";
+        }
+    }
+
+    my $client = OpenILS::Utils::HTTPClient->new();
+    my $res = $client->request(
+        $method,
+        $uri,
+        $headers,
+        $content,
+        $request_timeout
+    );
+    if (!defined ($res)) {
+        $logger->error('EbookAPI: no HTTP response received');
+        return;
+    } else {
+        $logger->info("EbookAPI: response received from server: " . $res->status_line);
+        return {
+            is_success => $res->is_success,
+            status     => $res->status_line,
+            content    => OpenSRF::Utils::JSON->JSON2perl($res->decoded_content)
+        };
+    }
+}
+
+sub get_availability {
+    my ($self, $conn, $session_id, $title_id) = @_;
+    my $handler = new_handler($session_id);
+    return $handler->do_availability_lookup($title_id);
+}
+__PACKAGE__->register_method(
+    method => 'get_availability',
+    api_name => 'open-ils.ebook_api.title.availability',
+    api_level => 1,
+    argc => 2,
+    signature => {
+        desc => "Get availability info for an ebook title",
+        params => [
+            {
+                name => 'session_id',
+                desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+                type => 'string'
+            },
+            {
+                name => 'title_id',
+                desc => 'The title ID (ISBN, unique identifier, etc.)',
+                type => 'string'
+            }
+        ],
+        return => {
+            desc => 'Returns 1 if title is available, 0 if not available, or undef if availability info could not be retrieved',
+            type => 'number'
+        }
+    }
+);
+
+sub get_holdings {
+    my ($self, $conn, $session_id, $title_id) = @_;
+    my $handler = new_handler($session_id);
+    return $handler->do_holdings_lookup($title_id);
+}
+__PACKAGE__->register_method(
+    method => 'get_holdings',
+    api_name => 'open-ils.ebook_api.title.holdings',
+    api_level => 1,
+    argc => 2,
+    signature => {
+        desc => "Get detailed holdings info (copy counts and formats) for an ebook title, or basic availability if holdings info is unavailable",
+        params => [
+            {
+                name => 'session_id',
+                desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+                type => 'string'
+            },
+            {
+                name => 'title_id',
+                desc => 'The title ID (ISBN, unique identifier, etc.)',
+                type => 'string'
+            }
+        ],
+        return => {
+            desc => 'Returns a hashref of holdings info with one or more of the following keys: available (0 or 1), copies_owned, copies_available, formats (arrayref of strings)',
+            type => 'hashref'
+        }
+    }
+);
+
+# Wrapper function for performing transactions that require an authenticated
+# patron and a title identifier (checkout, checkin, renewal, etc).
+#
+# Params:
+# - title_id: ISBN (OneClickdigital), title identifier (OverDrive)
+# - barcode: patron barcode
+#
+sub do_xact {
+    my ($self, $conn, $auth, $session_id, $title_id, $barcode) = @_;
+
+    my $action;
+    if ($self->api_name =~ /checkout/) {
+        $action = 'checkout';
+    } elsif ($self->api_name =~ /checkin/) {
+        $action = 'checkin';
+    } elsif ($self->api_name =~ /renew/) {
+        $action = 'renew';
+    } elsif ($self->api_name =~ /place_hold/) {
+        $action = 'place_hold';
+    } elsif ($self->api_name =~ /cancel_hold/) {
+        $action = 'cancel_hold';
+    }
+    $logger->info("EbookAPI: doing $action for title $title_id...");
+
+    # verify that user is authenticated in EG
+    my $e = new_editor(authtoken => $auth);
+    if (!$e->checkauth) {
+        $logger->error("EbookAPI: authentication failed: " . $e->die_event);
+        return;
+    }
+
+    my $handler = new_handler($session_id);
+    my $user_token = $handler->do_patron_auth($barcode);
+
+    # handler method constructs and submits request (and handles any external authentication)
+    my $res = $handler->$action($title_id, $user_token);
+    if (defined ($res)) {
+        return $res;
+    } else {
+        $logger->error("EbookAPI: could not do $action for title $title_id and patron $barcode");
+        return;
+    }
+}
+__PACKAGE__->register_method(
+    method => 'do_xact',
+    api_name => 'open-ils.ebook_api.checkout',
+    api_level => 1,
+    argc => 4,
+    signature => {
+        desc => "Checkout an ebook title to a patron",
+        params => [
+            {
+                name => 'authtoken',
+                desc => 'Authentication token',
+                type => 'string'
+            },
+            {
+                name => 'session_id',
+                desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+                type => 'string'
+            },
+            {
+                name => 'title_id',
+                desc => 'The identifier of the title',
+                type => 'string'
+            },
+            {
+                name => 'barcode',
+                desc => 'The barcode of the patron to whom the title will be checked out',
+                type => 'string'
+            },
+        ],
+        return => {
+            desc => 'Success: { due_date => "2017-01-01" } / Failure: { error_msg => "Checkout limit reached." }',
+            type => 'hashref'
+        }
+    }
+);
+__PACKAGE__->register_method(
+    method => 'do_xact',
+    api_name => 'open-ils.ebook_api.renew',
+    api_level => 1,
+    argc => 4,
+    signature => {
+        desc => "Renew an ebook title for a patron",
+        params => [
+            {
+                name => 'authtoken',
+                desc => 'Authentication token',
+                type => 'string'
+            },
+            {
+                name => 'session_id',
+                desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+                type => 'string'
+            },
+            {
+                name => 'title_id',
+                desc => 'The identifier of the title to be renewed',
+                type => 'string'
+            },
+            {
+                name => 'barcode',
+                desc => 'The barcode of the patron to whom the title is checked out',
+                type => 'string'
+            },
+        ],
+        return => {
+            desc => 'Success: { due_date => "2017-01-01" } / Failure: { error_msg => "Renewal limit reached." }',
+            type => 'hashref'
+        }
+    }
+);
+__PACKAGE__->register_method(
+    method => 'do_xact',
+    api_name => 'open-ils.ebook_api.checkin',
+    api_level => 1,
+    argc => 4,
+    signature => {
+        desc => "Check in an ebook title for a patron",
+        params => [
+            {
+                name => 'authtoken',
+                desc => 'Authentication token',
+                type => 'string'
+            },
+            {
+                name => 'session_id',
+                desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+                type => 'string'
+            },
+            {
+                name => 'title_id',
+                desc => 'The identifier of the title to be checked in',
+                type => 'string'
+            },
+            {
+                name => 'barcode',
+                desc => 'The barcode of the patron to whom the title is checked out',
+                type => 'string'
+            },
+        ],
+        return => {
+            desc => 'Success: { } / Failure: { error_msg => "Checkin failed." }',
+            type => 'hashref'
+        }
+    }
+);
+__PACKAGE__->register_method(
+    method => 'do_xact',
+    api_name => 'open-ils.ebook_api.place_hold',
+    api_level => 1,
+    argc => 4,
+    signature => {
+        desc => "Place a hold on an ebook title for a patron",
+        params => [
+            {
+                name => 'authtoken',
+                desc => 'Authentication token',
+                type => 'string'
+            },
+            {
+                name => 'session_id',
+                desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+                type => 'string'
+            },
+            {
+                name => 'title_id',
+                desc => 'The identifier of the title',
+                type => 'string'
+            },
+            {
+                name => 'barcode',
+                desc => 'The barcode of the patron for whom the title is being held',
+                type => 'string'
+            },
+        ],
+        return => {
+            desc => 'Success: { queue_position => 1, queue_size => 1, expire_date => "2017-01-01" } / Failure: { error_msg => "Could not place hold." }',
+            type => 'hashref'
+        }
+    }
+);
+__PACKAGE__->register_method(
+    method => 'do_xact',
+    api_name => 'open-ils.ebook_api.cancel_hold',
+    api_level => 1,
+    argc => 4,
+    signature => {
+        desc => "Cancel a hold on an ebook title for a patron",
+        params => [
+            {
+                name => 'authtoken',
+                desc => 'Authentication token',
+                type => 'string'
+            },
+            {
+                name => 'session_id',
+                desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+                type => 'string'
+            },
+            {
+                name => 'title_id',
+                desc => 'The identifier of the title',
+                type => 'string'
+            },
+            {
+                name => 'barcode',
+                desc => 'The barcode of the patron',
+                type => 'string'
+            },
+        ],
+        return => {
+            desc => 'Success: { } / Failure: { error_msg => "Could not cancel hold." }',
+            type => 'hashref'
+        }
+    }
+);
+
+sub _get_patron_xacts {
+    my ($xact_type, $auth, $session_id, $barcode) = @_;
+
+    $logger->info("EbookAPI: getting $xact_type for patron $barcode");
+
+    # verify that user is authenticated in EG
+    my $e = new_editor(authtoken => $auth);
+    if (!$e->checkauth) {
+        $logger->error("EbookAPI: authentication failed: " . $e->die_event);
+        return;
+    }
+
+    my $handler = new_handler($session_id);
+    my $user_token = $handler->do_patron_auth($barcode);
+
+    my $xacts;
+    if ($xact_type eq 'checkouts') {
+        $xacts = $handler->get_patron_checkouts($user_token);
+    } elsif ($xact_type eq 'holds') {
+        $xacts = $handler->get_patron_holds($user_token);
+    } else {
+        $logger->error("EbookAPI: invalid transaction type '$xact_type'");
+        return;
+    }
+
+    # cache and return transaction details
+    $handler->{$xact_type} = $xacts;
+    # Overlay transactions onto existing cached handler.
+    if (update_cache($handler, 1)) {
+        return $handler->{$xact_type};
+    } else {
+        $logger->error("EbookAPI: error caching transaction details ($xact_type)");
+        return;
+    }
+}
+
+sub get_patron_xacts {
+    my ($self, $conn, $auth, $session_id, $barcode) = @_;
+    my $xact_type;
+    if ($self->api_name =~ /checkouts/) {
+        $xact_type = 'checkouts';
+    } elsif ($self->api_name =~ /holds/) {
+        $xact_type = 'holds';
+    }
+    return _get_patron_xacts($xact_type, $auth, $session_id, $barcode);
+}
+__PACKAGE__->register_method(
+    method => 'get_patron_xacts',
+    api_name => 'open-ils.ebook_api.patron.get_checkouts',
+    api_level => 1,
+    argc => 3,
+    signature => {
+        desc => "Get information about a patron's ebook checkouts",
+        params => [
+            {
+                name => 'authtoken',
+                desc => 'Authentication token',
+                type => 'string'
+            },
+            {
+                name => 'session_id',
+                desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+                type => 'string'
+            },
+            {
+                name => 'barcode',
+                desc => 'The barcode of the patron',
+                type => 'string'
+            }
+        ],
+        return => {
+            desc => 'Returns an array of transaction details, or undef if no details available',
+            type => 'array'
+        }
+    }
+);
+__PACKAGE__->register_method(
+    method => 'get_patron_xacts',
+    api_name => 'open-ils.ebook_api.patron.get_holds',
+    api_level => 1,
+    argc => 3,
+    signature => {
+        desc => "Get information about a patron's ebook holds",
+        params => [
+            {
+                name => 'authtoken',
+                desc => 'Authentication token',
+                type => 'string'
+            },
+            {
+                name => 'session_id',
+                desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+                type => 'string'
+            },
+            {
+                name => 'barcode',
+                desc => 'The barcode of the patron',
+                type => 'string'
+            }
+        ],
+        return => {
+            desc => 'Returns an array of transaction details, or undef if no details available',
+            type => 'array'
+        }
+    }
+);
+
+sub get_all_patron_xacts {
+    my ($self, $conn, $auth, $session_id, $barcode) = @_;
+    my $checkouts = _get_patron_xacts('checkouts', $auth, $session_id, $barcode);
+    my $holds = _get_patron_xacts('holds', $auth, $session_id, $barcode);
+    return {
+        checkouts => $checkouts,
+        holds     => $holds
+    };
+}
+__PACKAGE__->register_method(
+    method => 'get_all_patron_xacts',
+    api_name => 'open-ils.ebook_api.patron.get_transactions',
+    api_level => 1,
+    argc => 3,
+    signature => {
+        desc => "Get information about a patron's ebook checkouts and holds",
+        params => [
+            {
+                name => 'authtoken',
+                desc => 'Authentication token',
+                type => 'string'
+            },
+            {
+                name => 'session_id',
+                desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+                type => 'string'
+            },
+            {
+                name => 'barcode',
+                desc => 'The barcode of the patron',
+                type => 'string'
+            }
+        ],
+        return => {
+            desc => 'Returns a hashref of transactions: { checkouts => [], holds => [], failed => [] }',
+            type => 'hashref'
+        }
+    }
+);
+
+1;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/OneClickdigital.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/OneClickdigital.pm
new file mode 100644 (file)
index 0000000..9880b5f
--- /dev/null
@@ -0,0 +1,306 @@
+#!/usr/bin/perl
+
+# Copyright (C) 2015 BC Libraries Cooperative
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+# 
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+# 
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+package OpenILS::Application::EbookAPI::OneClickdigital;
+
+use strict;
+use warnings;
+
+use OpenILS::Application;
+use OpenILS::Application::EbookAPI;
+use base qw/OpenILS::Application::EbookAPI/;
+use OpenSRF::AppSession;
+use OpenSRF::EX qw(:try);
+use OpenSRF::Utils::SettingsClient;
+use OpenSRF::Utils::Logger qw($logger);
+use OpenSRF::Utils::Cache;
+use OpenILS::Application::AppUtils;
+use Data::Dumper;
+
+sub new {
+    my( $class, $args ) = @_;
+    $class = ref $class || $class;
+    return bless $args, $class;
+}
+
+sub ou {
+    my $self = shift;
+    return $self->{ou};
+}
+
+sub vendor {
+    my $self = shift;
+    return $self->{vendor};
+}
+
+sub session_id {
+    my $self = shift;
+    return $self->{session_id};
+}
+
+sub base_uri {
+    my $self = shift;
+    return $self->{base_uri};
+}
+
+sub library_id {
+    my $self = shift;
+    return $self->{library_id};
+}
+
+sub basic_token {
+    my $self = shift;
+    return $self->{basic_token};
+}
+
+sub patron_id {
+    my $self = shift;
+    return $self->{patron_id};
+}
+
+sub initialize {
+    my $self = shift;
+    my $ou = $self->{ou};
+
+    $self->{base_uri} = 'https://api.oneclickdigital.us/v1'; # TODO pull from org setting?
+
+    my $library_id = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.oneclickdigital.library_id');
+    if ($library_id) {
+        $self->{library_id} = $library_id;
+    } else {
+        $logger->error("EbookAPI: no OneClickdigital library ID found for org unit $ou");
+        return;
+    }
+
+    my $basic_token = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.oneclickdigital.basic_token');
+    if ($basic_token) {
+        $self->{basic_token} = $basic_token;
+    } else {
+        $logger->error("EbookAPI: no OneClickdigital basic token found for org unit $ou");
+        return;
+    }
+
+    return $self;
+
+}
+
+# OneClickdigital API does not require separate client auth;
+# we just need to include our basic auth token in requests
+sub do_client_auth {
+    my $self = shift;
+    return;
+}
+
+# retrieve OneClickdigital patron ID (if any) based on patron barcode
+# GET http://api.oneclickdigital.us/v1/rpc/libraries/{libraryID}/patrons/{barcode}
+sub do_patron_auth {
+    my ($self, $barcode) = @_;
+    my $base_uri = $self->{base_uri};
+    my $library_id = $self->{library_id};
+    my $session_id = $self->{session_id};
+    my $req = {
+        method => 'GET',
+        uri    => "$base_uri/rpc/libraries/$library_id/patrons/$barcode"
+    };
+    my $res = $self->request($req, $session_id);
+    # TODO distinguish between unregistered patrons and patron auth failure
+    if (defined ($res) && $res->{content}->{patronId}) {
+        return $res->{content}->{patronId};
+    }
+    $logger->error("EbookAPI: no OneClickdigital patron ID found for barcode $barcode");
+    return;
+}
+
+# does this title have available "copies"? y/n
+# GET http://api.oneclickdigital.us/v1/libraries/{libraryID}/media/{isbn}/availability
+sub do_availability_lookup {
+    my ($self, $isbn) = @_;
+    my $base_uri = $self->{base_uri};
+    my $library_id = $self->{library_id};
+    my $session_id = $self->{session_id};
+    my $req = {
+        method => 'GET',
+        uri    => "$base_uri/libraries/$library_id/media/$isbn/availability"
+    };
+    my $res = $self->request($req, $session_id);
+    if (defined ($res)) {
+        $logger->info("EbookAPI: received availability response for ISBN $isbn: " . Dumper $res);
+        return $res->{content}->{availability};
+    } else {
+        $logger->error("EbookAPI: could not retrieve OneClickdigital availability for ISBN $isbn");
+        return;
+    }
+}
+
+# OneClickdigital API does not support detailed holdings lookup,
+# so we return basic availability information.
+sub do_holdings_lookup {
+    my ($self, $isbn) = @_;
+    my $avail = $self->do_availability_lookup($isbn);
+    return { available => $avail };
+}
+
+# checkout an item to a patron
+# item is identified by ISBN, patron ID is their barcode
+# POST //api.{domain}/v1/libraries/{libraryId}/patrons/{patronId}/checkouts/{isbn}
+sub checkout {
+    my ($self, $isbn, $patron_id) = @_;
+    my $base_uri = $self->{base_uri};
+    my $library_id = $self->{library_id};
+    my $session_id = $self->{session_id};
+    my $req = {
+        method => 'POST',
+        uri    => "$base_uri/libraries/$library_id/patrons/$patron_id/checkouts/$isbn"
+    };
+    my $res = $self->request($req, $session_id);
+
+    # TODO: more sophisticated response handling
+    # HTTP 200 response indicates success, HTTP 409 indicates checkout limit reached
+    if (defined ($res)) {
+        if ($res->{is_success}) {
+            return {
+                xact_id => $res->{content}->{transactionId},
+                due_date => $res->{content}->{expiration}
+            };
+        } else {
+            $logger->error("EbookAPI: checkout failed for OneClickdigital title $isbn");
+            return { error_msg => $res->{content} };
+        }
+    } else {
+        $logger->error("EbookAPI: no response received from OneClickdigital server");
+        return;
+    }
+}
+
+# renew a checked-out item
+# item id = ISBN, patron id = barcode
+# PUT //api.{domain}/v1/libraries/{libraryId}/patrons/{patronId}/checkouts/{isbn}
+sub renew {
+    my ($self, $isbn, $patron_id) = @_;
+    my $base_uri = $self->{base_uri};
+    my $library_id = $self->{library_id};
+    my $session_id = $self->{session_id};
+    my $req = {
+        method => 'PUT',
+        uri    => "$base_uri/libraries/$library_id/patrons/$patron_id/checkouts/$isbn"
+    };
+    my $res = $self->request($req, $session_id);
+
+    # TODO: more sophisticated response handling
+    # HTTP 200 response indicates success
+    if (defined ($res)) {
+        if ($res->{is_success}) {
+            return {
+                xact_id => $res->{content}->{transactionId},
+                due_date => $res->{content}->{expiration}
+            };
+        } else {
+            $logger->error("EbookAPI: renewal failed for OneClickdigital title $isbn");
+            return { error_msg => $res->{content} };
+        }
+    } else {
+        $logger->error("EbookAPI: no response received from OneClickdigital server");
+        return;
+    }
+}
+
+# checkin a checked-out item
+# item id = ISBN, patron id = barcode
+# XXX API docs indicate that a bearer token is required!
+# DELETE //api.{domain}/v1/libraries/{libraryId}/patrons/{patronId}/checkouts/{isbn}
+sub checkin {
+}
+
+sub place_hold {
+}
+
+sub cancel_hold {
+}
+
+# GET //api.{domain}/v1/libraries/{libraryId}/patrons/{patronId}/checkouts/all
+sub get_patron_checkouts {
+    my ($self, $patron_id) = @_;
+    my $base_uri = $self->{base_uri};
+    my $library_id = $self->{library_id};
+    my $session_id = $self->{session_id};
+    my $req = {
+        method => 'GET',
+        uri    => "$base_uri/libraries/$library_id/patrons/$patron_id/checkouts/all"
+    };
+    my $res = $self->request($req, $session_id);
+
+    my $checkouts = [];
+    if (defined ($res)) {
+        $logger->info("EbookAPI: received response for OneClickdigital checkouts: " . Dumper $res);
+        foreach my $checkout (@{$res->{content}}) {
+            push @$checkouts, {
+                xact_id => $checkout->{transactionID},
+                title_id => $checkout->{isbn},
+                due_date => $checkout->{expiration},
+                download_url => $checkout->{downloadURL},
+                title => $checkout->{title},
+                author => $checkout->{authors}
+            };
+        };
+        $logger->info("EbookAPI: retrieved " . scalar(@$checkouts) . " OneClickdigital checkouts for patron $patron_id");
+        $self->{checkouts} = $checkouts;
+        return $self->{checkouts};
+    } else {
+        $logger->error("EbookAPI: failed to retrieve OneClickdigital checkouts for patron $patron_id");
+        return;
+    }
+}
+
+# GET //api.{domain}/v1/libraries/{libraryId}/patrons/{patronId}/holds/all
+sub get_patron_holds {
+    my ($self, $patron_id) = @_;
+    my $base_uri = $self->{base_uri};
+    my $library_id = $self->{library_id};
+    my $session_id = $self->{session_id};
+    my $req = {
+        method => 'GET',
+        uri    => "$base_uri/libraries/$library_id/patrons/$patron_id/holds/all"
+    };
+    my $res = $self->request($req, $session_id);
+
+    my $holds = [];
+    if (defined ($res)) {
+        $logger->info("EbookAPI: received response for OneClickdigital holds: " . Dumper $res);
+        foreach my $hold (@{$res->{content}}) {
+            push @$holds, {
+                xact_id => $hold->{transactionID},
+                title_id => $hold->{isbn},
+                expire_date => $hold->{expiration},
+                title => $hold->{title},
+                author => $hold->{authors},
+                # XXX queue position/size and pending vs ready info not available via API
+                queue_position => '-',
+                queue_size => '-',
+                is_ready => 0
+            };
+        };
+        $logger->info("EbookAPI: retrieved " . scalar(@$holds) . " OneClickdigital holds for patron $patron_id");
+        $self->{holds} = $holds;
+        return $self->{holds};
+    } else {
+        $logger->error("EbookAPI: failed to retrieve OneClickdigital holds for patron $patron_id");
+        return;
+    }
+}
+
+1;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/OverDrive.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/OverDrive.pm
new file mode 100644 (file)
index 0000000..97a301a
--- /dev/null
@@ -0,0 +1,562 @@
+#!/usr/bin/perl
+
+# Copyright (C) 2015 BC Libraries Cooperative
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+# 
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+# 
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+package OpenILS::Application::EbookAPI::OverDrive;
+
+use strict;
+use warnings;
+
+use OpenILS::Application;
+use OpenILS::Application::EbookAPI;
+use base qw/OpenILS::Application::EbookAPI/;
+use OpenSRF::AppSession;
+use OpenSRF::EX qw(:try);
+use OpenSRF::Utils::SettingsClient;
+use OpenSRF::Utils::Logger qw($logger);
+use OpenSRF::Utils::Cache;
+use OpenILS::Application::AppUtils;
+use Data::Dumper;
+
+sub new {
+    my( $class, $args ) = @_;
+    $class = ref $class || $class;
+    return bless $args, $class;
+}
+
+sub ou {
+    my $self = shift;
+    return $self->{ou};
+}
+
+sub vendor {
+    my $self = shift;
+    return $self->{vendor};
+}
+
+sub session_id {
+    my $self = shift;
+    return $self->{session_id};
+}
+
+sub account_id {
+    my $self = shift;
+    return $self->{account_id};
+}
+
+sub websiteid {
+    my $self = shift;
+    return $self->{websiteid};
+}
+
+sub authorizationname {
+    my $self = shift;
+    return $self->{authorizationname};
+}
+
+sub basic_token {
+    my $self = shift;
+    return $self->{basic_token};
+}
+
+sub bearer_token {
+    my $self = shift;
+    return $self->{bearer_token};
+}
+
+sub collection_token {
+    my $self = shift;
+    return $self->{collection_token};
+}
+
+sub granted_auth_uri {
+    my $self = shift;
+    return $self->{granted_auth_uri};
+}
+
+sub password_required {
+    my $self = shift;
+    return $self->{password_required};
+}
+
+sub patron_token {
+    my $self = shift;
+    return $self->{patron_token};
+}
+
+sub initialize {
+    my $self = shift;
+    my $ou = $self->{ou};
+
+    my $discovery_base_uri = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.discovery_base_uri');
+    $self->{discovery_base_uri} = $discovery_base_uri || 'https://api.overdrive.com/v1';
+    my $circulation_base_uri = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.circulation_base_uri');
+    $self->{circulation_base_uri} = $circulation_base_uri || 'https://patron.api.overdrive.com/v1';
+
+    my $account_id = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.account_id');
+    if ($account_id) {
+        $self->{account_id} = $account_id;
+    } else {
+        $logger->error("EbookAPI: no OverDrive account ID found for org unit $ou");
+        return;
+    }
+
+    my $websiteid = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.websiteid');
+    if ($websiteid) {
+        $self->{websiteid} = $websiteid;
+    } else {
+        $logger->error("EbookAPI: no OverDrive website ID found for org unit $ou");
+        return;
+    }
+
+    my $authorizationname = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.authorizationname');
+    if ($authorizationname) {
+        $self->{authorizationname} = $authorizationname;
+    } else {
+        $logger->error("EbookAPI: no OverDrive authorization name found for org unit $ou");
+        return;
+    }
+
+    my $basic_token = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.basic_token');
+    if ($basic_token) {
+        $self->{basic_token} = $basic_token;
+    } else {
+        $logger->error("EbookAPI: no OverDrive basic token found for org unit $ou");
+        return;
+    }
+
+    my $granted_auth_uri = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.granted_auth_redirect_uri');
+    if ($granted_auth_uri) {
+        $self->{granted_auth_uri} = $granted_auth_uri;
+    }
+
+    my $password_required = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.password_required') || 0;
+    $self->{password_required} = $password_required;
+
+    return $self;
+
+}
+
+# Wrapper method for HTTP requests.
+sub handle_http_request {
+    my $self = shift;
+    my $req = shift;
+
+    # Prep our request using defaults.
+    $req->{method} = 'GET' if (!$req->{method});
+    $req = $self->set_http_headers($req);
+
+    # Send the request.
+    my $res = $self->request($req, $self->{session_id});
+
+    $logger->info("EbookAPI: raw OverDrive HTTP response: " . Dumper $res);
+
+    # A "401 Unauthorized" response means we need to re-auth our client or patron.
+    if (defined ($res) && $res->{status} =~ /^401/) {
+        $logger->info("EbookAPI: 401 response received from OverDrive, re-authorizing...");
+
+        # Always re-auth client to ensure we have an up-to-date client token.
+        $self->do_client_auth();
+
+        # If we're using a Circulation API, redo patron auth too.
+        my $circulation_base_uri = $self->{circulation_base_uri};
+        if ($req->{uri} =~ /^$circulation_base_uri/) {
+            $self->do_patron_auth();
+        }
+
+        # Now we can update our headers with our fresh client/patron tokens
+        # and re-send our request.
+        $req = $self->set_http_headers($req);
+        return $self->request($req, $self->{session_id});
+    }
+
+    # For any non-401 response (including no response at all),
+    # just return whatever response we got (if any).
+    return $res;
+}
+
+# Set the correct headers for our request.
+# Authorization headers are determined by which API we're using:
+# - Circulation APIs use a patron access token.
+# - Discovery APIs use a regular access token.
+# - For other APIs, fallback to our basic token.
+sub set_http_headers {
+    my $self = shift;
+    my $req = shift;
+    $req->{headers} = {} if (!$req->{headers});
+    if (!$req->{headers}->{Authorization}) {
+        my $auth_type;
+        my $token;
+        my $circulation_base_uri = $self->{circulation_base_uri};
+        my $discovery_base_uri = $self->{discovery_base_uri};
+        if ($req->{uri} =~ /^$circulation_base_uri/) {
+            $auth_type = 'Bearer';
+            $token = $self->{patron_token};
+        } elsif ($req->{uri} =~ /^$discovery_base_uri/) {
+            $auth_type = 'Bearer';
+            $token = $self->{bearer_token};
+        } else {
+            $auth_type = 'Basic';
+            $token = $self->{basic_token};
+        }
+        if (!$token) {
+            $logger->error("EbookAPI: unable to set HTTP Authorization header without token");
+            $logger->error("EbookAPI: failed request: " . Dumper $req);
+            return;
+        } else {
+            $req->{headers}->{Authorization} = "$auth_type $token";
+        }
+    }
+    return $req;
+}
+
+# POST /token HTTP/1.1
+# Host: oauth.overdrive.com
+# Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
+# 
+# grant_type=client_credentials
+sub do_client_auth {
+    my $self = shift;
+    my $req = {
+        method  => 'POST',
+        uri     => 'https://oauth.overdrive.com/token',
+        headers => {
+            'Authorization' => 'Basic ' . $self->{basic_token},
+            'Content-Type'  => 'application/x-www-form-urlencoded;charset=UTF-8'
+        },
+        content => 'grant_type=client_credentials'
+    };
+    my $res = $self->request($req, $self->{session_id});
+
+    if (defined ($res)) {
+        if ($res->{content}->{access_token}) {
+            # save our access token for future use
+            $self->{bearer_token} = $res->{content}->{access_token};
+            # use access token to grab other library info (e.g. collection token)
+            $self->get_library_info();
+            return $res;
+        } else {
+            $logger->error("EbookAPI: bearer token not received from OverDrive API");
+            $logger->error("EbookAPI: bad response: " . Dumper $res);
+        }
+    } else {
+        $logger->error("EbookAPI: no client authentication response from OverDrive API");
+    }
+    return;
+}
+
+sub do_patron_auth {
+    my $self = shift;
+    my @args = @_;
+    if ($self->{granted_auth_uri}) {
+        return $self->do_granted_patron_auth(@args);
+    } else {
+        return $self->do_basic_patron_auth(@args);
+    }
+}
+
+# TODO
+sub do_granted_patron_auth {
+}
+
+# POST /patrontoken HTTP/1.1
+# Host: oauth-patron.overdrive.com
+# Authorization: Basic {Base64-encoded string}
+# Content-Type: application/x-www-form-urlencoded;charset=UTF-8
+# 
+# grant_type=password&username=1234567890&password=1234&scope=websiteid:12345 authorizationname:default
+# OR:
+# grant_type=password&username=1234567890&password=[ignore]&password_required=false&scope=websiteid:12345 authorizationname:default
+sub do_basic_patron_auth {
+    my $self = shift;
+    my $barcode = shift;
+
+    if ($barcode) {
+        if (!$self->{patron_barcode}) {
+            $self->{patron_barcode} = $barcode;
+        } elsif ($barcode ne $self->{patron_barcode}) {
+            $logger->error("EbookAPI: patron barcode in auth request does not match patron barcode for this session");
+            return;
+        }
+    } else {
+        if (!$self->{patron_barcode}) {
+            $logger->error("EbookAPI: Cannot authenticate patron with unknown barcode");
+        } else {
+            $barcode = $self->{patron_barcode};
+        }
+    }
+
+    # TODO handle cached/expired tokens?
+    # Making a request using an expired token will give a 401 Unauthorized error.
+    # Handle this appropriately.
+
+    # request content is an ugly url-encoded string
+    my $pw = (defined $self->{patron_password}) ? $self->{patron_password} : '';
+    my $content = 'grant_type=password';
+    $content .= "&username=$barcode";
+    if ($self->{password_required}) {
+        $content .= "&password=$pw";
+    } else {
+        $content .= '&password=xxx&password_required=false'
+    }
+    $content .= '&scope=websiteid:' . $self->{websiteid} . ' authorizationname:' . $self->{authorizationname};
+
+    my $req = {
+        method  => 'POST',
+        uri     => 'https://oauth-patron.overdrive.com/patrontoken',
+        headers => {
+            'Authorization' => 'Basic ' . $self->{basic_token},
+            'Content-Type'  => 'application/x-www-form-urlencoded;charset=UTF-8'
+        },
+        content => $content
+    };
+    my $res = $self->request($req, $self->{session_id});
+
+    if (defined ($res)) {
+        if ($res->{content}->{access_token}) {
+            $self->{patron_token} = $res->{content}->{access_token};
+            return $self->{patron_token};
+        } else {
+            $logger->error("EbookAPI: patron access token not received from OverDrive API");
+        }
+    } else {
+        $logger->error("EbookAPI: no patron authentication response from OverDrive API");
+    }
+    return;
+}
+
+# GET http://api.overdrive.com/v1/libraries/1225
+# User-Agent: {Your application}
+# Authorization: Bearer {OAuth access token}
+# Host: api.overdrive.com
+sub get_library_info {
+    my $self = shift;
+    my $library_id = $self->{account_id};
+    my $req = {
+        method  => 'GET',
+        uri     => $self->{discovery_base_uri} . "/libraries/$library_id"
+    };
+    if (my $res = $self->handle_http_request($req, $self->{session_id})) {
+        $self->{collection_token} = $res->{content}->{collectionToken};
+        return $self->{collection_token};
+    } else {
+        $logger->error("EbookAPI: OverDrive Library Account API request failed");
+        return;
+    }
+}
+
+# GET http://api.overdrive.com/v1/collections/v1L1BYwAAAA2Q/products/76c1b7d0-17f4-4c05-8397-c66c17411584/metadata
+# User-Agent: {Your application}
+# Authorization: Bearer {OAuth access token}
+# Host: api.overdrive.com
+sub get_title_info {
+    my $self = shift;
+    my $title_id = shift;
+    $self->do_client_auth() if (!$self->{bearer_token});
+    $self->get_library_info() if (!$self->{collection_token});
+    my $collection_token = $self->{collection_token};
+    my $req = {
+        method  => 'GET',
+        uri     => $self->{discovery_base_uri} . "/collections/$collection_token/products/$title_id/metadata"
+    };
+    if (my $res = $self->handle_http_request($req, $self->{session_id})) {
+        if ($res->{content}->{title}) {
+            return {
+                title  => $res->{content}->{title},
+                author => $res->{content}->{creators}[0]{name}
+            };
+        } else {
+            $logger->error("EbookAPI: OverDrive metadata lookup failed for $title_id");
+        }
+    } else {
+        $logger->error("EbookAPI: no metadata response from OverDrive API");
+    }
+    return;
+}
+
+# GET http://api.overdrive.com/v1/collections/L1BAAEAAA2i/products/76C1B7D0-17F4-4C05-8397-C66C17411584/availability
+# User-Agent: {Your application}
+# Authorization: Bearer {OAuth access token}
+# Host: api.overdrive.com
+sub do_availability_lookup {
+    my $self = shift;
+    my $title_id = shift;
+    $self->do_client_auth() if (!$self->{bearer_token});
+    $self->get_library_info() if (!$self->{collection_token});
+    my $req = {
+        method  => 'GET',
+        uri     => $self->{discovery_base_uri} . "/collections/" . $self->{collection_token} . "/products/$title_id/availability"
+    };
+    if (my $res = $self->handle_http_request($req, $self->{session_id})) {
+        return $res->{content}->{available};
+    } else {
+        $logger->error("EbookAPI: could not retrieve OverDrive availability for title $title_id");
+        return;
+    }
+}
+
+# Holdings lookup has two parts:
+#
+# 1. Copy availability: as above, but grab more details.
+#
+# 2. Formats:
+#     GET https://api.overdrive.com/v1/collections/v1L1BYwAAAA2Q/products/76c1b7d0-17f4-4c05-8397-c66c17411584/metadata
+#     User-Agent: {Your application}
+#     Authorization: Bearer {OAuth access token}
+#     Host: api.overdrive.com
+#
+sub do_holdings_lookup {
+    my ($self, $title_id) = @_;
+    $self->do_client_auth() if (!$self->{bearer_token});
+    $self->get_library_info() if (!$self->{collection_token});
+    my $collection_token = $self->{collection_token};
+
+    # prepare data structure to be used as return value
+    my $holdings = {
+        copies_owned => 0,
+        copies_available => 0,
+        formats => []
+    };
+
+    # request copy availability totals
+    my $avail_req = {
+        method  => 'GET',
+        uri     => $self->{discovery_base_uri} . "/collections/$collection_token/products/$title_id/availability"
+    };
+    if (my $avail_res = $self->handle_http_request($avail_req, $self->{session_id})) {
+        $holdings->{copies_owned} = $avail_res->{content}->{copiesOwned};
+        $holdings->{copies_available} = $avail_res->{content}->{copiesAvailable};
+    } else {
+        $logger->error("EbookAPI: failed to retrieve OverDrive holdings counts for title $title_id");
+    }
+
+    # request available formats
+    my $format_req = {
+        method  => 'GET',
+        uri     => $self->{discovery_base_uri} . "/collections/$collection_token/products/$title_id/metadata"
+    };
+    if (my $format_res = $self->handle_http_request($format_req, $self->{session_id})) {
+        if ($format_res->{content}->{formats}) {
+            foreach my $f (@{$format_res->{content}->{formats}}) {
+                push @{$holdings->{formats}}, $f->{name};
+            }
+        } else {
+            $logger->info("EbookAPI: OverDrive holdings format request for title $title_id contained no format information");
+        }
+    } else {
+        $logger->error("EbookAPI: failed to retrieve OverDrive holdings formats for title $title_id");
+    }
+
+    return $holdings;
+}
+
+# List of patron checkouts:
+# GET http://patron.api.overdrive.com/v1/patrons/me/checkouts
+# User-Agent: {Your application}
+# Authorization: Bearer {OAuth patron access token}
+# Host: patron.api.overdrive.com
+#
+# Response looks like this:
+# {
+#     "totalItems": 4,
+#     "totalCheckouts": 2,
+#     "checkouts": [
+#         {
+#             "reserveId": "A03EAC2C-C088-46C6-B9E9-59D6C11A3596",
+#             "expires": "2015-08-11T18:53:00Z",
+#             ...
+#         }
+#     ],
+#     ...
+# }
+#
+# To get title metadata (e.g. title/author), do get_title_info(reserveId).
+sub get_patron_checkouts {
+    my $self = shift;
+    my $patron_token = shift;
+    if (my $res = $self->do_get_patron_xacts('checkouts', $patron_token)) {
+        my $checkouts = [];
+        foreach my $checkout (@{$res->{content}->{checkouts}}) {
+            my $title_id = $checkout->{reserveId};
+            my $title_info = $self->get_title_info($title_id);
+            # TODO get download URL - need to "lock in" a format first, see OD Checkouts API docs
+            push @$checkouts, {
+                title_id => $title_id,
+                due_date => $checkout->{expires},
+                title => $title_info->{title},
+                author => $title_info->{author}
+            }
+        };
+        $self->{checkouts} = $checkouts;
+        return $self->{checkouts};
+    } else {
+        $logger->error("EbookAPI: unable to retrieve OverDrive checkouts for patron " . $self->{patron_barcode});
+        return;
+    }
+}
+
+sub get_patron_holds {
+    my $self = shift;
+    my $patron_token = shift;
+    if (my $res = $self->do_get_patron_xacts('holds', $patron_token)) {
+        my $holds = [];
+        foreach my $hold (@{$res->{content}->{holds}}) {
+            my $title_id = $hold->{reserveId};
+            my $title_info = $self->get_title_info($title_id);
+            my $this_hold = {
+                title_id => $title_id,
+                queue_position => $hold->{holdListPosition},
+                queue_size => $hold->{numberOfHolds},
+                # TODO: special handling for ready-to-checkout holds
+                is_ready => ( $hold->{actions}->{checkout} ) ? 1 : 0,
+                create_date => $hold->{holdPlacedDate},
+                expire_date => ( $hold->{holdExpires} ) ? $hold->{holdExpires} : '-',
+                title => $title_info->{title},
+                author => $title_info->{author}
+            };
+            # TODO: hold suspensions
+            push @$holds, $this_hold;
+        }
+        $self->{holds} = $holds;
+        return $self->{holds};
+    } else {
+        $logger->error("EbookAPI: unable to retrieve OverDrive holds for patron " . $self->{patron_barcode});
+        return;
+    }
+}
+
+# generic function for retrieving patron transactions
+sub do_get_patron_xacts {
+    my $self = shift;
+    my $xact_type = shift;
+    my $patron_token = shift;
+    if (!$patron_token) {
+        if ($self->{patron_barcode}) {
+            $self->do_client_auth() if (!$self->{bearer_token});
+            $self->do_patron_auth();
+        } else {
+            $logger->error("EbookAPI: Cannot retrieve OverDrive $xact_type with no patron information");
+        }
+    }
+    my $req = {
+        method  => 'GET',
+        uri     => $self->{circulation_base_uri} . "/patrons/me/$xact_type"
+    };
+    return $self->handle_http_request($req, $self->{session_id});
+}
+
+1;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/Test.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/Test.pm
new file mode 100644 (file)
index 0000000..a20846c
--- /dev/null
@@ -0,0 +1,464 @@
+#!/usr/bin/perl
+
+# Copyright (C) 2015 BC Libraries Cooperative
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+# 
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+# 
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+# ====================================================================== 
+# OpenSRF requests are handled by the main OpenILS::Application::EbookAPI module,
+# which determines which "handler" submodule to use based on the params of the
+# OpenSRF request.  Each vendor API (OneClickdigital, OverDrive, etc.) has its
+# own separate handler class, since they all work a little differently.
+#
+# An instance of the handler class represents an EbookAPI session -- that is, we
+# instantiate a new handler object when we start a new session with the external API.
+# Thus everything we need to talk to the API, like client keys or auth tokens, is
+# an attribute of the handler object.
+#
+# API endpoints are defined in the handler class.  The handler constructs HTTP
+# requests, then passes them to the the request() method of the parent class
+# (OpenILS::Application::EbookAPI), which sets some default headers and manages
+# the actual mechanics of sending the request and receiving the response.  It's
+# up to the handler class to do something with the response.
+#
+# At a minimum, each handler must have the following methods, since the parent
+# class presumes they exist; it may be a no-op if the API doesn't support that
+# bit of functionality:
+#
+#   - initialize: assign values for basic attributes (e.g. library_id,
+#     basic_token) based on library settings
+#   - do_client_auth: authenticate client with external API (e.g. get client
+#     token if needed)
+#   - do_patron_auth: get a patron-specific bearer token, or just the patron ID
+#   - do_holdings_lookup: how many total/available "copies" are there for this
+#     title? (n/a for OneClickdigital)
+#   - do_availability_lookup: does this title have available "copies"? y/n
+#   - checkout
+#   - renew
+#   - checkin
+#   - place_hold
+#   - suspend_hold (n/a for OneClickdigital)
+#   - cancel_hold
+#   - get_patron_checkouts: returns an array of hashrefs representing checkouts;
+#     each checkout hashref has the following keys:
+#       - xact_id
+#       - title_id
+#       - due_date
+#       - download_url
+#       - title
+#       - author
+#   - get_patron_holds
+# ====================================================================== 
+
+package OpenILS::Application::EbookAPI::Test;
+
+use strict;
+use warnings;
+
+use OpenILS::Application;
+use OpenILS::Application::EbookAPI;
+use base qw/OpenILS::Application::EbookAPI/;
+use OpenSRF::AppSession;
+use OpenSRF::EX qw(:try);
+use OpenSRF::Utils::SettingsClient;
+use OpenSRF::Utils::Logger qw($logger);
+use OpenSRF::Utils::Cache;
+use OpenILS::Application::AppUtils;
+use DateTime;
+use DateTime::Format::ISO8601;
+
+my $U = 'OpenILS::Application::AppUtils';
+
+# create new handler object
+sub new {
+    my( $class, $args ) = @_;
+
+    # A new handler object represents a new API session, so we instantiate it
+    # by passing it a hashref containing the following basic attributes
+    # available to us when we start the session:
+    #   - vendor: a string indicating the vendor whose API we're talking to
+    #   - ou: org unit ID for current session
+    #   - session_id: unique ID for the session represented by this object
+
+    $class = ref $class || $class;
+    return bless $args, $class;
+}
+
+# set API-specific handler attributes based on library settings
+sub initialize {
+    my $self = shift;
+
+    # At a minimum, you are likely to need some kind of basic API key or token
+    # to allow the client (Evergreen) to use the API.
+    # Other attributes will vary depending on the API.  Consult your API
+    # documentation for details.
+
+    return $self;
+}
+
+# authorize client session against API
+sub do_client_auth {
+    my $self = shift;
+
+    # Some APIs require client authorization, and may return an auth token
+    # which must be included in subsequent requests.  This is where you do
+    # that.  If you get an auth token, you'll want to add it as an attribute to
+    # the handler object so that it's available to use in subsequent requests.
+    # If your API doesn't require this step, you don't need to return anything
+    # here.
+
+    return;
+}
+
+# authenticate patron against API
+sub do_patron_auth {
+    my $self = shift;
+
+    # We authenticate the patron using the barcode of their active card.
+    # We may capture this on OPAC login (along with password, if required),
+    # in which case it should already be an attribute of the handler object;
+    # otherwise, it should be passed to this method as a parameter.
+    my $barcode = shift;
+    if ($barcode) {
+        if (!$self->{patron_barcode}) {
+            $self->{patron_barcode} = $barcode;
+        } elsif ($barcode ne $self->{patron_barcode}) {
+            $logger->error("EbookAPI: patron barcode in auth request does not match patron barcode for this session");
+            return;
+        }
+    } else {
+        if (!$self->{patron_barcode}) {
+            $logger->error("EbookAPI: Cannot authenticate patron with unknown barcode");
+        } else {
+            $barcode = $self->{patron_barcode};
+        }
+    }
+
+    # We really don't want to be handling the patron's unencrypted password.
+    # But if we need to, it should be added to our handler object on login
+    # via the open-ils.ebook_api.patron.cache_password OpenSRF API call
+    # before we attempt to authenticate the patron against the external API.
+    my $password;
+    if ($self->{patron_password}) {
+        $password = $self->{patron_password};
+    }
+
+    # return external patron ID or patron auth token
+
+    # For testing, only barcode 99999359616 is valid.
+    return 'USER001' if ($barcode eq '99999359616');
+
+    # All other values return undef.
+    return undef;
+}
+
+# get detailed holdings information (copy counts and formats), OR basic
+# availability if detailed info is not provided by the API
+sub do_holdings_lookup {
+    my $self = shift;
+
+    # External ID for title.  Depending on the API, this could be an ISBN
+    # or an identifier unique to that vendor.
+    my $title_id = shift;
+
+    # Prepare data structure to be used as return value.
+    # NOTE: If the external API does not provide detailed holdings info,
+    # return simple availability information: { available => 1 }
+    my $holdings = {
+        copies_owned => 0,
+        copies_available => 0,
+        formats => []
+    };
+
+    # 001 and 002 are unavailable.
+    if ($title_id eq '001' || $title_id eq '002') {
+        $holdings->{copies_owned} = 1;
+        $holdings->{copies_available} = 0;
+        push @{$holdings->{formats}}, 'ebook';
+    }
+
+    # 003 is available.
+    if ($title_id eq '003') {
+        $holdings->{copies_owned} = 1;
+        $holdings->{copies_available} = 1;
+        push @{$holdings->{formats}}, 'ebook';
+    }
+
+    # All other title IDs are unknown.
+
+    return $holdings;
+}
+
+# look up whether a title is currently available for checkout; returns a boolean value
+sub do_availability_lookup {
+    my $self = shift;
+
+    # External ID for title.  Depending on the API, this could be an ISBN
+    # or an identifier unique to that vendor.
+    my $title_id = shift;
+
+    # At this point, you would lookup title availability via an API request.
+    # In our case, since this is a test module, we just return availability info
+    # based on hard-coded values:
+
+    # 001 and 002 are unavailable.
+    return 0 if ($title_id eq '001');
+    return 0 if ($title_id eq '002');
+
+    # 003 is available.
+    return 1 if ($title_id eq '003');
+
+    # All other title IDs are unknown.
+    return undef;
+}
+
+# check out a title to a patron
+sub checkout {
+    my $self = shift;
+
+    # External ID of title to be checked out.
+    my $title_id = shift;
+
+    # Patron ID or patron auth token, as returned by do_patron_auth().
+    my $user_token = shift;
+
+    # If checkout succeeds, the response is a hashref with the following fields:
+    # - due_date
+    # - xact_id (optional)
+    #
+    # If checkout fails, the response is a hashref with the following fields:
+    # - error_msg: a string containing an error message or description of why
+    #   the checkout failed (e.g. "Checkout limit of (4) reached").
+    #
+    # If no valid response is received from the API, return undef.
+
+    # For testing purposes, user ID USER001 is our only valid user, 
+    # and title 003 is the only available title.
+    if ($title_id && $user_token) {
+        if ($user_token eq 'USER001' && $title_id eq '003') {
+            return { due_date => DateTime->today()->add( days => 14 )->iso8601() };
+        } else {
+            return { msg => 'Checkout failed.' };
+        }
+    } else {
+        return undef;
+    }
+
+}
+
+sub renew {
+    my $self = shift;
+
+    # External ID of title to be renewed.
+    my $title_id = shift;
+
+    # Patron ID or patron auth token, as returned by do_patron_auth().
+    my $user_token = shift;
+
+    # If renewal succeeds, the response is a hashref with the following fields:
+    # - due_date
+    # - xact_id (optional)
+    #
+    # If renewal fails, the response is a hashref with the following fields:
+    # - error_msg: a string containing an error message or description of why
+    #   the renewal failed (e.g. "Renewal limit reached").
+    #
+    # If no valid response is received from the API, return undef.
+
+    # For testing purposes, user ID USER001 is our only valid user, 
+    # and title 001 is the only renewable title.
+    if ($title_id && $user_token) {
+        if ($user_token eq 'USER001' && $title_id eq '001') {
+            return { due_date => DateTime->today()->add( days => 14 )->iso8601() };
+        } else {
+            return { error_msg => 'Renewal failed.' };
+        }
+    } else {
+        return undef;
+    }
+}
+
+sub checkin {
+    my $self = shift;
+
+    # External ID of title to be checked in.
+    my $title_id = shift;
+
+    # Patron ID or patron auth token, as returned by do_patron_auth().
+    my $user_token = shift;
+
+    # If checkin succeeds, return an empty hashref (actually it doesn't
+    # need to be empty, it just must NOT contain "error_msg" as a key).
+    #
+    # If checkin fails, return a hashref with the following fields:
+    # - error_msg: a string containing an error message or description of why
+    #   the checkin failed (e.g. "Checkin failed").
+    #
+    # If no valid response is received from the API, return undef.
+
+    # For testing purposes, user ID USER001 is our only valid user, 
+    # and title 003 is the only title that can be checked in.
+    if ($title_id && $user_token) {
+        if ($user_token eq 'USER001' && $title_id eq '003') {
+            return {};
+        } else {
+            return { error_msg => 'Checkin failed' };
+        }
+    } else {
+        return undef;
+    }
+}
+
+sub place_hold {
+    my $self = shift;
+
+    # External ID of title to be held.
+    my $title_id = shift;
+
+    # Patron ID or patron auth token, as returned by do_patron_auth().
+    my $user_token = shift;
+
+    # If hold is successfully placed, return a hashref with the following
+    # fields:
+    # - queue_position: this user's position in hold queue for this title
+    # - queue_size: total number of holds on this title
+    # - expire_date: when the hold expires
+    #
+    # If hold fails, return a hashref with the following fields:
+    # - error_msg: a string containing an error message or description of why
+    #   the hold failed (e.g. "Hold limit (4) reached").
+    #
+    # If no valid response is received from the API, return undef.
+
+    # For testing purposes, we always and only allow placing a hold on title
+    # 002 by user ID USER001.
+    if ($title_id && $user_token) {
+        if ($user_token eq 'USER001' && $title_id eq '002') {
+            return {
+                queue_position => 1,
+                queue_size => 1,
+                expire_date => DateTime->today()->add( days => 70 )->iso8601()
+            };
+        } else {
+            return { error_msg => 'Unable to place hold' };
+        }
+    } else {
+        return undef;
+    }
+}
+
+sub cancel_hold {
+    my $self = shift;
+
+    # External ID of title.
+    my $title_id = shift;
+
+    # Patron ID or patron auth token, as returned by do_patron_auth().
+    my $user_token = shift;
+
+    # If hold is successfully canceled, return an empty hashref (actually it
+    # doesn't need to be empty, it just must NOT contain "error_msg" as a key).
+    #
+    # If hold is NOT canceled, return a hashref with the following fields:
+    # - error_msg: a string containing an error message or description of why
+    #   the hold was not canceled (e.g. "Hold could not be canceled"). 
+    #
+    # If no valid response is received from the API, return undef.
+
+    # For testing purposes, we always and only allow canceling a hold on title
+    # 002 by user ID USER001.
+    if ($title_id && $user_token) {
+        if ($user_token eq 'USER001' && $title_id eq '002') {
+            return {};
+        } else {
+            return { error_msg => 'Unable to cancel hold' };
+        }
+    } else {
+        return undef;
+    }
+}
+
+sub suspend_hold {
+}
+
+sub get_patron_checkouts {
+    my $self = shift;
+
+    # Patron ID or patron auth token.
+    my $user_token = shift;
+
+    # Return an array of hashrefs representing checkouts;
+    # each hashref should have the following keys:
+    #   - xact_id: unique ID for this transaction (if used by API)
+    #   - title_id: unique ID for this title
+    #   - due_date
+    #   - download_url
+    #   - title: title of item, formatted for display
+    #   - author: author of item, formatted for display
+
+    my $checkouts = [];
+    # USER001 is our only valid user, so we only return checkouts for them.
+    if ($user_token eq 'USER001') {
+        push @$checkouts, {
+            xact_id => '1',
+            title_id => '001',
+            due_date => DateTime->today()->add( days => 7 )->iso8601(),
+            download_url => 'http://example.com/ebookapi/t/001/download',
+            title => 'The Fellowship of the Ring',
+            author => 'J. R. R. Tolkien'
+        };
+    }
+    $self->{checkouts} = $checkouts;
+    return $self->{checkouts};
+}
+
+sub get_patron_holds {
+    my $self = shift;
+
+    # Patron ID or patron auth token.
+    my $user_token = shift;
+
+    # Return an array of hashrefs representing holds;
+    # each hashref should have the following keys:
+    #   - title_id: unique ID for this title
+    #   - queue_position: this user's position in hold queue for this title
+    #   - queue_size: total number of holds on this title
+    #   - is_ready: whether hold is currently available for checkout
+    #   - is_frozen: whether hold is suspended
+    #   - thaw_date: when hold suspension expires (if suspended)
+    #   - create_date: when the hold was placed
+    #   - expire_date: when the hold expires
+    #   - title: title of item, formatted for display
+    #   - author: author of item, formatted for display
+
+    my $holds = [];
+    # USER001 is our only valid user, so we only return checkouts for them.
+    if ($user_token eq 'USER001') {
+        push @$holds, {
+            title_id => '002',
+            queue_position => 1,
+            queue_size => 1,
+            is_ready => 0,
+            is_frozen => 0,
+            create_date => DateTime->today()->subtract( days => 10 )->iso8601(),
+            expire_date => DateTime->today()->add( days => 60 )->iso8601(),
+            title => 'The Two Towers',
+            author => 'J. R. R. Tolkien'
+        };
+    }
+    $self->{holds} = $holds;
+    return $self->{holds};
+}
+
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Utils/HTTPClient.pm b/Open-ILS/src/perlmods/lib/OpenILS/Utils/HTTPClient.pm
new file mode 100644 (file)
index 0000000..6474586
--- /dev/null
@@ -0,0 +1,131 @@
+package OpenILS::Utils::HTTPClient;
+
+use strict;
+use warnings;
+
+use OpenSRF::Utils::SettingsClient;
+use OpenSRF::Utils::Logger qw($logger);
+use OpenSRF::Utils::JSON;
+use LWP::UserAgent;
+use HTTP::Request;
+
+sub new {
+    my $class = shift;
+
+    my $self = {};
+    bless $self, $class;
+
+    $self->_initialize();
+
+    return $self;
+}
+
+sub _initialize {
+    my $self = shift;
+
+    # pull settings from opensrf.xml config
+    my $conf = OpenSRF::Utils::SettingsClient->new();
+    my $settings = $conf->config_value('http_client');
+
+    if ($settings->{useragent}) {
+        $self->{useragent} = $settings->{useragent};
+    }
+    if ($settings->{default_timeout}) {
+        $self->{default_timeout} = $settings->{default_timeout};
+    }
+
+    # SSL handling options. When communicating over HTTPS, LWP::UserAgent
+    # falls back to the environment variables whose values are set here.
+    # See LWP::UserAgent docs for details.
+    foreach my $opt (keys %{$settings->{ssl_opts}}) {
+        # check for a valid SSL cert?
+        if ($opt eq 'verify_hostname') {
+            $ENV{PERL_LWP_SSL_VERIFY_HOSTNAME} = $settings->{ssl_opts}->{verify_hostname};
+        # path to directory for CA certificate files
+        } elsif ($opt eq 'SSL_ca_path') {
+            $ENV{PERL_LWP_SSL_CA_PATH} = $settings->{ssl_opts}->{SSL_ca_path};
+        # path to CA certificate file
+        } elsif ($opt eq 'SSL_ca_file') {
+            $ENV{PERL_LWP_SSL_CA_FILE} = $settings->{ssl_opts}->{SSL_ca_file};
+        }
+    }
+
+    return $self;
+}
+
+# request(): Send an HTTP request.
+#
+# Params:
+#   $method - HTTP method (GET, POST, PUT, DELETE)
+#   $uri - URI of resource to be requested
+#   $header - hashref containing HTTP headers
+#   $content - content of request
+#   $request_timeout - timeout value in seconds; defaults to 60s
+#   $useragent - user agent string; defaults to SameOrigin/1.0
+#
+# Returns an HTTP::Response object, or undef if the request failed/timed out.
+# Use $res->content to get response content.
+#
+sub request {
+    my ($self, $method, $uri, $headers, $content, $request_timeout, $useragent) = @_;
+    my $ua = new LWP::UserAgent;
+
+    $request_timeout = $request_timeout || $self->{default_timeout} || 60;
+    $ua->timeout($request_timeout);
+
+    $useragent = $useragent || $self->{useragent} || 'SameOrigin/1.0';
+    $ua->agent($useragent);
+
+    my $h = HTTP::Headers->new();
+    foreach my $k (keys %$headers) {
+        $h->header($k => $headers->{$k});
+    }
+
+    my $req = HTTP::Request->new(
+        $method,
+        $uri,
+        $h,
+        $content
+    );
+    my $res;
+
+    eval {
+        $logger->info("HTTPClient: sending HTTP $method request to $uri");
+        $res = $ua->request($req);
+    } or do {
+        $logger->info("HTTPClient: execution error");
+        return undef;
+    };
+
+    if ($res->status_line =~ /timeout/) {
+        $logger->info("HTTPClient: timeout error: " . $res->status_line);
+        return undef;
+    }
+
+    # TODO handle HTTP response status codes
+
+    return $res;
+}
+
+# Wrappers for request() using specific HTTP methods (GET, POST etc).
+sub get {
+    my $self = shift;
+    return $self->request('GET', @_);
+}
+
+sub post {
+    my $self = shift;
+    return $self->request('POST', @_);
+}
+
+sub put {
+    my $self = shift;
+    return $self->request('PUT', @_);
+}
+
+sub delete {
+    my $self = shift;
+    return $self->request('DELETE', @_);
+}
+
+1;
index 3b1a661..f2f5d8f 100644 (file)
@@ -302,6 +302,7 @@ sub load_common {
             $ctx->{authtoken} = $e->authtoken;
             $ctx->{authtime} = $e->authtime;
             $ctx->{user} = $e->requestor;
+            $ctx->{active_card} = $self->editor->retrieve_actor_card($ctx->{user}->card)->barcode;
             $ctx->{place_unfillable} = 1 if $e->requestor->wsid && $e->allowed('PLACE_UNFILLABLE_HOLD', $e->requestor->ws_ou);
 
             # The browser client does not set an OILS-Wrapper header (above).
diff --git a/Open-ILS/src/perlmods/live_t/20-lp1541559-ebook-api.t b/Open-ILS/src/perlmods/live_t/20-lp1541559-ebook-api.t
new file mode 100644 (file)
index 0000000..72054a5
--- /dev/null
@@ -0,0 +1,170 @@
+#!perl
+use strict; use warnings;
+use Test::More tests => 21; # XXX
+use OpenILS::Utils::TestUtils;
+
+diag("Tests Ebook API");
+
+# ------------------------------------------------------------ 
+# 1. Set up test environment.
+# ------------------------------------------------------------ 
+
+use constant EBOOK_API_VENDOR => 'ebook_test';
+use constant EBOOK_API_OU => 1;
+
+# Title IDs:
+# 001 - checked out to test user
+# 002 - not available (checked out to another user)
+# 003 - available
+# 004 - not found (invalid/does not exist in external system)
+
+# Patrons.
+use constant EBOOK_API_PATRON_USERNAME  => '99999359616';
+use constant EBOOK_API_PATRON_PASSWORD  => 'andreac1234';
+use constant EBOOK_API_PATRON_NOT_FOUND => 'patron-not-found';
+
+my $script = OpenILS::Utils::TestUtils->new();
+$script->bootstrap;
+
+my $ebook_api = $script->session('open-ils.ebook_api');
+
+# ------------------------------------------------------------ 
+# 2. Sessions.
+# ------------------------------------------------------------ 
+
+# Initiate a new EbookAPI session and get a session ID.
+# Returns undef unless a new session was created.
+my $session_id_req = $ebook_api->request(
+    'open-ils.ebook_api.start_session', EBOOK_API_VENDOR, EBOOK_API_OU);
+my $session_id = $session_id_req->recv->content;
+ok($session_id, 'Initiated an EbookAPI session');
+
+# Check that an EbookAPI session exists matching our session ID.
+my $ck_session_id_req = $ebook_api->request(
+       'open-ils.ebook_api.check_session', $session_id, EBOOK_API_VENDOR, EBOOK_API_OU);
+my $ck_session_id = $ck_session_id_req->recv->content;
+ok($ck_session_id eq $session_id, 'Validated existing EbookAPI session');
+
+# Given an invalid or expired session ID, fallback to initiating 
+# a new EbookAPI session, which gives us a new session ID.
+# Returns undef unless a new session was created.
+my $new_session_id_req = $ebook_api->request(
+    'open-ils.ebook_api.check_session', '', EBOOK_API_VENDOR, EBOOK_API_OU);
+my $new_session_id = $new_session_id_req->recv->content;
+ok($new_session_id, 'Initiated new EbookAPI session when valid session ID not provided');
+
+# ------------------------------------------------------------ 
+# 3. Title availability and holdings.
+# ------------------------------------------------------------ 
+
+# Title is not available.
+my $title_001_avail_req = $ebook_api->request(
+    'open-ils.ebook_api.title.availability', $session_id, '001');
+my $title_001_avail = $title_001_avail_req->recv->content;
+is($title_001_avail, 0, 'Availability check 1/3 (not available)');
+
+# Title is available.
+my $title_003_avail_req = $ebook_api->request(
+    'open-ils.ebook_api.title.availability', $session_id, '003');
+my $title_003_avail = $title_003_avail_req->recv->content;
+is($title_003_avail, 1, 'Availability check 2/3 (available)');
+
+# Title is not found (availability lookup returns undef).
+my $title_004_avail_req = $ebook_api->request(
+    'open-ils.ebook_api.title.availability', $session_id, '004');
+my $title_004_avail = (defined $title_004_avail_req && defined $title_004_avail_req->recv) ? $title_004_avail_req->recv->content : undef;
+is($title_004_avail, undef, 'Availability check 3/3 (not found)');
+
+# Title has holdings, none available.
+my $title_001_holdings_req = $ebook_api->request(
+    'open-ils.ebook_api.title.holdings', $session_id, '001');
+my $title_001_holdings = $title_001_holdings_req->recv->content;
+ok(ref($title_001_holdings) && $title_001_holdings->{copies_owned} == 1 && $title_001_holdings->{copies_available} == 0 && $title_001_holdings->{formats}->[0] eq 'ebook', 'Holdings check 1/3 (1 owned, 0 available)');
+
+# Title has holdings, one copy available.
+my $title_003_holdings_req = $ebook_api->request(
+    'open-ils.ebook_api.title.holdings', $session_id, '003');
+my $title_003_holdings = $title_003_holdings_req->recv->content;
+ok(ref($title_003_holdings) && $title_003_holdings->{copies_owned} == 1 && $title_003_holdings->{copies_available} == 1 && $title_003_holdings->{formats}->[0] eq 'ebook', 'Holdings check 2/3 (1 owned, 1 available)');
+
+# Title not found, no holdings.
+my $title_004_holdings_req = $ebook_api->request(
+    'open-ils.ebook_api.title.holdings', $session_id, '004');
+my $title_004_holdings = $title_004_holdings_req->recv->content;
+ok(ref($title_004_holdings) && $title_004_holdings->{copies_owned} == 0 && $title_004_holdings->{copies_available} == 0 && scalar(@{$title_004_holdings->{formats}}) == 0, 'Holdings check 3/3 (0 owned, 0 available)');
+
+# ------------------------------------------------------------ 
+# 4. Patron authentication and caching.
+# ------------------------------------------------------------ 
+
+# Authenticate our test patron.
+$script->authenticate({
+        username => EBOOK_API_PATRON_USERNAME,
+        password => EBOOK_API_PATRON_PASSWORD,
+        type => 'opac'
+    });
+ok($script->authtoken, 'Have an authtoken');
+my $authtoken = $script->authtoken;
+
+# open-ils.ebook_api.patron.cache_password
+my $updated_cache_id_req = $ebook_api->request(
+    'open-ils.ebook_api.patron.cache_password', $session_id, EBOOK_API_PATRON_PASSWORD);
+my $updated_cache_id = $updated_cache_id_req->recv->content;
+ok($updated_cache_id eq $session_id, 'Session cache was updated with patron password');
+
+# ------------------------------------------------------------ 
+# 5. Patron transactions.
+# ------------------------------------------------------------ 
+
+# open-ils.ebook_api.patron.get_checkouts
+my $checkouts_req = $ebook_api->request(
+    'open-ils.ebook_api.patron.get_checkouts', $authtoken, $session_id, EBOOK_API_PATRON_USERNAME);
+my $checkouts = $checkouts_req->recv->content;
+ok(ref($checkouts) && defined $checkouts->[0]->{title_id}, 'Retrieved ebook checkouts for patron');
+
+# open-ils.ebook_api.patron.get_holds
+my $holds_req = $ebook_api->request(
+    'open-ils.ebook_api.patron.get_holds', $authtoken, $session_id, EBOOK_API_PATRON_USERNAME);
+my $holds = $holds_req->recv->content;
+ok(ref($holds) && defined $holds->[0]->{title_id}, 'Retrieved ebook holds for patron');
+
+# open-ils.ebook_api.patron.get_transactions
+my $xacts_req = $ebook_api->request(
+    'open-ils.ebook_api.patron.get_transactions', $authtoken, $session_id, EBOOK_API_PATRON_USERNAME);
+my $xacts = $xacts_req->recv->content;
+ok(ref($xacts) && exists $xacts->{checkouts} && exists $xacts->{holds}, 'Retrieved transactions for patron');
+ok(defined $xacts->{checkouts}->[0]->{title_id}, 'Retrieved transactions include checkouts');
+ok(defined $xacts->{holds}->[0]->{title_id}, 'Retrieved transactions include holds');
+
+# open-ils.ebook_api.checkout
+my $checkout_req = $ebook_api->request(
+    'open-ils.ebook_api.checkout', $authtoken, $session_id, '003', EBOOK_API_PATRON_USERNAME);
+my $checkout = $checkout_req->recv->content;
+ok(exists $checkout->{due_date}, 'Ebook checked out');
+
+# open-ils.ebook_api.renew
+my $renew_req = $ebook_api->request(
+    'open-ils.ebook_api.renew', $authtoken, $session_id, '001', EBOOK_API_PATRON_USERNAME);
+my $renew = $renew_req->recv->content;
+ok(exists $renew->{due_date}, 'Ebook renewed');
+
+# open-ils.ebook_api.checkin
+my $checkin_req = $ebook_api->request(
+    'open-ils.ebook_api.checkin', $authtoken, $session_id, '003', EBOOK_API_PATRON_USERNAME);
+my $checkin = $checkin_req->recv->content;
+ok(ref($checkin) && !exists $checkin->{error_msg}, 'Ebook checked in');
+
+# open-ils.ebook_api.cancel_hold
+my $cancel_hold_req = $ebook_api->request(
+    'open-ils.ebook_api.cancel_hold', $authtoken, $session_id, '002', EBOOK_API_PATRON_USERNAME);
+my $cancel_hold = $cancel_hold_req->recv->content;
+ok(ref($cancel_hold) && !exists $checkin->{error_msg}, 'Ebook hold canceled');
+
+# open-ils.ebook_api.place_hold
+my $place_hold_req = $ebook_api->request(
+    'open-ils.ebook_api.place_hold', $authtoken, $session_id, '002', EBOOK_API_PATRON_USERNAME);
+my $place_hold = $place_hold_req->recv->content;
+ok(exists $place_hold->{expire_date}, 'Ebook hold placed');
+
+# TODO: suspend hold
+
index 180686f..548bea3 100644 (file)
@@ -1,6 +1,6 @@
 #!perl -T
 
-use Test::More tests => 29;
+use Test::More tests => 30;
 use Test::Warn;
 use utf8;
 
@@ -20,6 +20,7 @@ use_ok( 'OpenILS::Utils::PermitHold' );
 use_ok( 'OpenILS::Utils::RemoteAccount' );
 use_ok( 'OpenILS::Utils::ZClient' );
 use_ok( 'OpenILS::Utils::EDIReader' );
+use_ok( 'OpenILS::Utils::HTTPClient' );
 
 # LP 800269 - Test MFHD holdings for records that only contain a caption field
 my $co_marc = MARC::Record->new();
diff --git a/Open-ILS/src/perlmods/t/23-OpenILS-Application-EbookAPI.t b/Open-ILS/src/perlmods/t/23-OpenILS-Application-EbookAPI.t
new file mode 100644 (file)
index 0000000..d5f5121
--- /dev/null
@@ -0,0 +1,11 @@
+#!perl -T
+
+use Test::More tests => 4;
+
+BEGIN {
+    use_ok( 'OpenILS::Application::EbookAPI' );
+    use_ok( 'OpenILS::Application::EbookAPI::Test' );
+    use_ok( 'OpenILS::Application::EbookAPI::OverDrive' );
+    use_ok( 'OpenILS::Application::EbookAPI::OneClickdigital' );
+}
+
index 4281c59..06928cd 100644 (file)
@@ -91,7 +91,7 @@ CREATE TRIGGER no_overlapping_deps
     BEFORE INSERT OR UPDATE ON config.db_patch_dependencies
     FOR EACH ROW EXECUTE PROCEDURE evergreen.array_overlap_check ('deprecates');
 
-INSERT INTO config.upgrade_log (version, applied_to) VALUES ('1025', :eg_version); -- bshum/kmlussier
+INSERT INTO config.upgrade_log (version, applied_to) VALUES ('1028', :eg_version); -- jeffdavis/kmlussier
 
 CREATE TABLE config.bib_source (
        id              SERIAL  PRIMARY KEY,
index f831fc9..a1f036d 100644 (file)
@@ -136,14 +136,14 @@ INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath,
 INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, facet_xpath, facet_field, authority_xpath, browse_xpath ) VALUES 
     (10, 'author', 'other', oils_i18n_gettext(10, 'Other Author', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:name[@type='personal' and not(mods32:role/mods32:roleTerm[text()='creator'])]$$, $$//*[local-name()='namePart']$$, TRUE, '//@xlink:href',$$//*[local-name()='namePart']$$ ); -- /* to fool vim */;
 
-INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, facet_field, authority_xpath ) VALUES 
-    (11, 'subject', 'geographic', oils_i18n_gettext(11, 'Geographic Subject', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:subject/mods32:geographic$$, TRUE, '//@xlink:href' );
+INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, facet_field, authority_xpath, browse_field ) VALUES 
+    (11, 'subject', 'geographic', oils_i18n_gettext(11, 'Geographic Subject', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:subject/mods32:geographic$$, TRUE, '//@xlink:href', FALSE );
 INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, facet_xpath, facet_field, authority_xpath ) VALUES 
     (12, 'subject', 'name', oils_i18n_gettext(12, 'Name Subject', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:subject/mods32:name$$, $$//*[local-name()='namePart']$$, TRUE, '//@xlink:href' ); -- /* to fool vim */;
-INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, facet_field, authority_xpath ) VALUES 
-    (13, 'subject', 'temporal', oils_i18n_gettext(13, 'Temporal Subject', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:subject/mods32:temporal$$, TRUE, '//@xlink:href' );
-INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, facet_field, authority_xpath ) VALUES 
-    (14, 'subject', 'topic', oils_i18n_gettext(14, 'Topic Subject', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:subject/mods32:topic$$, TRUE, '//@xlink:href' );
+INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, facet_field, authority_xpath, browse_field ) VALUES 
+    (13, 'subject', 'temporal', oils_i18n_gettext(13, 'Temporal Subject', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:subject/mods32:temporal$$, TRUE, '//@xlink:href', FALSE );
+INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, facet_field, authority_xpath, browse_field ) VALUES 
+    (14, 'subject', 'topic', oils_i18n_gettext(14, 'Topic Subject', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:subject/mods32:topic$$, TRUE, '//@xlink:href', FALSE );
 --INSERT INTO config.metabib_field ( id, field_class, name, format, xpath ) VALUES 
 --  ( id, field_class, name, xpath ) VALUES ( 'subject', 'genre', 'mods32', $$//mods32:mods/mods32:genre$$ );
 INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, browse_field ) VALUES 
@@ -189,6 +189,29 @@ INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath,
 
 UPDATE config.metabib_field SET joiner = ' -- ' WHERE field_class = 'subject' AND name NOT IN ('name', 'complete');
 
+INSERT INTO config.metabib_field ( id, field_class, name, label, 
+     format, xpath, search_field, browse_field, authority_xpath, joiner ) VALUES
+    (34, 'subject', 'topic_browse', oils_i18n_gettext(34, 'Topic Browse', 'cmf', 'label'), 
+     'mods32', $$//mods32:mods/mods32:subject[local-name(./*[1]) = "topic"]$$, FALSE, TRUE, '//@xlink:href', ' -- ' ); -- /* to fool vim */;
+
+INSERT INTO config.metabib_field ( id, field_class, name, label, 
+     format, xpath, search_field, browse_field, authority_xpath, joiner ) VALUES
+    (35, 'subject', 'geographic_browse', oils_i18n_gettext(35, 'Geographic Name Browse', 'cmf', 'label'), 
+     'mods32', $$//mods32:mods/mods32:subject[local-name(./*[1]) = "geographic"]$$, FALSE, TRUE, '//@xlink:href', ' -- ' ); -- /* to fool vim */;
+
+INSERT INTO config.metabib_field ( id, field_class, name, label, 
+     format, xpath, search_field, browse_field, authority_xpath, joiner ) VALUES
+    (36, 'subject', 'temporal_browse', oils_i18n_gettext(36, 'Temporal Term Browse', 'cmf', 'label'), 
+     'mods32', $$//mods32:mods/mods32:subject[local-name(./*[1]) = "temporal"]$$, FALSE, TRUE, '//@xlink:href', ' -- ' ); -- /* to fool vim */;
+
+INSERT INTO config.metabib_field_index_norm_map (field,norm)
+    SELECT  m.id,
+            i.id
+      FROM  config.metabib_field m,
+        config.index_normalizer i
+      WHERE i.func IN ('naco_normalize')
+            AND m.id IN (34, 35, 36);
+
 SELECT SETVAL('config.metabib_field_id_seq', GREATEST(1000, (SELECT MAX(id) FROM config.metabib_field)));
 
 INSERT INTO config.metabib_search_alias (alias,field_class) VALUES ('kw','keyword');
@@ -13230,19 +13253,19 @@ INSERT INTO authority.control_set_bib_field_metabib_field_map (bib_field, metabi
 
     SELECT  DISTINCT b.id AS bib_field, m.id AS metabib_field
       FROM  authority.control_set_bib_field b JOIN authority.control_set_authority_field a ON (b.authority_field = a.id), config.metabib_field m
-      WHERE a.tag = '148' AND m.name = 'temporal'
+      WHERE a.tag = '148' AND m.name = 'temporal_browse'
 
         UNION
 
     SELECT  DISTINCT b.id AS bib_field, m.id AS metabib_field
       FROM  authority.control_set_bib_field b JOIN authority.control_set_authority_field a ON (b.authority_field = a.id), config.metabib_field m
-      WHERE a.tag = '150' AND m.name = 'topic'
+      WHERE a.tag = '150' AND m.name = 'topic_browse'
 
         UNION
 
     SELECT  DISTINCT b.id AS bib_field, m.id AS metabib_field
       FROM  authority.control_set_bib_field b JOIN authority.control_set_authority_field a ON (b.authority_field = a.id), config.metabib_field m
-      WHERE a.tag = '151' AND m.name = 'geographic'
+      WHERE a.tag = '151' AND m.name = 'geographic_browse'
 
         UNION
 
@@ -16643,3 +16666,173 @@ INSERT INTO config.global_flag (name, label, value, enabled) VALUES (
     TRUE
 );
 
+INSERT INTO config.settings_group (name, label)
+    VALUES ('ebook_api', 'Ebook API Integration');
+
+INSERT INTO config.org_unit_setting_type
+    (name, label, description, grp, datatype) 
+VALUES (
+    'ebook_api.overdrive.discovery_base_uri',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.discovery_base_uri',
+        'OverDrive Discovery API Base URI',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.discovery_base_uri',
+        'Base URI for OverDrive Discovery API (defaults to https://api.overdrive.com/v1). Using HTTPS here is strongly encouraged.',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.circulation_base_uri',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.circulation_base_uri',
+        'OverDrive Circulation API Base URI',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.circulation_base_uri',
+        'Base URI for OverDrive Circulation API (defaults to https://patron.api.overdrive.com/v1). Using HTTPS here is strongly encouraged.',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.account_id',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.account_id',
+        'OverDrive Account ID',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.account_id',
+        'Account ID (a.k.a. Library ID) for this library, as assigned by OverDrive',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.websiteid',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.websiteid',
+        'OverDrive Website ID',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.websiteid',
+        'Website ID for this library, as assigned by OverDrive',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.authorizationname',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.authorizationname',
+        'OverDrive Authorization Name',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.authorizationname',
+        'Authorization name for this library, as assigned by OverDrive',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.basic_token',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.basic_token',
+        'OverDrive Basic Token',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.basic_token',
+        'Basic token for client authentication with OverDrive API (supplied by OverDrive)',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.granted_auth_redirect_uri',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.granted_auth_redirect_uri',
+        'OverDrive Granted Authorization Redirect URI',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.granted_auth_redirect_uri',
+        'URI provided to OverDrive for use with granted authorization',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.password_required',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.password_required',
+        'OverDrive Password Required',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.password_required',
+        'Does this library require a password when authenticating patrons with the OverDrive API?',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'bool'
+);
+
+INSERT INTO config.org_unit_setting_type
+    (name, label, description, grp, datatype) 
+VALUES (
+    'ebook_api.oneclickdigital.library_id',
+    oils_i18n_gettext(
+        'ebook_api.oneclickdigital.library_id',
+        'OneClickdigital Library ID',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.oneclickdigital.library_id',
+        'Identifier assigned to this library by OneClickdigital',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.oneclickdigital.basic_token',
+    oils_i18n_gettext(
+        'ebook_api.oneclickdigital.basic_token',
+        'OneClickdigital Basic Token',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.oneclickdigital.basic_token',
+        'Basic token for client authentication with OneClickdigital API (supplied by OneClickdigital)',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+);
index 2a86875..6e2fa3d 100755 (executable)
@@ -114,18 +114,18 @@ $F$ LANGUAGE SQL STABLE;
 
 -- Now make sure that the a query against it doesn't break
 PREPARE thrower AS select mmr_mra::varchar from unapi.mmr_mra
-(15,'','',null::text[],'CONS',0,null::HSTORE,null::HSTORE,true,1);
+(240,'','',null::text[],'CONS',0,null::HSTORE,null::HSTORE,true,1);
 
 SELECT performs_ok( 'thrower',250,'Generic check for unapi.mmr_mra breakage' ); 
 
 -- Make sure that the function returns the new XML property source_list
 SELECT is(  
 (
-select mmr_mra::varchar ~ 'source_list="15,16,17"' from unapi.mmr_mra
-(15,'','',null::text[],'CONS',0,null::HSTORE,null::HSTORE,true,1)
-), true, 'unapi.mmr_mra results have source_list="15,16,17sfaf"' );
+select mmr_mra::varchar ~ 'source_list="242,243,244"' from unapi.mmr_mra
+(240,'','',null::text[],'CONS',0,null::HSTORE,null::HSTORE,true,1)
+), true, 'unapi.mmr_mra results have source_list="242,243,244sfaf"' );
 
 
 -- Finish the tests and clean up.
 SELECT * FROM finish();
-ROLLBACK;
\ No newline at end of file
+ROLLBACK;
index ad4fc47..aa36a3a 100644 (file)
@@ -76,10 +76,6 @@ ALTER TABLE biblio.record_entry DISABLE TRIGGER USER;
 UPDATE biblio.record_entry SET fingerprint = biblio.extract_fingerprint(marc) WHERE NOT deleted;
 ALTER TABLE biblio.record_entry ENABLE TRIGGER USER;
 
-SELECT metabib.remap_metarecord_for_bib(id, fingerprint)
-FROM biblio.record_entry
-WHERE NOT deleted;
-
 \qecho Remapping metarecords
 SELECT metabib.remap_metarecord_for_bib(id, fingerprint)
 FROM biblio.record_entry
diff --git a/Open-ILS/src/sql/Pg/upgrade/1026.data.subject_browse.sql b/Open-ILS/src/sql/Pg/upgrade/1026.data.subject_browse.sql
new file mode 100644 (file)
index 0000000..4a26a5a
--- /dev/null
@@ -0,0 +1,58 @@
+BEGIN;
+
+SELECT evergreen.upgrade_deps_block_check('1026', :eg_version);
+
+INSERT INTO config.metabib_field ( id, field_class, name, label, 
+     format, xpath, search_field, browse_field, authority_xpath, joiner ) VALUES
+    (34, 'subject', 'topic_browse', oils_i18n_gettext(34, 'Topic Browse', 'cmf', 'label'), 
+     'mods32', $$//mods32:mods/mods32:subject[local-name(./*[1]) = "topic"]$$, FALSE, TRUE, '//@xlink:href', ' -- ' ); -- /* to fool vim */;
+
+INSERT INTO config.metabib_field ( id, field_class, name, label, 
+     format, xpath, search_field, browse_field, authority_xpath, joiner ) VALUES
+    (35, 'subject', 'geographic_browse', oils_i18n_gettext(35, 'Geographic Name Browse', 'cmf', 'label'), 
+     'mods32', $$//mods32:mods/mods32:subject[local-name(./*[1]) = "geographic"]$$, FALSE, TRUE, '//@xlink:href', ' -- ' ); -- /* to fool vim */;
+
+INSERT INTO config.metabib_field ( id, field_class, name, label, 
+     format, xpath, search_field, browse_field, authority_xpath, joiner ) VALUES
+    (36, 'subject', 'temporal_browse', oils_i18n_gettext(36, 'Temporal Term Browse', 'cmf', 'label'), 
+     'mods32', $$//mods32:mods/mods32:subject[local-name(./*[1]) = "temporal"]$$, FALSE, TRUE, '//@xlink:href', ' -- ' ); -- /* to fool vim */;
+
+INSERT INTO config.metabib_field_index_norm_map (field,norm)
+    SELECT  m.id,
+            i.id
+      FROM  config.metabib_field m,
+        config.index_normalizer i
+      WHERE i.func IN ('naco_normalize')
+            AND m.id IN (34, 35, 36);
+
+UPDATE config.metabib_field
+SET browse_field = FALSE
+WHERE field_class = 'subject' AND name = 'topic'
+AND id = 14;
+UPDATE config.metabib_field
+SET browse_field = FALSE
+WHERE field_class = 'subject' AND name = 'geographic'
+AND id = 13;
+UPDATE config.metabib_field
+SET browse_field = FALSE
+WHERE field_class = 'subject' AND name = 'temporal'
+AND id = 11;
+
+UPDATE authority.control_set_bib_field_metabib_field_map
+SET metabib_field = 34
+WHERE metabib_field = 14;
+UPDATE authority.control_set_bib_field_metabib_field_map
+SET metabib_field = 35
+WHERE metabib_field = 13;
+UPDATE authority.control_set_bib_field_metabib_field_map
+SET metabib_field = 36
+WHERE metabib_field = 11;
+
+COMMIT;
+
+\qecho This is a browse-only reingest of your bib records. It may take a while.
+\qecho You may cancel now without losing the effect of the rest of the
+\qecho upgrade script, and arrange the reingest later.
+\qecho .
+SELECT metabib.reingest_metabib_field_entries(id, TRUE, FALSE, TRUE)
+    FROM biblio.record_entry;
diff --git a/Open-ILS/src/sql/Pg/upgrade/1027.data.org-setting.ebook-api-oneclickdigital.sql b/Open-ILS/src/sql/Pg/upgrade/1027.data.org-setting.ebook-api-oneclickdigital.sql
new file mode 100644 (file)
index 0000000..92a1df8
--- /dev/null
@@ -0,0 +1,45 @@
+BEGIN;
+
+SELECT evergreen.upgrade_deps_block_check('1027', :eg_version);
+
+INSERT INTO config.settings_group (name, label)
+    VALUES ('ebook_api', 'Ebook API Integration');
+
+INSERT INTO config.org_unit_setting_type
+    (name, label, description, grp, datatype) 
+VALUES (
+    'ebook_api.oneclickdigital.library_id',
+    oils_i18n_gettext(
+        'ebook_api.oneclickdigital.library_id',
+        'OneClickdigital Library ID',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.oneclickdigital.library_id',
+        'Identifier assigned to this library by OneClickdigital',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.oneclickdigital.basic_token',
+    oils_i18n_gettext(
+        'ebook_api.oneclickdigital.basic_token',
+        'OneClickdigital Basic Token',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.oneclickdigital.basic_token',
+        'Basic token for client authentication with OneClickdigital API (supplied by OneClickdigital)',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+);
+
+COMMIT;
+
diff --git a/Open-ILS/src/sql/Pg/upgrade/1028.data.org-setting.ebook-api-overdrive.sql b/Open-ILS/src/sql/Pg/upgrade/1028.data.org-setting.ebook-api-overdrive.sql
new file mode 100644 (file)
index 0000000..2878bc2
--- /dev/null
@@ -0,0 +1,138 @@
+BEGIN;
+
+SELECT evergreen.upgrade_deps_block_check('1028', :eg_version);
+
+INSERT INTO config.org_unit_setting_type
+    (name, label, description, grp, datatype) 
+VALUES (
+    'ebook_api.overdrive.discovery_base_uri',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.discovery_base_uri',
+        'OverDrive Discovery API Base URI',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.discovery_base_uri',
+        'Base URI for OverDrive Discovery API (defaults to https://api.overdrive.com/v1). Using HTTPS here is strongly encouraged.',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.circulation_base_uri',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.circulation_base_uri',
+        'OverDrive Circulation API Base URI',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.circulation_base_uri',
+        'Base URI for OverDrive Circulation API (defaults to https://patron.api.overdrive.com/v1). Using HTTPS here is strongly encouraged.',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.account_id',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.account_id',
+        'OverDrive Account ID',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.account_id',
+        'Account ID (a.k.a. Library ID) for this library, as assigned by OverDrive',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.websiteid',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.websiteid',
+        'OverDrive Website ID',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.websiteid',
+        'Website ID for this library, as assigned by OverDrive',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.authorizationname',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.authorizationname',
+        'OverDrive Authorization Name',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.authorizationname',
+        'Authorization name for this library, as assigned by OverDrive',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.basic_token',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.basic_token',
+        'OverDrive Basic Token',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.basic_token',
+        'Basic token for client authentication with OverDrive API (supplied by OverDrive)',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.granted_auth_redirect_uri',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.granted_auth_redirect_uri',
+        'OverDrive Granted Authorization Redirect URI',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.granted_auth_redirect_uri',
+        'URI provided to OverDrive for use with granted authorization',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.password_required',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.password_required',
+        'OverDrive Password Required',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.password_required',
+        'Does this library require a password when authenticating patrons with the OverDrive API?',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'bool'
+);
+
+COMMIT;
+
diff --git a/Open-ILS/src/sql/Pg/version-upgrade/2.11.3-2.12-beta-upgrade-db.sql b/Open-ILS/src/sql/Pg/version-upgrade/2.11.3-2.12-beta-upgrade-db.sql
new file mode 100644 (file)
index 0000000..b7140e2
--- /dev/null
@@ -0,0 +1,777 @@
+--Upgrade Script for 2.11.3 to 2.12-beta
+\set eg_version '''2.12-beta'''
+BEGIN;
+
+SELECT evergreen.upgrade_deps_block_check('1006', :eg_version);
+
+-- This function is used to help clean up facet labels. Due to quirks in
+-- MARC parsing, some facet labels may be generated with periods or commas
+-- at the end.  This will strip a trailing commas off all the time, and
+-- periods when they don't look like they are part of initials.
+--      Smith, John    =>  no change
+--      Smith, John,   =>  Smith, John
+--      Smith, John.   =>  Smith, John
+--      Public, John Q. => no change
+CREATE OR REPLACE FUNCTION metabib.trim_trailing_punctuation ( TEXT ) RETURNS TEXT AS $$
+DECLARE
+    result    TEXT;
+    last_char TEXT;
+BEGIN
+    result := $1;
+    last_char = substring(result from '.$');
+
+    IF last_char = ',' THEN
+        result := substring(result from '^(.*),$');
+
+    ELSIF last_char = '.' THEN
+        IF substring(result from ' \w\.$') IS NULL THEN
+            result := substring(result from '^(.*)\.$');
+        END IF;
+    END IF;
+
+    RETURN result;
+
+END;
+$$ language 'plpgsql';
+
+INSERT INTO config.index_normalizer (name, description, func, param_count) VALUES (
+       'Trim Trailing Punctuation',
+       'Eliminate extraneous trailing commas and periods in text',
+       'metabib.trim_trailing_punctuation',
+       0
+);
+
+INSERT INTO config.metabib_field_index_norm_map (field,norm,pos)
+    SELECT  m.id,
+            i.id,
+            -1
+      FROM  config.metabib_field m,
+            config.index_normalizer i
+      WHERE i.func = 'metabib.trim_trailing_punctuation'
+            AND m.id IN (7,8,9,10);
+
+SELECT evergreen.upgrade_deps_block_check('1007', :eg_version);
+
+UPDATE config.record_attr_definition
+SET description = oils_i18n_gettext('audience', 'Audience', 'crad', 'label')
+WHERE description IS NULL
+AND name = 'audience';
+UPDATE config.record_attr_definition
+SET description = oils_i18n_gettext('bib_level', 'Bib Level', 'crad', 'label')
+WHERE description IS NULL
+AND name = 'bib_level';
+UPDATE config.record_attr_definition
+SET description = oils_i18n_gettext('item_form', 'Item Form', 'crad', 'label')
+WHERE description IS NULL
+AND name = 'item_form';
+UPDATE config.record_attr_definition
+SET description = oils_i18n_gettext('item_lang', 'Language', 'crad', 'label')
+WHERE description IS NULL
+AND name = 'item_lang';
+UPDATE config.record_attr_definition
+SET description = oils_i18n_gettext('lit_form', 'Literary Form', 'crad', 'label')
+WHERE description IS NULL
+AND name = 'lit_form';
+UPDATE config.record_attr_definition
+SET description = oils_i18n_gettext('item_type', 'Item Type', 'crad', 'label')
+WHERE description IS NULL
+AND name = 'item_type';
+UPDATE config.record_attr_definition
+SET description = oils_i18n_gettext('vr_format', 'Video Format', 'crad', 'label')
+WHERE description IS NULL
+AND name = 'vr_format';
+
+SELECT evergreen.upgrade_deps_block_check('1008', :eg_version);
+
+CREATE OR REPLACE FUNCTION evergreen.unaccent_and_squash ( IN arg text) RETURNS text
+    IMMUTABLE STRICT AS $$
+       BEGIN
+       RETURN evergreen.lowercase(unaccent(regexp_replace(arg, '[\s[:punct:]]','','g')));
+       END;
+$$ LANGUAGE PLPGSQL;
+
+SELECT evergreen.upgrade_deps_block_check('1009', :eg_version);
+
+INSERT into config.org_unit_setting_type
+( name, grp, label, description, datatype, fm_class ) VALUES
+( 'acq.copy_status_on_receiving', 'acq',
+    oils_i18n_gettext('acq.copy_status_on_receiving',
+        'Initial status for received items',
+        'coust', 'label'),
+    oils_i18n_gettext('acq.copy_status_on_receiving',
+        'Allows staff to designate a custom copy status on received lineitems.  Default status is "In Process".',
+        'coust', 'description'),
+    'link', 'ccs');
+
+-- remove unused org unit setting for self checkout interface
+SELECT evergreen.upgrade_deps_block_check('1010', :eg_version);
+
+DELETE FROM actor.org_unit_setting WHERE name = 'circ.selfcheck.require_patron_password';
+
+DELETE FROM config.org_unit_setting_type WHERE name = 'circ.selfcheck.require_patron_password';
+
+DELETE FROM config.org_unit_setting_type_log WHERE field_name = 'circ.selfcheck.require_patron_password';
+
+DELETE FROM permission.usr_perm_map WHERE perm IN (SELECT id FROM permission.perm_list WHERE code = 'UPDATE_ORG_UNIT_SETTING.circ.selfcheck.require_patron_password');
+
+DELETE FROM permission.grp_perm_map WHERE perm IN (SELECT id FROM permission.perm_list WHERE code = 'UPDATE_ORG_UNIT_SETTING.circ.selfcheck.require_patron_password');
+
+DELETE FROM permission.perm_list WHERE code = 'UPDATE_ORG_UNIT_SETTING.circ.selfcheck.require_patron_password';
+
+SELECT evergreen.upgrade_deps_block_check('1011', :eg_version);
+
+INSERT INTO config.org_unit_setting_type
+    (name, grp, label, description, datatype)
+    VALUES
+        ('circ.in_house_use.copy_alert',
+         'circ',
+         oils_i18n_gettext('circ.in_house_use.copy_alert',
+             'Display copy alert for in-house-use',
+             'coust', 'label'),
+         oils_i18n_gettext('circ.in_house_use.copy_alert',
+             'Display copy alert for in-house-use',
+             'coust', 'description'),
+         'bool'),
+        ('circ.in_house_use.checkin_alert',
+         'circ',
+         oils_i18n_gettext('circ.in_house_use.checkin_alert',
+             'Display copy location checkin alert for in-house-use',
+             'coust', 'label'),
+         oils_i18n_gettext('circ.in_house_use.checkin_alert',
+             'Display copy location checkin alert for in-house-use',
+             'coust', 'description'),
+         'bool');
+
+SELECT evergreen.upgrade_deps_block_check('1014', :eg_version);
+-- this update of unapi.mmr_mra() removed since 1015 has a newer version
+  
+SELECT evergreen.upgrade_deps_block_check('1015', :eg_version);
+
+CREATE OR REPLACE FUNCTION unapi.mmr_mra (
+    obj_id BIGINT,
+    format TEXT,
+    ename TEXT,
+    includes TEXT[],
+    org TEXT,
+    depth INT DEFAULT NULL,
+    slimit HSTORE DEFAULT NULL,
+    soffset HSTORE DEFAULT NULL,
+    include_xmlns BOOL DEFAULT TRUE,
+    pref_lib INT DEFAULT NULL
+) RETURNS XML AS $F$
+    SELECT  XMLELEMENT(
+        name attributes,
+        XMLATTRIBUTES(
+            CASE WHEN $9 THEN 'http://open-ils.org/spec/indexing/v1' ELSE NULL END AS xmlns,
+            'tag:open-ils.org:U2@mmr/' || $1 AS metarecord
+        ),
+        (SELECT XMLAGG(foo.y)
+          FROM (
+            WITH sourcelist AS (
+                WITH aou AS (SELECT COALESCE(id, (evergreen.org_top()).id) AS id
+                    FROM actor.org_unit WHERE shortname = $5 LIMIT 1)
+                SELECT source
+                FROM metabib.metarecord_source_map mmsm, aou
+                WHERE metarecord = $1 AND (
+                    EXISTS (
+                        SELECT 1 FROM asset.opac_visible_copies
+                        WHERE record = source AND circ_lib IN (
+                            SELECT id FROM actor.org_unit_descendants(aou.id, $6))
+                        LIMIT 1
+                    )
+                    OR EXISTS (SELECT 1 FROM located_uris(source, aou.id, $10) LIMIT 1)
+                    OR EXISTS (SELECT 1 FROM biblio.record_entry b JOIN config.bib_source src ON (b.source = src.id) WHERE src.transcendant AND b.id = mmsm.source LIMIT 1)
+                )
+            )
+            SELECT  cmra.aid,
+                    XMLELEMENT(
+                        name field,
+                        XMLATTRIBUTES(
+                            cmra.attr AS name,
+                            cmra.value AS "coded-value",
+                            cmra.aid AS "cvmid",
+                            rad.composite,
+                            rad.multi,
+                            rad.filter,
+                            rad.sorter,
+                            cmra.source_list
+                        ),
+                        cmra.value
+                    )
+              FROM  (
+                SELECT DISTINCT aid, attr, value, STRING_AGG(x.id::TEXT, ',') AS source_list
+                  FROM (
+                    SELECT  v.source AS id,
+                            c.id AS aid,
+                            c.ctype AS attr,
+                            c.code AS value
+                      FROM  metabib.record_attr_vector_list v
+                            JOIN config.coded_value_map c ON ( c.id = ANY( v.vlist ) )
+                    ) AS x
+                    JOIN sourcelist ON (x.id = sourcelist.source)
+                    GROUP BY 1, 2, 3
+                ) AS cmra
+                JOIN config.record_attr_definition rad ON (cmra.attr = rad.name)
+                UNION ALL
+            SELECT  umra.aid,
+                    XMLELEMENT(
+                        name field,
+                        XMLATTRIBUTES(
+                            umra.attr AS name,
+                            rad.composite,
+                            rad.multi,
+                            rad.filter,
+                            rad.sorter
+                        ),
+                        umra.value
+                    )
+              FROM  (
+                SELECT DISTINCT aid, attr, value
+                  FROM (
+                    SELECT  v.source AS id,
+                            m.id AS aid,
+                            m.attr AS attr,
+                            m.value AS value
+                      FROM  metabib.record_attr_vector_list v
+                            JOIN metabib.uncontrolled_record_attr_value m ON ( m.id = ANY( v.vlist ) )
+                    ) AS x
+                    JOIN sourcelist ON (x.id = sourcelist.source)
+                ) AS umra
+                JOIN config.record_attr_definition rad ON (umra.attr = rad.name)
+                ORDER BY 1
+
+            )foo(id,y)
+        )
+    )
+$F$ LANGUAGE SQL STABLE;
+  
+SELECT evergreen.upgrade_deps_block_check('1016', :eg_version);
+
+INSERT INTO config.biblio_fingerprint (name, xpath, format)
+    VALUES (
+        'PartName',
+        '//mods32:mods/mods32:titleInfo/mods32:partName',
+        'mods32'
+    );
+
+INSERT INTO config.biblio_fingerprint (name, xpath, format)
+    VALUES (
+        'PartNumber',
+        '//mods32:mods/mods32:titleInfo/mods32:partNumber',
+        'mods32'
+    );
+
+SELECT evergreen.upgrade_deps_block_check('1017', :eg_version);
+
+CREATE OR REPLACE FUNCTION biblio.extract_fingerprint ( marc text ) RETURNS TEXT AS $func$
+DECLARE
+       idx             config.biblio_fingerprint%ROWTYPE;
+       xfrm            config.xml_transform%ROWTYPE;
+       prev_xfrm       TEXT;
+       transformed_xml TEXT;
+       xml_node        TEXT;
+       xml_node_list   TEXT[];
+       raw_text        TEXT;
+    output_text TEXT := '';
+BEGIN
+
+    IF marc IS NULL OR marc = '' THEN
+        RETURN NULL;
+    END IF;
+
+       -- Loop over the indexing entries
+       FOR idx IN SELECT * FROM config.biblio_fingerprint ORDER BY format, id LOOP
+
+               SELECT INTO xfrm * from config.xml_transform WHERE name = idx.format;
+
+               -- See if we can skip the XSLT ... it's expensive
+               IF prev_xfrm IS NULL OR prev_xfrm <> xfrm.name THEN
+                       -- Can't skip the transform
+                       IF xfrm.xslt <> '---' THEN
+                               transformed_xml := oils_xslt_process(marc,xfrm.xslt);
+                       ELSE
+                               transformed_xml := marc;
+                       END IF;
+
+                       prev_xfrm := xfrm.name;
+               END IF;
+
+               raw_text := COALESCE(
+            naco_normalize(
+                ARRAY_TO_STRING(
+                    oils_xpath(
+                        '//text()',
+                        (oils_xpath(
+                            idx.xpath,
+                            transformed_xml,
+                            ARRAY[ARRAY[xfrm.prefix, xfrm.namespace_uri]] 
+                        ))[1]
+                    ),
+                    ''
+                )
+            ),
+            ''
+        );
+
+        raw_text := REGEXP_REPLACE(raw_text, E'\\[.+?\\]', E'');
+        raw_text := REGEXP_REPLACE(raw_text, E'\\mthe\\M|\\man?d?d\\M', E'', 'g'); -- arg! the pain!
+
+        IF idx.first_word IS TRUE THEN
+            raw_text := REGEXP_REPLACE(raw_text, E'^(\\w+).*?$', E'\\1');
+        END IF;
+
+               output_text := output_text || idx.name || ':' ||
+                                          REGEXP_REPLACE(raw_text, E'\\s+', '', 'g') || ' ';
+
+       END LOOP;
+
+    RETURN BTRIM(output_text);
+
+END;
+$func$ LANGUAGE PLPGSQL;
+
+SELECT evergreen.upgrade_deps_block_check('1019', :eg_version);
+
+CREATE OR REPLACE FUNCTION
+    action.hold_request_regen_copy_maps(
+        hold_id INTEGER, copy_ids INTEGER[]) RETURNS VOID AS $$
+    DELETE FROM action.hold_copy_map WHERE hold = $1;
+    INSERT INTO action.hold_copy_map (hold, target_copy) SELECT $1, UNNEST($2);
+$$ LANGUAGE SQL;
+
+-- DATA
+
+INSERT INTO config.global_flag (name, label, value, enabled) VALUES (
+    'circ.holds.retarget_interval',
+    oils_i18n_gettext(
+        'circ.holds.retarget_interval',
+        'Holds Retarget Interval', 
+        'cgf',
+        'label'
+    ),
+    '24h',
+    TRUE
+);
+
+SELECT evergreen.upgrade_deps_block_check('1020', :eg_version);
+
+CREATE OR REPLACE FUNCTION actor.org_unit_ancestor_setting_batch_by_org(
+    setting_name TEXT, org_ids INTEGER[]) 
+    RETURNS SETOF actor.org_unit_setting AS 
+$FUNK$
+DECLARE
+    setting RECORD;
+    org_id INTEGER;
+BEGIN
+    /*  Returns one actor.org_unit_setting row per org unit ID provided.
+        When no setting exists for a given org unit, the setting row
+        will contain all empty values. */
+    FOREACH org_id IN ARRAY org_ids LOOP
+        SELECT INTO setting * FROM 
+            actor.org_unit_ancestor_setting(setting_name, org_id);
+        RETURN NEXT setting;
+    END LOOP;
+    RETURN;
+END;
+$FUNK$ LANGUAGE plpgsql STABLE;
+
+SELECT evergreen.upgrade_deps_block_check('1021', :eg_version);
+
+-- Add missing permissions noted in LP 1517137 adjusting those added manually and ignoring those already in place.
+
+DO $$
+DECLARE fixperm TEXT[3];
+DECLARE modify BOOLEAN;
+DECLARE permid BIGINT;
+DECLARE oldid BIGINT;
+BEGIN
+
+FOREACH fixperm SLICE 1 IN ARRAY ARRAY[
+  ['564', 'MARK_ITEM_CATALOGING', 'Allow a user to mark an item status as ''cataloging'''],
+  ['565', 'MARK_ITEM_DAMAGED', 'Allow a user to mark an item status as ''damaged'''],
+  ['566', 'MARK_ITEM_DISCARD', 'Allow a user to mark an item status as ''discard'''],
+  ['567', 'MARK_ITEM_RESERVES', 'Allow a user to mark an item status as ''reserves'''],
+  ['568', 'ADMIN_ORG_UNIT_SETTING_TYPE_LOG', 'Allow a user to modify the org unit settings log'],
+  ['570', 'CREATE_POP_BADGE', 'Allow a user to create a new popularity badge'],
+  ['571', 'DELETE_POP_BADGE', 'Allow a user to delete a popularity badge'],
+  ['572', 'UPDATE_POP_BADGE', 'Allow a user to modify a popularity badge'],
+  ['573', 'CREATE_POP_PARAMETER', 'Allow a user to create a popularity badge parameter'],
+  ['574', 'DELETE_POP_PARAMETER', 'Allow a user to delete a popularity badge parameter'],
+  ['575', 'UPDATE_POP_PARAMETER', 'Allow a user to modify a popularity badge parameter'],
+  ['576', 'CREATE_AUTHORITY_RECORD', 'Allow a user to create an authority record'],
+  ['577', 'DELETE_AUTHORITY_RECORD', 'Allow a user to delete an authority record'],
+  ['578', 'UPDATE_AUTHORITY_RECORD', 'Allow a user to modify an authority record'],
+  ['579', 'CREATE_AUTHORITY_CONTROL_SET', 'Allow a user to create an authority control set'],
+  ['580', 'DELETE_AUTHORITY_CONTROL_SET', 'Allow a user to delete an authority control set'],
+  ['581', 'UPDATE_AUTHORITY_CONTROL_SET', 'Allow a user to modify an authority control set'],
+  ['582', 'ACTOR_USER_DELETE_OPEN_XACTS.override', 'Override the ACTOR_USER_DELETE_OPEN_XACTS event'],
+  ['583', 'PATRON_EXCEEDS_LOST_COUNT.override', 'Override the PATRON_EXCEEDS_LOST_COUNT event'],
+  ['584', 'MAX_HOLDS.override', 'Override the MAX_HOLDS event'],
+  ['585', 'ITEM_DEPOSIT_REQUIRED.override', 'Override the ITEM_DEPOSIT_REQUIRED event'],
+  ['586', 'ITEM_DEPOSIT_PAID.override', 'Override the ITEM_DEPOSIT_PAID event'],
+  ['587', 'COPY_STATUS_LOST_AND_PAID.override', 'Override the COPY_STATUS_LOST_AND_PAID event'],
+  ['588', 'ITEM_NOT_HOLDABLE.override', 'Override the ITEM_NOT_HOLDABLE event'],
+  ['589', 'ITEM_RENTAL_FEE_REQUIRED.override', 'Override the ITEM_RENTAL_FEE_REQUIRED event']
+]
+LOOP
+  permid := CAST (fixperm[1] AS BIGINT);
+  -- Has this permission already been manually applied at the expected id?
+  PERFORM * FROM permission.perm_list WHERE id = permid;
+  IF NOT FOUND THEN
+    UPDATE permission.perm_list SET code = code || '_local' WHERE code = fixperm[2] AND id > 1000 RETURNING id INTO oldid;
+    modify := FOUND;
+
+    INSERT INTO permission.perm_list (id, code, description) VALUES (permid, fixperm[2], fixperm[3]);
+
+    -- Several of these are rather unlikely for these particular permissions but safer > sorry.
+    IF modify THEN
+      UPDATE permission.grp_perm_map SET perm = permid WHERE perm = oldid;
+      UPDATE config.org_unit_setting_type SET update_perm = permid WHERE update_perm = oldid;
+      UPDATE permission.usr_object_perm_map SET perm = permid WHERE perm = oldid;
+      UPDATE permission.usr_perm_map SET perm = permid WHERE perm = oldid;
+      UPDATE config.org_unit_setting_type SET view_perm = permid WHERE view_perm = oldid;
+      UPDATE config.z3950_source SET use_perm = permid WHERE use_perm = oldid;
+      DELETE FROM permission.perm_list WHERE id = oldid;
+    END IF;
+  END IF;
+END LOOP;
+
+END$$;
+
+SELECT evergreen.upgrade_deps_block_check('1022', :eg_version);
+
+CREATE OR REPLACE FUNCTION vandelay.merge_record_xml_using_profile ( incoming_marc TEXT, existing_marc TEXT, merge_profile_id BIGINT ) RETURNS TEXT AS $$
+DECLARE
+    merge_profile   vandelay.merge_profile%ROWTYPE;
+    dyn_profile     vandelay.compile_profile%ROWTYPE;
+    target_marc     TEXT;
+    source_marc     TEXT;
+    replace_rule    TEXT;
+    match_count     INT;
+BEGIN
+
+    IF existing_marc IS NULL OR incoming_marc IS NULL THEN
+        -- RAISE NOTICE 'no marc for source or target records';
+        RETURN NULL;
+    END IF;
+
+    IF merge_profile_id IS NOT NULL THEN
+        SELECT * INTO merge_profile FROM vandelay.merge_profile WHERE id = merge_profile_id;
+        IF FOUND THEN
+            dyn_profile.add_rule := COALESCE(merge_profile.add_spec,'');
+            dyn_profile.strip_rule := COALESCE(merge_profile.strip_spec,'');
+            dyn_profile.replace_rule := COALESCE(merge_profile.replace_spec,'');
+            dyn_profile.preserve_rule := COALESCE(merge_profile.preserve_spec,'');
+        ELSE
+            -- RAISE NOTICE 'merge profile not found';
+            RETURN NULL;
+        END IF;
+    ELSE
+        -- RAISE NOTICE 'no merge profile specified';
+        RETURN NULL;
+    END IF;
+
+    IF dyn_profile.replace_rule <> '' AND dyn_profile.preserve_rule <> '' THEN
+        -- RAISE NOTICE 'both replace [%] and preserve [%] specified', dyn_profile.replace_rule, dyn_profile.preserve_rule;
+        RETURN NULL;
+    END IF;
+
+    IF dyn_profile.replace_rule = '' AND dyn_profile.preserve_rule = '' AND dyn_profile.add_rule = '' AND dyn_profile.strip_rule = '' THEN
+        -- Since we have nothing to do, just return a target record as is
+        RETURN existing_marc;
+    ELSIF dyn_profile.preserve_rule <> '' THEN
+        source_marc = existing_marc;
+        target_marc = incoming_marc;
+        replace_rule = dyn_profile.preserve_rule;
+    ELSE
+        source_marc = incoming_marc;
+        target_marc = existing_marc;
+        replace_rule = dyn_profile.replace_rule;
+    END IF;
+
+    RETURN vandelay.merge_record_xml( target_marc, source_marc, dyn_profile.add_rule, replace_rule, dyn_profile.strip_rule );
+
+END;
+$$ LANGUAGE PLPGSQL;
+
+SELECT evergreen.upgrade_deps_block_check('1023', :eg_version);
+
+INSERT into config.org_unit_setting_type
+( name, grp, label, description, datatype, fm_class ) VALUES
+(
+    'cat.default_merge_profile', 'cat',
+    oils_i18n_gettext(
+        'cat.default_merge_profile',
+        'Default Merge Profile (Z39.50 and Record Buckets)',
+        'coust',
+        'label'
+    ),
+     oils_i18n_gettext(
+        'cat.default_merge_profile',
+        'Default merge profile to use during Z39.50 imports and record bucket merges',
+        'coust',
+        'description'
+    ),
+    'link',
+    'vmp'
+);
+
+SELECT evergreen.upgrade_deps_block_check('1024', :eg_version);
+
+-- Add new column "rtl" with default of false
+ALTER TABLE config.i18n_locale ADD COLUMN rtl BOOL NOT NULL DEFAULT FALSE;
+
+SELECT evergreen.upgrade_deps_block_check('1025', :eg_version);
+
+-- Add Arabic (Jordan) to i18n_locale table as a stock language option
+INSERT INTO config.i18n_locale (code,marc_code,name,description,rtl)
+    VALUES ('ar-JO', 'ara', oils_i18n_gettext('ar-JO', 'Arabic (Jordan)', 'i18n_l', 'name'),
+        oils_i18n_gettext('ar-JO', 'Arabic (Jordan)', 'i18n_l', 'description'), 'true');
+
+SELECT evergreen.upgrade_deps_block_check('1026', :eg_version);
+
+INSERT INTO config.metabib_field ( id, field_class, name, label, 
+     format, xpath, search_field, browse_field, authority_xpath, joiner ) VALUES
+    (34, 'subject', 'topic_browse', oils_i18n_gettext(34, 'Topic Browse', 'cmf', 'label'), 
+     'mods32', $$//mods32:mods/mods32:subject[local-name(./*[1]) = "topic"]$$, FALSE, TRUE, '//@xlink:href', ' -- ' ); -- /* to fool vim */;
+
+INSERT INTO config.metabib_field ( id, field_class, name, label, 
+     format, xpath, search_field, browse_field, authority_xpath, joiner ) VALUES
+    (35, 'subject', 'geographic_browse', oils_i18n_gettext(35, 'Geographic Name Browse', 'cmf', 'label'), 
+     'mods32', $$//mods32:mods/mods32:subject[local-name(./*[1]) = "geographic"]$$, FALSE, TRUE, '//@xlink:href', ' -- ' ); -- /* to fool vim */;
+
+INSERT INTO config.metabib_field ( id, field_class, name, label, 
+     format, xpath, search_field, browse_field, authority_xpath, joiner ) VALUES
+    (36, 'subject', 'temporal_browse', oils_i18n_gettext(36, 'Temporal Term Browse', 'cmf', 'label'), 
+     'mods32', $$//mods32:mods/mods32:subject[local-name(./*[1]) = "temporal"]$$, FALSE, TRUE, '//@xlink:href', ' -- ' ); -- /* to fool vim */;
+
+INSERT INTO config.metabib_field_index_norm_map (field,norm)
+    SELECT  m.id,
+            i.id
+      FROM  config.metabib_field m,
+        config.index_normalizer i
+      WHERE i.func IN ('naco_normalize')
+            AND m.id IN (34, 35, 36);
+
+UPDATE config.metabib_field
+SET browse_field = FALSE
+WHERE field_class = 'subject' AND name = 'topic'
+AND id = 14;
+UPDATE config.metabib_field
+SET browse_field = FALSE
+WHERE field_class = 'subject' AND name = 'geographic'
+AND id = 13;
+UPDATE config.metabib_field
+SET browse_field = FALSE
+WHERE field_class = 'subject' AND name = 'temporal'
+AND id = 11;
+
+UPDATE authority.control_set_bib_field_metabib_field_map
+SET metabib_field = 34
+WHERE metabib_field = 14;
+UPDATE authority.control_set_bib_field_metabib_field_map
+SET metabib_field = 35
+WHERE metabib_field = 13;
+UPDATE authority.control_set_bib_field_metabib_field_map
+SET metabib_field = 36
+WHERE metabib_field = 11;
+
+SELECT evergreen.upgrade_deps_block_check('1027', :eg_version);
+
+INSERT INTO config.settings_group (name, label)
+    VALUES ('ebook_api', 'Ebook API Integration');
+
+INSERT INTO config.org_unit_setting_type
+    (name, label, description, grp, datatype) 
+VALUES (
+    'ebook_api.oneclickdigital.library_id',
+    oils_i18n_gettext(
+        'ebook_api.oneclickdigital.library_id',
+        'OneClickdigital Library ID',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.oneclickdigital.library_id',
+        'Identifier assigned to this library by OneClickdigital',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.oneclickdigital.basic_token',
+    oils_i18n_gettext(
+        'ebook_api.oneclickdigital.basic_token',
+        'OneClickdigital Basic Token',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.oneclickdigital.basic_token',
+        'Basic token for client authentication with OneClickdigital API (supplied by OneClickdigital)',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+);
+
+INSERT INTO config.org_unit_setting_type
+    (name, label, description, grp, datatype) 
+VALUES (
+    'ebook_api.overdrive.discovery_base_uri',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.discovery_base_uri',
+        'OverDrive Discovery API Base URI',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.discovery_base_uri',
+        'Base URI for OverDrive Discovery API (defaults to https://api.overdrive.com/v1). Using HTTPS here is strongly encouraged.',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.circulation_base_uri',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.circulation_base_uri',
+        'OverDrive Circulation API Base URI',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.circulation_base_uri',
+        'Base URI for OverDrive Circulation API (defaults to https://patron.api.overdrive.com/v1). Using HTTPS here is strongly encouraged.',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.account_id',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.account_id',
+        'OverDrive Account ID',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.account_id',
+        'Account ID (a.k.a. Library ID) for this library, as assigned by OverDrive',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.websiteid',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.websiteid',
+        'OverDrive Website ID',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.websiteid',
+        'Website ID for this library, as assigned by OverDrive',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.authorizationname',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.authorizationname',
+        'OverDrive Authorization Name',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.authorizationname',
+        'Authorization name for this library, as assigned by OverDrive',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.basic_token',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.basic_token',
+        'OverDrive Basic Token',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.basic_token',
+        'Basic token for client authentication with OverDrive API (supplied by OverDrive)',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.granted_auth_redirect_uri',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.granted_auth_redirect_uri',
+        'OverDrive Granted Authorization Redirect URI',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.granted_auth_redirect_uri',
+        'URI provided to OverDrive for use with granted authorization',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.password_required',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.password_required',
+        'OverDrive Password Required',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.password_required',
+        'Does this library require a password when authenticating patrons with the OverDrive API?',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'bool'
+);
+
+COMMIT;
+
+\qecho Running some data updates outside of the main transaction
+\qecho =========================================================
+\qecho Update some indexes on actor.usr
+REINDEX INDEX actor.actor_usr_first_given_name_unaccent_idx;
+REINDEX INDEX actor.actor_usr_second_given_name_unaccent_idx;
+REINDEX INDEX actor.actor_usr_family_name_unaccent_idx;
+
+\qecho Recalculating bib fingerprints; this may take a while
+ALTER TABLE biblio.record_entry DISABLE TRIGGER USER;
+UPDATE biblio.record_entry SET fingerprint = biblio.extract_fingerprint(marc) WHERE NOT deleted;
+ALTER TABLE biblio.record_entry ENABLE TRIGGER USER;
+
+\qecho Remapping metarecords
+SELECT metabib.remap_metarecord_for_bib(id, fingerprint)
+FROM biblio.record_entry
+WHERE NOT deleted;
+
+\qecho Running a browse-only reingest of your bib records. It may take a while.
+\qecho You may cancel now without losing the effect of the rest of the
+\qecho upgrade script, and arrange the reingest later.
+\qecho .
+SELECT metabib.reingest_metabib_field_entries(id, TRUE, FALSE, TRUE)
+    FROM biblio.record_entry;
+
index 40546c8..4d4773d 100644 (file)
@@ -2,6 +2,7 @@
 <script type="text/javascript" src='[% ctx.media_prefix %]/js/ui/default/acq/common/vlagent.js'> </script>
 <script type="text/javascript" src='[% ctx.media_prefix %]/js/ui/default/acq/common/li_table.js'> </script>
 <script type="text/javascript" src='[% ctx.media_prefix %]/js/ui/default/acq/financial/claim_voucher.js'> </script>
+<script type="text/javascript" src='[% ctx.media_prefix %]/js/file-saver/FileSaver.min.js'> </script>
 <div id='acq-lit-table-container'>
 
     <div> <!-- Container for actions selector and paging controls.
                                     </td>
                                 </tr>
                                 <tr>
-                                    <td colspan='0'>
+                                    <td colspan='8'>
                                         <table><tr>
                                             <td>[% l('Order Identifier') %]</td>
                                             <td>
                                     </td>
                                 </tr>
                                 <tr>
-                                    <td colspan='0'>
+                                    <td colspan='8'>
                                         <span name="liid"># </span> | 
                                         <span name="li_existing_count">0</span> 
                                         <span name="catalog" class='hidden'> | <a title='[% l('Show In Catalog') %]' name="catalog_link" href="javascript:void(0);">[% l('&#x279F; catalog') %]</a></span> 
                     </td>
                     <td>
                         <select name='actions'>
-                            <option name='action_none'>[% l('-- Actions --') %]</option>
-                            <option name='action_update_barcodes'>[% l('Update Barcodes') %]</option>
-                            <option name='action_holdings_maint'>[% l('Holdings Maint.') %]</option>
-                            <option name='action_manage_claims'>[% l('Claims') %]</option>
-                            <option name='action_view_history'>[% l('View History') %]</option>
+                            <option name='action_none' value='action_none'>[% l('-- Actions --') %]</option>
+                            <option name='action_update_barcodes' value='action_update_barcodes'>[% l('Update Barcodes') %]</option>
+                            <option name='action_holdings_maint' value='action_holdings_maint'>[% l('Holdings Maint.') %]</option>
+                            <option name='action_manage_claims' value='action_manage_claims'>[% l('Claims') %]</option>
+                            <option name='action_view_history' value='action_view_history'>[% l('View History') %]</option>
                         </select>
                     </td>
                     <td>
                     <td><input type='text' size='8' name='price'/></td>
                 </tr>
                 <tr id='acq-inline-copies-row' class='acq-inline-copies-row'>
-                    <td colspan='0'>
+                    <td colspan='8'>
                         <table class='acq-li-inline-copies-table'>
                             <thead>
                                 <tr>
index 864304b..6d5ef52 100644 (file)
@@ -114,12 +114,12 @@ div.select-box-wrapper {
     display:inline-block;
 }
 
-#dashboard {
+#dashboard, #dashboard_e {
     margin-top: 1em;
     height: 3em;
 }
 
-#dashboard span.dash-align a {
+#dashboard span.dash-align a, #dashboard_e span.dash-align a {
     font-weight: bold;
     text-decoration: none;
 }
@@ -130,9 +130,9 @@ div.select-box-wrapper {
 
 #logout_link { left: 1px; }
 
-#dash_checked { color: [% css_colors.text_attention %]; }
-#dash_holds { color: [% css_colors.text_attention %]; }
-#dash_pickup { color: [% css_colors.text_goodnews %]; }
+#dash_checked, #dash_e_checked { color: [% css_colors.text_attention %]; }
+#dash_holds, #dash_e_holds { color: [% css_colors.text_attention %]; }
+#dash_pickup, #dash_e_pickup { color: [% css_colors.text_goodnews %]; }
 
 /*  
 #dash_fines { color: [% css_colors.text_badnews %]; }
@@ -945,7 +945,7 @@ div.result_table_utils_cont {
     /*padding-left:10px;*/
 }
 
-#acct_checked_main_header, #acct_holds_main_header, #acct_checked_hist_header, #acct_holds_hist_header, #acct_list_header, #acct_list_header_anon, #temp_list_holds, #acct_messages_main_header {
+#acct_checked_main_header, #acct_holds_main_header, #acct_checked_hist_header, #acct_holds_hist_header, #acct_list_header, #acct_list_header_anon, #temp_list_holds, #acct_messages_main_header, #ebook_circs_main_table, #ebook_holds_main_table {
     border-collapse: collapse;
 }
 
@@ -956,12 +956,12 @@ div.result_table_utils_cont {
 
 .hold_note_title { font-weight: bold; }
 
-#acct_checked_main_header td, #acct_holds_main_header td, #acct_checked_hist_header td, #acct_holds_hist_header td, #acct_list_header td, #acct_list_header_anon td, #temp_list_holds td, #acct_messages_main_header td {
+#acct_checked_main_header td, #acct_holds_main_header td, #acct_checked_hist_header td, #acct_holds_hist_header td, #acct_list_header td, #acct_list_header_anon td, #temp_list_holds td, #acct_messages_main_header, #ebook_circs_main_table td, #ebook_holds_main_table td {
     background: [% css_colors.accent_lighter2 %];
     padding: 10px;
 }
 
-#acct_checked_main_header th, #acct_holds_main_header th, #acct_checked_hist_header th, acct_holds_hist_header th, #acct_list_header th, #acct_list_header_anon th, #temp_list_holds th, #acct_messages_main_header th {
+#acct_checked_main_header th, #acct_holds_main_header th, #acct_checked_hist_header th, acct_holds_hist_header th, #acct_list_header th, #acct_list_header_anon th, #temp_list_holds th, #acct_messages_main_header, #ebook_circs_main_table th, #ebook_holds_main_table th {
     text-align: left;
     padding: 0px 10px 0px 10px;
 }
@@ -2089,10 +2089,10 @@ a.preflib_change {
         border-bottom: none;
     }
         /* Force table to not be like tables anymore */
-        table#acct_checked_main_header thead tr th, table#acct_holds_main_header thead tr th, table#acct_checked_hist_header thead tr th, #acct_holds_hist_header thead tr th {
+        table#acct_checked_main_header thead tr th, table#acct_holds_main_header thead tr th, table#acct_checked_hist_header thead tr th, #acct_holds_hist_header thead tr th, #ebook_circs_main_table thead tr th, #ebook_holds_main_table thead tr th {
                 display: block;
         }
-        table#acct_checked_main_header tbody tr td, table#acct_holds_main_header tbody tr td, table#acct_checked_hist_header tbody tr td, #acct_holds_hist_header tbody tr td {
+        table#acct_checked_main_header tbody tr td, table#acct_holds_main_header tbody tr td, table#acct_checked_hist_header tbody tr td, #acct_holds_hist_header tbody tr td, #ebook_circs_main_table thead tr td, #ebook_holds_main_table thead tr td {
                 display: block;
         }
 
index 7e6edc4..8e989c4 100644 (file)
         <div class="align">
             <a href='[% mkurl('circs',{},1) %]'>[% l("Current Items Checked Out") %]</a>
         </div>
+        [%- IF ebook_api.enabled %]
+        <div class="align">
+            <a href="[% mkurl('ebook_circs',{},1) %]">[% l("E-Items Currently Checked Out") %]</a>
+        </div>
+        [%- END %]
         <div class="align selected">
             <a href="#">[% l("Check Out History") %]</a>
         </div>
index 4715925..bd93d7b 100644 (file)
         <div class="align selected">
             <a href="#">[% l("Current Items Checked Out") %]</a>
         </div>
+        [%- IF ebook_api.enabled %]
+        <div class="align">
+            <a href="[% mkurl('ebook_circs',{},1) %]">[% l("E-Items Currently Checked Out") %]</a>
+        </div>
+        [%- END %]
         <div class="align">
             <a href="[% mkurl('circ_history',{},1) %]">[% l("Check Out History") %]</a>
         </div>
diff --git a/Open-ILS/src/templates/opac/myopac/ebook_circs.tt2 b/Open-ILS/src/templates/opac/myopac/ebook_circs.tt2
new file mode 100644 (file)
index 0000000..5a53976
--- /dev/null
@@ -0,0 +1,42 @@
+[%  PROCESS "opac/parts/header.tt2";
+    PROCESS "opac/parts/misc_util.tt2";
+    PROCESS "opac/parts/myopac/column_sort_support.tt2";
+    WRAPPER "opac/parts/myopac/base.tt2";
+    myopac_page = "ebook_circs"  %]
+<h3 class="sr-only">[% l('E-Items Currently Checked Out') %]</h3>
+<div id='myopac_checked_div'>
+
+    <div id="acct_checked_tabs">
+        <div class="align">
+            <a href="[% mkurl('circs',{},1) %]">[% l("Current Items Checked Out") %]</a>
+        </div>
+        <div class="align selected">
+            <a href="#">[% l("E-Items Currently Checked Out") %]</a>
+        </div>
+        <div class="align">
+            <a href="[% mkurl('circ_history',{},1) %]">[% l("Check Out History") %]</a>
+        </div>
+    </div>
+
+    <div class="header_middle">
+        <span class="float-left">[% l('E-Items Currently Checked Out') %]</span>
+    </div>
+    <div class="clear-both"></div>
+    <div id="no_ebook_circs" class="warning_box hidden">[% l('You have no e-items checked out.') %]</div>
+    <div id='ebook_circs_main' class="hidden">
+        <table id="ebook_circs_main_table"
+            title="[% l('E-Items Currently Checked Out') %]"
+            class="table_no_border_space table_no_cell_pad item_list_padding">
+            <thead>
+            <tr>
+                <th>[% l("Title") %]</th>
+                <th>[% l("Author") %]</th>
+                <th>[% l("Due Date") %]</th>
+                <th>[% l("Actions") %]</th>
+            </tr>
+            </thead>
+            <tbody id="ebook_circs_main_table_body"></tbody>
+        </table>
+    </div>
+</div>
+[% END %]
diff --git a/Open-ILS/src/templates/opac/myopac/ebook_holds.tt2 b/Open-ILS/src/templates/opac/myopac/ebook_holds.tt2
new file mode 100644 (file)
index 0000000..76bde68
--- /dev/null
@@ -0,0 +1,51 @@
+[%  PROCESS "opac/parts/header.tt2";
+    PROCESS "opac/parts/misc_util.tt2";
+    PROCESS "opac/parts/hold_status.tt2";
+    PROCESS "opac/parts/myopac/column_sort_support.tt2";
+    WRAPPER "opac/parts/myopac/base.tt2";
+    myopac_page = "ebook_holds";
+    limit = (ctx.holds_limit.defined) ? ctx.holds_limit : 0;
+    offset = (ctx.holds_offset.defined) ? ctx.holds_offset : 0;
+    count = (ctx.holds_ids.size.defined) ? ctx.holds_ids.size : 0;
+%]
+<h3 class="sr-only">[% l('My E-Item Holds') %]</h3>
+<div id='myopac_holds_div'>
+
+    <div id="acct_holds_tabs">
+        <div class="align">
+            <a href='[% mkurl('holds', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("Items on Hold") %]</a>
+        </div>
+        <div class="align selected">
+            <a href='#'>[% l("E-Items on Hold") %]</a>
+        </div>
+        <div class="align">
+            <a href='[% mkurl('ebook_holds_ready', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("E-Items Ready for Checkout") %]</a>
+        </div>
+        <div class="align">
+            <a href='[% mkurl('hold_history', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("Holds History") %]</a>
+        </div>
+    </div>
+
+    <div class="header_middle">
+        <span class="float-left">[% l('E-Items on Hold') %]</span>
+    </div>
+    <div class="clear-both"></div>
+    <div id="no_ebook_holds" class="warning_box hidden">[% l('You have no e-item holds.') %]</div>
+    <div id='ebook_holds_main' class="hidden">
+        <table id="ebook_holds_main_table"
+            title="[% l('E-Items on Hold') %]"
+            class="table_no_border_space table_no_cell_pad item_list_padding">
+            <thead>
+            <tr>
+                <th>[% l("Title") %]</th>
+                <th>[% l("Author") %]</th>
+                <th>[% l("Expire Date") %]</th>
+                <th>[% l("Status") %]</th>
+                <th>[% l("Actions") %]</th>
+            </tr>
+            </thead>
+            <tbody id="ebook_holds_main_table_body"></tbody>
+        </table>
+    </div>
+</div>
+[% END %]
diff --git a/Open-ILS/src/templates/opac/myopac/ebook_holds_ready.tt2 b/Open-ILS/src/templates/opac/myopac/ebook_holds_ready.tt2
new file mode 100644 (file)
index 0000000..006c986
--- /dev/null
@@ -0,0 +1,50 @@
+[%  PROCESS "opac/parts/header.tt2";
+    PROCESS "opac/parts/misc_util.tt2";
+    PROCESS "opac/parts/hold_status.tt2";
+    PROCESS "opac/parts/myopac/column_sort_support.tt2";
+    WRAPPER "opac/parts/myopac/base.tt2";
+    myopac_page = "ebook_holds_ready";
+    limit = (ctx.holds_limit.defined) ? ctx.holds_limit : 0;
+    offset = (ctx.holds_offset.defined) ? ctx.holds_offset : 0;
+    count = (ctx.holds_ids.size.defined) ? ctx.holds_ids.size : 0;
+%]
+<h3 class="sr-only">[% l('E-Items Ready for Checkout') %]</h3>
+<div id='myopac_holds_div'>
+
+    <div id="acct_holds_tabs">
+        <div class="align">
+            <a href='[% mkurl('holds', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("Items on Hold") %]</a>
+        </div>
+        <div class="align">
+            <a href='[% mkurl('ebook_holds', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("E-Items on Hold") %]</a>
+        </div>
+        <div class="align selected">
+            <a href='#'>[% l("E-Items Ready for Checkout") %]</a>
+        </div>
+        <div class="align">
+            <a href='[% mkurl('hold_history', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("Holds History") %]</a>
+        </div>
+    </div>
+
+    <div class="header_middle">
+        <span class="float-left">[% l('E-Items Ready for Checkout') %]</span>
+    </div>
+    <div class="clear-both"></div>
+    <div id="no_ebook_holds" class="warning_box hidden">[% l('You have no e-item holds ready to be checked out.') %]</div>
+    <div id='ebook_holds_main' class="hidden">
+        <table id="ebook_holds_main_table"
+            title="[% l('E-Items Ready for Checkout') %]"
+            class="table_no_border_space table_no_cell_pad item_list_padding">
+            <thead>
+            <tr>
+                <th>[% l("Title") %]</th>
+                <th>[% l("Author") %]</th>
+                <th>[% l("Expire Date") %]</th>
+                <th>[% l("Actions") %]</th>
+            </tr>
+            </thead>
+            <tbody id="ebook_holds_main_table_body"></tbody>
+        </table>
+    </div>
+</div>
+[% END %]
index 9905e6c..16b4c02 100644 (file)
         <div class="align">
             <a href='[% mkurl('holds',{},['limit','offset']) %]'>[% l("Items on Hold") %]</a>
         </div>
+        [% IF ebook_api.enabled %]
+        <div class="align">
+            <a href='[% mkurl('ebook_holds', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("E-Items on Hold") %]</a>
+        </div>
+        <div class="align">
+            <a href='[% mkurl('ebook_holds_ready', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("E-Items Ready for Checkout") %]</a>
+        </div>
+        [% END %]
         <div class="align selected">
             <a href="#">[% l("Holds History") %]</a>
         </div>
index 7fc808c..1ba5c9c 100644 (file)
         <div class="align selected">
             <a href='#'>[% l("Items on Hold") %]</a>
         </div>
+        [% IF ebook_api.enabled %]
+        <div class="align">
+            <a href='[% mkurl('ebook_holds', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("E-Items on Hold") %]</a>
+        </div>
+        <div class="align">
+            <a href='[% mkurl('ebook_holds_ready', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("E-Items Ready for Checkout") %]</a>
+        </div>
+        [% END %]
         <div class="align">
             <a href='[% mkurl('hold_history', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("Holds History") %]</a>
         </div>
index 4e0e3b9..3af7bb7 100644 (file)
@@ -51,6 +51,17 @@ google_analytics.enabled = 'false';
 google_analytics.code = 'UA-9999999-99';
 
 ##############################################################################
+# Ebook API integration
+##############################################################################
+ebook_api.enabled = 'false';
+ebook_api.ebook_test.enabled = 'false';
+ebook_api.ebook_test.base_uris = [ 'http://example.com/ebookapi/t/' ];
+ebook_api.oneclickdigital.enabled = 'false';
+ebook_api.oneclickdigital.base_uris = [ 'http://example.oneclickdigital.com/Products/ProductDetail.aspx' ];
+ebook_api.overdrive.enabled = 'false';
+ebook_api.overdrive.base_uris = [ 'http://elm.lib.overdrive.com/' ];
+
+##############################################################################
 # Enable "Forgot your password?" prompt at login
 ##############################################################################
 reset_password = 'true';
diff --git a/Open-ILS/src/templates/opac/parts/ebook_api/avail.tt2 b/Open-ILS/src/templates/opac/parts/ebook_api/avail.tt2
new file mode 100644 (file)
index 0000000..f75a951
--- /dev/null
@@ -0,0 +1,49 @@
+[%-
+# Display holdings/availability info from ebook API
+#
+# We require the following info:
+# - rec_id: internal ID for this record (rec.id in search results, ctx.bre_id in record summary)
+# - ebook_id: external ID for title (ISBN for OneClickdigital, unique identifier for OverDrive)
+# - vendor (oneclickdigital, overdrive)
+
+IF args.ebook_test_id;
+    ebook.ebook_id = args.ebook_test_id;
+    ebook.vendor = 'ebook_test';
+ELSIF args.oneclickdigital_id;
+    ebook.ebook_id = args.oneclickdigital_id;
+    ebook.vendor = 'oneclickdigital';
+ELSIF args.overdrive_id;
+    ebook.ebook_id = args.overdrive_id;
+    ebook.vendor = 'overdrive';
+END;
+
+IF ebook.ebook_id;
+
+    IF ctx.page == 'rresult';
+        ebook.rec_id = rec.id;
+    ELSE;
+        ebook.rec_id = ctx.bre_id;
+    END;
+
+# This div is hidden by default. The JS layer will unhide it, use the ebook_id
+# to retrieve holdings/availability info via the appropriate vendor API, and
+# overwrite the div's contents with that information.
+-%]
+<div id="[% ebook.rec_id %]" class="ebook_avail hidden">
+    <div id="[% ebook.ebook_id %]" class="[% ebook.vendor %]_avail">
+        <table id="[% ebook.rec_id %]_ebook_holdings" class="result_holdings_table hidden">
+            <thead>
+                <tr>
+                    <th>[% l('Available Formats') %]</th>
+                    <th>[% l('Status') %]</th>
+                </tr>
+            <tbody>
+                <tr>
+                    <td id="[% ebook.rec_id %]_formats"></td>
+                    <td id="[% ebook.rec_id %]_status"></td>
+                </tr>
+            </tbody>
+        </table>
+    </div>
+</div>
+[% END %]
diff --git a/Open-ILS/src/templates/opac/parts/ebook_api/avail_js.tt2 b/Open-ILS/src/templates/opac/parts/ebook_api/avail_js.tt2
new file mode 100644 (file)
index 0000000..f70f115
--- /dev/null
@@ -0,0 +1,49 @@
+<script type="text/javascript">
+dojo.addOnLoad(function() {
+
+    // detect ebooks on current page for each vendor
+    dojo.forEach(vendor_list, function(v) {
+        var vendor = new Vendor(v);
+        var ebook_nodes = dojo.query("." + v + "_avail");
+        console.log('found ' + ebook_nodes.length + ' ebooks on this page');
+
+        // we have ebooks for this vendor, so let's get availability info etc.
+        if (ebook_nodes.length > 0) {
+            checkSession(v, function(v,ses) {
+                ebook_nodes.forEach(function(node) {
+                    var ebook = new Ebook(v, node.getAttribute("id"));
+                    ebook.rec_id = node.parentNode.getAttribute("id");
+                    vendor.ebooks.push(ebook);
+
+                    ebook.getHoldings( function(holdings) {
+                        if (typeof holdings.available !== 'undefined') {
+                            var avail = holdings.available;
+                            if (avail == 1) {
+                                node.innerHTML = 'This title is available online.';
+                            } else if (avail == 0) {
+                                node.innerHTML = 'This title is not currently available.';
+                            } else {
+                                console.log(ebook.id + ' has bad availability: ' + avail);
+                            }
+                        } else {
+                            if (holdings.formats.length > 0) {
+                                var formats_ul = dojo.create("ul", null, ebook.rec_id + '_formats');
+                                dojo.forEach(holdings.formats, function(f) {
+                                    dojo.create("li", { innerHTML: f }, formats_ul);
+                                });
+                                var status_node = dojo.byId(ebook.rec_id + '_status');
+                                var status_str = holdings.copies_available + ' of ' + holdings.copies_owned + ' available';
+                                status_node.innerHTML = status_str;
+                                dojo.removeClass(ebook.rec_id + '_ebook_holdings', "hidden");
+                            }
+                        }
+                        // unhide holdings/availability info now that it's populated
+                        removeClass(node.parentNode, "hidden");
+                    });
+                });
+            });
+        }
+    });
+
+});
+</script>
diff --git a/Open-ILS/src/templates/opac/parts/ebook_api/base_js.tt2 b/Open-ILS/src/templates/opac/parts/ebook_api/base_js.tt2
new file mode 100644 (file)
index 0000000..632e534
--- /dev/null
@@ -0,0 +1,71 @@
+[%
+# HTML display chunks
+progress_icon = '<img id="ebook_avail_spinner" src="/opac/images/progressbar_green.gif" alt="' _ l("Checking availability for this item...") _ '"/>'
+%]
+
+<script type="text/javascript" src="[% ctx.media_prefix %]/js/ui/default/opac/ebook_api/session.js"></script>
+<script type="text/javascript" src="[% ctx.media_prefix %]/js/ui/default/opac/ebook_api/ebook.js"></script>
+<script type="text/javascript">
+
+// translatable strings as JS variables
+var l_strings = {};
+l_strings.download = '[% l('Download') %]';
+l_strings.ready_for_checkout = '[% l('Ready for Checkout') %]';
+l_strings.suspended = '[% l('Suspended') %]';
+
+// give us cookies!
+dojo.require("dojo.cookie");
+
+// context org unit
+[% IF !ctx.page OR ctx.page != 'rresult';
+    PROCESS get_library;
+END %]
+var ou = [% loc_value %];
+
+// list of enabled vendors
+var vendor_list = [];
+[% IF ebook_api.ebook_test.enabled == 'true' %]
+vendor_list.push('ebook_test');
+[% END %]
+[% IF ebook_api.oneclickdigital.enabled == 'true' %]
+vendor_list.push('oneclickdigital');
+[% END %]
+[% IF ebook_api.overdrive.enabled == 'true' %]
+vendor_list.push('overdrive');
+[% END %]
+
+var cookie_registry = [ 'ebook_xact_cache' ];
+dojo.forEach(vendor_list, function(v) {
+    cookie_registry.push(v);
+});
+
+[% IF ctx.user %]
+// user- or login-specific vars
+var authtoken = '[% ctx.authtoken %]';
+var patron_id = '[% ctx.active_card %]'; // using barcode of active card as patron ID
+
+var myopac_page;
+[% IF myopac_page %]
+myopac_page = "[% myopac_page %]";
+[% END %]
+
+[% END %]
+
+// enforce removal of ebook API cookies on logout
+dojo.addOnLoad(function() {
+    var logout_handle = dojo.connect(dojo.byId('#logout_link'), 'onclick', function() {
+        dojo.forEach(cookie_registry, function(cookie) {
+            dojo.cookie(cookie, '', {path: '/', expires: '-1h'});
+        });
+        // When we switch to jQuery, use .one()
+        // instead of dojo's .connect() and .disconnect()
+        dojo.disconnect(logout_handle);
+    });
+});
+</script>
+
+[%- IF ctx.user %]
+<script type="text/javascript" src="[% ctx.media_prefix %]/js/ui/default/opac/ebook_api/relation.js"></script>
+<script type="text/javascript" src="[% ctx.media_prefix %]/js/ui/default/opac/ebook_api/loggedin.js"></script>
+[%- END %]
+
diff --git a/Open-ILS/src/templates/opac/parts/ebook_api/login_js.tt2 b/Open-ILS/src/templates/opac/parts/ebook_api/login_js.tt2
new file mode 100644 (file)
index 0000000..23ed256
--- /dev/null
@@ -0,0 +1,41 @@
+<script type="text/javascript">
+var vendors_requiring_password = [];
+
+[% IF !loc_value; PROCESS get_library; END; %]
+[% IF ebook_api.overdrive.enabled == 'true'
+    AND loc_value
+    AND ctx.get_org_setting(loc_value, 'ebook_api.overdrive.password_required') %]
+vendors_requiring_password.push('overdrive');
+[% END %]
+
+dojo.addOnLoad(function() {
+    var handle = dojo.connect(dojo.byId('#login-form-box'), 'onclick', function(evt) {
+        // disconnect this event since it's one-time-only
+        // (when we switch to jQuery, we can use .one() here)
+        dojo.disconnect(handle);
+
+        // we cache the username (and password) for now, but will
+        // replace that with the patron's active barcode later
+        vendors_requiring_password.forEach(function(v) {
+            if (vendor_list.includes(v)) {
+                checkSession(v, function(v,ses) {
+                    var username = dojo.byId('#username_field').value;
+                    var password = dojo.byId('#password_field').value;
+                    new OpenSRF.ClientSession('open-ils.ebook_api').request({
+                        method: 'open-ils.ebook_api.patron.cache_password',
+                        params: [ ses, password ],
+                        async: true,
+                        oncomplete: function(r) {
+                            var resp = r.recv();
+                            if (resp) {
+                                console.log('patron password has been cached');
+                                return;
+                            }
+                        }
+                    }).send();
+                });
+            }
+        });
+    });
+});
+</script>
index 8b60ba9..5b34c16 100644 (file)
         want_dojo = 1;
     END;
 
+    IF ebook_api.enabled == 'true';
+        want_dojo = 1;
+    END;
+
     # Especially useful for image 'alt' tags and link title tags,
     # where the content may need to be unique (making it longer)
     # but should not exceed 75 chars for ideal screen reader support.
index 5cfdebe..245af43 100644 (file)
     src="[% ctx.media_prefix %]/js/ui/default/opac/copyloc.js"></script>
 [% END %]
 
+[% IF ebook_api.enabled == 'true' %]
+    [% INCLUDE "opac/parts/ebook_api/base_js.tt2" %]
+    [% INCLUDE "opac/parts/ebook_api/avail_js.tt2" IF (ctx.page == 'rresult' OR ctx.page == 'record') %]
+    [% INCLUDE "opac/parts/ebook_api/login_js.tt2" IF (ctx.page == 'login') %]
+[% END %]
+
 <!-- provide a JS friendly org unit hash -->
 <script type="text/javascript">
 var aou_hash = {
index d7ab819..69ca1b4 100644 (file)
             END;
         END;
 
+        IF ebook_api.overdrive.enabled == 'true';
+            FOR marc037 IN xml.findnodes('//*[@tag="037"]');
+                marc037_id = marc037.findnodes('./*[@code="a"]').textContent;
+                marc037_source = marc037.findnodes('./*[@code="b"]').textContent;
+                IF marc037_source.match('OverDrive') AND marc037_id;
+                    args.overdrive_id = marc037_id;
+                    LAST;
+                END;
+            END;
+        END;
+
         # Extract the 856 URLs that are not otherwise represented by asset.uri's
         args.online_res = [];
         FOR node IN xml.findnodes('//*[@tag="856" and @ind1="4" and (@ind2="0" or @ind2="1")]');
                         res.note = '';
                     END;
                     args.uris.push(res);
+
+                    IF ebook_api.ebook_test.enabled == 'true';
+                        IF !args.ebook_test_id;
+                            FOR base_uri IN ebook_api.ebook_test.base_uris;
+                                IF res.href.match(base_uri);
+                                    args.ebook_test_id = res.href.remove(base_uri);
+                                    LAST;
+                                END;
+                            END;
+                        END;
+                    END;
+
+                    IF ebook_api.oneclickdigital.enabled == 'true';
+                        # A record might conceivably have multiple OneClickdigital URIs,
+                        # but we use (the same) ISBN as the ebook ID in each case.
+                        IF !args.oneclickdigital_id;
+                            FOR base_uri IN ebook_api.oneclickdigital.base_uris;
+                                IF res.href.match(base_uri);
+                                    # found a OneClickdigital URI, let's grab our ID and move on
+                                    args.oneclickdigital_id = clean_isbn;
+                                    LAST;
+                                END;
+                            END;
+                        END;
+                    END;
+
+                    IF ebook_api.overdrive.enabled == 'true';
+                        # Ideally we already have an OverDrive record ID from MARC 037 (see above).
+                        # But for older records, it will be embedded in the URI in MARC 856.
+                        IF !args.overdrive_id;
+                            FOR base_uri IN ebook_api.overdrive.base_uris;
+                                IF res.href.match(base_uri);
+                                    args.overdrive_id = res.href.remove('^.*/ContentDetails.htm\?ID=');
+                                    LAST;
+                                END;
+                            END;
+                        END;
+                    END;
                 END;
+
                 NEXT;
             ELSE;
                 copies = volume.findnodes('./*[local-name()="copies"]/*[local-name()="copy"]');
index 16ad59b..705ba02 100644 (file)
                     <td class='td-right'>
                         <a href="[% mkurl(ctx.opac_root _ '/myopac/circs') %]"
                             title="[% l('View My Checked Out Items') %]">
-                            [% l("View All") %]
+                            [% l("Items Currently Checked out ([_1])", ctx.user_stats.checkouts.total_out) %]
+                        </a>
+                    </td>
+                    <td class="td-right hidden" id="acct_sum_ebook_circs">
+                        <a href="[% mkurl(ctx.opac_root _ '/myopac/circs?e_items') %]"
+                            title="[% l('View My Checked Out E-Items') %]">
+                            [% l("E-Items Currently Checked out") %] (<span id="acct_sum_ebook_circ_total">-</span>)
                         </a>
                     </td>
                 </tr>
                     <td class='td-right'>
                         <a href="[% mkurl(ctx.opac_root _ '/myopac/holds') %]"
                             title="[% l('View My Holds') %]">
-                            [% l('View All') %]
+                            [% l('Items Currently on Hold ([_1])', ctx.user_stats.holds.total) %]
+                        </a>
+                    </td>
+                    <td class="td-right hidden" id="acct_sum_ebook_holds">
+                        <a href="[% mkurl(ctx.opac_root _ '/myopac/holds?e_items') %]"
+                            title="[% l('View My E-Items On Hold') %]">
+                            [% l("E-Items Currently on Hold") %] (<span id="acct_sum_ebook_hold_total">-</span>)
                         </a>
                     </td>
                 </tr>
                     <td class='td-right'>
                         <a href="[% mkurl(ctx.opac_root _ '/myopac/holds', {available => 1}) %]"
                             title="[% l('View My Holds Ready for Pickup') %]">
-                            [% l('View All') %]
+                            [% l('Items ready for pickup ([_1])', ctx.user_stats.holds.ready) %]
+                        </a>
+                    </td>
+                    <td class="td-right hidden" id="acct_sum_ebook_holds_ready">
+                        <a href="[% mkurl(ctx.opac_root _ '/myopac/holds?e_items&available=1') %]"
+                            title="[% l('View My E-Items Ready for Pickup') %]">
+                            [% l("E-Items ready for pickup") %] (<span id="acct_sum_ebook_hold_ready_total">-</span>)
                         </a>
                     </td>
                 </tr>
index ff822d6..b125b03 100644 (file)
@@ -186,6 +186,11 @@ IF num_uris > 0;
     [%- END %]
     [%- IF num_uris > 1 %]</ul>[% END %]
 </div>
+[%
+IF ebook_api.enabled == 'true';
+    INCLUDE "opac/parts/ebook_api/avail.tt2";
+END;
+%]
 [%- END %]
 <div id="copy_hold_counts">
 [%-
index 5f3cbc3..ddfeacb 100644 (file)
@@ -381,6 +381,11 @@ END;
                                                              %]
                                                         [% END %] <!-- END detail_record_view -->
                                                     </table>
+                                                    [% 
+                                                        IF ebook_api.enabled == 'true';
+                                                            INCLUDE "opac/parts/ebook_api/avail.tt2";
+                                                        END;
+                                                    %]
                                                     [% PROCESS "opac/parts/result/copy_counts.tt2" %]
                                                     [% IF rec.user_circulated %]
                                                     <div class="result_item_circulated">
index 719ffa1..fc12a35 100644 (file)
                         %]</span> [% l("Fines") %]</a>
                 </span>
             </div>
+            <div id="dashboard_e" class="hidden">
+                <span class="dash-align">
+                    <a class="dash-link" href="[% mkurl(ctx.opac_root _ '/myopac/ebook_circs')
+                        %]"><span id="dash_e_checked">-</span> [% l("E-Items Checked Out") %]</a>
+                </span>
+                <span class="dash_divider">|</span>
+                <span class="dash-align">
+                    <a class="dash-link" href="[% mkurl(ctx.opac_root _ '/myopac/ebook_holds')
+                        %]"><span id="dash_e_holds">-</span> [% l("E-Items on Hold") %]</a>
+                </span>
+                <span class="dash_divider">|</span>
+                <span class="dash-align">
+                    <a class="dash-link" href="[% mkurl(ctx.opac_root _ '/myopac/ebook_holds_ready')
+                        %]"><span id="dash_e_pickup">-</span> [% l("E-Items Ready for Checkout") %]</a>
+                </span>
+            </div>
         </div>
         [% END %]
     </div>
index b3a17d1..060c4b3 100644 (file)
 <!--<script src="[% ctx.media_prefix %]/js/ui/default/staff/reporter/services/template.js"></script>-->
 <!--[% INCLUDE 'staff/reporter/share/report_strings.tt2' %]-->
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/acq/app.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/marcrecord.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/services/record.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/services/tagtable.js"></script>
+[% INCLUDE 'staff/cat/share/marcedit_strings.tt2' %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/services/marcedit.js"></script>
 <link rel="stylesheet" href="[% ctx.base_path %]/staff/css/acq.css" />
 [% END %]
 
diff --git a/Open-ILS/src/templates/staff/acq/t_edit_marc_order_record.tt2 b/Open-ILS/src/templates/staff/acq/t_edit_marc_order_record.tt2
new file mode 100644 (file)
index 0000000..1deeb5b
--- /dev/null
@@ -0,0 +1,16 @@
+<div>
+  <div class="modal-header">
+    <button type="button" class="close"
+      ng-click="cancel()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title">[% l('Edit MARC Order Record') %]</h4>
+  </div>
+  <div class="modal-body">
+    <eg-marc-edit-record dirty-flag="dirty_flag" marc-xml="args.marc_xml"
+                         in-place-mode="true" record-type="bre" save-label="[% l('Modify') %]" />
+  </div>
+  <div class="modal-footer">
+    <input type="submit" ng-click="ok(args)"
+        class="btn btn-primary" value="[% l('Use Edits') %]"/>
+    <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+  </div>
+</div>
index fcd79e8..f51ea00 100644 (file)
@@ -9,11 +9,11 @@
 
 [%
     interfaces = [
-     [ l('Resource Attribute Maps'), "./admin/booking/conify/resource_attr_map" ]
-    ,[ l('Resource Attribute Values'), "./admin/booking/conify/resource_attr_value" ]
-    ,[ l('Resource Attributes'), "./admin/booking/conify/resource_attr" ]
-    ,[ l('Resource Types'), "./admin/booking/conify/resource_type" ]
-    ,[ l('Resources'), "./admin/booking/conify/resource" ]
+     [ l('Resources'), "./admin/server/booking/resource" ]
+    ,[ l('Resource Attribute Maps'), "./admin/server/booking/resource_attr_map" ]
+    ,[ l('Resource Attribute Values'), "./admin/server/booking/resource_attr_value" ]
+    ,[ l('Resource Attributes'), "./admin/server/booking/resource_attr" ]
+    ,[ l('Resource Types'), "./admin/server/booking/resource_type" ]
    ];
 
    USE table(interfaces, cols=3);
index 1932120..5d8bf43 100644 (file)
     ,[ l('Authority Thesauri'), "./admin/server/cat/authority/thesaurus" ]
     ,[ l('Best-Hold Selection Sort Order'), "./admin/server/config/best_hold_order" ]
     ,[ l('Billing Types'), "./admin/server/config/billing_type" ]
-    ,[ l('Booking Resource Attribute Maps'), "./admin/server/booking/resource_attr_map" ]
-    ,[ l('Booking Resource Attribute Values'), "./admin/server/booking/resource_attr_value" ]
-    ,[ l('Booking Resource Attributes'), "./admin/server/booking/resource_attr" ]
-    ,[ l('Booking Resource Types'), "./admin/server/booking/resource_type" ]
-    ,[ l('Booking Resources'), "./admin/server/booking/resource" ]
     ,[ l('Call Number Prefixes'), "./admin/server/config/acn_prefix" ]
     ,[ l('Call Number Suffixes'), "./admin/server/config/acn_suffix" ]
     ,[ l('Circulation Duration Rules'), "./admin/server/config/rule_circ_duration" ]
index bd618ac..4a5181e 100644 (file)
@@ -37,6 +37,8 @@
       label="[% l('Add Items to Bucket') %]"></eg-grid-action>
     <eg-grid-action handler="requestItems"
       label="[% l('Request Items') %]"></eg-grid-action>
+    <eg-grid-action handler="view_place_orders"
+      label="[% l('View/Place Orders') %]"></eg-grid-action>
     <eg-grid-action handler="attach_to_peer_bib"
       label="[% l('Link as Conjoined to Previously Marked Bib Record') %]"></eg-grid-action>
     <eg-grid-action handler="markLibAsVolTarget"
index 087c4e0..9755360 100644 (file)
@@ -9,6 +9,11 @@
 
   <eg-grid-action handler="add_copies_to_bucket"
     label="[% l('Add Items to Bucket') %]"></eg-grid-action>
+  <eg-grid-action handler="make_copies_bookable"
+    label="[% l('Make Items Bookable') %]"></eg-grid-action>
+  <eg-grid-action handler="book_copies_now"
+    disabled="need_one_selected"
+    label="[% l('Book Item Now') %]"></eg-grid-action>
   <eg-grid-action handler="requestItems"
     label="[% l('Request Items') %]"></eg-grid-action>
   <eg-grid-action handler="attach_to_peer_bib"
index fd2e133..d7ed391 100644 (file)
@@ -42,8 +42,8 @@
           <button class="btn btn-primary" ng-click="saveRecord()">{{ saveLabel || "[% l('Save') %]"}}</button>
         </span>
         <span class="btn-group">
-          <button ng-hide="brandNewRecord || embedded || Record().deleted()" class="btn btn-default" ng-click="deleteRecord()">[% l('Delete') %]</button>
-          <button ng-if="!brandNewRecord && Record().deleted()" class="btn btn-default" ng-click="undeleteRecord()">[% l('Undelete') %]</button>
+          <button ng-hide="brandNewRecord || embedded || Record().deleted() == 't'" class="btn btn-default" ng-click="deleteRecord()">[% l('Delete') %]</button>
+          <button ng-hide="brandNewRecord || Record().deleted() != 't'" class="btn btn-default" ng-click="undeleteRecord()">[% l('Undelete') %]</button>
         </span>
         <span class="btn-group">
           <button class="btn btn-default" ng-click="showHelp = !showHelp">[% l('Help') %]</button>
diff --git a/Open-ILS/tests/datasets/sql/assets_mr.sql b/Open-ILS/tests/datasets/sql/assets_mr.sql
new file mode 100644 (file)
index 0000000..2fafd63
--- /dev/null
@@ -0,0 +1,28 @@
+-- Create call numbers
+SELECT evergreen.populate_call_number(4, 'MR ', 'IMPORT MR', NULL); -- BR1
+SELECT evergreen.populate_call_number(5, 'MR ', 'IMPORT MR', NULL); -- BR2
+SELECT evergreen.populate_call_number(6, 'MR ', 'IMPORT MR', NULL); -- BR3
+SELECT evergreen.populate_call_number(7, 'MR ', 'IMPORT MR', NULL); -- BR4
+SELECT evergreen.populate_call_number(9, 'MR ', 'IMPORT MR', NULL); -- BM1
+
+-- Create copies
+SELECT evergreen.populate_copy(4, 4, 'MR40000', 'MR'); -- BR1
+SELECT evergreen.populate_copy(5, 5, 'MR50000', 'MR'); -- BR2
+SELECT evergreen.populate_copy(6, 6, 'MR60000', 'MR'); -- BR3
+SELECT evergreen.populate_copy(7, 7, 'MR70000', 'MR'); -- BR4
+SELECT evergreen.populate_copy(9, 9, 'MR90000', 'MR'); -- BM1
+
+SELECT evergreen.populate_copy(4, 4, 'MR41000', 'MR'); -- BR1
+SELECT evergreen.populate_copy(4, 4, 'MR42000', 'MR'); -- BR1
+SELECT evergreen.populate_copy(4, 4, 'MR43000', 'MR'); -- BR1
+SELECT evergreen.populate_copy(4, 4, 'MR44000', 'MR'); -- BR1
+SELECT evergreen.populate_copy(4, 4, 'MR45000', 'MR'); -- BR1
+SELECT evergreen.populate_copy(4, 4, 'MR46000', 'MR'); -- BR1
+SELECT evergreen.populate_copy(4, 4, 'MR47000', 'MR'); -- BR1
+SELECT evergreen.populate_copy(4, 4, 'MR48000', 'MR'); -- BR1
+SELECT evergreen.populate_copy(4, 4, 'MR49000', 'MR'); -- BR1
+
+SELECT evergreen.populate_copy(5, 5, 'MR51000', 'MR'); -- BR2
+SELECT evergreen.populate_copy(6, 6, 'MR61000', 'MR'); -- BR3
+SELECT evergreen.populate_copy(7, 7, 'MR71000', 'MR'); -- BR4
+SELECT evergreen.populate_copy(9, 9, 'MR91000', 'MR'); -- BM1
diff --git a/Open-ILS/tests/datasets/sql/bibs_ebook_api.sql b/Open-ILS/tests/datasets/sql/bibs_ebook_api.sql
new file mode 100644 (file)
index 0000000..1a47fe6
--- /dev/null
@@ -0,0 +1,8 @@
+\set bib_tag '''IMPORT EBOOK_API'''
+
+INSERT INTO marcxml_import (tag, marc) VALUES
+(:bib_tag,'<record    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"    xsi:schemaLocation="http://www.loc.gov/MARC21/slim http://www.loc.gov/standards/marcxml/schema/MARC21slim.xsd"    xmlns="http://www.loc.gov/MARC21/slim"><leader>00620nam a22      i 4500</leader><controlfield tag="005">20161210000052.0</controlfield><controlfield tag="008">070101s                o           eng d</controlfield><datafield tag="040" ind1=" " ind2=" "><subfield code="a">SITKA</subfield><subfield code="b">eng</subfield><subfield code="e">rda</subfield><subfield code="c">SITKA</subfield></datafield><datafield tag="100" ind1="1" ind2=" "><subfield code="a">Tolkien, J. R. R.</subfield><subfield code="q">(John Ronald Reuel),</subfield><subfield code="d">1892-1973,</subfield><subfield code="e">author.</subfield></datafield><datafield tag="245" ind1="1" ind2=" "><subfield code="a">The fellowship of the ring /</subfield><subfield code="c">by J.R.R. Tolkien.</subfield></datafield><datafield tag="250" ind1=" " ind2=" "><subfield code="a">2nd ed.</subfield></datafield><datafield tag="336" ind1=" " ind2=" "><subfield code="a">text</subfield><subfield code="2">rdacontent</subfield></datafield><datafield tag="337" ind1=" " ind2=" "><subfield code="a">computer</subfield><subfield code="2">rdamedia</subfield></datafield><datafield tag="338" ind1=" " ind2=" "><subfield code="a">online resource</subfield><subfield code="2">rdacarrier</subfield></datafield><datafield tag="490" ind1="1" ind2=" "><subfield code="a">The Lord of the rings / J.R.R. Tolkien ;</subfield><subfield code="v">pt. 1</subfield></datafield><datafield tag="800" ind1="1" ind2=" "><subfield code="a">Tolkien, J. R. R.</subfield><subfield code="q">(John Ronald Reuel),</subfield><subfield code="d">1892-1973.</subfield><subfield code="t">Lord of the rings</subfield></datafield><datafield tag="856" ind1="4" ind2="0"><subfield code="u">http://example.com/ebookapi/t/001</subfield><subfield code="y">Click to access online</subfield><subfield code="9">CONS</subfield></datafield></record>'),
+(:bib_tag,'<record    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"    xsi:schemaLocation="http://www.loc.gov/MARC21/slim http://www.loc.gov/standards/marcxml/schema/MARC21slim.xsd"    xmlns="http://www.loc.gov/MARC21/slim"><leader>00620nam a22      i 4500</leader><controlfield tag="005">20161210000052.0</controlfield><controlfield tag="008">070101s                o           eng d</controlfield><datafield tag="040" ind1=" " ind2=" "><subfield code="a">SITKA</subfield><subfield code="b">eng</subfield><subfield code="e">rda</subfield><subfield code="c">SITKA</subfield></datafield><datafield tag="100" ind1="1" ind2=" "><subfield code="a">Tolkien, J. R. R.</subfield><subfield code="q">(John Ronald Reuel),</subfield><subfield code="d">1892-1973,</subfield><subfield code="e">author.</subfield></datafield><datafield tag="245" ind1="1" ind2=" "><subfield code="a">The two towers /</subfield><subfield code="c">by J.R.R. Tolkien.</subfield></datafield><datafield tag="250" ind1=" " ind2=" "><subfield code="a">2nd ed.</subfield></datafield><datafield tag="336" ind1=" " ind2=" "><subfield code="a">text</subfield><subfield code="2">rdacontent</subfield></datafield><datafield tag="337" ind1=" " ind2=" "><subfield code="a">computer</subfield><subfield code="2">rdamedia</subfield></datafield><datafield tag="338" ind1=" " ind2=" "><subfield code="a">online resource</subfield><subfield code="2">rdacarrier</subfield></datafield><datafield tag="490" ind1="1" ind2=" "><subfield code="a">The Lord of the rings / J.R.R. Tolkien ;</subfield><subfield code="v">pt. 2</subfield></datafield><datafield tag="800" ind1="1" ind2=" "><subfield code="a">Tolkien, J. R. R.</subfield><subfield code="q">(John Ronald Reuel),</subfield><subfield code="d">1892-1973.</subfield><subfield code="t">Lord of the rings</subfield></datafield><datafield tag="856" ind1="4" ind2="0"><subfield code="u">http://example.com/ebookapi/t/002</subfield><subfield code="y">Click to access online</subfield><subfield code="9">CONS</subfield></datafield></record>'),
+(:bib_tag,'<record    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"    xsi:schemaLocation="http://www.loc.gov/MARC21/slim http://www.loc.gov/standards/marcxml/schema/MARC21slim.xsd"    xmlns="http://www.loc.gov/MARC21/slim"><leader>00620nam a22      i 4500</leader><controlfield tag="005">20161210000052.0</controlfield><controlfield tag="008">070101s                o           eng d</controlfield><datafield tag="040" ind1=" " ind2=" "><subfield code="a">SITKA</subfield><subfield code="b">eng</subfield><subfield code="e">rda</subfield><subfield code="c">SITKA</subfield></datafield><datafield tag="100" ind1="1" ind2=" "><subfield code="a">Tolkien, J. R. R.</subfield><subfield code="q">(John Ronald Reuel),</subfield><subfield code="d">1892-1973,</subfield><subfield code="e">author.</subfield></datafield><datafield tag="245" ind1="1" ind2=" "><subfield code="a">The return of the king /</subfield><subfield code="c">by J.R.R. Tolkien.</subfield></datafield><datafield tag="250" ind1=" " ind2=" "><subfield code="a">2nd ed.</subfield></datafield><datafield tag="336" ind1=" " ind2=" "><subfield code="a">text</subfield><subfield code="2">rdacontent</subfield></datafield><datafield tag="337" ind1=" " ind2=" "><subfield code="a">computer</subfield><subfield code="2">rdamedia</subfield></datafield><datafield tag="338" ind1=" " ind2=" "><subfield code="a">online resource</subfield><subfield code="2">rdacarrier</subfield></datafield><datafield tag="490" ind1="1" ind2=" "><subfield code="a">The Lord of the rings / J.R.R. Tolkien ;</subfield><subfield code="v">pt. 3</subfield></datafield><datafield tag="800" ind1="1" ind2=" "><subfield code="a">Tolkien, J. R. R.</subfield><subfield code="q">(John Ronald Reuel),</subfield><subfield code="d">1892-1973.</subfield><subfield code="t">Lord of the rings</subfield></datafield><datafield tag="856" ind1="4" ind2="0"><subfield code="u">http://example.com/ebookapi/t/003</subfield><subfield code="y">Click to access online</subfield><subfield code="9">CONS</subfield></datafield></record>'),
+(:bib_tag,'<record    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"    xsi:schemaLocation="http://www.loc.gov/MARC21/slim http://www.loc.gov/standards/marcxml/schema/MARC21slim.xsd"    xmlns="http://www.loc.gov/MARC21/slim"><leader>00620nam a22      i 4500</leader><controlfield tag="005">20161210000052.0</controlfield><controlfield tag="008">070101s                o           eng d</controlfield><datafield tag="040" ind1=" " ind2=" "><subfield code="a">SITKA</subfield><subfield code="b">eng</subfield><subfield code="e">rda</subfield><subfield code="c">SITKA</subfield></datafield><datafield tag="100" ind1="1" ind2=" "><subfield code="a">Tolkien, J. R. R.</subfield><subfield code="q">(John Ronald Reuel),</subfield><subfield code="d">1892-1973,</subfield><subfield code="e">author.</subfield></datafield><datafield tag="245" ind1="1" ind2=" "><subfield code="a">The hobbit, or, There and back again /</subfield><subfield code="c">by J.R.R. Tolkien.</subfield></datafield><datafield tag="250" ind1=" " ind2=" "><subfield code="a">New ed.</subfield></datafield><datafield tag="336" ind1=" " ind2=" "><subfield code="a">text</subfield><subfield code="2">rdacontent</subfield></datafield><datafield tag="337" ind1=" " ind2=" "><subfield code="a">computer</subfield><subfield code="2">rdamedia</subfield></datafield><datafield tag="338" ind1=" " ind2=" "><subfield code="a">online resource</subfield><subfield code="2">rdacarrier</subfield></datafield><datafield tag="856" ind1="4" ind2="0"><subfield code="u">http://example.com/ebookapi/t/004</subfield><subfield code="y">Click to access online</subfield><subfield code="9">CONS</subfield></datafield></record>')
+;
diff --git a/Open-ILS/tests/datasets/sql/bibs_mr.sql b/Open-ILS/tests/datasets/sql/bibs_mr.sql
new file mode 100644 (file)
index 0000000..28b6c46
--- /dev/null
@@ -0,0 +1,12 @@
+\set bib_tag '''IMPORT MR'''
+
+INSERT INTO marcxml_import (tag, marc) VALUES
+
+(:bib_tag,'<record><leader>00918nim a22002535  4500</leader><controlfield tag="001">2838534</controlfield><controlfield tag="005">20131130185904.0</controlfield><controlfield tag="007">sd|uungnnmmneu</controlfield><controlfield tag="008">130904                             eng  </controlfield><datafield tag="020" ind1=" " ind2=" "><subfield code="a">9781482926323 (CMD)</subfield><subfield code="c">$19.95</subfield></datafield><datafield tag="020" ind1=" " ind2=" "><subfield code="a">1482926326 (CMD)</subfield><subfield code="c">$19.95</subfield></datafield><datafield tag="037" ind1=" " ind2=" "><subfield code="b">Blackstone Audio Inc, Special Markets Po Box 969, Ashland, OR, USA, 97520, (541)4886035</subfield><subfield code="n">SAN 173-2811</subfield></datafield><datafield tag="040" ind1=" " ind2=" "><subfield code="d">UtOrBLW</subfield></datafield><datafield tag="082" ind1="0" ind2=" "><subfield code="a">813</subfield></datafield><datafield tag="092" ind1=" " ind2=" "><subfield code="a">813.0000</subfield></datafield><datafield tag="100" ind1="1" ind2=" "><subfield code="a">Lovecraft, H. P./ Herrmann, Edward (NRT)</subfield></datafield><datafield tag="245" ind1="1" ind2="0"><subfield code="a">At the Mountains of Madness</subfield><subfield code="h">[sound recording].</subfield></datafield><datafield tag="264" ind1=" " ind2="1"><subfield code="a">[Place of publication not identified] :</subfield><subfield code="b">Blackstone Audio Inc,</subfield><subfield code="c">2013.</subfield></datafield><datafield tag="336" ind1=" " ind2=" "><subfield code="a">spoken word</subfield><subfield code="2">rdacontent</subfield></datafield><datafield tag="337" ind1=" " ind2=" "><subfield code="a">audio</subfield><subfield code="2">rdamedia</subfield></datafield><datafield tag="338" ind1=" " ind2=" "><subfield code="a">audio disc</subfield><subfield code="2">rdacarrier</subfield></datafield></record>'),
+(:bib_tag,'<record><leader>01423pam a2200373 a 4500</leader><controlfield tag="001">3079565</controlfield><controlfield tag="005">20080108231100.0</controlfield><controlfield tag="008">080108s2005    nyu      b    001 1 eng  </controlfield><datafield tag="010" ind1=" " ind2=" "><subfield code="a">2005-041555</subfield></datafield><datafield tag="020" ind1=" " ind2=" "><subfield code="a">0812974417 (pbk.) :</subfield><subfield code="c">$15.95</subfield></datafield><datafield tag="035" ind1=" " ind2=" "><subfield code="a">(DLC)2005041555</subfield></datafield><datafield tag="040" ind1=" " ind2=" "><subfield code="a">DLC</subfield><subfield code="c">DLC</subfield><subfield code="d">DLC</subfield></datafield><datafield tag="043" ind1=" " ind2=" "><subfield code="a">t------</subfield></datafield><datafield tag="082" ind1="0" ind2="0"><subfield code="a">813/.52</subfield><subfield code="2">22</subfield></datafield><datafield tag="099" ind1=" " ind2=" "><subfield code="a">F</subfield><subfield code="a">LOVECRAFT</subfield></datafield><datafield tag="100" ind1="1" ind2=" "><subfield code="a">Lovecraft, H. P.</subfield><subfield code="q">(Howard Phillips),</subfield><subfield code="d">1890-1937.</subfield></datafield><datafield tag="245" ind1="1" ind2="0"><subfield code="a">At the mountains of madness /</subfield><subfield code="c">H.P. Lovecraft ; introduction by China Mie ville.</subfield></datafield><datafield tag="250" ind1=" " ind2=" "><subfield code="a">Definitive ed.</subfield></datafield><datafield tag="260" ind1=" " ind2=" "><subfield code="a">New York :</subfield><subfield code="b">Modern Library,</subfield><subfield code="c">2005.</subfield></datafield><datafield tag="300" ind1=" " ind2=" "><subfield code="a">xxv, 186 p. ;</subfield><subfield code="c">20 cm.</subfield></datafield><datafield tag="501" ind1=" " ind2=" "><subfield code="a">MARCIVE 12/19/07</subfield></datafield><datafield tag="504" ind1=" " ind2=" "><subfield code="a">Includes bibliographical references (p. xxiv-xxv) and index.</subfield></datafield><datafield tag="505" ind1="0" ind2=" "><subfield code="a">At the mountains of madness -- Supernatural horror in literature.</subfield></datafield><datafield tag="650" ind1=" " ind2="0"><subfield code="a">Fossils</subfield><subfield code="x">Collection and preservation</subfield><subfield code="v">Fiction.</subfield></datafield><datafield tag="650" ind1=" " ind2="0"><subfield code="a">Scientific expeditions</subfield><subfield code="v">Fiction.</subfield></datafield><datafield tag="650" ind1=" " ind2="0"><subfield code="a">Supernatural in literature.</subfield></datafield><datafield tag="650" ind1=" " ind2="0"><subfield code="a">Stone carving</subfield><subfield code="v">Fiction.</subfield></datafield><datafield tag="651" ind1=" " ind2="0"><subfield code="a">Antarctica</subfield><subfield code="v">Fiction.</subfield></datafield><datafield tag="655" ind1=" " ind2="7"><subfield code="a">Fantasy fiction.</subfield><subfield code="2">gsafd</subfield></datafield><datafield tag="655" ind1=" " ind2="7"><subfield code="a">Horror fiction.</subfield><subfield code="2">gsafd</subfield></datafield><datafield tag="700" ind1="1" ind2="2"><subfield code="a">Lovecraft, H. P.</subfield><subfield code="q">(Howard Phillips),</subfield><subfield code="d">1890-1937.</subfield><subfield code="t">Supernatural horror in literature.</subfield></datafield></record>'),
+(:bib_tag,'<record><leader>01639nim a2200445 a 4500</leader><controlfield tag="001">4101339</controlfield><controlfield tag="005">20140714090509.0</controlfield><controlfield tag="007">sd fungnnmmned</controlfield><controlfield tag="008">140709s2012    tnunnnn        f  n eng d</controlfield><datafield tag="020" ind1=" " ind2=" "><subfield code="a">9781843795940</subfield></datafield><datafield tag="028" ind1="0" ind2="1"><subfield code="a">DD10435</subfield><subfield code="b">Recorded Books</subfield></datafield><datafield tag="035" ind1=" " ind2=" "><subfield code="a">(CtWfDGI)rdd90010435</subfield></datafield><datafield tag="040" ind1=" " ind2=" "><subfield code="a">CtWfDGI</subfield><subfield code="b">eng</subfield><subfield code="c">CtWfDGI</subfield></datafield><datafield tag="043" ind1=" " ind2=" "><subfield code="a">t------</subfield></datafield><datafield tag="050" ind1="1" ind2="4"><subfield code="a">PS3523.O833</subfield><subfield code="b">A96 2012b</subfield></datafield><datafield tag="082" ind1="0" ind2="4"><subfield code="a">813/.52</subfield><subfield code="2">23</subfield></datafield><datafield tag="098" ind1="1" ind2="4"><subfield code="a">V</subfield><subfield code="a">LOVE</subfield><subfield code="a">AMM</subfield><subfield code="a">R 35</subfield></datafield><datafield tag="100" ind1="1" ind2=" "><subfield code="a">Lovecraft, H. P.</subfield><subfield code="q">(Howard Phillips),</subfield><subfield code="d">1890-1937.</subfield></datafield><datafield tag="245" ind1="1" ind2="0"><subfield code="a">At the mountains of madness</subfield><subfield code="h">[sound recording] /</subfield><subfield code="c">H.P. Lovecraft.</subfield></datafield><datafield tag="250" ind1=" " ind2=" "><subfield code="a">Unabridged.</subfield></datafield><datafield tag="260" ind1=" " ind2=" "><subfield code="a">Franklin, TN :</subfield><subfield code="b">Naxos AudioBooks ;</subfield><subfield code="a">Prince Frederick, MD :</subfield><subfield code="b">Distributed by Recorded Books,</subfield><subfield code="c">p2012.</subfield></datafield><datafield tag="300" ind1=" " ind2=" "><subfield code="a">4 sound discs (5 hr.) :</subfield><subfield code="b">digital ;</subfield><subfield code="c">4 3/4 in.</subfield></datafield><datafield tag="500" ind1=" " ind2=" "><subfield code="a">Title from container.</subfield></datafield><datafield tag="500" ind1=" " ind2=" "><subfield code="a">Compact disc.</subfield></datafield><datafield tag="500" ind1=" " ind2=" "><subfield code="a">In container (17 cm.).</subfield></datafield><datafield tag="511" ind1="0" ind2=" "><subfield code="a">Read by William Roberts.</subfield></datafield><datafield tag="520" ind1=" " ind2=" "><subfield code="a">An old man consents to a radio interview and a number of terrible truths are revealed about an expedition of Antarctica in the 1920&apos;s where everyone died horribly.</subfield></datafield><datafield tag="650" ind1=" " ind2="0"><subfield code="a">Scientific expeditions</subfield><subfield code="v">Fiction.</subfield></datafield><datafield tag="650" ind1=" " ind2="0"><subfield code="a">Stone carving</subfield><subfield code="v">Fiction.</subfield></datafield><datafield tag="650" ind1=" " ind2="0"><subfield code="a">Supernatural in literature.</subfield></datafield><datafield tag="650" ind1=" " ind2="0"><subfield code="a">Fossils</subfield><subfield code="x">Collection and preservation</subfield><subfield code="v">Fiction.</subfield></datafield><datafield tag="651" ind1=" " ind2="0"><subfield code="a">Antarctica</subfield><subfield code="v">Fiction.</subfield></datafield><datafield tag="655" ind1=" " ind2="7"><subfield code="a">Horror fiction.</subfield><subfield code="2">gsafd</subfield></datafield><datafield tag="655" ind1=" " ind2="7"><subfield code="a">Fantasy fiction.</subfield><subfield code="2">gsafd</subfield></datafield><datafield tag="655" ind1=" " ind2="7"><subfield code="a">Audiobooks.</subfield><subfield code="2">lcgft</subfield></datafield><datafield tag="700" ind1="1" ind2=" "><subfield code="a">Roberts, William,</subfield><subfield code="d">1943-</subfield><subfield code="4">nrt</subfield></datafield><datafield tag="710" ind1="2" ind2=" "><subfield code="a">Naxos AudioBooks, Ltd.</subfield></datafield><datafield tag="710" ind1="2" ind2=" "><subfield code="a">Recorded Books, LLC.</subfield></datafield></record>'),
+(:bib_tag,'<record><leader>01132cam a2200337Ka 4500</leader><controlfield tag="001">9403800</controlfield><controlfield tag="005">20170130113807.0</controlfield><controlfield tag="008">160622s2017    nyu     d     000 1 eng d</controlfield><datafield tag="035" ind1=" " ind2=" "><subfield code="a">(OCoLC)952384050</subfield></datafield><datafield tag="040" ind1=" " ind2=" "><subfield code="a">BTCTA</subfield><subfield code="b">eng</subfield><subfield code="c">BTCTA</subfield><subfield code="d">BDX</subfield><subfield code="d">TEFBT</subfield><subfield code="d">OCLCO</subfield><subfield code="d">OCLCF</subfield><subfield code="d">TEFBT</subfield></datafield><datafield tag="020" ind1=" " ind2=" "><subfield code="a">9781524755614</subfield></datafield><datafield tag="020" ind1=" " ind2=" "><subfield code="a">1524755613</subfield></datafield><datafield tag="049" ind1=" " ind2=" "><subfield code="a">HQBA</subfield></datafield><datafield tag="082" ind1="0" ind2="4"><subfield code="a">813/.6</subfield><subfield code="2">23</subfield></datafield><datafield tag="099" ind1=" " ind2=" "><subfield code="a">LP</subfield><subfield code="a">CLINE</subfield></datafield><datafield tag="100" ind1="1" ind2=" "><subfield code="a">Cline, Ernest.</subfield></datafield><datafield tag="245" ind1="1" ind2="0"><subfield code="a">Ready player one /</subfield><subfield code="c">Ernest Cline.</subfield></datafield><datafield tag="260" ind1=" " ind2=" "><subfield code="a">[New York] :</subfield><subfield code="b">Random House Large Print,</subfield><subfield code="c">[2017]</subfield></datafield><datafield tag="300" ind1=" " ind2=" "><subfield code="a">608 p. (large print) ;</subfield><subfield code="c">24 cm.</subfield></datafield><datafield tag="650" ind1=" " ind2="0"><subfield code="a">Regression (Civilization)</subfield><subfield code="v">Fiction.</subfield></datafield><datafield tag="650" ind1=" " ind2="0"><subfield code="a">Virtual reality</subfield><subfield code="v">Fiction.</subfield></datafield><datafield tag="650" ind1=" " ind2="0"><subfield code="a">Shared virtual environments</subfield><subfield code="v">Fiction.</subfield></datafield><datafield tag="650" ind1=" " ind2="0"><subfield code="a">Utopias</subfield><subfield code="v">Fiction.</subfield></datafield><datafield tag="650" ind1=" " ind2="0"><subfield code="a">Puzzles</subfield><subfield code="v">Fiction.</subfield></datafield><datafield tag="650" ind1=" " ind2="0"><subfield code="a">Large type books.</subfield></datafield><datafield tag="655" ind1=" " ind2="7"><subfield code="a">Fantasy fiction.</subfield><subfield code="2">gsafd</subfield></datafield><datafield tag="929" ind1=" " ind2=" "><subfield code="a">B&amp;T</subfield></datafield></record>'),
+(:bib_tag,'<record><leader>01817cim a2200481 i 4500</leader><controlfield tag="001">9206381</controlfield><controlfield tag="005">20120814110343.0</controlfield><controlfield tag="006">m        h        </controlfield><controlfield tag="007">cr una||||||||</controlfield><controlfield tag="007">sz usnnnn|||ed</controlfield><controlfield tag="008">111201s2011    nyunnnn o      ||   eng d</controlfield><datafield tag="020" ind1=" " ind2=" "><subfield code="a">9780307913166 (electronic audio bk.)</subfield></datafield><datafield tag="035" ind1=" " ind2=" "><subfield code="a">(TLC)erc0000004936</subfield></datafield><datafield tag="035" ind1=" " ind2=" "><subfield code="a">(OCoLC)749998216</subfield></datafield><datafield tag="037" ind1=" " ind2=" "><subfield code="a">F9F9D65D-C3C8-445C-AE9D-D82B72C241D0</subfield><subfield code="b">OverDrive, Inc.</subfield><subfield code="n">http://www.overdrive.com</subfield></datafield><datafield tag="040" ind1=" " ind2=" "><subfield code="a">TLC</subfield><subfield code="c">TLC</subfield><subfield code="d">TLC</subfield><subfield code="e">rda</subfield></datafield><datafield tag="050" ind1="0" ind2="0"><subfield code="a">PS3603.L548</subfield><subfield code="b">R43 2011</subfield></datafield><datafield tag="082" ind1="0" ind2="0"><subfield code="a">813/.6</subfield><subfield code="2">22</subfield></datafield><datafield tag="100" ind1="1" ind2=" "><subfield code="a">Cline, Ernest,</subfield><subfield code="e">author.</subfield></datafield><datafield tag="245" ind1="1" ind2="0"><subfield code="a">Ready player one</subfield><subfield code="h">[electronic resource] /</subfield><subfield code="c">Ernest Cline.</subfield></datafield><datafield tag="264" ind1=" " ind2="1"><subfield code="a">New York :</subfield><subfield code="b">Crown Publishers</subfield></datafield><datafield tag="264" ind1=" " ind2="4"><subfield code="c">©2011</subfield></datafield><datafield tag="300" ind1=" " ind2=" "><subfield code="a">1 sound file :</subfield><subfield code="b">digital</subfield></datafield><datafield tag="336" ind1=" " ind2=" "><subfield code="a">spoken word</subfield><subfield code="b">spw</subfield><subfield code="2">rdacontent</subfield></datafield><datafield tag="337" ind1=" " ind2=" "><subfield code="a">computer</subfield><subfield code="b">c</subfield><subfield code="2">rdamedia</subfield></datafield><datafield tag="338" ind1=" " ind2=" "><subfield code="a">online resource</subfield><subfield code="b">cr</subfield><subfield code="2">rdacarrier</subfield></datafield><datafield tag="347" ind1=" " ind2=" "><subfield code="a">audio file</subfield><subfield code="2">rda</subfield></datafield><datafield tag="380" ind1=" " ind2=" "><subfield code="a">eAudiobook</subfield><subfield code="2">tlcgt</subfield></datafield><datafield tag="385" ind1=" " ind2=" "><subfield code="a">General</subfield><subfield code="2">tlctarget</subfield></datafield><datafield tag="500" ind1=" " ind2=" "><subfield code="a">Electronic audio file.</subfield></datafield><datafield tag="533" ind1=" " ind2=" "><subfield code="a">Electronic reproduction.</subfield><subfield code="b">New York</subfield><subfield code="c">Penguin Random House Audio Publishing Group</subfield><subfield code="d">2011</subfield><subfield code="n">Available via World Wide Web.</subfield></datafield><datafield tag="650" ind1=" " ind2="0"><subfield code="a">Regression (Civilization)</subfield><subfield code="v">Fiction.</subfield></datafield><datafield tag="650" ind1=" " ind2="0"><subfield code="a">Virtual reality</subfield><subfield code="v">Fiction.</subfield></datafield><datafield tag="650" ind1=" " ind2="0"><subfield code="a">Utopias</subfield><subfield code="v">Fiction.</subfield></datafield><datafield tag="650" ind1=" " ind2="0"><subfield code="a">Puzzles</subfield><subfield code="v">Fiction.</subfield></datafield><datafield tag="655" ind1=" " ind2="7"><subfield code="a">Fantasy fiction.</subfield><subfield code="2">gsafd</subfield></datafield><datafield tag="655" ind1=" " ind2="7"><subfield code="a">Electronic audio books.</subfield><subfield code="2">local</subfield></datafield><datafield tag="710" ind1="2" ind2=" "><subfield code="a">OverDrive, Inc.,</subfield><subfield code="e">distributor.</subfield></datafield></record>'),
+(:bib_tag,'<record><leader>01619cam a2200409Ia 4500</leader><controlfield tag="001">9150274</controlfield><controlfield tag="005">20120530113527.0</controlfield><controlfield tag="007">sd fungnnmmned</controlfield><controlfield tag="008">110523s2011    mdu    e      000 1 eng d</controlfield><datafield tag="020" ind1=" " ind2=" "><subfield code="a">9780307970060 :</subfield><subfield code="c">40.00</subfield></datafield><datafield tag="020" ind1=" " ind2=" "><subfield code="a">030797006X :</subfield><subfield code="c">40.00</subfield></datafield><datafield tag="028" ind1="0" ind2="2"><subfield code="a">RHA3179</subfield><subfield code="b">Books on Tape</subfield></datafield><datafield tag="035" ind1=" " ind2=" "><subfield code="a">3760223</subfield></datafield><datafield tag="035" ind1=" " ind2=" "><subfield code="a">(OCoLC)726753599</subfield></datafield><datafield tag="035" ind1=" " ind2=" "><subfield code="a">3760223</subfield></datafield><datafield tag="037" ind1=" " ind2=" "><subfield code="b">Books on Tape</subfield><subfield code="n">http://library.booksontape.com</subfield></datafield><datafield tag="040" ind1=" " ind2=" "><subfield code="a">TEFBT</subfield><subfield code="c">TEFBT</subfield><subfield code="d">TEFBT</subfield></datafield><datafield tag="050" ind1="1" ind2="4"><subfield code="a">PS3603.L548</subfield><subfield code="b">R43 2011ab</subfield></datafield><datafield tag="082" ind1="0" ind2="4"><subfield code="a">813/.6</subfield><subfield code="2">23</subfield></datafield><datafield tag="092" ind1=" " ind2=" "><subfield code="a">BCD Cline</subfield></datafield><datafield tag="100" ind1="1" ind2=" "><subfield code="a">Cline, Ernest</subfield></datafield><datafield tag="245" ind1="1" ind2="0"><subfield code="a">Ready player one /</subfield><subfield code="c">Ernest Cline.</subfield><subfield code="h">[sound recording] </subfield></datafield><datafield tag="250" ind1=" " ind2=" "><subfield code="a">Unabridged sound recording</subfield></datafield><datafield tag="260" ind1=" " ind2=" "><subfield code="a">[Westminster, Md.] :</subfield><subfield code="b">Books on Tape,</subfield><subfield code="c">p2011.</subfield></datafield><datafield tag="300" ind1=" " ind2=" "><subfield code="a">13 sound discs ; 15 hours, 41 minutes :</subfield><subfield code="b">digital ;</subfield><subfield code="c">4 3/4 in.</subfield></datafield><datafield tag="500" ind1=" " ind2=" "><subfield code="a">Unabridged.</subfield></datafield><datafield tag="511" ind1="0" ind2=" "><subfield code="a">Read by Wil Wheaton.</subfield></datafield><datafield tag="520" ind1=" " ind2=" "><subfield code="a">At once wildly original and stuffed with irresistible nostalgia, READY PLAYER ONE is a spectacularly genre-busting, ambitious, and charming debut-- part quest novel, part love story, and part virtual space opera set in a universe where spell-slinging mages battle giant Japanese robots, entire planets are inspired by Blade Runner, and flying DeLoreans achieve light speed.</subfield></datafield><datafield tag="650" ind1=" " ind2="0"><subfield code="a">Regression (Civilization)</subfield><subfield code="v">Fiction.</subfield></datafield><datafield tag="650" ind1=" " ind2="0"><subfield code="a">Virtual reality</subfield><subfield code="v">Fiction.</subfield></datafield><datafield tag="650" ind1=" " ind2="0"><subfield code="a">Utopias</subfield><subfield code="v">Fiction.</subfield></datafield><datafield tag="650" ind1=" " ind2="0"><subfield code="a">Puzzles</subfield><subfield code="v">Fiction.</subfield></datafield><datafield tag="655" ind1=" " ind2="0"><subfield code="a">Audiobooks.</subfield></datafield><datafield tag="655" ind1=" " ind2="7"><subfield code="a">Fantasy fiction</subfield></datafield></record>'),
+(:bib_tag,'<record><leader>00975pam a2200337 a 4500</leader><controlfield tag="001">8112628</controlfield><controlfield tag="005">20110823130500.0</controlfield><controlfield tag="008">110422s2011    nyu           000 1 eng  </controlfield><datafield tag="010" ind1=" " ind2=" "><subfield code="a">  2011015247</subfield></datafield><datafield tag="020" ind1=" " ind2=" "><subfield code="a">9780307887436 :</subfield><subfield code="c">$24.00</subfield></datafield><datafield tag="020" ind1=" " ind2=" "><subfield code="a">030788743X :</subfield><subfield code="c">$24.00</subfield></datafield><datafield tag="035" ind1=" " ind2=" "><subfield code="a">(DLC)  2011015247</subfield></datafield><datafield tag="040" ind1=" " ind2=" "><subfield code="a">DLC</subfield><subfield code="c">DLC</subfield><subfield code="d">NjBwBT</subfield><subfield code="d">GCmBT</subfield></datafield><datafield tag="042" ind1=" " ind2=" "><subfield code="a">pcc</subfield></datafield><datafield tag="050" ind1="0" ind2="0"><subfield code="a">PS3603.L548</subfield><subfield code="b">R43 2011</subfield></datafield><datafield tag="082" ind1="0" ind2="0"><subfield code="a">813/.6</subfield><subfield code="2">22</subfield></datafield><datafield tag="100" ind1="1" ind2=" "><subfield code="a">Cline, Ernest.</subfield></datafield><datafield tag="245" ind1="1" ind2="0"><subfield code="a">Ready player one /</subfield><subfield code="c">Ernest Cline.</subfield></datafield><datafield tag="250" ind1=" " ind2=" "><subfield code="a">1st ed.</subfield></datafield><datafield tag="260" ind1=" " ind2=" "><subfield code="a">New York :</subfield><subfield code="b">Crown Publishers,</subfield><subfield code="c">c2011.</subfield></datafield><datafield tag="300" ind1=" " ind2=" "><subfield code="a">374 p. ;</subfield><subfield code="c">25 cm.</subfield></datafield><datafield tag="650" ind1=" " ind2="0"><subfield code="a">Regression (Civilization)</subfield><subfield code="v">Fiction.</subfield></datafield><datafield tag="650" ind1=" " ind2="0"><subfield code="a">Virtual reality</subfield><subfield code="v">Fiction.</subfield></datafield><datafield tag="650" ind1=" " ind2="0"><subfield code="a">Utopias</subfield><subfield code="v">Fiction.</subfield></datafield><datafield tag="650" ind1=" " ind2="0"><subfield code="a">Puzzles</subfield><subfield code="v">Fiction.</subfield></datafield><datafield tag="655" ind1=" " ind2="7"><subfield code="a">Fantasy fiction.</subfield><subfield code="2">gsafd</subfield></datafield><datafield tag="850" ind1=" " ind2=" "><subfield code="b">1</subfield></datafield></record>')
+;
index 1d4914e..472795d 100644 (file)
@@ -65,6 +65,22 @@ INSERT INTO biblio.record_entry (marc, last_xact_id)
 -- funds, orders, etc.
 \i acq.sql
 
+-- delete previously imported bibs
+DELETE FROM marcxml_import;
+
+-- load EbookAPI bibs
+\i bibs_ebook_api.sql
+
+-- load metarecord bibs
+\i bibs_mr.sql
+
+-- insert all loaded bibs into the biblio.record_entry in insert order
+INSERT INTO biblio.record_entry (marc, last_xact_id)
+    SELECT marc, tag FROM marcxml_import ORDER BY id;
+
+-- load MR copies, etc.
+\i assets_mr.sql
+
 -- clean up the env
 \i env_destroy.sql
 
index 459681f..47021d2 100644 (file)
@@ -4,11 +4,16 @@ if(!dojo._hasResource["openils.XUL"]) {
     dojo.declare('openils.XUL', null, {});
 
     openils.XUL.Component_copy;
-    try {
-        openils.XUL.Component_copy = Components;
-    } catch (e) {
-        openils.XUL.Component_copy = null;
-    };
+    if (!window.IAMBROWSER) {
+        // looks like Firefox also exposes 'Components', so its
+        // existence is not sufficient check of XUL-ness
+        try {
+            if (Components.classes)
+                openils.XUL.Component_copy = Components;
+        } catch (e) {
+            openils.XUL.Component_copy = null;
+        };
+    }
 
     openils.XUL.isXUL = function() {
         if(location.protocol == 'chrome:' || location.protocol == 'oils:') return true;
index db49e08..886d4be 100644 (file)
@@ -35,13 +35,24 @@ if (!dojo._hasResource["openils.widget.XULTermLoader"]) {
                     "span", {"innerHTML": this.terms.length},
                     this.labelNode, "first"
                 );
-                this.buttonNode = dojo.create(
-                    "button", {
-                        "innerHTML": this._.BUTTON_TEXT,
-                        "onclick": function() { self.loadTerms(); }
-                    },
-                    this.domNode, "last"
-                );
+                if (window.parent.IEMBEDXUL) {
+                    this.buttonNode = dojo.create(
+                        "input", {
+                            "type" : "file",
+                            "innerHTML": this._.BUTTON_TEXT,
+                            "onchange": function(evt) { self.loadTerms(evt); }
+                        },
+                        this.domNode, "last"
+                    );
+                } else {
+                    this.buttonNode = dojo.create(
+                        "button", {
+                            "innerHTML": this._.BUTTON_TEXT,
+                            "onclick": function() { self.loadTerms(); }
+                        },
+                        this.domNode, "last"
+                    );
+                }
 
                 if (this.args.parentNode)
                     dojo.place(this.domNode, this.args.parentNode, "last");
@@ -57,31 +68,49 @@ if (!dojo._hasResource["openils.widget.XULTermLoader"]) {
             "focus": function() {
                 this.buttonNode.focus();
             },
-            "loadTerms": function() {
+            "loadTerms": function(evt) {
                 try {
                     if (this.terms.length >= this.args.termLimit) {
                         alert(this._.TERM_LIMIT);
                         return;
                     }
-                    var data = this[
-                        this.parseCSV ? "parseAsCSV" : "parseUnimaginatively"
-                    ](
-                        openils.XUL.contentFromFileOpenDialog(
-                            this._.CHOOSE_FILE, this.args.fileSizeLimit
-                        )
-                    );
+                    var data;
+                    var self = this;
 
-                    if (data.length + this.terms.length >=
-                        this.args.termLimit) {
-                        alert(this._.TERM_LIMIT_SOME);
-                        var can = this.args.termLimit - this.terms.length;
-                        if (can > 0)
-                            this.terms = this.terms.concat(data.slice(0, can));
+                    function updateTermList() {
+                        if (data.length + self.terms.length >=
+                            self.args.termLimit) {
+                            alert(self._.TERM_LIMIT_SOME);
+                            var can = self.args.termLimit - self.terms.length;
+                            if (can > 0)
+                                self.terms = self.terms.concat(data.slice(0, can));
+                        } else {
+                            self.terms = self.terms.concat(data);
+                        }
+                        self.attr("value", self.terms);
+                        self.updateCount();
+                    }
+
+                    if (evt && window.IAMBROWSER) {
+                        var reader = new FileReader();
+                        reader.onloadend = function(evt) {
+                            data = self[
+                                self.parseCSV ? "parseAsCSV" : "parseUnimaginatively"
+                            ](evt.target.result);
+                            updateTermList();
+                        };
+                        reader.readAsText(evt.target.files[0]);
                     } else {
-                        this.terms = this.terms.concat(data);
+                        data = this[
+                            this.parseCSV ? "parseAsCSV" : "parseUnimaginatively"
+                        ](
+                            openils.XUL.contentFromFileOpenDialog(
+                                this._.CHOOSE_FILE, this.args.fileSizeLimit
+                            )
+                        );
+                        updateTermList();
                     }
-                    this.attr("value", this.terms);
-                    this.updateCount();
+
                 } catch(E) {
                     alert(E);
                 }
diff --git a/Open-ILS/web/js/file-saver/FileSaver.js b/Open-ILS/web/js/file-saver/FileSaver.js
new file mode 100644 (file)
index 0000000..fb71494
--- /dev/null
@@ -0,0 +1,188 @@
+/* FileSaver.js
+ * A saveAs() FileSaver implementation.
+ * 1.3.2
+ * 2016-06-16 18:25:19
+ *
+ * By Eli Grey, http://eligrey.com
+ * License: MIT
+ *   See https://github.com/eligrey/FileSaver.js/blob/master/LICENSE.md
+ */
+
+/*global self */
+/*jslint bitwise: true, indent: 4, laxbreak: true, laxcomma: true, smarttabs: true, plusplus: true */
+
+/*! @source http://purl.eligrey.com/github/FileSaver.js/blob/master/FileSaver.js */
+
+var saveAs = saveAs || (function(view) {
+       "use strict";
+       // IE <10 is explicitly unsupported
+       if (typeof view === "undefined" || typeof navigator !== "undefined" && /MSIE [1-9]\./.test(navigator.userAgent)) {
+               return;
+       }
+       var
+                 doc = view.document
+                 // only get URL when necessary in case Blob.js hasn't overridden it yet
+               , get_URL = function() {
+                       return view.URL || view.webkitURL || view;
+               }
+               , save_link = doc.createElementNS("http://www.w3.org/1999/xhtml", "a")
+               , can_use_save_link = "download" in save_link
+               , click = function(node) {
+                       var event = new MouseEvent("click");
+                       node.dispatchEvent(event);
+               }
+               , is_safari = /constructor/i.test(view.HTMLElement) || view.safari
+               , is_chrome_ios =/CriOS\/[\d]+/.test(navigator.userAgent)
+               , throw_outside = function(ex) {
+                       (view.setImmediate || view.setTimeout)(function() {
+                               throw ex;
+                       }, 0);
+               }
+               , force_saveable_type = "application/octet-stream"
+               // the Blob API is fundamentally broken as there is no "downloadfinished" event to subscribe to
+               , arbitrary_revoke_timeout = 1000 * 40 // in ms
+               , revoke = function(file) {
+                       var revoker = function() {
+                               if (typeof file === "string") { // file is an object URL
+                                       get_URL().revokeObjectURL(file);
+                               } else { // file is a File
+                                       file.remove();
+                               }
+                       };
+                       setTimeout(revoker, arbitrary_revoke_timeout);
+               }
+               , dispatch = function(filesaver, event_types, event) {
+                       event_types = [].concat(event_types);
+                       var i = event_types.length;
+                       while (i--) {
+                               var listener = filesaver["on" + event_types[i]];
+                               if (typeof listener === "function") {
+                                       try {
+                                               listener.call(filesaver, event || filesaver);
+                                       } catch (ex) {
+                                               throw_outside(ex);
+                                       }
+                               }
+                       }
+               }
+               , auto_bom = function(blob) {
+                       // prepend BOM for UTF-8 XML and text/* types (including HTML)
+                       // note: your browser will automatically convert UTF-16 U+FEFF to EF BB BF
+                       if (/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(blob.type)) {
+                               return new Blob([String.fromCharCode(0xFEFF), blob], {type: blob.type});
+                       }
+                       return blob;
+               }
+               , FileSaver = function(blob, name, no_auto_bom) {
+                       if (!no_auto_bom) {
+                               blob = auto_bom(blob);
+                       }
+                       // First try a.download, then web filesystem, then object URLs
+                       var
+                                 filesaver = this
+                               , type = blob.type
+                               , force = type === force_saveable_type
+                               , object_url
+                               , dispatch_all = function() {
+                                       dispatch(filesaver, "writestart progress write writeend".split(" "));
+                               }
+                               // on any filesys errors revert to saving with object URLs
+                               , fs_error = function() {
+                                       if ((is_chrome_ios || (force && is_safari)) && view.FileReader) {
+                                               // Safari doesn't allow downloading of blob urls
+                                               var reader = new FileReader();
+                                               reader.onloadend = function() {
+                                                       var url = is_chrome_ios ? reader.result : reader.result.replace(/^data:[^;]*;/, 'data:attachment/file;');
+                                                       var popup = view.open(url, '_blank');
+                                                       if(!popup) view.location.href = url;
+                                                       url=undefined; // release reference before dispatching
+                                                       filesaver.readyState = filesaver.DONE;
+                                                       dispatch_all();
+                                               };
+                                               reader.readAsDataURL(blob);
+                                               filesaver.readyState = filesaver.INIT;
+                                               return;
+                                       }
+                                       // don't create more object URLs than needed
+                                       if (!object_url) {
+                                               object_url = get_URL().createObjectURL(blob);
+                                       }
+                                       if (force) {
+                                               view.location.href = object_url;
+                                       } else {
+                                               var opened = view.open(object_url, "_blank");
+                                               if (!opened) {
+                                                       // Apple does not allow window.open, see https://developer.apple.com/library/safari/documentation/Tools/Conceptual/SafariExtensionGuide/WorkingwithWindowsandTabs/WorkingwithWindowsandTabs.html
+                                                       view.location.href = object_url;
+                                               }
+                                       }
+                                       filesaver.readyState = filesaver.DONE;
+                                       dispatch_all();
+                                       revoke(object_url);
+                               }
+                       ;
+                       filesaver.readyState = filesaver.INIT;
+
+                       if (can_use_save_link) {
+                               object_url = get_URL().createObjectURL(blob);
+                               setTimeout(function() {
+                                       save_link.href = object_url;
+                                       save_link.download = name;
+                                       click(save_link);
+                                       dispatch_all();
+                                       revoke(object_url);
+                                       filesaver.readyState = filesaver.DONE;
+                               });
+                               return;
+                       }
+
+                       fs_error();
+               }
+               , FS_proto = FileSaver.prototype
+               , saveAs = function(blob, name, no_auto_bom) {
+                       return new FileSaver(blob, name || blob.name || "download", no_auto_bom);
+               }
+       ;
+       // IE 10+ (native saveAs)
+       if (typeof navigator !== "undefined" && navigator.msSaveOrOpenBlob) {
+               return function(blob, name, no_auto_bom) {
+                       name = name || blob.name || "download";
+
+                       if (!no_auto_bom) {
+                               blob = auto_bom(blob);
+                       }
+                       return navigator.msSaveOrOpenBlob(blob, name);
+               };
+       }
+
+       FS_proto.abort = function(){};
+       FS_proto.readyState = FS_proto.INIT = 0;
+       FS_proto.WRITING = 1;
+       FS_proto.DONE = 2;
+
+       FS_proto.error =
+       FS_proto.onwritestart =
+       FS_proto.onprogress =
+       FS_proto.onwrite =
+       FS_proto.onabort =
+       FS_proto.onerror =
+       FS_proto.onwriteend =
+               null;
+
+       return saveAs;
+}(
+          typeof self !== "undefined" && self
+       || typeof window !== "undefined" && window
+       || this.content
+));
+// `self` is undefined in Firefox for Android content script context
+// while `this` is nsIContentFrameMessageManager
+// with an attribute `content` that corresponds to the window
+
+if (typeof module !== "undefined" && module.exports) {
+  module.exports.saveAs = saveAs;
+} else if ((typeof define !== "undefined" && define !== null) && (define.amd !== null)) {
+  define("FileSaver.js", function() {
+    return saveAs;
+  });
+}
diff --git a/Open-ILS/web/js/file-saver/FileSaver.min.js b/Open-ILS/web/js/file-saver/FileSaver.min.js
new file mode 100644 (file)
index 0000000..9a1e397
--- /dev/null
@@ -0,0 +1,2 @@
+/*! @source http://purl.eligrey.com/github/FileSaver.js/blob/master/FileSaver.js */
+var saveAs=saveAs||function(e){"use strict";if(typeof e==="undefined"||typeof navigator!=="undefined"&&/MSIE [1-9]\./.test(navigator.userAgent)){return}var t=e.document,n=function(){return e.URL||e.webkitURL||e},r=t.createElementNS("http://www.w3.org/1999/xhtml","a"),o="download"in r,a=function(e){var t=new MouseEvent("click");e.dispatchEvent(t)},i=/constructor/i.test(e.HTMLElement)||e.safari,f=/CriOS\/[\d]+/.test(navigator.userAgent),u=function(t){(e.setImmediate||e.setTimeout)(function(){throw t},0)},s="application/octet-stream",d=1e3*40,c=function(e){var t=function(){if(typeof e==="string"){n().revokeObjectURL(e)}else{e.remove()}};setTimeout(t,d)},l=function(e,t,n){t=[].concat(t);var r=t.length;while(r--){var o=e["on"+t[r]];if(typeof o==="function"){try{o.call(e,n||e)}catch(a){u(a)}}}},p=function(e){if(/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(e.type)){return new Blob([String.fromCharCode(65279),e],{type:e.type})}return e},v=function(t,u,d){if(!d){t=p(t)}var v=this,w=t.type,m=w===s,y,h=function(){l(v,"writestart progress write writeend".split(" "))},S=function(){if((f||m&&i)&&e.FileReader){var r=new FileReader;r.onloadend=function(){var t=f?r.result:r.result.replace(/^data:[^;]*;/,"data:attachment/file;");var n=e.open(t,"_blank");if(!n)e.location.href=t;t=undefined;v.readyState=v.DONE;h()};r.readAsDataURL(t);v.readyState=v.INIT;return}if(!y){y=n().createObjectURL(t)}if(m){e.location.href=y}else{var o=e.open(y,"_blank");if(!o){e.location.href=y}}v.readyState=v.DONE;h();c(y)};v.readyState=v.INIT;if(o){y=n().createObjectURL(t);setTimeout(function(){r.href=y;r.download=u;a(r);h();c(y);v.readyState=v.DONE});return}S()},w=v.prototype,m=function(e,t,n){return new v(e,t||e.name||"download",n)};if(typeof navigator!=="undefined"&&navigator.msSaveOrOpenBlob){return function(e,t,n){t=t||e.name||"download";if(!n){e=p(e)}return navigator.msSaveOrOpenBlob(e,t)}}w.abort=function(){};w.readyState=w.INIT=0;w.WRITING=1;w.DONE=2;w.error=w.onwritestart=w.onprogress=w.onwrite=w.onabort=w.onerror=w.onwriteend=null;return m}(typeof self!=="undefined"&&self||typeof window!=="undefined"&&window||this.content);if(typeof module!=="undefined"&&module.exports){module.exports.saveAs=saveAs}else if(typeof define!=="undefined"&&define!==null&&define.amd!==null){define("FileSaver.js",function(){return saveAs})}
diff --git a/Open-ILS/web/js/file-saver/LICENSE.md b/Open-ILS/web/js/file-saver/LICENSE.md
new file mode 100644 (file)
index 0000000..32ef3ca
--- /dev/null
@@ -0,0 +1,11 @@
+The MIT License
+
+Copyright © 2016 [Eli Grey][1].
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+  [1]: http://eligrey.com
diff --git a/Open-ILS/web/js/file-saver/README.md b/Open-ILS/web/js/file-saver/README.md
new file mode 100644 (file)
index 0000000..4cb9293
--- /dev/null
@@ -0,0 +1,135 @@
+If you need to save really large files bigger then the blob's size limitation or don't have 
+enough RAM, then have a look at the more advanced [StreamSaver.js](https://github.com/jimmywarting/StreamSaver.js)
+that can save data directly to the hard drive asynchronously with the power of the new streams API. That will have
+support for progress, cancelation and knowing when it's done writing
+
+FileSaver.js
+============
+
+FileSaver.js implements the `saveAs()` FileSaver interface in browsers that do
+not natively support it. There is a [FileSaver.js demo][1] that demonstrates saving
+various media types.
+
+FileSaver.js is the solution to saving files on the client-side, and is perfect for
+webapps that need to generate files, or for saving sensitive information that shouldn't be
+sent to an external server.
+
+Looking for `canvas.toBlob()` for saving canvases? Check out
+[canvas-toBlob.js][2] for a cross-browser implementation.
+
+Supported browsers
+------------------
+
+| Browser        | Constructs as | Filenames    | Max Blob Size | Dependencies |
+| -------------- | ------------- | ------------ | ------------- | ------------ |
+| Firefox 20+    | Blob          | Yes          | 800 MiB       | None         |
+| Firefox < 20   | data: URI     | No           | n/a           | [Blob.js](https://github.com/eligrey/Blob.js) |
+| Chrome         | Blob          | Yes          | [500 MiB][3]  | None         |
+| Chrome for Android | Blob      | Yes          | [500 MiB][3]  | None         |
+| Edge           | Blob          | Yes          | ?             | None         |
+| IE 10+         | Blob          | Yes          | 600 MiB       | None         |
+| Opera 15+      | Blob          | Yes          | 500 MiB       | None         |
+| Opera < 15     | data: URI     | No           | n/a           | [Blob.js](https://github.com/eligrey/Blob.js) |
+| Safari 6.1+*   | Blob          | No           | ?             | None         |
+| Safari < 6     | data: URI     | No           | n/a           | [Blob.js](https://github.com/eligrey/Blob.js) |
+
+Feature detection is possible:
+
+```js
+try {
+    var isFileSaverSupported = !!new Blob;
+} catch (e) {}
+```
+
+### IE < 10
+
+It is possible to save text files in IE < 10 without Flash-based polyfills.
+See [ChenWenBrian and koffsyrup's `saveTextAs()`](https://github.com/koffsyrup/FileSaver.js#examples) for more details.
+
+### Safari 6.1+
+
+Blobs may be opened instead of saved sometimes—you may have to direct your Safari users to manually
+press <kbd>⌘</kbd>+<kbd>S</kbd> to save the file after it is opened. Using the `application/octet-stream` MIME type to force downloads [can cause issues in Safari](https://github.com/eligrey/FileSaver.js/issues/12#issuecomment-47247096).
+
+### iOS
+
+saveAs must be run within a user interaction event such as onTouchDown or onClick; setTimeout will prevent saveAs from triggering. Due to restrictions in iOS saveAs opens in a new window instead of downloading, if you want this fixed please [tell Apple](https://bugs.webkit.org/show_bug.cgi?id=102914) how this bug is affecting you.
+
+Syntax
+------
+
+```js
+FileSaver saveAs(Blob/File data, optional DOMString filename, optional Boolean disableAutoBOM)
+```
+
+Pass `true` for `disableAutoBOM` if you don't want FileSaver.js to automatically provide Unicode text encoding hints (see: [byte order mark](https://en.wikipedia.org/wiki/Byte_order_mark)).
+
+Examples
+--------
+
+### Saving text
+
+```js
+var blob = new Blob(["Hello, world!"], {type: "text/plain;charset=utf-8"});
+saveAs(blob, "hello world.txt");
+```
+
+The standard W3C File API [`Blob`][4] interface is not available in all browsers.
+[Blob.js][5] is a cross-browser `Blob` implementation that solves this.
+
+### Saving a canvas
+
+```js
+var canvas = document.getElementById("my-canvas"), ctx = canvas.getContext("2d");
+// draw to canvas...
+canvas.toBlob(function(blob) {
+    saveAs(blob, "pretty image.png");
+});
+```
+
+Note: The standard HTML5 `canvas.toBlob()` method is not available in all browsers.
+[canvas-toBlob.js][6] is a cross-browser `canvas.toBlob()` that polyfills this.
+
+### Saving File
+
+You can save a File constructor without specifying a filename. The
+File itself already contains a name, There is a hand full of ways to get a file
+instance (from storage, file input, new constructor)
+But if you still want to change the name, then you can change it in the 2nd argument
+
+```js
+var file = new File(["Hello, world!"], "hello world.txt", {type: "text/plain;charset=utf-8"});
+saveAs(file);
+```
+
+
+
+![Tracking image](https://in.getclicky.com/212712ns.gif)
+
+  [1]: http://eligrey.com/demos/FileSaver.js/
+  [2]: https://github.com/eligrey/canvas-toBlob.js
+  [3]: https://code.google.com/p/chromium/issues/detail?id=375297
+  [4]: https://developer.mozilla.org/en-US/docs/DOM/Blob
+  [5]: https://github.com/eligrey/Blob.js
+  [6]: https://github.com/eligrey/canvas-toBlob.js
+
+Contributing
+------------
+
+The `FileSaver.js` distribution file is compiled with Uglify.js like so:
+
+```bash
+uglifyjs FileSaver.js --mangle --comments /@source/ > FileSaver.min.js
+# or simply:
+npm run build
+```
+
+Please make sure you build a production version before submitting a pull request.
+
+Installation
+------------------
+
+```bash
+npm install file-saver --save
+bower install file-saver
+```
diff --git a/Open-ILS/web/js/file-saver/bower.json b/Open-ILS/web/js/file-saver/bower.json
new file mode 100644 (file)
index 0000000..6428c78
--- /dev/null
@@ -0,0 +1,21 @@
+{
+  "name": "file-saver",
+  "main": "FileSaver.js",
+  "version": "1.3.3",
+  "homepage": "https://github.com/eligrey/FileSaver.js",
+  "authors": [
+    "Eli Grey <me@eligrey.com>"
+  ],
+  "description": "An HTML5 saveAs() FileSaver implementation",
+  "keywords": [
+    "filesaver",
+    "saveas",
+    "blob"
+  ],
+  "license": "MIT",
+  "ignore": [
+      "*",
+      "!FileSaver.*js",
+      "!LICENSE.md"
+  ]
+}
diff --git a/Open-ILS/web/js/file-saver/demo/demo.css b/Open-ILS/web/js/file-saver/demo/demo.css
new file mode 100644 (file)
index 0000000..de4a78d
--- /dev/null
@@ -0,0 +1,50 @@
+html {\r
+       background-color: #DDD;\r
+}\r
+body {\r
+    -webkit-box-sizing: content-box;\r
+    -moz-box-sizing: content-box;\r
+    box-sizing: content-box;\r
+       width: 900px;\r
+       margin: 0 auto;\r
+       font-family: Verdana, Helvetica, Arial, sans-serif;\r
+       -webkit-box-shadow: 0 0 10px 2px rgba(0, 0, 0, .5);\r
+       -moz-box-shadow: 0 0 10px 2px rgba(0, 0, 0, .5);\r
+       box-shadow: 0 0 10px 2px rgba(0, 0, 0, .5);\r
+       padding: 7px 25px 70px;\r
+       background-color: #FFF;\r
+}\r
+h1, h2, h3, h4, h5, h6 {\r
+       font-family: Georgia, "Times New Roman", serif;\r
+}\r
+h2, form {\r
+       text-align: center;\r
+}\r
+form {\r
+       margin-top: 5px;\r
+}\r
+.input {\r
+       width: 500px;\r
+       height: 300px;\r
+       margin: 0 auto;\r
+       display: block;\r
+}\r
+section {\r
+       margin-top: 40px;\r
+}\r
+#canvas {\r
+       cursor: crosshair;\r
+}\r
+#canvas, #html {\r
+       border: 1px solid #000;\r
+}\r
+.filename {\r
+       text-align: right;\r
+}\r
+#html {\r
+    -webkit-box-sizing: border-box;\r
+    -moz-box-sizing: border-box;\r
+    box-sizing: border-box;\r
+       overflow: auto;\r
+       padding: 1em;\r
+}\r
diff --git a/Open-ILS/web/js/file-saver/demo/demo.js b/Open-ILS/web/js/file-saver/demo/demo.js
new file mode 100755 (executable)
index 0000000..2b3dc34
--- /dev/null
@@ -0,0 +1,216 @@
+/*! FileSaver.js demo script
+ *  2016-05-26
+ *
+ *  By Eli Grey, http://eligrey.com
+ *  License: MIT
+ *    See LICENSE.md
+ */
+
+/*! @source http://purl.eligrey.com/github/FileSaver.js/blob/master/demo/demo.js */
+
+/*jshint laxbreak: true, laxcomma: true, smarttabs: true*/
+/*global saveAs, self*/
+
+(function(view) {
+"use strict";
+// The canvas drawing portion of the demo is based off the demo at
+// http://www.williammalone.com/articles/create-html5-canvas-javascript-drawing-app/
+var
+         document = view.document
+       , $ = function(id) {
+               return document.getElementById(id);
+       }
+       , session = view.sessionStorage
+       // only get URL when necessary in case Blob.js hasn't defined it yet
+       , get_blob = function() {
+               return view.Blob;
+       }
+
+       , canvas = $("canvas")
+       , canvas_options_form = $("canvas-options")
+       , canvas_filename = $("canvas-filename")
+       , canvas_clear_button = $("canvas-clear")
+
+       , text = $("text")
+       , text_options_form = $("text-options")
+       , text_filename = $("text-filename")
+
+       , html = $("html")
+       , html_options_form = $("html-options")
+       , html_filename = $("html-filename")
+
+       , ctx = canvas.getContext("2d")
+       , drawing = false
+       , x_points = session.x_points || []
+       , y_points = session.y_points || []
+       , drag_points = session.drag_points || []
+       , add_point = function(x, y, dragging) {
+               x_points.push(x);
+               y_points.push(y);
+               drag_points.push(dragging);
+       }
+       , draw = function(){
+               canvas.width = canvas.width;
+               ctx.lineWidth = 6;
+               ctx.lineJoin = "round";
+               ctx.strokeStyle = "#000000";
+               var
+                         i = 0
+                       , len = x_points.length
+               ;
+               for(; i < len; i++) {
+                       ctx.beginPath();
+                       if (i && drag_points[i]) {
+                               ctx.moveTo(x_points[i-1], y_points[i-1]);
+                       } else {
+                               ctx.moveTo(x_points[i]-1, y_points[i]);
+                       }
+                       ctx.lineTo(x_points[i], y_points[i]);
+                       ctx.closePath();
+                       ctx.stroke();
+               }
+       }
+       , stop_drawing = function() {
+               drawing = false;
+       }
+
+       // Title guesser and document creator available at https://gist.github.com/1059648
+       , guess_title = function(doc) {
+               var
+                         h = "h6 h5 h4 h3 h2 h1".split(" ")
+                       , i = h.length
+                       , headers
+                       , header_text
+               ;
+               while (i--) {
+                       headers = doc.getElementsByTagName(h[i]);
+                       for (var j = 0, len = headers.length; j < len; j++) {
+                               header_text = headers[j].textContent.trim();
+                               if (header_text) {
+                                       return header_text;
+                               }
+                       }
+               }
+       }
+       , doc_impl = document.implementation
+       , create_html_doc = function(html) {
+               var
+                         dt = doc_impl.createDocumentType('html', null, null)
+                       , doc = doc_impl.createDocument("http://www.w3.org/1999/xhtml", "html", dt)
+                       , doc_el = doc.documentElement
+                       , head = doc_el.appendChild(doc.createElement("head"))
+                       , charset_meta = head.appendChild(doc.createElement("meta"))
+                       , title = head.appendChild(doc.createElement("title"))
+                       , body = doc_el.appendChild(doc.createElement("body"))
+                       , i = 0
+                       , len = html.childNodes.length
+               ;
+               charset_meta.setAttribute("charset", html.ownerDocument.characterSet);
+               for (; i < len; i++) {
+                       body.appendChild(doc.importNode(html.childNodes.item(i), true));
+               }
+               var title_text = guess_title(doc);
+               if (title_text) {
+                       title.appendChild(doc.createTextNode(title_text));
+               }
+               return doc;
+       }
+;
+canvas.width = 500;
+canvas.height = 300;
+
+  if (typeof x_points === "string") {
+       x_points = JSON.parse(x_points);
+} if (typeof y_points === "string") {
+       y_points = JSON.parse(y_points);
+} if (typeof drag_points === "string") {
+       drag_points = JSON.parse(drag_points);
+} if (session.canvas_filename) {
+       canvas_filename.value = session.canvas_filename;
+} if (session.text) {
+       text.value = session.text;
+} if (session.text_filename) {
+       text_filename.value = session.text_filename;
+} if (session.html) {
+       html.innerHTML = session.html;
+} if (session.html_filename) {
+       html_filename.value = session.html_filename;
+}
+
+drawing = true;
+draw();
+drawing = false;
+
+canvas_clear_button.addEventListener("click", function() {
+       canvas.width = canvas.width;
+       x_points.length =
+       y_points.length =
+       drag_points.length =
+               0;
+}, false);
+canvas.addEventListener("mousedown", function(event) {
+       event.preventDefault();
+       drawing = true;
+       add_point(event.pageX - canvas.offsetLeft, event.pageY - canvas.offsetTop, false);
+       draw();
+}, false);
+canvas.addEventListener("mousemove", function(event) {
+       if (drawing) {
+               add_point(event.pageX - canvas.offsetLeft, event.pageY - canvas.offsetTop, true);
+               draw();
+       }
+}, false);
+canvas.addEventListener("mouseup", stop_drawing, false);
+canvas.addEventListener("mouseout", stop_drawing, false);
+
+canvas_options_form.addEventListener("submit", function(event) {
+       event.preventDefault();
+       canvas.toBlobHD(function(blob) {
+               saveAs(
+                         blob
+                       , (canvas_filename.value || canvas_filename.placeholder) + ".png"
+               );
+       }, "image/png");
+}, false);
+
+text_options_form.addEventListener("submit", function(event) {
+       event.preventDefault();
+       var BB = get_blob();
+       saveAs(
+                 new BB(
+                         [text.value || text.placeholder]
+                       , {type: "text/plain;charset=" + document.characterSet}
+               )
+               , (text_filename.value || text_filename.placeholder) + ".txt"
+       );
+}, false);
+
+html_options_form.addEventListener("submit", function(event) {
+       event.preventDefault();
+       var
+                 BB = get_blob()
+               , xml_serializer = new XMLSerializer()
+               , doc = create_html_doc(html)
+       ;
+       saveAs(
+                 new BB(
+                         [xml_serializer.serializeToString(doc)]
+                       , {type: "text/plain;charset=" + document.characterSet}
+               )
+               , (html_filename.value || html_filename.placeholder) + ".xhtml"
+       );
+}, false);
+
+view.addEventListener("unload", function() {
+       session.x_points = JSON.stringify(x_points);
+       session.y_points = JSON.stringify(y_points);
+       session.drag_points = JSON.stringify(drag_points);
+       session.canvas_filename = canvas_filename.value;
+
+       session.text = text.value;
+       session.text_filename = text_filename.value;
+
+       session.html = html.innerHTML;
+       session.html_filename = html_filename.value;
+}, false);
+}(self));
diff --git a/Open-ILS/web/js/file-saver/demo/demo.min.js b/Open-ILS/web/js/file-saver/demo/demo.min.js
new file mode 100755 (executable)
index 0000000..d30333c
--- /dev/null
@@ -0,0 +1,2 @@
+/*! @source http://purl.eligrey.com/github/FileSaver.js/blob/master/demo/demo.js */
+(function(n){"use strict";var s=n.document,g=function(A){return s.getElementById(A)},b=n.sessionStorage,x=function(){return n.Blob},f=g("canvas"),r=g("canvas-options"),y=g("canvas-filename"),p=g("canvas-clear"),q=g("text"),t=g("text-options"),h=g("text-filename"),m=g("html"),e=g("html-options"),i=g("html-filename"),u=f.getContext("2d"),z=false,a=b.x_points||[],o=b.y_points||[],d=b.drag_points||[],j=function(A,C,B){a.push(A);o.push(C);d.push(B)},l=function(){f.width=f.width;u.lineWidth=6;u.lineJoin="round";u.strokeStyle="#000000";var B=0,A=a.length;for(;B<A;B++){u.beginPath();if(B&&d[B]){u.moveTo(a[B-1],o[B-1])}else{u.moveTo(a[B]-1,o[B])}u.lineTo(a[B],o[B]);u.closePath();u.stroke()}},c=function(){z=false},w=function(E){var D="h6 h5 h4 h3 h2 h1".split(" "),C=D.length,F,G;while(C--){F=E.getElementsByTagName(D[C]);for(var B=0,A=F.length;B<A;B++){G=F[B].textContent.trim();if(G){return G}}}},v=s.implementation,k=function(D){var B=v.createDocumentType("html",null,null),J=v.createDocument("http://www.w3.org/1999/xhtml","html",B),A=J.documentElement,H=A.appendChild(J.createElement("head")),K=H.appendChild(J.createElement("meta")),I=H.appendChild(J.createElement("title")),E=A.appendChild(J.createElement("body")),C=0,G=D.childNodes.length;K.setAttribute("charset",D.ownerDocument.characterSet);for(;C<G;C++){E.appendChild(J.importNode(D.childNodes.item(C),true))}var F=w(J);if(F){I.appendChild(J.createTextNode(F))}return J};f.width=500;f.height=300;if(typeof a==="string"){a=JSON.parse(a)}if(typeof o==="string"){o=JSON.parse(o)}if(typeof d==="string"){d=JSON.parse(d)}if(b.canvas_filename){y.value=b.canvas_filename}if(b.text){q.value=b.text}if(b.text_filename){h.value=b.text_filename}if(b.html){m.innerHTML=b.html}if(b.html_filename){i.value=b.html_filename}z=true;l();z=false;p.addEventListener("click",function(){f.width=f.width;a.length=o.length=d.length=0},false);f.addEventListener("mousedown",function(A){A.preventDefault();z=true;j(A.pageX-f.offsetLeft,A.pageY-f.offsetTop,false);l()},false);f.addEventListener("mousemove",function(A){if(z){j(A.pageX-f.offsetLeft,A.pageY-f.offsetTop,true);l()}},false);f.addEventListener("mouseup",c,false);f.addEventListener("mouseout",c,false);r.addEventListener("submit",function(A){A.preventDefault();f.toBlobHD(function(B){saveAs(B,(y.value||y.placeholder)+".png")},"image/png")},false);t.addEventListener("submit",function(A){A.preventDefault();var B=x();saveAs(new B([q.value||q.placeholder],{type:"text/plain;charset="+s.characterSet}),(h.value||h.placeholder)+".txt")},false);e.addEventListener("submit",function(B){B.preventDefault();var D=x(),A=new XMLSerializer,C=k(m);saveAs(new D([A.serializeToString(C)],{type:"text/plain;charset="+s.characterSet}),(i.value||i.placeholder)+".xhtml")},false);n.addEventListener("unload",function(){b.x_points=JSON.stringify(a);b.y_points=JSON.stringify(o);b.drag_points=JSON.stringify(d);b.canvas_filename=y.value;b.text=q.value;b.text_filename=h.value;b.html=m.innerHTML;b.html_filename=i.value},false)}(self));
\ No newline at end of file
diff --git a/Open-ILS/web/js/file-saver/demo/index.xhtml b/Open-ILS/web/js/file-saver/demo/index.xhtml
new file mode 100644 (file)
index 0000000..7a60efd
--- /dev/null
@@ -0,0 +1,57 @@
+<!DOCTYPE html>\r
+<html xmlns="http://www.w3.org/1999/xhtml" dir="ltr" lang="en-US-x-Hixie">\r
+<head>\r
+    <meta charset="utf-8"/>\r
+    <title>FileSaver.js demo</title>\r
+    <link rel="stylesheet" type="text/css" href="https://cdn.rawgit.com/eligrey/FileSaver.js/702cd2e820b680f88a0f299e33085c196806fc52/demo/demo.css"/>\r
+</head>\r
+<body>\r
+    <h1><a href="https://github.com/eligrey/FileSaver.js">FileSaver.js</a> demo</h1>\r
+    <p>\r
+        The following examples demonstrate how it is possible to generate and save any type of data right in the browser using the <code>saveAs()</code> FileSaver interface, without contacting any servers.\r
+    </p>\r
+    <section id="image-demo">\r
+        <h2>Saving an image</h2>\r
+        <canvas class="input" id="canvas" width="500" height="300"/>\r
+        <form id="canvas-options">\r
+            <label>Filename: <input type="text" class="filename" id="canvas-filename" placeholder="doodle"/>.png</label>\r
+            <input type="submit" value="Save"/>\r
+            <input type="button" id="canvas-clear" value="Clear"/>\r
+        </form>\r
+    </section>\r
+    <section id="text-demo">\r
+        <h2>Saving text</h2>\r
+        <textarea class="input" id="text" placeholder="Once upon a time..."/>\r
+        <form id="text-options">\r
+            <label>Filename: <input type="text" class="filename" id="text-filename" placeholder="a plain document"/>.txt</label>\r
+            <input type="submit" value="Save"/>\r
+        </form>\r
+    </section>\r
+    <section id="html-demo">\r
+        <h2>Saving rich text</h2>\r
+        <div class="input" id="html" contenteditable="">\r
+            <h3>Some example rich text</h3>\r
+            <ul>\r
+                <li><del>Plain</del> <ins>Boring</ins> text.</li>\r
+                <li><em>Emphasized text!</em></li>\r
+                <li><strong>Strong text!</strong></li>\r
+                <li>\r
+                    <svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="70" height="70">\r
+                        <circle cx="35" cy="35" r="35" fill="red"/>\r
+                        <text x="10" y="40">image</text>\r
+                    </svg>\r
+                </li>\r
+                <li><a href="https://github.com/eligrey/FileSaver.js">A link.</a></li>\r
+            </ul>\r
+        </div>\r
+        <form id="html-options">\r
+            <label>Filename: <input type="text" class="filename" id="html-filename" placeholder="a rich document"/>.xhtml</label>\r
+            <input type="submit" value="Save"/>\r
+        </form>\r
+    </section>\r
+    <script async="" src="https://cdn.rawgit.com/eligrey/Blob.js/0cef2746414269b16834878a8abc52eb9d53e6bd/Blob.js"/>\r
+    <script async="" src="https://cdn.rawgit.com/eligrey/canvas-toBlob.js/f1a01896135ab378aa5c0118eadd81da55e698d8/canvas-toBlob.js"/>\r
+    <script async="" src="https://cdn.rawgit.com/eligrey/FileSaver.js/e9d941381475b5df8b7d7691013401e171014e89/FileSaver.min.js"/>\r
+    <script async="" src="https://cdn.rawgit.com/eligrey/FileSaver.js/597b6cd0207ce408a6d34890b5b2826b13450714/demo/demo.js"/>\r
+</body>\r
+</html>\r
diff --git a/Open-ILS/web/js/file-saver/package.json b/Open-ILS/web/js/file-saver/package.json
new file mode 100644 (file)
index 0000000..0593fa4
--- /dev/null
@@ -0,0 +1,37 @@
+{
+  "name": "file-saver",
+  "version": "1.3.3",
+  "description": "An HTML5 saveAs() FileSaver implementation",
+  "main": "FileSaver.js",
+  "scripts": {
+    "test": "echo \"Error: no test specified\" && exit 0",
+    "build": "uglifyjs FileSaver.js --mangle --comments /@source/ > FileSaver.min.js"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/eligrey/FileSaver.js"
+  },
+  "keywords": [
+    "filesaver",
+    "saveas",
+    "blob"
+  ],
+  "author": {
+    "name": "Eli Grey",
+    "email": "me@eligrey.com"
+  },
+  "license": "MIT",
+  "bugs": {
+    "url": "https://github.com/eligrey/FileSaver.js/issues"
+  },
+  "homepage": "https://github.com/eligrey/FileSaver.js#readme",
+  "devDependencies": {
+    "uglify-js": "^2.6.2"
+  },
+  "readme": "If you need to save really large files bigger then the blob's size limitation or don't have \nenough RAM, then have a look at the more advanced [StreamSaver.js](https://github.com/jimmywarting/StreamSaver.js)\nthat can save data directly to the hard drive asynchronously with the power of the new streams API. That will have\nsupport for progress, cancelation and knowing when it's done writing\n\nFileSaver.js\n============\n\nFileSaver.js implements the `saveAs()` FileSaver interface in browsers that do\nnot natively support it. There is a [FileSaver.js demo][1] that demonstrates saving\nvarious media types.\n\nFileSaver.js is the solution to saving files on the client-side, and is perfect for\nwebapps that need to generate files, or for saving sensitive information that shouldn't be\nsent to an external server.\n\nLooking for `canvas.toBlob()` for saving canvases? Check out\n[canvas-toBlob.js][2] for a cross-browser implementation.\n\nSupported browsers\n------------------\n\n| Browser        | Constructs as | Filenames    | Max Blob Size | Dependencies |\n| -------------- | ------------- | ------------ | ------------- | ------------ |\n| Firefox 20+    | Blob          | Yes          | 800 MiB       | None         |\n| Firefox < 20   | data: URI     | No           | n/a           | [Blob.js](https://github.com/eligrey/Blob.js) |\n| Chrome         | Blob          | Yes          | [500 MiB][3]  | None         |\n| Chrome for Android | Blob      | Yes          | [500 MiB][3]  | None         |\n| Edge           | Blob          | Yes          | ?             | None         |\n| IE 10+         | Blob          | Yes          | 600 MiB       | None         |\n| Opera 15+      | Blob          | Yes          | 500 MiB       | None         |\n| Opera < 15     | data: URI     | No           | n/a           | [Blob.js](https://github.com/eligrey/Blob.js) |\n| Safari 6.1+*   | Blob          | No           | ?             | None         |\n| Safari < 6     | data: URI     | No           | n/a           | [Blob.js](https://github.com/eligrey/Blob.js) |\n\nFeature detection is possible:\n\n```js\ntry {\n    var isFileSaverSupported = !!new Blob;\n} catch (e) {}\n```\n\n### IE < 10\n\nIt is possible to save text files in IE < 10 without Flash-based polyfills.\nSee [ChenWenBrian and koffsyrup's `saveTextAs()`](https://github.com/koffsyrup/FileSaver.js#examples) for more details.\n\n### Safari 6.1+\n\nBlobs may be opened instead of saved sometimes—you may have to direct your Safari users to manually\npress <kbd>⌘</kbd>+<kbd>S</kbd> to save the file after it is opened. Using the `application/octet-stream` MIME type to force downloads [can cause issues in Safari](https://github.com/eligrey/FileSaver.js/issues/12#issuecomment-47247096).\n\n### iOS\n\nsaveAs must be run within a user interaction event such as onTouchDown or onClick; setTimeout will prevent saveAs from triggering. Due to restrictions in iOS saveAs opens in a new window instead of downloading, if you want this fixed please [tell Apple](https://bugs.webkit.org/show_bug.cgi?id=102914) how this bug is affecting you.\n\nSyntax\n------\n\n```js\nFileSaver saveAs(Blob/File data, optional DOMString filename, optional Boolean disableAutoBOM)\n```\n\nPass `true` for `disableAutoBOM` if you don't want FileSaver.js to automatically provide Unicode text encoding hints (see: [byte order mark](https://en.wikipedia.org/wiki/Byte_order_mark)).\n\nExamples\n--------\n\n### Saving text\n\n```js\nvar blob = new Blob([\"Hello, world!\"], {type: \"text/plain;charset=utf-8\"});\nsaveAs(blob, \"hello world.txt\");\n```\n\nThe standard W3C File API [`Blob`][4] interface is not available in all browsers.\n[Blob.js][5] is a cross-browser `Blob` implementation that solves this.\n\n### Saving a canvas\n\n```js\nvar canvas = document.getElementById(\"my-canvas\"), ctx = canvas.getContext(\"2d\");\n// draw to canvas...\ncanvas.toBlob(function(blob) {\n    saveAs(blob, \"pretty image.png\");\n});\n```\n\nNote: The standard HTML5 `canvas.toBlob()` method is not available in all browsers.\n[canvas-toBlob.js][6] is a cross-browser `canvas.toBlob()` that polyfills this.\n\n### Saving File\n\nYou can save a File constructor without specifying a filename. The\nFile itself already contains a name, There is a hand full of ways to get a file\ninstance (from storage, file input, new constructor)\nBut if you still want to change the name, then you can change it in the 2nd argument\n\n```js\nvar file = new File([\"Hello, world!\"], \"hello world.txt\", {type: \"text/plain;charset=utf-8\"});\nsaveAs(file);\n```\n\n\n\n![Tracking image](https://in.getclicky.com/212712ns.gif)\n\n  [1]: http://eligrey.com/demos/FileSaver.js/\n  [2]: https://github.com/eligrey/canvas-toBlob.js\n  [3]: https://code.google.com/p/chromium/issues/detail?id=375297\n  [4]: https://developer.mozilla.org/en-US/docs/DOM/Blob\n  [5]: https://github.com/eligrey/Blob.js\n  [6]: https://github.com/eligrey/canvas-toBlob.js\n\nContributing\n------------\n\nThe `FileSaver.js` distribution file is compiled with Uglify.js like so:\n\n```bash\nuglifyjs FileSaver.js --mangle --comments /@source/ > FileSaver.min.js\n# or simply:\nnpm run build\n```\n\nPlease make sure you build a production version before submitting a pull request.\n\nInstallation\n------------------\n\n```bash\nnpm install file-saver --save\nbower install file-saver\n```\n",
+  "readmeFilename": "README.md",
+  "_id": "file-saver@1.3.3",
+  "_shasum": "cdd4c44d3aa264eac2f68ec165bc791c34af1232",
+  "_from": "file-saver@",
+  "_resolved": "https://registry.npmjs.org/file-saver/-/file-saver-1.3.3.tgz"
+}
index 6ef5126..95ca98a 100644 (file)
@@ -11,7 +11,7 @@ function InvoiceLinkDialogManager(which, target) {
             var join = (idx == 0) ? '?' : '&';
             path += join + "attach_" + self.which + "=" + id;
         });
-        if (openils.XUL.isXUL()) {
+        if (openils.XUL.isXUL() && !window.IAMBROWSER) {
             openils.XUL.newTabEasy(
                 path,
                 /* tab title */ dojo.string.substitute(
index 7a34720..44e7b97 100644 (file)
@@ -710,7 +710,8 @@ function AcqLiTable() {
         row.setAttribute('li', li.id());
         var tds = dojo.query('[attr]', row);
         dojo.forEach(tds, function(td) {self.setRowAttr(td, liWrapper, td.getAttribute('attr'), td.getAttribute('attr_type'));});
-        dojo.query('[name=source_label]', row)[0].appendChild(document.createTextNode(li.source_label()));
+        if (li.source_label() !== null)
+            dojo.query('[name=source_label]', row)[0].appendChild(document.createTextNode(li.source_label()));
 
         // so we can scroll to it later
         dojo.query('[name=bib-info-cell]', row)[0].id = 'li-title-ref-' + li.id();
@@ -765,10 +766,13 @@ function AcqLiTable() {
                         openils.Util.show(nodeByName('queue', row), 'inline');
                         var link = nodeByName("queue_link", row);
                         link.onclick = function() { 
+                            var url = oilsBasePath + '/vandelay/vandelay?qtype=bib&qid=' + qrec.queue()
                             // open a new tab to the vandelay queue for this record
-                            openils.XUL.newTabEasy(
-                                oilsBasePath + '/vandelay/vandelay?qtype=bib&qid=' + qrec.queue()
-                            );
+                            if (window.IAMBROWSER) {
+                                xulG.relay_url(url);
+                            } else {
+                                openils.XUL.newTabEasy(url);
+                            }
                         }
                     }
                 }
@@ -918,7 +922,6 @@ function AcqLiTable() {
         option.disabled = !(count || eligible);
         option.innerHTML =
             dojo.string.substitute(localeStrings.NUM_CLAIMS_EXISTING, [count]);
-        option.onclick = function() { self.claimDialog.show(li); };
     };
 
     this.clearEligibility = function(li) {
@@ -1220,13 +1223,26 @@ function AcqLiTable() {
     this.updateLiState = function(li, row) {
         if (!row) row = this._findLiRow(li);
 
+        nodeByName("actions", row).onchange = function() {
+            switch(this.options[this.selectedIndex].value) {
+                case 'action_update_barcodes':
+                    self.showRealCopyEditUI(li);
+                    nodeByName("action_none", row).selected = true;
+                    break;
+                case 'action_holdings_maint':
+                    (self.generateMakeRecTab( li.eg_bib_id(), 'copy_browser', row ))();
+                    break;
+                case 'action_manage_claims':
+                    self.fetchClaimInfo(li.id(), true, function(full) { self.claimDialog.show(full) }, row);
+                    break;
+                case 'action_view_history':
+                    location.href = oilsBasePath + '/acq/lineitem/history/' + li.id();
+                    break;
+            }
+        };
         var actUpdateBarcodes = nodeByName("action_update_barcodes", row);
         var actHoldingsMaint = nodeByName("action_holdings_maint", row);
 
-        // always allow access to LI history
-        nodeByName('action_view_history', row).onclick = 
-            function() { location.href = oilsBasePath + '/acq/lineitem/history/' + li.id(); };
-
         /* handle row coloring for based on LI state */
         openils.Util.removeCSSClass(row, /^oils-acq-li-state-/);
         openils.Util.addCSSClass(row, "oils-acq-li-state-" + li.state());
@@ -1236,10 +1252,13 @@ function AcqLiTable() {
             openils.Util.show(nodeByName("invoices_span", row), "inline");
             var link = nodeByName("invoices_link", row);
             link.onclick = function() {
-                openils.XUL.newTabEasy(
-                    oilsBasePath + "/acq/search/unified?so=" +
-                    base64Encode({"jub":[{"id": li.id()}]}) + "&rt=invoice"
-                );
+                var url = oilsBasePath + "/acq/search/unified?so=" +
+                          base64Encode({"jub":[{"id": li.id()}]}) + "&rt=invoice"
+                if (window.IAMBROWSER) {
+                    xulG.relay_url(url);
+                } else {
+                    openils.XUL.newTabEasy(url);
+                }
                 return false;
             };
         }
@@ -1255,13 +1274,7 @@ function AcqLiTable() {
                 (lids && !lids.filter(function(lid) { return lid.eg_copy_id() })[0] )) {
 
             actUpdateBarcodes.disabled = false;
-            actUpdateBarcodes.onclick = function() {
-                self.showRealCopyEditUI(li);
-                nodeByName("action_none", row).selected = true;
-            }
             actHoldingsMaint.disabled = false;
-            actHoldingsMaint.onclick =