From c323d290c1d7a14cf444cc17fee2d5348386957b Mon Sep 17 00:00:00 2001
From: Jesse Weaver <pianohacker@gmail.com>
Date: Wed, 29 May 2013 14:59:57 -0600
Subject: [PATCH] Bug 10486 - Allow external Z39.50 targets to be searched from
 the OPAC

This adds a new page to the OPAC that can search several Z39.50 targets
using Pazpar2.

Test plan:
1) Apply patches.
2) Update database.
3) Add one or more Z39.50 targets, using the Administration -> External
   targets screen. Note that not all targets support keyword search, but
   here are some that do:
     * Library of Congress: lx2.loc.gov, 210, LCDB, none, none, MARC21, UTF-8
     * BNF2: z3950.bnf.fr, 2211, TOUT, Z3950, Z3950_BNF, UNIMARC, UTF-8
4) Perform a search on the OPAC, check to see if the "external targets"
   notice pops up at the top.
5) Click on the results link near that message.
6) This should bring back results from all enabled targets, all of which
   should be clickable to bring up a detailed view of the record.

(This version fixes some typos and accidental changes.)
---
 C4/Search.pm                                       |   30 +
 C4/XSLT.pm                                         |   24 +-
 Makefile.PL                                        |    2 +-
 admin/external_targets.pl                          |  125 +++
 etc/koha-conf.xml                                  |   13 -
 etc/koha-httpd.conf                                |   15 +-
 etc/pazpar2/generic-settings.xml                   |   21 +
 etc/pazpar2/pazpar2.xml                            |    2 +-
 etc/pazpar2/unimarc-work-groups.xsl                |   98 ++
 installer/data/mysql/sysprefs.sql                  |    1 +
 installer/data/mysql/updatedatabase.pl             |   33 +
 .../intranet-tmpl/prog/en/css/staff-global.css     |   14 +
 .../intranet-tmpl/prog/en/includes/admin-menu.inc  |    1 +
 .../prog/en/modules/admin/admin-home.tt            |    2 +
 .../prog/en/modules/admin/external-targets.tt      |  180 ++++
 .../prog/en/modules/admin/preferences/opac.pref    |    7 +
 koha-tmpl/opac-tmpl/bootstrap/css/opac.css         |   21 +-
 .../opac-tmpl/bootstrap/en/modules/opac-results.tt |   59 +-
 .../bootstrap/en/xslt/MARC21slim2OPACResults.xsl   |    7 +-
 .../bootstrap/en/xslt/MARC21slimUtils.xsl          |    2 +-
 .../bootstrap/en/xslt/UNIMARCslim2OPACResults.xsl  |    4 +
 koha-tmpl/opac-tmpl/ccsr/en/css/opac.css           |   16 +
 koha-tmpl/opac-tmpl/ccsr/en/js/externalsearch.js   |  106 ++
 .../en/lib/jquery/plugins/jquery.lightbox_me.js    |  254 +++++
 koha-tmpl/opac-tmpl/lib/pz2.js                     | 1124 ++++++++++++++++++++
 koha-tmpl/opac-tmpl/prog/en/css/opac.css           |   27 +-
 koha-tmpl/opac-tmpl/prog/en/js/externalsearch.js   |  106 ++
 .../en/lib/jquery/plugins/jquery.lightbox_me.js    |  254 +++++
 .../prog/en/modules/opac-external-search.tt        |  240 +++++
 .../opac-tmpl/prog/en/modules/opac-results.tt      |   59 +-
 .../prog/en/xslt/MARC21slim2OPACResults.xsl        |    6 +-
 .../opac-tmpl/prog/en/xslt/MARC21slimUtils.xsl     |    2 +-
 .../prog/en/xslt/UNIMARCslim2OPACResults.xsl       |    4 +
 opac/opac-external-search.pl                       |   62 ++
 opac/opac-search.pl                                |   12 +-
 opac/svc/pazpar2_init                              |  100 ++
 rewrite-config.PL                                  |    8 +-
 t/XSLT.t                                           |   94 +-
 38 files changed, 3063 insertions(+), 72 deletions(-)
 create mode 100755 admin/external_targets.pl
 create mode 100644 etc/pazpar2/generic-settings.xml
 create mode 100644 etc/pazpar2/unimarc-work-groups.xsl
 create mode 100644 koha-tmpl/intranet-tmpl/prog/en/modules/admin/external-targets.tt
 create mode 100644 koha-tmpl/opac-tmpl/ccsr/en/js/externalsearch.js
 create mode 100644 koha-tmpl/opac-tmpl/ccsr/en/lib/jquery/plugins/jquery.lightbox_me.js
 create mode 100644 koha-tmpl/opac-tmpl/lib/pz2.js
 create mode 100644 koha-tmpl/opac-tmpl/prog/en/js/externalsearch.js
 create mode 100644 koha-tmpl/opac-tmpl/prog/en/lib/jquery/plugins/jquery.lightbox_me.js
 create mode 100644 koha-tmpl/opac-tmpl/prog/en/modules/opac-external-search.tt
 create mode 100755 opac/opac-external-search.pl
 create mode 100755 opac/svc/pazpar2_init

diff --git a/C4/Search.pm b/C4/Search.pm
index a7aa0f7..58ca4d7 100644
--- a/C4/Search.pm
+++ b/C4/Search.pm
@@ -72,6 +72,7 @@ This module provides searching functions for Koha's bibliographic databases
   &GetDistinctValues
   &enabled_staff_search_views
   &PurgeSearchHistory
+  &GetExternalSearchTargets
 );
 
 # make all your functions, whether exported or not;
@@ -2379,6 +2380,35 @@ sub _ZOOM_event_loop {
     }
 }
 
+=head2 GetExternalSearchTargets
+
+Returns the list of Z39.50 servers that are marked for search in the OPAC using
+Pazpar2.
+
+=cut
+
+sub GetExternalSearchTargets {
+    my ( $branchcode ) = @_;
+
+    if ( $branchcode ) {
+        return C4::Context->dbh->selectall_arrayref( q{
+            SELECT * FROM external_targets et
+            LEFT JOIN external_target_restrictions etr
+                ON (etr.target_id = et.target_id and etr.branchcode = ?)
+            WHERE etr.target_id IS NULL
+            ORDER BY et.name
+        }, { Slice => {} }, $branchcode );
+    } else {
+        return C4::Context->dbh->selectall_arrayref( q{
+            SELECT * FROM external_targets et
+            LEFT JOIN external_target_restrictions etr USING (target_id)
+            GROUP by et.target_id
+            HAVING branchcode IS NULL
+            ORDER BY et.name
+        }, { Slice => {} } );
+    }
+}
+
 
 END { }    # module clean-up code here (global destructor)
 
diff --git a/C4/XSLT.pm b/C4/XSLT.pm
index d85b048..7ebac8a 100644
--- a/C4/XSLT.pm
+++ b/C4/XSLT.pm
@@ -42,6 +42,7 @@ BEGIN {
     $VERSION = 3.07.00.049;
     @ISA = qw(Exporter);
     @EXPORT = qw(
+        &XSLTGetFilename
         &XSLTParse4Display
         &GetURI
     );
@@ -156,9 +157,10 @@ sub _get_best_default_xslt_filename {
     return $xslfilename;
 }
 
-sub XSLTParse4Display {
-    my ( $biblionumber, $orig_record, $xslsyspref, $fixamps, $hidden_items ) = @_;
-    my $xslfilename = C4::Context->preference($xslsyspref);
+sub XSLTGetFilename {
+    my ( $marcflavour, $xslsyspref ) = @_;
+
+    my $xslfilename = $marcflavour eq C4::Context->preference('marcflavour') ? C4::Context->preference($xslsyspref) : 'default';
     if ( $xslfilename =~ /^\s*"?default"?\s*$/i ) {
         my $htdocs;
         my $theme;
@@ -167,22 +169,22 @@ sub XSLTParse4Display {
         if ($xslsyspref eq "XSLTDetailsDisplay") {
             $htdocs  = C4::Context->config('intrahtdocs');
             $theme   = C4::Context->preference("template");
-            $xslfile = C4::Context->preference('marcflavour') .
+            $xslfile = $marcflavour .
                        "slim2intranetDetail.xsl";
         } elsif ($xslsyspref eq "XSLTResultsDisplay") {
             $htdocs  = C4::Context->config('intrahtdocs');
             $theme   = C4::Context->preference("template");
-            $xslfile = C4::Context->preference('marcflavour') .
+            $xslfile = $marcflavour .
                         "slim2intranetResults.xsl";
         } elsif ($xslsyspref eq "OPACXSLTDetailsDisplay") {
             $htdocs  = C4::Context->config('opachtdocs');
             $theme   = C4::Context->preference("opacthemes");
-            $xslfile = C4::Context->preference('marcflavour') .
+            $xslfile = $marcflavour .
                        "slim2OPACDetail.xsl";
         } elsif ($xslsyspref eq "OPACXSLTResultsDisplay") {
             $htdocs  = C4::Context->config('opachtdocs');
             $theme   = C4::Context->preference("opacthemes");
-            $xslfile = C4::Context->preference('marcflavour') .
+            $xslfile = $marcflavour .
                        "slim2OPACResults.xsl";
         }
         $xslfilename = _get_best_default_xslt_filename($htdocs, $theme, $lang, $xslfile);
@@ -193,6 +195,14 @@ sub XSLTParse4Display {
         $xslfilename =~ s/\{langcode\}/$lang/;
     }
 
+    return $xslfilename;
+}
+
+sub XSLTParse4Display {
+    my ( $biblionumber, $orig_record, $xslsyspref, $fixamps, $hidden_items ) = @_;
+
+    my $xslfilename = XSLTGetFilename( C4::Context->preference( 'marcflavour' ), $xslsyspref );
+
     # grab the XML, run it through our stylesheet, push it out to the browser
     my $record = transformMARCXML4XSLT($biblionumber, $orig_record);
     #return $record->as_formatted();
diff --git a/Makefile.PL b/Makefile.PL
index 66f9a0f..07f16e8 100644
--- a/Makefile.PL
+++ b/Makefile.PL
@@ -536,7 +536,7 @@ if ($config{'INSTALL_ZEBRA'} eq "yes") {
     );
     if ($config{'INSTALL_PAZPAR2'} eq 'yes') {
         push @{ $pl_files->{'rewrite-config.PL'} }, (
-            'blib/PAZPAR2_CONF_DIR/koha-biblios.xml',
+            'blib/PAZPAR2_CONF_DIR/generic-settings.xml',
             'blib/PAZPAR2_CONF_DIR/pazpar2.xml'
         );
     }
diff --git a/admin/external_targets.pl b/admin/external_targets.pl
new file mode 100755
index 0000000..0fc9037
--- /dev/null
+++ b/admin/external_targets.pl
@@ -0,0 +1,125 @@
+#!/usr/bin/perl
+#
+# Copyright 2013 Jesse Weaver
+#
+# This file is part of Koha.
+#
+# Koha 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.
+#
+# Koha 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 Koha; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+#
+
+use Modern::Perl '2010';
+
+use CGI;
+
+use C4::Auth;
+use C4::Context;
+use C4::Branch;
+use C4::Output;
+use URI::Escape;
+
+our $dbh = C4::Context->dbh;
+our $input = new CGI;
+
+our ( $template, $loggedinuser, $cookie ) = get_template_and_user( {
+    template_name => "admin/external-targets.tt",
+    query => $input,
+    type => "intranet",
+    authnotrequired => 0,
+    flagsrequired => {parameters => 'parameters_remaining_permissions'},
+    debug => 1,
+} );
+
+our $op = $input->param( 'op' ) // 'show';
+$template->{VARS}->{op} = $op;
+
+given ( $op ) {
+    when ( 'show' ) { show_external_targets(); }
+    when ( 'add' ) { show_edit_form(); }
+    when ( 'edit' ) { show_edit_form(); }
+    when ( 'save' ) { save_target(); }
+    when ( 'delete' ) { delete_target(); }
+}
+
+output_html_with_http_headers $input, $cookie, $template->output;
+
+sub show_external_targets {
+    $template->{VARS}->{saved_id} = $input->param( 'saved_id' );
+    $template->{VARS}->{deleted_name} = $input->param( 'deleted_name' );
+    $template->{VARS}->{targets} = $dbh->selectall_arrayref( q{
+        SELECT *
+        FROM external_targets
+    }, { Slice => {} } );
+}
+
+sub show_edit_form {
+    $template->{VARS}->{branches} = GetBranchesLoop( undef, 0 );
+    $template->{VARS}->{syntaxes} = [ 'MARC21', 'UNIMARC', 'NORMARC' ];
+    $template->{VARS}->{encodings} = { 'utf8' => 'UTF-8', 'marc8' => 'MARC-8' };
+
+    my $target_id;
+    if ( $target_id = $input->param( 'target_id' ) ) {
+        $template->{VARS}->{target} = $dbh->selectrow_hashref( q{ SELECT * FROM external_targets WHERE target_id = ? }, {}, $target_id );
+
+        my $available_branches = $dbh->selectall_hashref( q{ SELECT * FROM external_target_restrictions WHERE target_id = ? }, 'branchcode', {}, $target_id );
+
+        foreach my $branch ( @{ $template->{VARS}->{branches} } ) {
+            $branch->{selected} = 1 if ( $available_branches->{$branch->{branchcode}} );
+        }
+    }
+}
+
+sub save_target {
+    my $target_id;
+    if ( $target_id = $input->param( 'target_id' ) ) {
+        $dbh->do( q{
+            UPDATE external_targets
+            SET name = ?, host = ?, port = ?, db = ?, userid = ?, password = ?, syntax = ?, encoding = ?
+            WHERE target_id = ?
+        }, {}, map { $input->param( $_ ) // '' } qw( name host port db userid password syntax encoding target_id ) );
+    } else {
+        $dbh->do( q{
+            INSERT
+            INTO external_targets(name, host, port, db, userid, password, syntax, encoding)
+            VALUES(?, ?, ?, ?, ?, ?, ?, ?)
+        }, {}, map { $input->param( $_ ) // '' } qw( name host port db userid password syntax encoding ) );
+        $target_id = $dbh->last_insert_id( undef, undef, undef, undef );
+    }
+
+    $dbh->do( q{
+        DELETE
+        FROM external_target_restrictions
+        WHERE target_id = ?
+    }, {}, $target_id );
+
+    foreach my $branchcode ( $input->param( 'branch' ) ) {
+        $dbh->do( q{
+            INSERT
+            INTO external_target_restrictions(branchcode, target_id)
+            VALUES(?, ?)
+        }, {}, $branchcode, $target_id );
+    }
+
+    print $input->redirect( '/cgi-bin/koha/admin/external_targets.pl?saved_id=' . $target_id );
+    exit;
+}
+
+sub delete_target {
+    my ($target_id, $target);
+
+    return unless ( $target_id = $input->param( 'target_id' ) and $target = $dbh->selectrow_hashref( q{ SELECT * FROM external_targets WHERE target_id = ? }, {}, $target_id ) );
+
+    $dbh->do( q{ DELETE FROM external_targets WHERE target_id = ? }, {}, $target_id );
+
+    print $input->redirect( '/cgi-bin/koha/admin/external_targets.pl?deleted_name=' . uri_escape( $target->{'name'} ) );
+}
diff --git a/etc/koha-conf.xml b/etc/koha-conf.xml
index 1da5378..587f2ee 100644
--- a/etc/koha-conf.xml
+++ b/etc/koha-conf.xml
@@ -19,19 +19,6 @@
 <listen id="publicserver" >tcp:@:__ZEBRA_SRU_BIBLIOS_PORT__</listen>
 -->
 
-<!-- Settings for special biblio server instance for PazPar2.
-     Because PazPar2 only connects to a Z39.50 server using TCP/IP,
-     it cannot use the Unix-domain socket that biblioserver uses.
-     Therefore, a custom server is defined. -->
-__PAZPAR2_TOGGLE_XML_PRE__
-<listen id="mergeserver">tcp:@:__MERGE_SERVER_PORT__</listen>
-<server id="mergeserver"  listenref="mergeserver"> 
-    <directory>__ZEBRA_DATA_DIR__/biblios</directory>
-    <config>__ZEBRA_CONF_DIR__/__ZEBRA_BIB_CFG__</config>
-    <cql2rpn>__ZEBRA_CONF_DIR__/pqf.properties</cql2rpn>
-</server>
-__PAZPAR2_TOGGLE_XML_POST__
-
 <!-- BIBLIOGRAPHIC RECORDS -->
 <server id="biblioserver"  listenref="biblioserver"> 
     <directory>__ZEBRA_DATA_DIR__/biblios</directory>
diff --git a/etc/koha-httpd.conf b/etc/koha-httpd.conf
index f9a1970..df3dae1 100644
--- a/etc/koha-httpd.conf
+++ b/etc/koha-httpd.conf
@@ -32,7 +32,7 @@
    </Directory>
 
    # Secure internal stuff
-   <DirectoryMatch "__OPAC_WWW_DIR__/.*/(modules|xslt|includes)">
+   <DirectoryMatch "__OPAC_WWW_DIR__/.*/(modules|includes)">
       Order deny,allow
       Deny from all
    </DirectoryMatch>
@@ -113,6 +113,17 @@
      RewriteRule ^/isbn/([^\/]*)/?$ /search?q=isbn:$1 [PT]
      RewriteRule ^/issn/([^\/]*)/?$ /search?q=issn:$1 [PT]
    </IfModule>
+
+   __PAZPAR2_TOGGLE_HTTPD_PRE__
+     <Proxy *>
+         AddDefaultCharset off
+         Order deny,allow
+         Allow from all
+     </Proxy>
+
+     ProxyRequests off
+     ProxyPass /pazpar2/search.pz2 http://__PAZPAR2_HOST__:__PAZPAR2_PORT__/search.pz2
+   __PAZPAR2_TOGGLE_HTTPD_POST__
 </VirtualHost>
 
 ## Intranet
@@ -149,7 +160,7 @@
    </Directory>
 
    # Secure internal stuff
-   <DirectoryMatch "__INTRANET_WWW_DIR__/.*/(modules|xslt|includes)">
+   <DirectoryMatch "__INTRANET_WWW_DIR__/.*/(modules|includes)">
       Order deny,allow
       Deny from all
    </DirectoryMatch>
diff --git a/etc/pazpar2/generic-settings.xml b/etc/pazpar2/generic-settings.xml
new file mode 100644
index 0000000..5d56b99
--- /dev/null
+++ b/etc/pazpar2/generic-settings.xml
@@ -0,0 +1,21 @@
+<settings target="*">
+  <!-- This file introduces default settings for pazpar2 -->
+  <!-- $Id: loc.xml,v 1.2 2007-07-10 13:43:07 adam Exp $ -->
+
+  <!-- mapping for unqualified search -->
+  <set name="pz:cclmap:term" value="u=1016 t=l,r s=al"/>
+
+  <!-- field-specific mappings -->
+
+  <set name="pz:cclmap:au" value="u=1004 s=al"/>
+  <set name="pz:cclmap:ti" value="u=4 s=al"/>
+  <set name="pz:cclmap:su" value="u=21 s=al"/>
+  <set name="pz:cclmap:isbn" value="u=7"/>
+  <set name="pz:cclmap:issn" value="u=8"/>
+  <set name="pz:cclmap:date" value="u=30 r=r"/>
+
+  <!-- Retrieval settings -->
+
+  <set name="pz:elements" value="F"/>
+
+</settings>
diff --git a/etc/pazpar2/pazpar2.xml b/etc/pazpar2/pazpar2.xml
index 2269647..a3a3404 100644
--- a/etc/pazpar2/pazpar2.xml
+++ b/etc/pazpar2/pazpar2.xml
@@ -4,7 +4,7 @@
   
   <server>
     <listen port="__PAZPAR2_PORT__"/>
-    <settings src="__PAZPAR2_CONF_DIR__/koha-biblios.xml"/>
+    <settings src="__PAZPAR2_CONF_DIR__/generic-settings.xml"/>
 
     <relevance>
       <icu_chain id="relevance" locale="el">
diff --git a/etc/pazpar2/unimarc-work-groups.xsl b/etc/pazpar2/unimarc-work-groups.xsl
new file mode 100644
index 0000000..50cbe3c
--- /dev/null
+++ b/etc/pazpar2/unimarc-work-groups.xsl
@@ -0,0 +1,98 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<xsl:stylesheet
+    version="1.0"
+    xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
+    xmlns:pz="http://www.indexdata.com/pazpar2/1.0"
+    xmlns:marc="http://www.loc.gov/MARC21/slim">
+
+ <xsl:output indent="yes" method="xml" version="1.0" encoding="UTF-8"/>
+
+
+  <xsl:template match="/marc:record">
+    <pz:record>
+
+      <xsl:for-each select="marc:controlfield[@tag='001']">
+        <pz:metadata type="id">
+          <xsl:value-of select="."/>
+        </pz:metadata>
+      </xsl:for-each>
+
+      <!-- -->
+      <xsl:for-each select="marc:datafield[@tag='020']">
+	<xsl:if test="marc:subfield[@code='a'] = 'US'">
+          <pz:metadata type="lccn">
+	    <xsl:value-of select="marc:subfield[@code='b']"/>
+	  </pz:metadata>
+	</xsl:if>
+      </xsl:for-each>
+
+      <xsl:for-each select="marc:datafield[@tag='010']">
+        <pz:metadata type="isbn">
+	  <xsl:value-of select="marc:subfield[@code='a']"/>
+	</pz:metadata>
+      </xsl:for-each>
+
+      <xsl:for-each select="marc:datafield[@tag='011']">
+        <pz:metadata type="issn">
+	  <xsl:value-of select="marc:subfield[@code='a']"/>
+	</pz:metadata>
+      </xsl:for-each>
+
+
+      <xsl:for-each select="marc:datafield[@tag='200']">
+        <pz:metadata type="work-title">
+          <xsl:value-of select="marc:subfield[@code='a']"/>
+        </pz:metadata>
+      </xsl:for-each>
+
+
+      <!-- Date of Pulbication -->
+      <xsl:for-each select="marc:datafield[@tag='210']">
+        <pz:metadata type="date">
+	  <xsl:value-of select="marc:subfield[@code='d']"/>
+	</pz:metadata>
+      </xsl:for-each>
+
+      <!--  Usmarc 650 maps to unimarc 606 and marc21 653 maps to unimarc 610 -->
+      <xsl:for-each select="marc:datafield[@tag='606' or @tag='610']">
+	<pz:metadata type="subject">
+	  <xsl:value-of select="marc:subfield[@code='a']"/>
+	</pz:metadata>
+      </xsl:for-each>
+
+      <xsl:for-each select="marc:datafield[@tag &gt;= 300 and @tag &lt;= 345]
+                            [@tag != '325']">
+        <pz:metadata type="description">
+            <xsl:value-of select="*/text()"/>
+        </pz:metadata>
+      </xsl:for-each>
+
+
+      <!-- Author : primary, alternative and secondary responsibility (equivalent marc21 tags : 100, 700 -->
+      <xsl:for-each select="marc:datafield[@tag='700' or @tag='701' or @tag='702']">
+	<pz:metadata type="work-author">
+	  <xsl:value-of select="marc:subfield[@code='a']"/>
+          <xsl:text>, </xsl:text>
+	  <xsl:value-of select="marc:subfield[@code='b']"/>
+	</pz:metadata>
+      </xsl:for-each>
+
+      <!-- Author : marc21 tag 720 maps to unimarc 730
+      <xsl:for-each select="marc:datafield[@tag='730']">
+	<pz:metadata type="author">
+	  <xsl:value-of select="marc:subfield[@code='a']"/>
+	</pz:metadata>
+      </xsl:for-each>
+      -->
+
+      <!-- -->
+      <xsl:for-each select="marc:datafield[@tag='856']">
+	<pz:metadata type="url">
+	  <xsl:value-of select="marc:subfield[@code='u']"/>
+	</pz:metadata>
+      </xsl:for-each>
+
+    </pz:record>
+  </xsl:template>
+
+</xsl:stylesheet>
diff --git a/installer/data/mysql/sysprefs.sql b/installer/data/mysql/sysprefs.sql
index 1668d37..918c1f2 100644
--- a/installer/data/mysql/sysprefs.sql
+++ b/installer/data/mysql/sysprefs.sql
@@ -257,6 +257,7 @@ INSERT INTO systempreferences ( `variable`, `value`, `options`, `explanation`, `
 ('OpacRenewalAllowed','0',NULL,'If ON, users can renew their issues directly from their OPAC account','YesNo'),
 ('OpacRenewalBranch','checkoutbranch','itemhomebranch|patronhomebranch|checkoutbranch|null','Choose how the branch for an OPAC renewal is recorded in statistics','Choice'),
 ('OPACResultsSidebar','','70|10','Define HTML to be included on the search results page, underneath the facets sidebar','Textarea'),
+('OPACSearchExternalTargets','0',NULL,'Whether to search external targets in the OPAC','YesNo'),
 ('OPACSearchForTitleIn','<li><a  href=\"http://worldcat.org/search?q={TITLE}\" target=\"_blank\">Other Libraries (WorldCat)</a></li>\n<li><a href=\"http://www.scholar.google.com/scholar?q={TITLE}\" target=\"_blank\">Other Databases (Google Scholar)</a></li>\n<li><a href=\"http://www.bookfinder.com/search/?author={AUTHOR}&amp;title={TITLE}&amp;st=xl&amp;ac=qr\" target=\"_blank\">Online Stores (Bookfinder.com)</a></li>\n<li><a href=\"http://openlibrary.org/search/?author=({AUTHOR})&title=({TITLE})\" target=\"_blank\">Open Library (openlibrary.org)</a></li>','70|10','Enter the HTML that will appear in the \'Search for this title in\' box on the detail page in the OPAC.  Enter {TITLE}, {AUTHOR}, or {ISBN} in place of their respective variables in the URL. Leave blank to disable \'More Searches\' menu.','Textarea'),
 ('OpacSeparateHoldings','0',NULL,'Separate current branch holdings from other holdings (OPAC)','YesNo'),
 ('OpacSeparateHoldingsBranch','homebranch','homebranch|holdingbranch','Branch used to separate holdings (OPAC)','Choice'),
diff --git a/installer/data/mysql/updatedatabase.pl b/installer/data/mysql/updatedatabase.pl
index f043d0b..02b34be 100755
--- a/installer/data/mysql/updatedatabase.pl
+++ b/installer/data/mysql/updatedatabase.pl
@@ -7953,6 +7953,39 @@ if ( C4::Context->preference("Version") < TransformToNum($DBversion) ) {
     SetVersion($DBversion);
 }
 
+$DBversion = "3.13.00.XXX";
+if(CheckVersion($DBversion)) {
+    $dbh->do(
+"INSERT IGNORE INTO systempreferences (variable,value,explanation,options,type) VALUES('OPACSearchExternalTargets','0','Whether to search external targets in the OPAC','','YesNo')"
+    );
+    $dbh->do( q{
+CREATE TABLE `external_targets` (
+  `target_id` int(11) NOT NULL AUTO_INCREMENT,
+  `host` varchar(128) NOT NULL,
+  `port` int(11) NOT NULL,
+  `db` varchar(64) NOT NULL,
+  `userid` varchar(64) DEFAULT '',
+  `password` varchar(64) DEFAULT '',
+  `name` varchar(64) NOT NULL,
+  `syntax` varchar(64) NOT NULL,
+  `encoding` varchar(16) DEFAULT 'MARC-8',
+  PRIMARY KEY (`target_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=21 DEFAULT CHARSET=utf8
+    } );
+    $dbh->do( q{
+CREATE TABLE `external_target_restrictions` (
+  `branchcode` varchar(10) NOT NULL,
+  `target_id` int(11) NOT NULL,
+  KEY `branchcode` (`branchcode`),
+  KEY `target_id` (`target_id`),
+  CONSTRAINT `external_target_restrictions_ibfk_1` FOREIGN KEY (`branchcode`) REFERENCES `branches` (`branchcode`) ON DELETE CASCADE,
+  CONSTRAINT `external_target_restrictions_ibfk_2` FOREIGN KEY (`target_id`) REFERENCES `external_targets` (`target_id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8
+    } );
+    print "Upgrade to $DBversion done (Bug 10486 - Allow external Z39.50 targets to be searched from the OPAC)\n";
+    SetVersion($DBversion);
+}
+
 =head1 FUNCTIONS
 
 =head2 TableExists($table)
diff --git a/koha-tmpl/intranet-tmpl/prog/en/css/staff-global.css b/koha-tmpl/intranet-tmpl/prog/en/css/staff-global.css
index 2ea7b4a..5506134 100644
--- a/koha-tmpl/intranet-tmpl/prog/en/css/staff-global.css
+++ b/koha-tmpl/intranet-tmpl/prog/en/css/staff-global.css
@@ -270,6 +270,16 @@ tr.even td, tr.even.highlight td {
     border-right : 1px solid #BCBCBC;
 }
 
+tr.highlight td {
+	background-color : #F6F6F6;
+	border-color : #BCBCBC;
+}
+
+tr.highlight th[scope=row] {
+	background-color : #DDDDDD;
+	border-color : #BCBCBC;
+}
+
 td.od {
 	color : #cc0000;
 	font-weight : bold;
@@ -287,6 +297,10 @@ tr.odd.onissue td {
 	background-color: #FFFFE1;
 }
 
+tr.updated td {
+    background-color: #FFFFBB;
+}
+
 tfoot td {
 	background-color : #f3f3f3;
 	font-weight : bold;
diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/admin-menu.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/admin-menu.inc
index 91878bf..486581c 100644
--- a/koha-tmpl/intranet-tmpl/prog/en/includes/admin-menu.inc
+++ b/koha-tmpl/intranet-tmpl/prog/en/includes/admin-menu.inc
@@ -46,6 +46,7 @@
     <li><a href="/cgi-bin/koha/admin/classsources.pl">Classification sources</a></li>
     <li><a href="/cgi-bin/koha/admin/matching-rules.pl">Record matching rules</a></li>
     <li><a href="/cgi-bin/koha/admin/oai_sets.pl">OAI sets configuration</a></li>
+    <li><a href="/cgi-bin/koha/admin/external_targets.pl">External search targets</a></li>
 </ul>
 
 <h5>Acquisition parameters</h5>
diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/admin-home.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/admin-home.tt
index 46a3b3a..aac3b19 100644
--- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/admin-home.tt
+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/admin-home.tt
@@ -84,6 +84,8 @@
       <dt><a href="/cgi-bin/koha/admin/searchengine/solr/indexes.pl">Search engine configuration</a></dt>
       <dd>Manage indexes, facets, and their mappings to MARC fields and subfields.</dd>
     [% END %]
+    <dt><a href="/cgi-bin/koha/admin/external_targets.pl">External search targets</a></dt>
+    <dd>Define external search targets that can be searched from the OPAC.</dd>
 </dl>
 
 <h3>Acquisition parameters</h3>
diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/external-targets.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/external-targets.tt
new file mode 100644
index 0000000..8858a62
--- /dev/null
+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/external-targets.tt
@@ -0,0 +1,180 @@
+[% INCLUDE 'doc-head-open.inc' %]
+[% IF op == 'show' %]
+<title>Koha &rsaquo; Administration &rsaquo; External targets</title>
+[% ELSIF op == 'add' %]
+<title>Koha &rsaquo; Administration &rsaquo; External targets &rsaquo; Create an external target</title>
+[% ELSIF op == 'edit' %]
+<title>Koha &rsaquo; Administration &rsaquo; External targets &rsaquo; Editing '[% target.name %]'</title>
+[% END %]
+[% INCLUDE 'doc-head-close.inc' %]
+<link rel="stylesheet" type="text/css" href="[% themelang %]/css/datatables.css" />
+<script type="text/javascript" src="[% themelang %]/lib/jquery/plugins/jquery.dataTables.min.js"></script>
+[% INCLUDE 'datatables-strings.inc' %]
+<script type="text/javascript" src="[% themelang %]/js/datatables.js"></script>
+<script type="text/javascript">
+//<![CDATA[
+ $(document).ready(function() {
+    [% IF ( targets.size ) %]
+    var dTable = $("#targets").dataTable( $.extend( true, {}, dataTablesDefaults, {
+        aoColumnDefs: [
+            { aTargets: [ 1,2,3,4,5 ], bSortable: false, bSearchable: false },
+        ],
+        asStripeClasses: [ '', 'highlight' ],
+        bPaginate: false,
+    } ) );
+
+    [% IF saved_id %]
+    $( '#targets tr[data-targetid=[% saved_id %]]' ).addClass( 'updated' );
+    [% END %]
+
+    $( '#targets .delete' ).click( function() {
+        return confirm( _("Are you sure you wish to delete this target?") );
+    } );
+    [% END %]
+ });
+//]]>
+</script>
+
+</head>
+<body id="admin_z3950servers" class="admin">
+[% INCLUDE 'header.inc' %]
+[% INCLUDE 'cat-search.inc' %]
+
+[% IF op == 'show' %]
+
+<div id="breadcrumbs"><a href="/cgi-bin/koha/mainpage.pl">Home</a> &rsaquo; <a href="/cgi-bin/koha/admin/admin-home.pl">Administration</a> &rsaquo; External targets</div>
+
+[% ELSIF op == 'add' %]
+
+<div id="breadcrumbs"><a href="/cgi-bin/koha/mainpage.pl">Home</a> &rsaquo; <a href="/cgi-bin/koha/admin/admin-home.pl">Administration</a> &rsaquo; <a href="/cgi-bin/koha/admin/external_targets.pl">External targets</a> &rsaquo; Create an external target</div>
+
+[% ELSIF op == 'edit' %]
+
+<div id="breadcrumbs"><a href="/cgi-bin/koha/mainpage.pl">Home</a> &rsaquo; <a href="/cgi-bin/koha/admin/admin-home.pl">Administration</a> &rsaquo; <a href="/cgi-bin/koha/admin/external_targets.pl">External targets</a> &rsaquo; Editing '[% target.name %]'</div>
+
+[% END %]
+
+<div id="doc3" class="yui-t2">
+<div id="bd">
+
+<div id="yui-main"><div class="yui-b">
+
+[% IF op == 'show' %]
+
+[% IF deleted_name %]
+<div class="alert">
+    <p>Deleted target '[% deleted_name %]'</p>
+</div>
+[% END %]
+
+<div id="toolbar" class="btn-toolbar">
+    <a id="newtarget" class="btn btn-small" href="/cgi-bin/koha/admin/external_targets.pl?op=add"><i class="icon-plus"></i> New external target</a>
+</div>
+
+<p>These Z39.50 targets are searched in the OPAC using the "external targets" feature. Note that Pazpar2 must be installed and configured for this to work.</p>
+
+[% IF targets.size %]
+<table id="targets">
+    <thead><tr><th>Name</th><th>Connection</th><th>Login</th><th>Syntax</th><th>Encoding</th><th>&nbsp;</th><th>&nbsp;</th></tr></thead>
+    <tbody>
+        [% FOREACH target = targets %]
+        <tr data-targetid="[% target.target_id %]">
+            <td>[% target.name %]</td>
+            <td>[% target.host %]:[% target.port %]/[% target.db %]</td>
+            <td>[% IF target.userid %][% target.userid %] / [% IF target.password %]********[% ELSE %]<span class="hint">none</span>[% END %][% ELSE %]<span class="hint">none</span>[% END %]</td>
+            <td>[% target.syntax %]</td>
+            <td>
+                [% IF target.encoding == 'marc8' %]
+                MARC-8
+                [% ELSIF target.encoding == 'utf8' %]
+                UTF-8
+                [% END %]
+            </td>
+            <td><a href="/cgi-bin/koha/admin/external_targets.pl?op=edit&amp;target_id=[% target.target_id %]">Edit</a></td>
+            <td><a class="delete" href="/cgi-bin/koha/admin/external_targets.pl?op=delete&amp;target_id=[% target.target_id %]">Delete</a></td>
+        </tr>
+        [% END %]
+    </tbody>
+</table>
+[% ELSE %]
+<p>No external targets have been defined yet.</p>
+[% END %]
+
+[% ELSIF op == 'add' || op == 'edit' %]
+
+<form action="/cgi-bin/koha/admin/external_targets.pl" method="POST">
+    [% IF op == 'add' %]
+    <h1>Create an external target</h1>
+    [% ELSIF op == 'edit' %]
+    <h1>Editing '[% target.name %]'</h1>
+    [% END %]
+
+    <input type="hidden" name="op" value="save">
+    <input type="hidden" name="target_id" value="[% target.target_id %]">
+
+    <fieldset class="rows">
+        <ol>
+            <li><label for="name">Name:</label> <input type="text" id="name" name="name" value="[% target.name %]" required></li>
+            <li><label for="host">Host:</label> <input type="text" id="host" name="host" value="[% target.host %]" required></li>
+            <li><label for="port">Port:</label> <input type="num" id="port" name="port" value="[% target.port %]" required></li>
+            <li><label for="db">Database:</label> <input type="text" id="db" name="db" value="[% target.db %]" required></li>
+            <li><label for="userid">User:</label> <input type="text" id="userid" name="userid" value="[% target.userid %]"></li>
+            <li><label for="password">Password:</label> <input type="password" id="password" name="password" value="[% target.password %]" autocomplete="off"></li>
+            <li>
+                <label for="syntax">Syntax:</label>
+                <select id="syntax" name="syntax">
+                    [% FOREACH syntax = syntaxes %]
+                    [% IF syntax == target.syntax %]
+                    <option selected>[% syntax %]
+                    [% ELSE %]
+                    <option>[% syntax %]</option>
+                    [% END %]
+                    [% END %]
+                </select>
+            </li>
+            <li>
+                <label for="encoding">Encoding:</label>
+                <select id="encoding" name="encoding">
+                    [% FOREACH encoding = encodings %]
+                    [% IF encoding.key == target.encoding %]
+                    <option value="[% encoding.key %]" selected>[% encoding.value %]
+                    [% ELSE %]
+                    <option value="[% encoding.key %]">[% encoding.value %]</option>
+                    [% END %]
+                    [% END %]
+                </select>
+            </li>
+            <li>
+                <label>Restricted libraries:</label>
+                <fieldset>
+                    <legend>Not available to patrons from:</legend>
+                    <p>Only targets with no restrictions will be shown to anonymous (not logged in) users.</p>
+                    <ol>
+                        [% FOREACH branch = branches %]
+                        <li>
+                            <label for="branch-[% branch.branchcode %]">[% branch.branchname %]</label>
+                            [% IF branch.selected %]
+                            <input type="checkbox" id="branch-[% branch.branchcode %]" name="branch" value="[% branch.branchcode %]" checked />
+                            [% ELSE %]
+                            <input type="checkbox" id="branch-[% branch.branchcode %]" name="branch" value="[% branch.branchcode %]" />
+                            [% END %]
+                        </li>
+                        [% END %]
+                    </ol>
+                </fieldset>
+            </li>
+        </ol>
+    </fieldset>
+
+    <fieldset class="action"><input type="submit" value="Save"></fieldset>
+</form>
+
+[% END %]
+
+</div></div>
+<div class="yui-b">
+[% INCLUDE 'admin-menu.inc' %]
+</div>
+
+</div>
+[% INCLUDE 'intranet-bottom.inc' %]
diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/opac.pref b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/opac.pref
index 1364ff1..6fc161b 100644
--- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/opac.pref
+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/opac.pref
@@ -430,6 +430,13 @@ OPAC:
                   yes: Allow
                   no: Do not allow
             - users to add a note when placing a hold.
+        -
+            - pref: OPACSearchExternalTargets
+              default: 0
+              choices:
+                  yes: Search
+                  no: "Don't search"
+            - external targets from the OPAC.
 
     Policy:
         -
diff --git a/koha-tmpl/opac-tmpl/bootstrap/css/opac.css b/koha-tmpl/opac-tmpl/bootstrap/css/opac.css
index 99719d5..105beb7 100644
--- a/koha-tmpl/opac-tmpl/bootstrap/css/opac.css
+++ b/koha-tmpl/opac-tmpl/bootstrap/css/opac.css
@@ -2142,7 +2142,7 @@ td img {
   margin: 0;
   padding: 0;
 }
-#overdrive-results {
+#overdrive-results, #pazpar2-results {
   font-weight: bold;
   padding-left: 1em;
 }
@@ -2278,6 +2278,25 @@ a.reviewlink:visited {
   -moz-box-shadow: 0px 1px 1px 0px rgba(0, 0, 0, 0.2);
   box-shadow: 0px 1px 1px 0px rgba(0, 0, 0, 0.2);
 }
+.sourcecol {
+  vertical-align: top;
+  width: 100px;
+}
+.notesrow label {
+  font-weight: bold;
+}
+.notesrow span {
+  display: block;
+}
+.notesrow textarea {
+  width: 100%;
+}
+#modal-overlay {
+  background-color: white;
+  border-radius: 5px;
+  max-width: 75%;
+  padding: 15px;
+}
 @media only screen and (min-width: 0px) and (max-width: 304px) {
   /* Screens bewteen 0 and 304 pixels wide */
   #oh:after {
diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-results.tt b/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-results.tt
index 8f0a089..fbdbb72 100644
--- a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-results.tt
+++ b/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-results.tt
@@ -552,7 +552,12 @@
 [% IF ( OverDriveEnabled ) %]<script type="text/javascript" src="[% interface %]/[% theme %]/js/overdrive.js"></script>[% END %]
 <script type="text/javascript" src="[% interface %]/[% theme %]/lib/jquery/plugins/jquery.checkboxes.min.js"></script>
 [% IF ( OpacHighlightedWords ) %]<script type="text/javascript" src="[% interface %]/[% theme %]/lib/jquery/plugins/jquery.highlight-3.js"></script>
-[% END %]<script type="text/javascript">
+[% END %]
+[% IF ( Koha.Preference('OPACSearchExternalTargets') ) %]
+<script type="text/javascript" src="/opac-tmpl/lib/pz2.js"></script>
+<script type="text/javascript" src="[% interface %]/[% theme %]/js/externalsearch.js"></script>
+[% END %]
+<script type="text/javascript">
 //<![CDATA[
 [% IF ( Koha.Preference( 'opacuserlogin' ) == 1 ) && ( Koha.Preference( 'RequestOnOpac' ) == 1 ) %]
 function holdMultiple() {
@@ -836,21 +841,57 @@ $(document).ready(function(){
         $("#highlight_toggle_on" ).hide().click(function() {highlightOn() ;});
         $("#highlight_toggle_off").show().click(function() {highlightOff();});
     [% END %]
+
+    function StartExternalSearch(name, text) {
+        $( '#numresults' )
+            .eq( 0 )
+            .append( ' <span id="' + name + '-results">' + text + '... <img class="throbber" src="/opac-tmpl/lib/jquery/plugins/themes/classic/throbber.gif" /></span>' );
+    }
+    function FailExternalSearch( name, text ) {
+        $( '#' + name + '-results' ).html( text );
+    }
+    function FinishExternalSearch( name, text, numItems, url ) {
+        if ( numItems ) {
+            $( '#' + name + '-results' ).html( text.replace( '__LINK__', '<a href="' + url + '">' + _("__RESULTS__ results").replace( '__RESULTS__', numItems ) + '</a>' ) );
+        } else {
+            $( '#' + name + '-results' ).remove();
+        }
+    }
+
     [% IF ( OverDriveEnabled ) %]
-        var $overdrive_results = $( '<span id="overdrive-results">' + _( 'Searching OverDrive...' ) + ' <img class="throbber" src="[% interface %]/lib/jquery/plugins/themes/classic/throbber.gif" /></span>' );
-        $( '#numresults' ) .append( ' ' )
-            .append( $overdrive_results );
+        StartExternalSearch( 'overdrive', _("Searching OverDrive") );
         KOHA.OverDrive.Search( "[% OverDriveLibraryID %]", querystring, 1, 0, function( data ) {
             if ( data.error ) {
-                $overdrive_results.html( _( 'Error searching OverDrive collection' ) );
+                FailExternalSearch( 'overdrive', _("Error searching OverDrive collection") );
                 return;
             }
 
-            if ( data.totalItems ) {
-                $overdrive_results.html( _( 'Found' ) + ' <a href="/cgi-bin/koha/opac-overdrive-search.pl?q=' + escape( querystring ) + '">' + data.totalItems + ' ' + _( 'results' ) + '</a> ' + _( 'in OverDrive collection' ) );
-            } else {
-                $overdrive_results.remove();
+            FinishExternalSearch( 'overdrive', _("Found __LINK__ in OverDrive collection"), data.totalItems, '/cgi-bin/koha/opac-overdrive-search.pl?q=' + escape( querystring ) );
+        } );
+    [% END %]
+
+    [% IF ( OPACSearchExternalTargets ) %]
+        KOHA.ExternalSearch.targets = {
+            [% FOREACH target IN external_search_targets %]
+                '[% target.host %]:[% target.port %]/[% target.db %]': {
+                    id: '[% target.target_id %]',
+                    name: '[% target.name %]',
+                    syntax: '[% target.syntax %]',
+                },
+            [% END %]
+        };
+        var num_targets = 0; $.each( KOHA.ExternalSearch.targets, function() { num_targets++ } );
+        var first_succeeded;
+
+        StartExternalSearch( 'pazpar2', _("Searching external targets") );
+        KOHA.ExternalSearch.Search( querystring, 1, function( data ) {
+            if ( data.error ) {
+                if ( !first_succeeded ) FailExternalSearch( 'pazpar2', _("Error searching external targets") );
+                return;
             }
+
+            first_succeeded = true;
+            FinishExternalSearch( 'pazpar2', _("Found __LINK__ in __TARGETS__ external targets").replace( '__TARGETS__', num_targets ), data.total, '/cgi-bin/koha/opac-external-search.pl?q=' + escape( querystring ) );
         } );
     [% END %]
 [% END %]
diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/xslt/MARC21slim2OPACResults.xsl b/koha-tmpl/opac-tmpl/bootstrap/en/xslt/MARC21slim2OPACResults.xsl
index 4890682..dd3b07c 100644
--- a/koha-tmpl/opac-tmpl/bootstrap/en/xslt/MARC21slim2OPACResults.xsl
+++ b/koha-tmpl/opac-tmpl/bootstrap/en/xslt/MARC21slim2OPACResults.xsl
@@ -5,11 +5,13 @@
   xmlns:marc="http://www.loc.gov/MARC21/slim"
   xmlns:items="http://www.koha-community.org/items"
   xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
+  xmlns="http://www.w3.org/1999/xhtml"
   exclude-result-prefixes="marc items">
     <xsl:import href="MARC21slimUtils.xsl"/>
     <xsl:output method = "html" indent="yes" omit-xml-declaration = "yes" encoding="UTF-8"/>
     <xsl:key name="item-by-status" match="items:item" use="items:status"/>
     <xsl:key name="item-by-status-and-branch" match="items:item" use="concat(items:status, ' ', items:homebranch)"/>
+    <xsl:param name="showAvailability" select="true()"/>
 
     <xsl:template match="/">
             <xsl:apply-templates/>
@@ -1027,6 +1029,7 @@
                             </xsl:for-each>
                             </span>
                         </xsl:if>
+                        <xsl:if test="$showAvailability">
                         <span class="results_summary availability">
                         <span class="label">Availability: </span>
                         <xsl:choose>
@@ -1035,7 +1038,8 @@
                             <xsl:when test="string-length($AlternateHoldingsField)=3 and marc:datafield[@tag=$AlternateHoldingsField]">
                             <xsl:variable name="AlternateHoldingsCount" select="count(marc:datafield[@tag=$AlternateHoldingsField])"/>
                             <xsl:for-each select="marc:datafield[@tag=$AlternateHoldingsField][1]">
-                                <xsl:call-template select="marc:datafield[@tag=$AlternateHoldingsField]" name="subfieldSelect">
+                                <!-- Removed select="marc:datafield[@tag=$AlternateHoldingsField]" due to incompatibility with browser XSLT -->
+                                <xsl:call-template name="subfieldSelect">
                                     <xsl:with-param name="codes"><xsl:value-of select="$AlternateHoldingsSubfields"/></xsl:with-param>
                                     <xsl:with-param name="delimeter"><xsl:value-of select="$AlternateHoldingsSeparator"/></xsl:with-param>
                                 </xsl:call-template>
@@ -1143,6 +1147,7 @@
                        <xsl:text>). </xsl:text>                   </span>
                    </xsl:if>
                </span>
+               </xsl:if>
     <xsl:choose>
         <xsl:when test="($OPACItemLocation='location' or $OPACItemLocation='ccode') and (count(key('item-by-status', 'available'))!=0 or count(key('item-by-status', 'reference'))!=0)">
             <span class="results_summary" id="location">
diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/xslt/MARC21slimUtils.xsl b/koha-tmpl/opac-tmpl/bootstrap/en/xslt/MARC21slimUtils.xsl
index 9599566..07fd266 100644
--- a/koha-tmpl/opac-tmpl/bootstrap/en/xslt/MARC21slimUtils.xsl
+++ b/koha-tmpl/opac-tmpl/bootstrap/en/xslt/MARC21slimUtils.xsl
@@ -1,6 +1,6 @@
 <?xml version='1.0'?>
 <!DOCTYPE stylesheet [<!ENTITY nbsp "&#160;" >]>
-<xsl:stylesheet version="1.0" xmlns:marc="http://www.loc.gov/MARC21/slim" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
+<xsl:stylesheet version="1.0" xmlns:marc="http://www.loc.gov/MARC21/slim" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns="http://www.w3.org/1999/xhtml">
 	<xsl:template name="datafield">
 		<xsl:param name="tag"/>
 		<xsl:param name="ind1"><xsl:text> </xsl:text></xsl:param>
diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/xslt/UNIMARCslim2OPACResults.xsl b/koha-tmpl/opac-tmpl/bootstrap/en/xslt/UNIMARCslim2OPACResults.xsl
index 378e96c..7e4510b 100644
--- a/koha-tmpl/opac-tmpl/bootstrap/en/xslt/UNIMARCslim2OPACResults.xsl
+++ b/koha-tmpl/opac-tmpl/bootstrap/en/xslt/UNIMARCslim2OPACResults.xsl
@@ -6,12 +6,14 @@
   xmlns:marc="http://www.loc.gov/MARC21/slim"
   xmlns:items="http://www.koha-community.org/items"
   xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
+  xmlns="http://www.w3.org/1999/xhtml"
   exclude-result-prefixes="marc items">
 
 <xsl:import href="UNIMARCslimUtils.xsl"/>
 <xsl:output method = "html" indent="yes" omit-xml-declaration = "yes" encoding="UTF-8"/>
 <xsl:key name="item-by-status" match="items:item" use="items:status"/>
 <xsl:key name="item-by-status-and-branch" match="items:item" use="concat(items:status, ' ', items:homebranch)"/>
+<xsl:param name="showAvailability" select="true()"/>
 
 <xsl:template match="/">
   <xsl:apply-templates/>
@@ -95,6 +97,7 @@
 
   <xsl:call-template name="tag_215" />
 
+  <xsl:if test="$showAvailability">
   <span class="results_summary">
     <span class="label">Availability: </span>
     <xsl:choose>
@@ -238,6 +241,7 @@
       </span>
     </xsl:if>
   </span>
+  </xsl:if>
 
 </xsl:template>
 
diff --git a/koha-tmpl/opac-tmpl/ccsr/en/css/opac.css b/koha-tmpl/opac-tmpl/ccsr/en/css/opac.css
index 590cb19..7e59110 100644
--- a/koha-tmpl/opac-tmpl/ccsr/en/css/opac.css
+++ b/koha-tmpl/opac-tmpl/ccsr/en/css/opac.css
@@ -1596,6 +1596,10 @@ strong em, em strong  {
      vertical-align: top;
    width: 10px;
 }
+.sourcecol {
+	vertical-align: top;
+	width: 100px;
+}
 #container {
     color : #000;
 }
@@ -2865,3 +2869,15 @@ a.reviewlink,a.reviewlink:visited {
     display: block;
     overflow: auto;
 }
+
+#modal-overlay {
+    background-color: white;
+    border-radius: 5px;
+    max-width: 75%;
+    padding: 15px;
+}
+
+#overdrive-results, #pazpar2-results {
+    font-weight: bold;
+    padding-left: 1em;
+}
diff --git a/koha-tmpl/opac-tmpl/ccsr/en/js/externalsearch.js b/koha-tmpl/opac-tmpl/ccsr/en/js/externalsearch.js
new file mode 100644
index 0000000..b2fe4a7
--- /dev/null
+++ b/koha-tmpl/opac-tmpl/ccsr/en/js/externalsearch.js
@@ -0,0 +1,106 @@
+if ( typeof KOHA == "undefined" || !KOHA ) {
+    var KOHA = {};
+}
+
+KOHA.XSLTGet = ( function() {
+    // Horrible browser hack, but required due to the following hard-to-detect and long-unfixed bug:
+    // https://bugs.webkit.org/show_bug.cgi?id=60276
+    var isChrome = /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor);
+    var isSafari = /Safari/.test(navigator.userAgent) && /Apple Computer/.test(navigator.vendor);
+
+    if ( !isChrome && !isSafari ) return $.get;
+
+    return function( url ) {
+        var result = new jQuery.Deferred();
+        var basepath = url.match( /(.*\/)*/ )[0];
+
+        $.get( url ).done( function( xslDoc ) {
+            var xslImports = xslDoc.getElementsByTagNameNS( 'http://www.w3.org/1999/XSL/Transform', 'import' );
+            var importsRemaining = xslImports.length;
+
+            if ( importsRemaining == 0 ) {
+                result.resolve( xslDoc );
+                return;
+            }
+
+            $.each( xslImports, function( i, importElem ) {
+                var path = $( importElem ).attr( 'href' );
+                if ( !/^(\/|https?:)/.test( path ) ) path = basepath + path;
+
+                KOHA.XSLTGet( path ).done( function( subDoc ) {
+                    importsRemaining--;
+                    $( importElem ).replaceWith( subDoc.documentElement.childNodes );
+
+                    if ( importsRemaining == 0 ) result.resolve( xslDoc );
+                } ).fail( function() {
+                    importsRemaining = -1;
+
+                    result.reject();
+                } );
+            } );
+        } ).fail( function() {
+            result.reject();
+        } );
+
+        return result;
+    };
+} )();
+
+KOHA.TransformToFragment = function( xmlDoc, xslDoc ) {
+    if ( window.XSLTProcessor ) {
+        var proc = new XSLTProcessor();
+        proc.importStylesheet( xslDoc );
+        proc.setParameter( null, 'showAvailability', false );
+        return (new XMLSerializer).serializeToString( proc.transformToFragment( xmlDoc, document ) );
+    } else if ( window.ActiveXObject ) {
+        var xslt = new ActiveXObject( "Msxml2.XSLTemplate" );
+        xslt.stylesheet = xslDoc;
+        var xslProc = xslt.createProcessor();
+        xslProc.input = xmlDoc;
+        xslProc.addParameter( 'showAvailability', false );
+        xslProc.transform();
+        return xslProc.output;
+    }
+};
+
+KOHA.ExternalSearch = ( function() {
+    return {
+        targets: {},
+        Search: function( q, limit, callback ) {
+            var targetIDs = [];
+            var includedTargets = [];
+
+            $.each( KOHA.ExternalSearch.targets, function ( url, info ) {
+                if ( !info.disabled ) {
+                    includedTargets.push( url );
+                    targetIDs.push( info.id );
+                }
+            } );
+
+            if ( KOHA.ExternalSearch._pz !== undefined ) {
+                afterinit( KOHA.ExternalSearch._pz );
+            } else {
+                $.get( '/cgi-bin/koha/svc/pazpar2_init', { targets: targetIDs.join(',') }, function( data ) {
+                    KOHA.ExternalSearch._pz = new pz2({
+                        sessionId: data.sessionID,
+                        onshow: callback,
+                        errorhandler: function ( error ) { callback( { error: error } ) },
+                    } );
+                    afterinit( KOHA.ExternalSearch._pz );
+                } );
+            }
+
+            function afterinit( pz ) {
+                pz.search( q, limit, 'relevance:0', 'pz:id=' + includedTargets.join( '|' ) );
+            }
+        },
+        Fetch: function( offset, callback ) {
+            var pz = KOHA.ExternalSearch._pz;
+            pz.showCallback = callback;
+            pz.show( offset );
+        },
+        GetDetailedRecord: function( recid, callback ) {
+            KOHA.ExternalSearch._pz.record( recid, 0, undefined, { callback: callback } );
+        },
+    };
+} )();
diff --git a/koha-tmpl/opac-tmpl/ccsr/en/lib/jquery/plugins/jquery.lightbox_me.js b/koha-tmpl/opac-tmpl/ccsr/en/lib/jquery/plugins/jquery.lightbox_me.js
new file mode 100644
index 0000000..effe8a6
--- /dev/null
+++ b/koha-tmpl/opac-tmpl/ccsr/en/lib/jquery/plugins/jquery.lightbox_me.js
@@ -0,0 +1,254 @@
+/*
+* $ lightbox_me
+* By: Buck Wilson
+* Version : 2.3
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+
+(function($) {
+
+    $.fn.lightbox_me = function(options) {
+
+        return this.each(function() {
+
+            var
+                opts = $.extend({}, $.fn.lightbox_me.defaults, options),
+                $overlay = $(),
+                $self = $(this),
+                $iframe = $('<iframe id="foo" style="z-index: ' + (opts.zIndex + 1) + ';border: none; margin: 0; padding: 0; position: absolute; width: 100%; height: 100%; top: 0; left: 0; filter: mask();"/>'),
+                ie6 = ($.browser.msie && $.browser.version < 7);
+
+            if (opts.showOverlay) {
+                //check if there's an existing overlay, if so, make subequent ones clear
+               var $currentOverlays = $(".js_lb_overlay:visible");
+                if ($currentOverlays.length > 0){
+                    $overlay = $('<div class="lb_overlay_clear js_lb_overlay"/>');
+                } else {
+                    $overlay = $('<div class="' + opts.classPrefix + '_overlay js_lb_overlay"/>');
+                }
+            }
+
+            /*----------------------------------------------------
+               DOM Building
+            ---------------------------------------------------- */
+            if (ie6) {
+                var src = /^https/i.test(window.location.href || '') ? 'javascript:false' : 'about:blank';
+                $iframe.attr('src', src);
+                $('body').append($iframe);
+            } // iframe shim for ie6, to hide select elements
+            $('body').append($self.hide()).append($overlay);
+
+
+            /*----------------------------------------------------
+               Overlay CSS stuffs
+            ---------------------------------------------------- */
+
+            // set css of the overlay
+            if (opts.showOverlay) {
+                setOverlayHeight(); // pulled this into a function because it is called on window resize.
+                $overlay.css({ position: 'absolute', width: '100%', top: 0, left: 0, right: 0, bottom: 0, zIndex: (opts.zIndex + 2), display: 'none' });
+				if (!$overlay.hasClass('lb_overlay_clear')){
+			$overlay.css(opts.overlayCSS);
+                }
+            }
+
+            /*----------------------------------------------------
+               Animate it in.
+            ---------------------------------------------------- */
+               //
+            if (opts.showOverlay) {
+                $overlay.fadeIn(opts.overlaySpeed, function() {
+                    setSelfPosition();
+                    $self[opts.appearEffect](opts.lightboxSpeed, function() { setOverlayHeight(); setSelfPosition(); opts.onLoad()});
+                });
+            } else {
+                setSelfPosition();
+                $self[opts.appearEffect](opts.lightboxSpeed, function() { opts.onLoad()});
+            }
+
+            /*----------------------------------------------------
+               Hide parent if parent specified (parentLightbox should be jquery reference to any parent lightbox)
+            ---------------------------------------------------- */
+            if (opts.parentLightbox) {
+                opts.parentLightbox.fadeOut(200);
+            }
+
+
+            /*----------------------------------------------------
+               Bind Events
+            ---------------------------------------------------- */
+
+            $(window).resize(setOverlayHeight)
+                     .resize(setSelfPosition)
+                     .scroll(setSelfPosition);
+
+            $(window).bind('keyup.lightbox_me', observeKeyPress);
+
+            if (opts.closeClick) {
+                $overlay.click(function(e) { closeLightbox(); e.preventDefault; });
+            }
+            $self.delegate(opts.closeSelector, "click", function(e) {
+                closeLightbox(); e.preventDefault();
+            });
+            $self.bind('close', closeLightbox);
+            $self.bind('reposition', setSelfPosition);
+
+
+
+            /*--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+              -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- */
+
+
+            /*----------------------------------------------------
+               Private Functions
+            ---------------------------------------------------- */
+
+            /* Remove or hide all elements */
+            function closeLightbox() {
+                var s = $self[0].style;
+                if (opts.destroyOnClose) {
+                    $self.add($overlay).remove();
+                } else {
+                    $self.add($overlay).hide();
+                }
+
+                //show the hidden parent lightbox
+                if (opts.parentLightbox) {
+                    opts.parentLightbox.fadeIn(200);
+                }
+
+                $iframe.remove();
+
+				// clean up events.
+                $self.undelegate(opts.closeSelector, "click");
+
+                $(window).unbind('reposition', setOverlayHeight);
+                $(window).unbind('reposition', setSelfPosition);
+                $(window).unbind('scroll', setSelfPosition);
+                $(window).unbind('keyup.lightbox_me');
+                if (ie6)
+                    s.removeExpression('top');
+                opts.onClose();
+            }
+
+
+            /* Function to bind to the window to observe the escape/enter key press */
+            function observeKeyPress(e) {
+                if((e.keyCode == 27 || (e.DOM_VK_ESCAPE == 27 && e.which==0)) && opts.closeEsc) closeLightbox();
+            }
+
+
+            /* Set the height of the overlay
+                    : if the document height is taller than the window, then set the overlay height to the document height.
+                    : otherwise, just set overlay height: 100%
+            */
+            function setOverlayHeight() {
+                if ($(window).height() < $(document).height()) {
+                    $overlay.css({height: $(document).height() + 'px'});
+                     $iframe.css({height: $(document).height() + 'px'});
+                } else {
+                    $overlay.css({height: '100%'});
+                    if (ie6) {
+                        $('html,body').css('height','100%');
+                        $iframe.css('height', '100%');
+                    } // ie6 hack for height: 100%; TODO: handle this in IE7
+                }
+            }
+
+
+            /* Set the position of the modal'd window ($self)
+                    : if $self is taller than the window, then make it absolutely positioned
+                    : otherwise fixed
+            */
+            function setSelfPosition() {
+                var s = $self[0].style;
+
+                // reset CSS so width is re-calculated for margin-left CSS
+                $self.css({left: '50%', marginLeft: ($self.outerWidth() / 2) * -1,  zIndex: (opts.zIndex + 3) });
+
+
+                /* we have to get a little fancy when dealing with height, because lightbox_me
+                    is just so fancy.
+                 */
+
+                // if the height of $self is bigger than the window and self isn't already position absolute
+                if (($self.height() + 80  >= $(window).height()) && ($self.css('position') != 'absolute' || ie6)) {
+
+                    // we are going to make it positioned where the user can see it, but they can still scroll
+                    // so the top offset is based on the user's scroll position.
+                    var topOffset = $(document).scrollTop() + 40;
+                    $self.css({position: 'absolute', top: topOffset + 'px', marginTop: 0})
+                    if (ie6) {
+                        s.removeExpression('top');
+                    }
+                } else if ($self.height()+ 80  < $(window).height()) {
+                    //if the height is less than the window height, then we're gonna make this thing position: fixed.
+                    // in ie6 we're gonna fake it.
+                    if (ie6) {
+                        s.position = 'absolute';
+                        if (opts.centered) {
+                            s.setExpression('top', '(document.documentElement.clientHeight || document.body.clientHeight) / 2 - (this.offsetHeight / 2) + (blah = document.documentElement.scrollTop ? document.documentElement.scrollTop : document.body.scrollTop) + "px"')
+                            s.marginTop = 0;
+                        } else {
+                            var top = (opts.modalCSS && opts.modalCSS.top) ? parseInt(opts.modalCSS.top) : 0;
+                            s.setExpression('top', '((blah = document.documentElement.scrollTop ? document.documentElement.scrollTop : document.body.scrollTop) + '+top+') + "px"')
+                        }
+                    } else {
+                        if (opts.centered) {
+                            $self.css({ position: 'fixed', top: '50%', marginTop: ($self.outerHeight() / 2) * -1})
+                        } else {
+                            $self.css({ position: 'fixed'}).css(opts.modalCSS);
+                        }
+
+                    }
+                }
+            }
+
+        });
+
+
+
+    };
+
+    $.fn.lightbox_me.defaults = {
+
+        // animation
+        appearEffect: "fadeIn",
+        appearEase: "",
+        overlaySpeed: 250,
+        lightboxSpeed: 300,
+
+        // close
+        closeSelector: ".close",
+        closeClick: true,
+        closeEsc: true,
+
+        // behavior
+        destroyOnClose: false,
+        showOverlay: true,
+        parentLightbox: false,
+
+        // callbacks
+        onLoad: function() {},
+        onClose: function() {},
+
+        // style
+        classPrefix: 'lb',
+        zIndex: 999,
+        centered: false,
+        modalCSS: {top: '40px'},
+        overlayCSS: {background: 'black', opacity: .3}
+    }
+})(jQuery);
\ No newline at end of file
diff --git a/koha-tmpl/opac-tmpl/lib/pz2.js b/koha-tmpl/opac-tmpl/lib/pz2.js
new file mode 100644
index 0000000..4234c07
--- /dev/null
+++ b/koha-tmpl/opac-tmpl/lib/pz2.js
@@ -0,0 +1,1124 @@
+/*
+ * pz2.js - pazpar2's javascript client library.
+ * Copyright (C) 2006-2013 Index Data.
+ *
+ * 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.
+*/
+
+//since explorer is flawed
+if (!window['Node']) {
+    window.Node = new Object();
+    Node.ELEMENT_NODE = 1;
+    Node.ATTRIBUTE_NODE = 2;
+    Node.TEXT_NODE = 3;
+    Node.CDATA_SECTION_NODE = 4;
+    Node.ENTITY_REFERENCE_NODE = 5;
+    Node.ENTITY_NODE = 6;
+    Node.PROCESSING_INSTRUCTION_NODE = 7;
+    Node.COMMENT_NODE = 8;
+    Node.DOCUMENT_NODE = 9;
+    Node.DOCUMENT_TYPE_NODE = 10;
+    Node.DOCUMENT_FRAGMENT_NODE = 11;
+    Node.NOTATION_NODE = 12;
+}
+
+// prevent execution of more than once
+if(typeof window.pz2 == "undefined") {
+window.undefined = window.undefined;
+
+var pz2 = function ( paramArray )
+{
+
+    // at least one callback required
+    if ( !paramArray )
+        throw new Error("Pz2.js: Array with parameters has to be supplied.");
+
+    //supported pazpar2's protocol version
+    this.suppProtoVer = '1';
+    if (typeof paramArray.pazpar2path != "undefined")
+        this.pz2String = paramArray.pazpar2path;
+    else
+        this.pz2String = "/pazpar2/search.pz2";
+    this.useSessions = true;
+
+    this.stylesheet = paramArray.detailstylesheet || null;
+    //load stylesheet if required in async mode
+    if( this.stylesheet ) {
+        var context = this;
+        var request = new pzHttpRequest( this.stylesheet );
+        request.get( {}, function ( doc ) { context.xslDoc = doc; } );
+    }
+
+    this.errorHandler = paramArray.errorhandler || null;
+    this.showResponseType = paramArray.showResponseType || "xml";
+
+    // function callbacks
+    this.initCallback = paramArray.oninit || null;
+    this.statCallback = paramArray.onstat || null;
+    this.showCallback = paramArray.onshow || null;
+    this.termlistCallback = paramArray.onterm || null;
+    this.recordCallback = paramArray.onrecord || null;
+    this.bytargetCallback = paramArray.onbytarget || null;
+    this.resetCallback = paramArray.onreset || null;
+
+    // termlist keys
+    this.termKeys = paramArray.termlist || "subject";
+
+    // some configurational stuff
+    this.keepAlive = 50000;
+
+    if ( paramArray.keepAlive < this.keepAlive )
+        this.keepAlive = paramArray.keepAlive;
+
+    this.sessionID = paramArray.sessionId || null;
+    this.serviceId = paramArray.serviceId || null;
+    this.initStatusOK = false;
+    this.pingStatusOK = false;
+    this.searchStatusOK = false;
+
+    // for sorting
+    this.currentSort = "relevance";
+
+    // where are we?
+    this.currentStart = 0;
+    // currentNum can be overwritten in show
+    this.currentNum = 20;
+
+    // last full record retrieved
+    this.currRecID = null;
+
+    // current query
+    this.currQuery = null;
+
+    //current raw record offset
+    this.currRecOffset = null;
+
+    //timers
+    this.pingTimer = null;
+    this.statTime = paramArray.stattime || 1000;
+    this.statTimer = null;
+    this.termTime = paramArray.termtime || 1000;
+    this.termTimer = null;
+    this.showTime = paramArray.showtime || 1000;
+    this.showTimer = null;
+    this.showFastCount = 4;
+    this.bytargetTime = paramArray.bytargettime || 1000;
+    this.bytargetTimer = null;
+    this.recordTime = paramArray.recordtime || 500;
+    this.recordTimer = null;
+
+    // counters for each command and applied delay
+    this.dumpFactor = 500;
+    this.showCounter = 0;
+    this.termCounter = 0;
+    this.statCounter = 0;
+    this.bytargetCounter = 0;
+    this.recordCounter = 0;
+
+    // active clients, updated by stat and show
+    // might be an issue since bytarget will poll accordingly
+    this.activeClients = 1;
+
+    // if in proxy mode no need to init
+    if (paramArray.usesessions != undefined) {
+         this.useSessions = paramArray.usesessions;
+        this.initStatusOK = true;
+    }
+    // else, auto init session or wait for a user init?
+    if (this.useSessions && paramArray.autoInit !== false) {
+        this.init(this.sessionID, this.serviceId);
+    }
+    // Version parameter
+    this.version = paramArray.version || null;
+};
+
+pz2.prototype =
+{
+    //error handler for async error throws
+   throwError: function (errMsg, errCode)
+   {
+        var err = new Error(errMsg);
+        if (errCode) err.code = errCode;
+
+        if (this.errorHandler) {
+            this.errorHandler(err);
+        }
+        else {
+            throw err;
+        }
+   },
+
+    // stop activity by clearing tiemouts
+   stop: function ()
+   {
+       clearTimeout(this.statTimer);
+       clearTimeout(this.showTimer);
+       clearTimeout(this.termTimer);
+       clearTimeout(this.bytargetTimer);
+    },
+
+    // reset status variables
+    reset: function ()
+    {
+        if ( this.useSessions ) {
+            this.sessionID = null;
+            this.initStatusOK = false;
+            this.pingStatusOK = false;
+            clearTimeout(this.pingTimer);
+        }
+        this.searchStatusOK = false;
+        this.stop();
+
+        if ( this.resetCallback )
+                this.resetCallback();
+    },
+
+    init: function (sessionId, serviceId)
+    {
+        this.reset();
+
+        // session id as a param
+        if (sessionId && this.useSessions ) {
+            this.initStatusOK = true;
+            this.sessionID = sessionId;
+            this.ping();
+        // old school direct pazpar2 init
+        } else if (this.useSessions) {
+            var context = this;
+            var request = new pzHttpRequest(this.pz2String, this.errorHandler);
+            var opts = {'command' : 'init'};
+            if (serviceId) opts.service = serviceId;
+            request.safeGet(
+                opts,
+                function(data) {
+                    if ( data.getElementsByTagName("status")[0]
+                            .childNodes[0].nodeValue == "OK" ) {
+                        if ( data.getElementsByTagName("protocol")[0]
+                                .childNodes[0].nodeValue
+                            != context.suppProtoVer )
+                            throw new Error(
+                                "Server's protocol not supported by the client"
+                            );
+                        context.initStatusOK = true;
+                        context.sessionID =
+                            data.getElementsByTagName("session")[0]
+                                .childNodes[0].nodeValue;
+                        if (data.getElementsByTagName("keepAlive").length > 0) {
+                            context.keepAlive = data.getElementsByTagName("keepAlive")[0].childNodes[0].nodeValue;
+                        }
+                        context.pingTimer =
+                            setTimeout(
+                                function () {
+                                    context.ping();
+                                },
+                                context.keepAlive
+                            );
+                        if ( context.initCallback )
+                            context.initCallback();
+                    }
+                    else
+                        context.throwError('Init failed. Malformed WS resonse.',
+                                            110);
+                }
+            );
+        // when through proxy no need to init
+        } else {
+            this.initStatusOK = true;
+	}
+    },
+    // no need to ping explicitly
+    ping: function ()
+    {
+        // pinging only makes sense when using pazpar2 directly
+        if( !this.initStatusOK || !this.useSessions )
+            throw new Error(
+            'Pz2.js: Ping not allowed (proxy mode) or session not initialized.'
+            );
+        var context = this;
+
+        clearTimeout(context.pingTimer);
+
+        var request = new pzHttpRequest(this.pz2String, this.errorHandler);
+        request.safeGet(
+            { "command": "ping", "session": this.sessionID, "windowid" : window.name },
+            function(data) {
+                if ( data.getElementsByTagName("status")[0]
+                        .childNodes[0].nodeValue == "OK" ) {
+                    context.pingStatusOK = true;
+                    context.pingTimer =
+                        setTimeout(
+                            function () {
+                                context.ping();
+                            },
+                            context.keepAlive
+                        );
+                }
+                else
+                    context.throwError('Ping failed. Malformed WS resonse.',
+                                        111);
+            }
+        );
+    },
+    search: function (query, num, sort, filter, showfrom, addParamsArr)
+    {
+        clearTimeout(this.statTimer);
+        clearTimeout(this.showTimer);
+        clearTimeout(this.termTimer);
+        clearTimeout(this.bytargetTimer);
+
+        this.showCounter = 0;
+        this.termCounter = 0;
+        this.bytargetCounter = 0;
+        this.statCounter = 0;
+        this.activeClients = 1;
+
+        // no proxy mode
+        if( !this.initStatusOK )
+            throw new Error('Pz2.js: session not initialized.');
+
+        if( query !== undefined )
+            this.currQuery = query;
+        else
+            throw new Error("Pz2.js: no query supplied to the search command.");
+
+        if ( showfrom !== undefined )
+            var start = showfrom;
+        else
+            var start = 0;
+
+	var searchParams = {
+          "command": "search",
+          "query": this.currQuery,
+          "session": this.sessionID,
+          "windowid" : window.name
+        };
+
+        if( sort !== undefined ) {
+            this.currentSort = sort;
+	    searchParams["sort"] = sort;
+	}
+        if (filter !== undefined)
+	        searchParams["filter"] = filter;
+
+        // copy additional parmeters, do not overwrite
+        if (addParamsArr != undefined) {
+            for (var prop in addParamsArr) {
+                if (!searchParams.hasOwnProperty(prop))
+                    searchParams[prop] = addParamsArr[prop];
+            }
+        }
+
+        var context = this;
+        var request = new pzHttpRequest(this.pz2String, this.errorHandler);
+        request.safeGet(
+            searchParams,
+            function(data) {
+                if ( data.getElementsByTagName("status")[0]
+                        .childNodes[0].nodeValue == "OK" ) {
+                    context.searchStatusOK = true;
+                    //piggyback search
+                    context.show(start, num, sort);
+                    if (context.statCallback)
+                        context.stat();
+                    if (context.termlistCallback)
+                        context.termlist();
+                    if (context.bytargetCallback)
+                        context.bytarget();
+                }
+                else
+                    context.throwError('Search failed. Malformed WS resonse.',
+                                        112);
+            }
+        );
+    },
+    stat: function()
+    {
+        if( !this.initStatusOK )
+            throw new Error('Pz2.js: session not initialized.');
+
+        // if called explicitly takes precedence
+        clearTimeout(this.statTimer);
+
+        var context = this;
+        var request = new pzHttpRequest(this.pz2String, this.errorHandler);
+        request.safeGet(
+            { "command": "stat", "session": this.sessionID, "windowid" : window.name },
+            function(data) {
+                if ( data.getElementsByTagName("stat") ) {
+                    var activeClients =
+                        Number( data.getElementsByTagName("activeclients")[0]
+                                    .childNodes[0].nodeValue );
+                    context.activeClients = activeClients;
+
+		    var stat = Element_parseChildNodes(data.documentElement);
+
+                    context.statCounter++;
+		    var delay = context.statTime
+                        + context.statCounter * context.dumpFactor;
+
+                    if ( activeClients > 0 )
+                        context.statTimer =
+                            setTimeout(
+                                function () {
+                                    context.stat();
+                                },
+                                delay
+                            );
+                    context.statCallback(stat);
+                }
+                else
+                    context.throwError('Stat failed. Malformed WS resonse.',
+                                        113);
+            }
+        );
+    },
+    show: function(start, num, sort, query_state)
+    {
+        if( !this.searchStatusOK && this.useSessions )
+            throw new Error(
+                'Pz2.js: show command has to be preceded with a search command.'
+            );
+
+        // if called explicitly takes precedence
+        clearTimeout(this.showTimer);
+
+        if( sort !== undefined )
+            this.currentSort = sort;
+        if( start !== undefined )
+            this.currentStart = Number( start );
+        if( num !== undefined )
+            this.currentNum = Number( num );
+
+        var context = this;
+        var request = new pzHttpRequest(this.pz2String, this.errorHandler);
+	var requestParameters =
+          {
+              "command": "show",
+              "session": this.sessionID,
+              "start": this.currentStart,
+              "num": this.currentNum,
+              "sort": this.currentSort,
+              "block": 1,
+              "type": this.showResponseType,
+              "windowid" : window.name
+          };
+        if (query_state)
+          requestParameters["query-state"] = query_state;
+	if (this.version && this.version > 0)
+	    requestParameters["version"] = this.version;
+        request.safeGet(
+	  requestParameters,
+          function(data, type) {
+            var show = null;
+            var activeClients = 0;
+            if (type === "json") {
+              show = {};
+              activeClients = Number(data.activeclients[0]);
+              show.activeclients = activeClients;
+              show.merged = Number(data.merged[0]);
+              show.total = Number(data.total[0]);
+              show.start = Number(data.start[0]);
+              show.num = Number(data.num[0]);
+              show.hits = data.hit;
+            } else if (data.getElementsByTagName("status")[0]
+                  .childNodes[0].nodeValue == "OK") {
+                // first parse the status data send along with records
+                // this is strictly bound to the format
+                activeClients =
+                  Number(data.getElementsByTagName("activeclients")[0]
+                      .childNodes[0].nodeValue);
+                show = {
+                  "activeclients": activeClients,
+                  "merged":
+                    Number( data.getElementsByTagName("merged")[0]
+                        .childNodes[0].nodeValue ),
+                  "total":
+                    Number( data.getElementsByTagName("total")[0]
+                        .childNodes[0].nodeValue ),
+                  "start":
+                    Number( data.getElementsByTagName("start")[0]
+                        .childNodes[0].nodeValue ),
+                  "num":
+                    Number( data.getElementsByTagName("num")[0]
+                        .childNodes[0].nodeValue ),
+                  "hits": []
+                };
+                // parse all the first-level nodes for all <hit> tags
+                var hits = data.getElementsByTagName("hit");
+                for (i = 0; i < hits.length; i++)
+                  show.hits[i] = Element_parseChildNodes(hits[i]);
+            } else {
+              context.throwError('Show failed. Malformed WS resonse.',
+                  114);
+            };
+
+	    var approxNode = data.getElementsByTagName("approximation");
+	    if (approxNode && approxNode[0] && approxNode[0].childNodes[0] && approxNode[0].childNodes[0].nodeValue)
+		show['approximation'] =
+		  Number( approxNode[0].childNodes[0].nodeValue);
+
+
+	      data.getElementsByTagName("")
+            context.activeClients = activeClients;
+            context.showCounter++;
+            var delay = context.showTime;
+            if (context.showCounter > context.showFastCount)
+              delay += context.showCounter * context.dumpFactor;
+            if ( activeClients > 0 )
+              context.showTimer = setTimeout(
+                function () {
+                  context.show();
+                },
+                delay);
+            context.showCallback(show);
+          }
+        );
+    },
+    record: function(id, offset, syntax, handler)
+    {
+        // we may call record with no previous search if in proxy mode
+        if(!this.searchStatusOK && this.useSessions)
+           throw new Error(
+            'Pz2.js: record command has to be preceded with a search command.'
+            );
+
+        if( id !== undefined )
+            this.currRecID = id;
+
+	var recordParams = {
+            "command": "record",
+            "session": this.sessionID,
+            "id": this.currRecID,
+            "windowid" : window.name
+        };
+
+	this.currRecOffset = null;
+        if (offset != undefined) {
+	    recordParams["offset"] = offset;
+            this.currRecOffset = offset;
+        }
+
+        if (syntax != undefined)
+            recordParams['syntax'] = syntax;
+
+        //overwrite default callback id needed
+        var callback = this.recordCallback;
+        var args = undefined;
+        if (handler != undefined) {
+            callback = handler['callback'];
+            args = handler['args'];
+        }
+
+        var context = this;
+        var request = new pzHttpRequest(this.pz2String, this.errorHandler);
+
+        request.safeGet(
+	    recordParams,
+            function(data) {
+                var recordNode;
+                var record;
+                //raw record
+                if (context.currRecOffset !== null) {
+                    record = new Array();
+                    record['xmlDoc'] = data;
+                    record['offset'] = context.currRecOffset;
+                    callback(record, args);
+                //pz2 record
+                } else if ( recordNode =
+                    data.getElementsByTagName("record")[0] ) {
+                    // if stylesheet was fetched do not parse the response
+                    if ( context.xslDoc ) {
+                        record = new Array();
+                        record['xmlDoc'] = data;
+                        record['xslDoc'] = context.xslDoc;
+                        record['recid'] =
+                            recordNode.getElementsByTagName("recid")[0]
+                                .firstChild.nodeValue;
+                    //parse record
+                    } else {
+                        record = Element_parseChildNodes(recordNode);
+                    }
+		    var activeClients =
+		       Number( data.getElementsByTagName("activeclients")[0]
+				.childNodes[0].nodeValue );
+		    context.activeClients = activeClients;
+                    context.recordCounter++;
+                    var delay = context.recordTime + context.recordCounter * context.dumpFactor;
+                    if ( activeClients > 0 )
+                        context.recordTimer =
+                           setTimeout (
+                               function() {
+                                  context.record(id, offset, syntax, handler);
+                                  },
+                                  delay
+                               );
+                    callback(record, args);
+                }
+                else
+                    context.throwError('Record failed. Malformed WS resonse.',
+                                        115);
+            }
+        );
+    },
+
+    termlist: function()
+    {
+        if( !this.searchStatusOK && this.useSessions )
+            throw new Error(
+            'Pz2.js: termlist command has to be preceded with a search command.'
+            );
+
+        // if called explicitly takes precedence
+        clearTimeout(this.termTimer);
+
+        var context = this;
+        var request = new pzHttpRequest(this.pz2String, this.errorHandler);
+        request.safeGet(
+            {
+                "command": "termlist",
+                "session": this.sessionID,
+                "name": this.termKeys,
+                "windowid" : window.name,
+		"version" : this.version
+
+            },
+            function(data) {
+                if ( data.getElementsByTagName("termlist") ) {
+                    var activeClients =
+                        Number( data.getElementsByTagName("activeclients")[0]
+                                    .childNodes[0].nodeValue );
+                    context.activeClients = activeClients;
+                    var termList = { "activeclients":  activeClients };
+                    var termLists = data.getElementsByTagName("list");
+                    //for each termlist
+                    for (i = 0; i < termLists.length; i++) {
+			var listName = termLists[i].getAttribute('name');
+                        termList[listName] = new Array();
+                        var terms = termLists[i].getElementsByTagName('term');
+                        //for each term in the list
+                        for (j = 0; j < terms.length; j++) {
+                            var term = {
+                                "name":
+                                    (terms[j].getElementsByTagName("name")[0]
+                                        .childNodes.length
+                                    ? terms[j].getElementsByTagName("name")[0]
+                                        .childNodes[0].nodeValue
+                                    : 'ERROR'),
+                                "freq":
+                                    terms[j]
+                                    .getElementsByTagName("frequency")[0]
+                                    .childNodes[0].nodeValue || 'ERROR'
+                            };
+
+			    // Only for xtargets: id, records, filtered
+                            var termIdNode =
+                                terms[j].getElementsByTagName("id");
+                            if(terms[j].getElementsByTagName("id").length)
+                                term["id"] =
+                                    termIdNode[0].childNodes[0].nodeValue;
+                            termList[listName][j] = term;
+
+			    var recordsNode  = terms[j].getElementsByTagName("records");
+			    if (recordsNode && recordsNode.length)
+				term["records"] = recordsNode[0].childNodes[0].nodeValue;
+
+			    var filteredNode  = terms[j].getElementsByTagName("filtered");
+			    if (filteredNode && filteredNode.length)
+				term["filtered"] = filteredNode[0].childNodes[0].nodeValue;
+
+                        }
+                    }
+
+                    context.termCounter++;
+                    var delay = context.termTime
+                        + context.termCounter * context.dumpFactor;
+                    if ( activeClients > 0 )
+                        context.termTimer =
+                            setTimeout(
+                                function () {
+                                    context.termlist();
+                                },
+                                delay
+                            );
+
+                   context.termlistCallback(termList);
+                }
+                else
+                    context.throwError('Termlist failed. Malformed WS resonse.',
+                                        116);
+            }
+        );
+
+    },
+    bytarget: function()
+    {
+        if( !this.initStatusOK && this.useSessions )
+            throw new Error(
+            'Pz2.js: bytarget command has to be preceded with a search command.'
+            );
+
+        // no need to continue
+        if( !this.searchStatusOK )
+            return;
+
+        // if called explicitly takes precedence
+        clearTimeout(this.bytargetTimer);
+
+        var context = this;
+        var request = new pzHttpRequest(this.pz2String, this.errorHandler);
+        request.safeGet(
+            {
+		"command": "bytarget",
+		"session": this.sessionID,
+		"block": 1,
+		"windowid" : window.name,
+		"version" : this.version
+	    },
+            function(data) {
+                if ( data.getElementsByTagName("status")[0]
+                        .childNodes[0].nodeValue == "OK" ) {
+                    var targetNodes = data.getElementsByTagName("target");
+                    var bytarget = new Array();
+                    for ( i = 0; i < targetNodes.length; i++) {
+                        bytarget[i] = new Array();
+                        for( j = 0; j < targetNodes[i].childNodes.length; j++ ) {
+                            if ( targetNodes[i].childNodes[j].nodeType
+                                == Node.ELEMENT_NODE ) {
+                                var nodeName =
+                                    targetNodes[i].childNodes[j].nodeName;
+				if (targetNodes[i].childNodes[j].firstChild != null)
+				{
+                                    var nodeText = targetNodes[i].childNodes[j]
+					.firstChild.nodeValue;
+                                    bytarget[i][nodeName] = nodeText;
+				}
+				else {
+				    bytarget[i][nodeName] = "";
+				}
+
+
+                            }
+                        }
+                        if (bytarget[i]["state"]=="Client_Disconnected") {
+                          bytarget[i]["hits"] = "Error";
+                        } else if (bytarget[i]["state"]=="Client_Error") {
+                          bytarget[i]["hits"] = "Error";
+                        } else if (bytarget[i]["state"]=="Client_Working") {
+                          bytarget[i]["hits"] = "...";
+                        }
+                        if (bytarget[i].diagnostic == "1") {
+                          bytarget[i].diagnostic = "Permanent system error";
+                        } else if (bytarget[i].diagnostic == "2") {
+                          bytarget[i].diagnostic = "Temporary system error";
+                        }
+                        var targetsSuggestions = targetNodes[i].getElementsByTagName("suggestions");
+                        if (targetsSuggestions != undefined && targetsSuggestions.length>0) {
+                          var suggestions = targetsSuggestions[0];
+                          bytarget[i]["suggestions"] = Element_parseChildNodes(suggestions);
+                        }
+                    }
+
+                    context.bytargetCounter++;
+                    var delay = context.bytargetTime
+                        + context.bytargetCounter * context.dumpFactor;
+                    if ( context.activeClients > 0 )
+                        context.bytargetTimer =
+                            setTimeout(
+                                function () {
+                                    context.bytarget();
+                                },
+                                delay
+                            );
+
+                    context.bytargetCallback(bytarget);
+                }
+                else
+                    context.throwError('Bytarget failed. Malformed WS resonse.',
+                                        117);
+            }
+        );
+    },
+
+    // just for testing, probably shouldn't be here
+    showNext: function(page)
+    {
+        var step = page || 1;
+        this.show( ( step * this.currentNum ) + this.currentStart );
+    },
+
+    showPrev: function(page)
+    {
+        if (this.currentStart == 0 )
+            return false;
+        var step = page || 1;
+        var newStart = this.currentStart - (step * this.currentNum );
+        this.show( newStart > 0 ? newStart : 0 );
+    },
+
+    showPage: function(pageNum)
+    {
+        //var page = pageNum || 1;
+        this.show(pageNum * this.currentNum);
+    }
+};
+
+/*
+********************************************************************************
+** AJAX HELPER CLASS ***********************************************************
+********************************************************************************
+*/
+var pzHttpRequest = function ( url, errorHandler ) {
+        this.maxUrlLength = 2048;
+        this.request = null;
+        this.url = url;
+        this.errorHandler = errorHandler || null;
+        this.async = true;
+        this.requestHeaders = {};
+
+        if ( window.XMLHttpRequest ) {
+            this.request = new XMLHttpRequest();
+        } else if ( window.ActiveXObject ) {
+            try {
+                this.request = new ActiveXObject( 'Msxml2.XMLHTTP' );
+            } catch (err) {
+                this.request = new ActiveXObject( 'Microsoft.XMLHTTP' );
+            }
+        }
+};
+
+
+pzHttpRequest.prototype =
+{
+    safeGet: function ( params, callback )
+    {
+        var encodedParams =  this.encodeParams(params);
+        var url = this._urlAppendParams(encodedParams);
+        if (url.length >= this.maxUrlLength) {
+            this.requestHeaders["Content-Type"]
+                = "application/x-www-form-urlencoded";
+            this._send( 'POST', this.url, encodedParams, callback );
+        } else {
+            this._send( 'GET', url, '', callback );
+        }
+    },
+
+    get: function ( params, callback )
+    {
+        this._send( 'GET', this._urlAppendParams(this.encodeParams(params)),
+            '', callback );
+    },
+
+    post: function ( params, data, callback )
+    {
+        this._send( 'POST', this._urlAppendParams(this.encodeParams(params)),
+            data, callback );
+    },
+
+    load: function ()
+    {
+        this.async = false;
+        this.request.open( 'GET', this.url, this.async );
+        this.request.send('');
+        if ( this.request.status == 200 )
+            return this.request.responseXML;
+    },
+
+    encodeParams: function (params)
+    {
+        var sep = "";
+        var encoded = "";
+        for (var key in params) {
+            if (params[key] != null) {
+                encoded += sep + key + '=' + encodeURIComponent(params[key]);
+                sep = '&';
+            }
+        }
+        return encoded;
+    },
+
+    _send: function ( type, url, data, callback)
+    {
+        var context = this;
+        this.callback = callback;
+        this.async = true;
+        this.request.open( type, url, this.async );
+        for (var key in this.requestHeaders)
+            this.request.setRequestHeader(key, this.requestHeaders[key]);
+        this.request.onreadystatechange = function () {
+            context._handleResponse(url); /// url used ONLY for error reporting
+        }
+        this.request.send(data);
+    },
+
+    _urlAppendParams: function (encodedParams)
+    {
+        if (encodedParams)
+            return this.url + "?" + encodedParams;
+        else
+            return this.url;
+    },
+
+    _handleResponse: function (savedUrlForErrorReporting)
+    {
+        if ( this.request.readyState == 4 ) {
+            // pick up appplication errors first
+            var errNode = null;
+            if (this.request.responseXML &&
+                (errNode = this.request.responseXML.documentElement)
+                && errNode.nodeName == 'error') {
+                var errMsg = errNode.getAttribute("msg");
+                var errCode = errNode.getAttribute("code");
+                var errAddInfo = '';
+                if (errNode.childNodes.length)
+                    errAddInfo = ': ' + errNode.childNodes[0].nodeValue;
+
+                var err = new Error(errMsg + errAddInfo);
+                err.code = errCode;
+
+                if (this.errorHandler) {
+                    this.errorHandler(err);
+                }
+                else {
+                    throw err;
+                }
+            } else if (this.request.status == 200 &&
+                       this.request.responseXML == null) {
+              if (this.request.responseText != null) {
+                //assume JSON
+
+		var json = null;
+		var text = this.request.responseText;
+		if (typeof window.JSON == "undefined")
+		    json = eval("(" + text + ")");
+		else {
+		    try	{
+			json = JSON.parse(text);
+		    }
+		    catch (e) {
+			// Safari: eval will fail as well. Considering trying JSON2 (non-native implementation) instead
+			/* DEBUG only works in mk2-mobile
+			if (document.getElementById("log"))
+			    document.getElementById("log").innerHTML = "" + e + " " + length + ": " + text;
+			*/
+			try {
+			    json = eval("(" + text + ")");
+			}
+			catch (e) {
+			    /* DEBUG only works in mk2-mobile
+			    if (document.getElementById("log"))
+				document.getElementById("log").innerHTML = "" + e + " " + length + ": " + text;
+			    */
+			}
+		    }
+		}
+		this.callback(json, "json");
+              } else {
+                var err = new Error("XML response is empty but no error " +
+                                    "for " + savedUrlForErrorReporting);
+                err.code = -1;
+                if (this.errorHandler) {
+                    this.errorHandler(err);
+                } else {
+                    throw err;
+                }
+              }
+            } else if (this.request.status == 200) {
+                this.callback(this.request.responseXML);
+            } else {
+                var err = new Error("HTTP response not OK: "
+                            + this.request.status + " - "
+                            + this.request.statusText );
+                err.code = '00' + this.request.status;
+                if (this.errorHandler) {
+                    this.errorHandler(err);
+                }
+                else {
+                    throw err;
+                }
+            }
+        }
+    }
+};
+
+/*
+********************************************************************************
+** XML HELPER FUNCTIONS ********************************************************
+********************************************************************************
+*/
+
+// DOMDocument
+
+if ( window.ActiveXObject) {
+    var DOMDoc = document;
+} else {
+    var DOMDoc = Document.prototype;
+}
+
+DOMDoc.newXmlDoc = function ( root )
+{
+    var doc;
+
+    if (document.implementation && document.implementation.createDocument) {
+        doc = document.implementation.createDocument('', root, null);
+    } else if ( window.ActiveXObject ) {
+        doc = new ActiveXObject("MSXML2.DOMDocument");
+        doc.loadXML('<' + root + '/>');
+    } else {
+        throw new Error ('No XML support in this browser');
+    }
+
+    return doc;
+}
+
+
+DOMDoc.parseXmlFromString = function ( xmlString )
+{
+    var doc;
+
+    if ( window.DOMParser ) {
+        var parser = new DOMParser();
+        doc = parser.parseFromString( xmlString, "text/xml");
+    } else if ( window.ActiveXObject ) {
+        doc = new ActiveXObject("MSXML2.DOMDocument");
+        doc.loadXML( xmlString );
+    } else {
+        throw new Error ("No XML parsing support in this browser.");
+    }
+
+    return doc;
+}
+
+DOMDoc.transformToDoc = function (xmlDoc, xslDoc)
+{
+    if ( window.XSLTProcessor ) {
+        var proc = new XSLTProcessor();
+        proc.importStylesheet( xslDoc );
+        return proc.transformToDocument(xmlDoc);
+    } else if ( window.ActiveXObject ) {
+        return document.parseXmlFromString(xmlDoc.transformNode(xslDoc));
+    } else {
+        alert( 'Unable to perform XSLT transformation in this browser' );
+    }
+}
+
+// DOMElement
+
+Element_removeFromDoc = function (DOM_Element)
+{
+    DOM_Element.parentNode.removeChild(DOM_Element);
+}
+
+Element_emptyChildren = function (DOM_Element)
+{
+    while( DOM_Element.firstChild ) {
+        DOM_Element.removeChild( DOM_Element.firstChild )
+    }
+}
+
+Element_appendTransformResult = function ( DOM_Element, xmlDoc, xslDoc )
+{
+    if ( window.XSLTProcessor ) {
+        var proc = new XSLTProcessor();
+        proc.importStylesheet( xslDoc );
+        var docFrag = false;
+        docFrag = proc.transformToFragment( xmlDoc, DOM_Element.ownerDocument );
+        DOM_Element.appendChild(docFrag);
+    } else if ( window.ActiveXObject ) {
+        DOM_Element.innerHTML = xmlDoc.transformNode( xslDoc );
+    } else {
+        alert( 'Unable to perform XSLT transformation in this browser' );
+    }
+}
+
+Element_appendTextNode = function (DOM_Element, tagName, textContent )
+{
+    var node = DOM_Element.ownerDocument.createElement(tagName);
+    var text = DOM_Element.ownerDocument.createTextNode(textContent);
+
+    DOM_Element.appendChild(node);
+    node.appendChild(text);
+
+    return node;
+}
+
+Element_setTextContent = function ( DOM_Element, textContent )
+{
+    if (typeof DOM_Element.textContent !== "undefined") {
+        DOM_Element.textContent = textContent;
+    } else if (typeof DOM_Element.innerText !== "undefined" ) {
+        DOM_Element.innerText = textContent;
+    } else {
+        throw new Error("Cannot set text content of the node, no such method.");
+    }
+}
+
+Element_getTextContent = function (DOM_Element)
+{
+    if ( typeof DOM_Element.textContent != 'undefined' ) {
+        return DOM_Element.textContent;
+    } else if (typeof DOM_Element.text != 'undefined') {
+        return DOM_Element.text;
+    } else {
+        throw new Error("Cannot get text content of the node, no such method.");
+    }
+}
+
+Element_parseChildNodes = function (node)
+{
+    var parsed = {};
+    var hasChildElems = false;
+    var textContent = '';
+
+    if (node.hasChildNodes()) {
+        var children = node.childNodes;
+        for (var i = 0; i < children.length; i++) {
+            var child = children[i];
+            switch (child.nodeType) {
+              case Node.ELEMENT_NODE:
+                hasChildElems = true;
+                var nodeName = child.nodeName;
+                if (!(nodeName in parsed))
+                    parsed[nodeName] = [];
+                parsed[nodeName].push(Element_parseChildNodes(child));
+                break;
+              case Node.TEXT_NODE:
+                textContent += child.nodeValue;
+                break;
+              case Node.CDATA_SECTION_NODE:
+                textContent += child.nodeValue;
+                break;
+            }
+        }
+    }
+
+    var attrs = node.attributes;
+    for (var i = 0; i < attrs.length; i++) {
+        hasChildElems = true;
+        var attrName = '@' + attrs[i].nodeName;
+        var attrValue = attrs[i].nodeValue;
+        parsed[attrName] = attrValue;
+    }
+
+    // if no nested elements/attrs set value to text
+    if (hasChildElems)
+      parsed['#text'] = textContent;
+    else
+      parsed = textContent;
+
+    return parsed;
+}
+
+/* do not remove trailing bracket */
+}
diff --git a/koha-tmpl/opac-tmpl/prog/en/css/opac.css b/koha-tmpl/opac-tmpl/prog/en/css/opac.css
index 9e15cee..4aa962a 100644
--- a/koha-tmpl/opac-tmpl/prog/en/css/opac.css
+++ b/koha-tmpl/opac-tmpl/prog/en/css/opac.css
@@ -1677,6 +1677,10 @@ strong em, em strong  {
 	vertical-align: top;
 	width: 10px;
 }
+.sourcecol {
+	vertical-align: top;
+	width: 100px;
+}
 #container {
 	color : #000;
 }
@@ -3006,6 +3010,16 @@ float:left;
 padding: 0.1em 0;
 }
 
+.notesrow label {
+    font-weight: bold;
+}
+.notesrow span {
+    display: block;
+}
+.notesrow textarea {
+    width: 100%;
+}
+
 .thumbnail-shelfbrowser span {
     margin: 0px auto;
 }
@@ -3030,7 +3044,7 @@ padding: 0.1em 0;
     background: #EEEEEE none;
 }
 
-#overdrive-results {
+#overdrive-results, #pazpar2-results {
     font-weight: bold;
     padding-left: 1em;
 }
@@ -3074,3 +3088,14 @@ padding: 0.1em 0;
     width: 35%;
     font-size: 111%;
 }
+
+.thumbnail-shelfbrowser span {
+    margin: 0px auto;
+}
+
+#modal-overlay {
+    background-color: white;
+    border-radius: 5px;
+    max-width: 75%;
+    padding: 15px;
+}
diff --git a/koha-tmpl/opac-tmpl/prog/en/js/externalsearch.js b/koha-tmpl/opac-tmpl/prog/en/js/externalsearch.js
new file mode 100644
index 0000000..b2fe4a7
--- /dev/null
+++ b/koha-tmpl/opac-tmpl/prog/en/js/externalsearch.js
@@ -0,0 +1,106 @@
+if ( typeof KOHA == "undefined" || !KOHA ) {
+    var KOHA = {};
+}
+
+KOHA.XSLTGet = ( function() {
+    // Horrible browser hack, but required due to the following hard-to-detect and long-unfixed bug:
+    // https://bugs.webkit.org/show_bug.cgi?id=60276
+    var isChrome = /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor);
+    var isSafari = /Safari/.test(navigator.userAgent) && /Apple Computer/.test(navigator.vendor);
+
+    if ( !isChrome && !isSafari ) return $.get;
+
+    return function( url ) {
+        var result = new jQuery.Deferred();
+        var basepath = url.match( /(.*\/)*/ )[0];
+
+        $.get( url ).done( function( xslDoc ) {
+            var xslImports = xslDoc.getElementsByTagNameNS( 'http://www.w3.org/1999/XSL/Transform', 'import' );
+            var importsRemaining = xslImports.length;
+
+            if ( importsRemaining == 0 ) {
+                result.resolve( xslDoc );
+                return;
+            }
+
+            $.each( xslImports, function( i, importElem ) {
+                var path = $( importElem ).attr( 'href' );
+                if ( !/^(\/|https?:)/.test( path ) ) path = basepath + path;
+
+                KOHA.XSLTGet( path ).done( function( subDoc ) {
+                    importsRemaining--;
+                    $( importElem ).replaceWith( subDoc.documentElement.childNodes );
+
+                    if ( importsRemaining == 0 ) result.resolve( xslDoc );
+                } ).fail( function() {
+                    importsRemaining = -1;
+
+                    result.reject();
+                } );
+            } );
+        } ).fail( function() {
+            result.reject();
+        } );
+
+        return result;
+    };
+} )();
+
+KOHA.TransformToFragment = function( xmlDoc, xslDoc ) {
+    if ( window.XSLTProcessor ) {
+        var proc = new XSLTProcessor();
+        proc.importStylesheet( xslDoc );
+        proc.setParameter( null, 'showAvailability', false );
+        return (new XMLSerializer).serializeToString( proc.transformToFragment( xmlDoc, document ) );
+    } else if ( window.ActiveXObject ) {
+        var xslt = new ActiveXObject( "Msxml2.XSLTemplate" );
+        xslt.stylesheet = xslDoc;
+        var xslProc = xslt.createProcessor();
+        xslProc.input = xmlDoc;
+        xslProc.addParameter( 'showAvailability', false );
+        xslProc.transform();
+        return xslProc.output;
+    }
+};
+
+KOHA.ExternalSearch = ( function() {
+    return {
+        targets: {},
+        Search: function( q, limit, callback ) {
+            var targetIDs = [];
+            var includedTargets = [];
+
+            $.each( KOHA.ExternalSearch.targets, function ( url, info ) {
+                if ( !info.disabled ) {
+                    includedTargets.push( url );
+                    targetIDs.push( info.id );
+                }
+            } );
+
+            if ( KOHA.ExternalSearch._pz !== undefined ) {
+                afterinit( KOHA.ExternalSearch._pz );
+            } else {
+                $.get( '/cgi-bin/koha/svc/pazpar2_init', { targets: targetIDs.join(',') }, function( data ) {
+                    KOHA.ExternalSearch._pz = new pz2({
+                        sessionId: data.sessionID,
+                        onshow: callback,
+                        errorhandler: function ( error ) { callback( { error: error } ) },
+                    } );
+                    afterinit( KOHA.ExternalSearch._pz );
+                } );
+            }
+
+            function afterinit( pz ) {
+                pz.search( q, limit, 'relevance:0', 'pz:id=' + includedTargets.join( '|' ) );
+            }
+        },
+        Fetch: function( offset, callback ) {
+            var pz = KOHA.ExternalSearch._pz;
+            pz.showCallback = callback;
+            pz.show( offset );
+        },
+        GetDetailedRecord: function( recid, callback ) {
+            KOHA.ExternalSearch._pz.record( recid, 0, undefined, { callback: callback } );
+        },
+    };
+} )();
diff --git a/koha-tmpl/opac-tmpl/prog/en/lib/jquery/plugins/jquery.lightbox_me.js b/koha-tmpl/opac-tmpl/prog/en/lib/jquery/plugins/jquery.lightbox_me.js
new file mode 100644
index 0000000..effe8a6
--- /dev/null
+++ b/koha-tmpl/opac-tmpl/prog/en/lib/jquery/plugins/jquery.lightbox_me.js
@@ -0,0 +1,254 @@
+/*
+* $ lightbox_me
+* By: Buck Wilson
+* Version : 2.3
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+
+(function($) {
+
+    $.fn.lightbox_me = function(options) {
+
+        return this.each(function() {
+
+            var
+                opts = $.extend({}, $.fn.lightbox_me.defaults, options),
+                $overlay = $(),
+                $self = $(this),
+                $iframe = $('<iframe id="foo" style="z-index: ' + (opts.zIndex + 1) + ';border: none; margin: 0; padding: 0; position: absolute; width: 100%; height: 100%; top: 0; left: 0; filter: mask();"/>'),
+                ie6 = ($.browser.msie && $.browser.version < 7);
+
+            if (opts.showOverlay) {
+                //check if there's an existing overlay, if so, make subequent ones clear
+               var $currentOverlays = $(".js_lb_overlay:visible");
+                if ($currentOverlays.length > 0){
+                    $overlay = $('<div class="lb_overlay_clear js_lb_overlay"/>');
+                } else {
+                    $overlay = $('<div class="' + opts.classPrefix + '_overlay js_lb_overlay"/>');
+                }
+            }
+
+            /*----------------------------------------------------
+               DOM Building
+            ---------------------------------------------------- */
+            if (ie6) {
+                var src = /^https/i.test(window.location.href || '') ? 'javascript:false' : 'about:blank';
+                $iframe.attr('src', src);
+                $('body').append($iframe);
+            } // iframe shim for ie6, to hide select elements
+            $('body').append($self.hide()).append($overlay);
+
+
+            /*----------------------------------------------------
+               Overlay CSS stuffs
+            ---------------------------------------------------- */
+
+            // set css of the overlay
+            if (opts.showOverlay) {
+                setOverlayHeight(); // pulled this into a function because it is called on window resize.
+                $overlay.css({ position: 'absolute', width: '100%', top: 0, left: 0, right: 0, bottom: 0, zIndex: (opts.zIndex + 2), display: 'none' });
+				if (!$overlay.hasClass('lb_overlay_clear')){
+			$overlay.css(opts.overlayCSS);
+                }
+            }
+
+            /*----------------------------------------------------
+               Animate it in.
+            ---------------------------------------------------- */
+               //
+            if (opts.showOverlay) {
+                $overlay.fadeIn(opts.overlaySpeed, function() {
+                    setSelfPosition();
+                    $self[opts.appearEffect](opts.lightboxSpeed, function() { setOverlayHeight(); setSelfPosition(); opts.onLoad()});
+                });
+            } else {
+                setSelfPosition();
+                $self[opts.appearEffect](opts.lightboxSpeed, function() { opts.onLoad()});
+            }
+
+            /*----------------------------------------------------
+               Hide parent if parent specified (parentLightbox should be jquery reference to any parent lightbox)
+            ---------------------------------------------------- */
+            if (opts.parentLightbox) {
+                opts.parentLightbox.fadeOut(200);
+            }
+
+
+            /*----------------------------------------------------
+               Bind Events
+            ---------------------------------------------------- */
+
+            $(window).resize(setOverlayHeight)
+                     .resize(setSelfPosition)
+                     .scroll(setSelfPosition);
+
+            $(window).bind('keyup.lightbox_me', observeKeyPress);
+
+            if (opts.closeClick) {
+                $overlay.click(function(e) { closeLightbox(); e.preventDefault; });
+            }
+            $self.delegate(opts.closeSelector, "click", function(e) {
+                closeLightbox(); e.preventDefault();
+            });
+            $self.bind('close', closeLightbox);
+            $self.bind('reposition', setSelfPosition);
+
+
+
+            /*--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+              -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- */
+
+
+            /*----------------------------------------------------
+               Private Functions
+            ---------------------------------------------------- */
+
+            /* Remove or hide all elements */
+            function closeLightbox() {
+                var s = $self[0].style;
+                if (opts.destroyOnClose) {
+                    $self.add($overlay).remove();
+                } else {
+                    $self.add($overlay).hide();
+                }
+
+                //show the hidden parent lightbox
+                if (opts.parentLightbox) {
+                    opts.parentLightbox.fadeIn(200);
+                }
+
+                $iframe.remove();
+
+				// clean up events.
+                $self.undelegate(opts.closeSelector, "click");
+
+                $(window).unbind('reposition', setOverlayHeight);
+                $(window).unbind('reposition', setSelfPosition);
+                $(window).unbind('scroll', setSelfPosition);
+                $(window).unbind('keyup.lightbox_me');
+                if (ie6)
+                    s.removeExpression('top');
+                opts.onClose();
+            }
+
+
+            /* Function to bind to the window to observe the escape/enter key press */
+            function observeKeyPress(e) {
+                if((e.keyCode == 27 || (e.DOM_VK_ESCAPE == 27 && e.which==0)) && opts.closeEsc) closeLightbox();
+            }
+
+
+            /* Set the height of the overlay
+                    : if the document height is taller than the window, then set the overlay height to the document height.
+                    : otherwise, just set overlay height: 100%
+            */
+            function setOverlayHeight() {
+                if ($(window).height() < $(document).height()) {
+                    $overlay.css({height: $(document).height() + 'px'});
+                     $iframe.css({height: $(document).height() + 'px'});
+                } else {
+                    $overlay.css({height: '100%'});
+                    if (ie6) {
+                        $('html,body').css('height','100%');
+                        $iframe.css('height', '100%');
+                    } // ie6 hack for height: 100%; TODO: handle this in IE7
+                }
+            }
+
+
+            /* Set the position of the modal'd window ($self)
+                    : if $self is taller than the window, then make it absolutely positioned
+                    : otherwise fixed
+            */
+            function setSelfPosition() {
+                var s = $self[0].style;
+
+                // reset CSS so width is re-calculated for margin-left CSS
+                $self.css({left: '50%', marginLeft: ($self.outerWidth() / 2) * -1,  zIndex: (opts.zIndex + 3) });
+
+
+                /* we have to get a little fancy when dealing with height, because lightbox_me
+                    is just so fancy.
+                 */
+
+                // if the height of $self is bigger than the window and self isn't already position absolute
+                if (($self.height() + 80  >= $(window).height()) && ($self.css('position') != 'absolute' || ie6)) {
+
+                    // we are going to make it positioned where the user can see it, but they can still scroll
+                    // so the top offset is based on the user's scroll position.
+                    var topOffset = $(document).scrollTop() + 40;
+                    $self.css({position: 'absolute', top: topOffset + 'px', marginTop: 0})
+                    if (ie6) {
+                        s.removeExpression('top');
+                    }
+                } else if ($self.height()+ 80  < $(window).height()) {
+                    //if the height is less than the window height, then we're gonna make this thing position: fixed.
+                    // in ie6 we're gonna fake it.
+                    if (ie6) {
+                        s.position = 'absolute';
+                        if (opts.centered) {
+                            s.setExpression('top', '(document.documentElement.clientHeight || document.body.clientHeight) / 2 - (this.offsetHeight / 2) + (blah = document.documentElement.scrollTop ? document.documentElement.scrollTop : document.body.scrollTop) + "px"')
+                            s.marginTop = 0;
+                        } else {
+                            var top = (opts.modalCSS && opts.modalCSS.top) ? parseInt(opts.modalCSS.top) : 0;
+                            s.setExpression('top', '((blah = document.documentElement.scrollTop ? document.documentElement.scrollTop : document.body.scrollTop) + '+top+') + "px"')
+                        }
+                    } else {
+                        if (opts.centered) {
+                            $self.css({ position: 'fixed', top: '50%', marginTop: ($self.outerHeight() / 2) * -1})
+                        } else {
+                            $self.css({ position: 'fixed'}).css(opts.modalCSS);
+                        }
+
+                    }
+                }
+            }
+
+        });
+
+
+
+    };
+
+    $.fn.lightbox_me.defaults = {
+
+        // animation
+        appearEffect: "fadeIn",
+        appearEase: "",
+        overlaySpeed: 250,
+        lightboxSpeed: 300,
+
+        // close
+        closeSelector: ".close",
+        closeClick: true,
+        closeEsc: true,
+
+        // behavior
+        destroyOnClose: false,
+        showOverlay: true,
+        parentLightbox: false,
+
+        // callbacks
+        onLoad: function() {},
+        onClose: function() {},
+
+        // style
+        classPrefix: 'lb',
+        zIndex: 999,
+        centered: false,
+        modalCSS: {top: '40px'},
+        overlayCSS: {background: 'black', opacity: .3}
+    }
+})(jQuery);
\ No newline at end of file
diff --git a/koha-tmpl/opac-tmpl/prog/en/modules/opac-external-search.tt b/koha-tmpl/opac-tmpl/prog/en/modules/opac-external-search.tt
new file mode 100644
index 0000000..5308ccd
--- /dev/null
+++ b/koha-tmpl/opac-tmpl/prog/en/modules/opac-external-search.tt
@@ -0,0 +1,240 @@
+[% INCLUDE 'doc-head-open.inc' %]
+[% IF ( LibraryNameTitle ) %][% LibraryNameTitle %][% ELSE %]Koha online[% END %] catalog &rsaquo; External search for '[% q | html %]'
+[% INCLUDE 'doc-head-close.inc' %]
+<script type="text/javascript" src="[% themelang %]/lib/jquery/plugins/jquery.lightbox_me.js"></script>
+<script type="text/javascript" src="/opac-tmpl/lib/pz2.js"></script>
+<script type="text/javascript" src="[% themelang %]/js/externalsearch.js"></script>
+<script type="text/javascript">
+var querystring = "[% q |replace( "'", "\'" ) |replace( '\n', '\\n' ) |replace( '\r', '\\r' ) |html %]";
+var results_per_page = [% OPACnumSearchResults %];
+KOHA.ExternalSearch.targets = {
+    [% FOREACH target IN external_search_targets %]
+        '[% target.host %]:[% target.port %]/[% target.db %]': {
+            name: '[% target.name %]',
+            syntax: '[% target.syntax %]',
+        },
+    [% END %]
+};
+
+var xsltResultStylesheets = {
+    [% FOREACH stylesheet IN xslt_result_stylesheets %]
+    '[% stylesheet.syntax %]': KOHA.XSLTGet( '[% stylesheet.url %]' ),
+    [% END %]
+};
+
+var xsltDetailStylesheets = {
+    [% FOREACH stylesheet IN xslt_detail_stylesheets %]
+    '[% stylesheet.syntax %]': KOHA.XSLTGet( '[% stylesheet.url %]' ),
+    [% END %]
+};
+
+var recordCache = {};
+var resultRenderCache = {};
+
+function showResult( syntax, recid ) {
+    if ( recordCache[ recid ] ) {
+        done( recordCache[ recid ] );
+    } else {
+        KOHA.ExternalSearch.GetDetailedRecord( recid, function( record ) {
+            done( recordCache[ recid ] = record.xmlDoc );
+        } );
+    }
+
+    function done( record ) {
+        xsltResultStylesheets[ syntax ].done( function( xslDoc ) {
+            var fragment = resultRenderCache[ recid ] = KOHA.TransformToFragment( record, xslDoc );
+            var $tr = $( '#results tr' ).filter( function() { return $( this ).data( 'recid' ) == recid } );
+            $tr.find( '.info' ).html( fragment );
+            $tr.find( 'a' ).attr( 'href', '#' ).click( function() {
+                showDetail( syntax, recid );
+
+                return false;
+            } );
+        } );
+    }
+}
+
+function showDetail( syntax, recid ) {
+    var record = recordCache[ recid ];
+    console.info((new XMLSerializer).serializeToString( record ));
+
+    xsltDetailStylesheets[ syntax ].done( function( xslDoc ) {
+        var fragment = KOHA.TransformToFragment( record, xslDoc );
+
+        $( '#modal-overlay' ).html( fragment ).lightbox_me( {
+            centered: true,
+        } );
+    } );
+}
+
+function search( offset, reset_search ) {
+    $( '#pazpar2-status' ).html( _("Searching external targets...") + '<img class="throbber" src="/opac-tmpl/lib/jquery/plugins/themes/classic/throbber.gif" />' );
+
+    if ( reset_search ) {
+        KOHA.ExternalSearch.Search( querystring, results_per_page, callback );
+    } else {
+        KOHA.ExternalSearch.Fetch( offset, callback );
+    }
+
+    function callback( data ) {
+        if ( data.error ) {
+            $( '#pazpar2-status' ).html( '<strong class="unavailable">' + _("Error searching external targets.") + '</strong>' );
+            return;
+        }
+
+        if ( !data.total ) {
+            $( '#pazpar2-status' ).html( '<strong>' + _("No results found in the external targets.") + '</strong>' );
+            return;
+        }
+
+        $( '#results tbody' ).empty();
+
+        $( '#pazpar2-status' ).html( '<strong>' + _("Found __RESULTS__ results in __TARGETS__ external targets.").replace('__RESULTS__', data.total).replace('__TARGETS__', $( '#targets-facet input:checked' ).length );
+
+        for ( var i = 0; data.hits[i]; i++ ) {
+            var hit = data.hits[i];
+            var results = [];
+            var recordSyntax = KOHA.ExternalSearch.targets[ hit.location[0]['@id'] ].syntax;
+
+            results.push( '<tr>' );
+
+            results.push( '<td class="sourcecol">', hit.location[0]['@name'], '</td>' );
+
+            results.push( '<td class="info">' );
+
+            if ( resultRenderCache[ hit.recid[0] ] ) {
+                results.push( resultRenderCache[ hit.recid[0] ] );
+            } else {
+                results.push( hit['md-work-title'] ? hit['md-work-title'][0] : _("Loading...") );
+                showResult( recordSyntax, hit.recid[0] );
+            }
+
+            results.push( '</td>' );
+
+            results.push( '</tr>' );
+            var $tr = $( results.join( '' ) );
+            $tr.data( 'recid', hit.recid[0] );
+            $( '#results tbody' ).append( $tr );
+
+            ( function( hit, recordSyntax ) {
+                $tr.find( 'a' ).attr( 'href', '#' ).click( function() {
+                    showDetail( recordSyntax, hit.recid[0] );
+
+                    return false;
+                } );
+            } )( hit, recordSyntax );
+        }
+
+        $( '#results tr:odd' ).addClass( 'highlight' );
+
+        var pages = [];
+        var cur_page = data.start / results_per_page;
+        var max_page = Math.floor( data.total / results_per_page );
+
+        if ( cur_page != 0 ) {
+            pages.push( '<a class="nav" href="#" data-offset="' + (offset - results_per_page) + '">&lt;&lt; ' + _("Previous") + '</a>' );
+        }
+
+        for ( var page = Math.max( 0, cur_page - 9 ); page <= Math.min( max_page, cur_page + 9 ); page++ ) {
+            if ( page == cur_page ) {
+                pages.push( ' <span class="current">' + ( page + 1 ) + '</span>' );
+            } else {
+                pages.push( ' <a class="nav" href="#" data-offset="' + ( page * results_per_page ) + '">' + ( page + 1 ) + '</a>' );
+            }
+        }
+
+        if ( cur_page < max_page ) {
+            pages.push( ' <a class="nav" href="#" data-offset="' + (offset + results_per_page) + '">' + _("Next") + ' >></a>' );
+        }
+
+        if ( pages.length > 1 ) $( '#top-pages, #bottom-pages' ).find( '.pages' ).html( pages.join( '' ) );
+    }
+}
+
+$( document ).ready( function() {
+    $( '#breadcrumbs p' )
+        .append( ' ' )
+        .append( '<span id="pazpar2-status"></span>' );
+
+    $( document ).on( 'click', 'a.nav', function() {
+        search( $( this ).data( 'offset' ) );
+        return false;
+    });
+
+    var reSearchTimeout;
+
+    $( '#targets-facet input' ).each( function() {
+        $( this ).click( function() {
+            KOHA.ExternalSearch.targets[ $( this ).data( 'url' ) ].disabled = !this.checked;
+
+            if ( reSearchTimeout ) clearTimeout( reSearchTimeout );
+
+            reSearchTimeout = setTimeout( function() {
+                if ( $( '#targets-facet input:checked' ).length ) search( 0, true );
+            }, 1000 );
+        } );
+
+        KOHA.ExternalSearch.targets[ $( this ).data( 'url' ) ].disabled = !this.checked;
+    } );
+
+    search( 0, true );
+} );
+</script>
+<style>
+.actions a.addtocart {
+    display: inline;
+}
+</style>
+</head>
+<body>
+<div id="doc3" class="yui-t1">
+
+<div id="bd">
+    [% INCLUDE 'masthead.inc' %]
+
+    <h1>External search for '[% q | html %]'</h1>
+    <div id="breadcrumbs">
+        <p></p>
+    </div>
+
+    <div id="yui-main"><div class="yui-b searchresults">
+        <div id="top-pages">
+            <div class="pages">
+            </div>
+        </div>
+        <table id="results">
+            <tbody>
+            </tbody>
+        </table>
+        <div id="bottom-pages">
+            <div class="pages">
+            </div>
+        </div>
+    </div></div>
+
+    <div class="yui-b"><div id="facetcontainer" class="container">
+    <div id="search-facets">
+
+        <h4>Refine your search</h4>
+
+        <ul>
+            <li id="targets-facet">
+                Targets
+                <ul>
+                    [% FOREACH target = external_search_targets %]
+                    <li>
+                        <input data-url="[% target.host %]:[% target.port %]/[% target.db %]" type="checkbox" id="target-[% loop.index %]" checked />
+                        <label for="target-[% loop.index %]">[% target.name %]
+                    </li>
+                    [% END %]
+                </ul>
+            </li>
+        </ul>
+    </div>
+    </div></div>
+</div>
+
+</div>
+
+<div id="modal-overlay" style="display: none"></div>
+[% INCLUDE 'opac-bottom.inc' %]
diff --git a/koha-tmpl/opac-tmpl/prog/en/modules/opac-results.tt b/koha-tmpl/opac-tmpl/prog/en/modules/opac-results.tt
index 9235b2b..89e1d38 100644
--- a/koha-tmpl/opac-tmpl/prog/en/modules/opac-results.tt
+++ b/koha-tmpl/opac-tmpl/prog/en/modules/opac-results.tt
@@ -16,7 +16,11 @@
   <link rel="stylesheet" type="text/css" href="[% themelang %]/css/jquery.rating.css" />
 [% END %]
 
-<script type="text/javascript" src="[% themelang %]/js/overdrive.js"></script>
+[% IF ( OverDriveEnabled ) %]<script type="text/javascript" src="[% themelang %]/js/overdrive.js"></script>[% END %]
+[% IF ( Koha.Preference('OPACSearchExternalTargets') ) %]
+<script type="text/javascript" src="/opac-tmpl/lib/pz2.js"></script>
+<script type="text/javascript" src="[% themelang %]/js/externalsearch.js"></script>
+[% END %]
 <script type="text/javascript" src="[% themelang %]/lib/jquery/plugins/jquery.checkboxes.min.js"></script>
 [% IF ( OpacHighlightedWords ) %]<script type="text/javascript" src="[% themelang %]/lib/jquery/plugins/jquery.highlight-3.js"></script>
 [% END %]<script type="text/javascript">
@@ -266,24 +270,59 @@ $(document).ready(function(){
         $("#highlight_toggle_on" ).hide().click(function() {highlightOn() ;});
         $("#highlight_toggle_off").show().click(function() {highlightOff();});
     [% END %]
+    function StartExternalSearch(name, text) {
+        $( '#breadcrumbs p' )
+            .eq( 0 )
+            .append( ' <span id="' + name + '-results">' + text + '... <img class="throbber" src="/opac-tmpl/lib/jquery/plugins/themes/classic/throbber.gif" /></span>' );
+    }
+    function FailExternalSearch( name, text ) {
+        $( '#' + name + '-results' ).html( text );
+    }
+    function FinishExternalSearch( name, text, numItems, url ) {
+        if ( numItems ) {
+            $( '#' + name + '-results' ).html( text.replace( '__LINK__', '<a href="' + url + '">'  + numItems + _(" results") + '</a>' ) );
+        } else {
+            $( '#' + name + '-results' ).remove();
+        }
+    }
+
     [% IF ( OverDriveEnabled ) %]
-        var $overdrive_results = $( '<span id="overdrive-results">' + _( 'Searching OverDrive...' ) + ' <img class="throbber" src="/opac-tmpl/lib/jquery/plugins/themes/classic/throbber.gif" /></span>' );
-        $( '#breadcrumbs p' ).eq(0)
-            .append( ' ' )
-            .append( $overdrive_results );
+        StartExternalSearch( 'overdrive', _("Searching OverDrive") );
         KOHA.OverDrive.Search( "[% OverDriveLibraryID %]", querystring, 1, 0, function( data ) {
             if ( data.error ) {
-                $overdrive_results.html( _( 'Error searching OverDrive collection' ) );
+                FailExternalSearch( 'overdrive', _("Error searching OverDrive collection") );
                 return;
             }
 
-            if ( data.totalItems ) {
-                $overdrive_results.html( _( 'Found' ) + ' <a href="/cgi-bin/koha/opac-overdrive-search.pl?q=' + escape( querystring ) + '">' + data.totalItems + ' ' + _( 'results' ) + '</a> ' + _( 'in OverDrive collection' ) );
-            } else {
-                $overdrive_results.remove();
+            FinishExternalSearch( 'overdrive', _("Found __LINK__ in OverDrive collection"), data.totalItems, '/cgi-bin/koha/opac-overdrive-search.pl?q=' + escape( querystring ) );
+        } );
+    [% END %]
+
+    [% IF ( OPACSearchExternalTargets ) %]
+        KOHA.ExternalSearch.targets = {
+            [% FOREACH target IN external_search_targets %]
+                '[% target.host %]:[% target.port %]/[% target.db %]': {
+                    id: '[% target.target_id %]',
+                    name: '[% target.name %]',
+                    syntax: '[% target.syntax %]',
+                },
+            [% END %]
+        };
+        var num_targets = 0; $.each( KOHA.ExternalSearch.targets, function() { num_targets++ } );
+        var first_succeeded;
+
+        StartExternalSearch( 'pazpar2', _("Searching external targets") );
+        KOHA.ExternalSearch.Search( querystring, 1, function( data ) {
+            if ( data.error ) {
+                if ( !first_succeeded ) FailExternalSearch( 'pazpar2', _("Error searching external targets") );
+                return;
             }
+
+            first_succeeded = true;
+            FinishExternalSearch( 'pazpar2', _("Found __LINK__ in ") + num_targets + _(" external targets"), data.total, '/cgi-bin/koha/opac-external-search.pl?q=' + escape( querystring ) );
         } );
     [% END %]
+
 [% END %]
 
 [% IF ( TagsInputEnabled && loggedinusername ) %]
diff --git a/koha-tmpl/opac-tmpl/prog/en/xslt/MARC21slim2OPACResults.xsl b/koha-tmpl/opac-tmpl/prog/en/xslt/MARC21slim2OPACResults.xsl
index 24ffa85..f71cdeb 100644
--- a/koha-tmpl/opac-tmpl/prog/en/xslt/MARC21slim2OPACResults.xsl
+++ b/koha-tmpl/opac-tmpl/prog/en/xslt/MARC21slim2OPACResults.xsl
@@ -5,11 +5,13 @@
   xmlns:marc="http://www.loc.gov/MARC21/slim"
   xmlns:items="http://www.koha-community.org/items"
   xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
+  xmlns="http://www.w3.org/1999/xhtml"
   exclude-result-prefixes="marc items">
     <xsl:import href="MARC21slimUtils.xsl"/>
     <xsl:output method = "html" indent="yes" omit-xml-declaration = "yes" encoding="UTF-8"/>
     <xsl:key name="item-by-status" match="items:item" use="items:status"/>
     <xsl:key name="item-by-status-and-branch" match="items:item" use="concat(items:status, ' ', items:homebranch)"/>
+    <xsl:param name="showAvailability" select="true()"/>
 
     <xsl:template match="/">
             <xsl:apply-templates/>
@@ -1027,6 +1029,7 @@
                             </xsl:for-each>
                             </span>
                         </xsl:if>
+                        <xsl:if test="$showAvailability">
                         <span class="results_summary availability">
                         <span class="label">Availability: </span>
                         <xsl:choose>
@@ -1035,7 +1038,7 @@
                             <xsl:when test="string-length($AlternateHoldingsField)=3 and marc:datafield[@tag=$AlternateHoldingsField]">
                             <xsl:variable name="AlternateHoldingsCount" select="count(marc:datafield[@tag=$AlternateHoldingsField])"/>
                             <xsl:for-each select="marc:datafield[@tag=$AlternateHoldingsField][1]">
-                                <xsl:call-template select="marc:datafield[@tag=$AlternateHoldingsField]" name="subfieldSelect">
+                                <xsl:call-template name="subfieldSelect">
                                     <xsl:with-param name="codes"><xsl:value-of select="$AlternateHoldingsSubfields"/></xsl:with-param>
                                     <xsl:with-param name="delimeter"><xsl:value-of select="$AlternateHoldingsSeparator"/></xsl:with-param>
                                 </xsl:call-template>
@@ -1145,6 +1148,7 @@
                        <xsl:text>). </xsl:text>                   </span>
                    </xsl:if>
                </span>
+               </xsl:if>
     <xsl:choose>
         <xsl:when test="($OPACItemLocation='location' or $OPACItemLocation='ccode') and (count(key('item-by-status', 'available'))!=0 or count(key('item-by-status', 'reference'))!=0)">
             <span class="results_summary" id="location">
diff --git a/koha-tmpl/opac-tmpl/prog/en/xslt/MARC21slimUtils.xsl b/koha-tmpl/opac-tmpl/prog/en/xslt/MARC21slimUtils.xsl
index df7c450..1f18054 100644
--- a/koha-tmpl/opac-tmpl/prog/en/xslt/MARC21slimUtils.xsl
+++ b/koha-tmpl/opac-tmpl/prog/en/xslt/MARC21slimUtils.xsl
@@ -1,6 +1,6 @@
 <?xml version='1.0'?>
 <!DOCTYPE stylesheet [<!ENTITY nbsp "&#160;" >]>
-<xsl:stylesheet version="1.0" xmlns:marc="http://www.loc.gov/MARC21/slim" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
+<xsl:stylesheet version="1.0" xmlns:marc="http://www.loc.gov/MARC21/slim" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns="http://www.w3.org/1999/xhtml">
 	<xsl:template name="datafield">
 		<xsl:param name="tag"/>
 		<xsl:param name="ind1"><xsl:text> </xsl:text></xsl:param>
diff --git a/koha-tmpl/opac-tmpl/prog/en/xslt/UNIMARCslim2OPACResults.xsl b/koha-tmpl/opac-tmpl/prog/en/xslt/UNIMARCslim2OPACResults.xsl
index 9dde821..aaac50c 100644
--- a/koha-tmpl/opac-tmpl/prog/en/xslt/UNIMARCslim2OPACResults.xsl
+++ b/koha-tmpl/opac-tmpl/prog/en/xslt/UNIMARCslim2OPACResults.xsl
@@ -6,12 +6,14 @@
   xmlns:marc="http://www.loc.gov/MARC21/slim"
   xmlns:items="http://www.koha-community.org/items"
   xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
+  xmlns="http://www.w3.org/1999/xhtml"
   exclude-result-prefixes="marc items">
 
 <xsl:import href="UNIMARCslimUtils.xsl"/>
 <xsl:output method = "html" indent="yes" omit-xml-declaration = "yes" encoding="UTF-8"/>
 <xsl:key name="item-by-status" match="items:item" use="items:status"/>
 <xsl:key name="item-by-status-and-branch" match="items:item" use="concat(items:status, ' ', items:homebranch)"/>
+<xsl:param name="showAvailability" select="true()"/>
 
 <xsl:template match="/">
   <xsl:apply-templates/>
@@ -99,6 +101,7 @@
 
   <xsl:call-template name="tag_215" />
 
+  <xsl:if test="$showAvailability">
   <span class="results_summary availability">
     <span class="label">Availability: </span>
     <xsl:choose>
@@ -247,6 +250,7 @@
       </span>
     </xsl:if>
   </span>
+  </xsl:if>
 
 </xsl:template>
 
diff --git a/opac/opac-external-search.pl b/opac/opac-external-search.pl
new file mode 100755
index 0000000..423dc0d
--- /dev/null
+++ b/opac/opac-external-search.pl
@@ -0,0 +1,62 @@
+#!/usr/bin/perl
+
+# Copyright 2013 ByWater Solutions
+#
+# This file is part of Koha.
+#
+# Koha 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 3 of the License, or
+# (at your option) any later version.
+#
+# Koha 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 Koha; if not, see <http://www.gnu.org/licenses>.
+
+use Modern::Perl;
+
+use CGI;
+
+use C4::Auth qw(:DEFAULT get_session);
+use C4::Output;
+use C4::Search qw( GetExternalSearchTargets );
+use C4::XSLT qw( XSLTGetFilename );
+
+my $cgi = new CGI;
+
+# Getting the template and auth
+my ($template, $loggedinuser, $cookie)
+= get_template_and_user({template_name => "opac-external-search.tmpl",
+                                query => $cgi,
+                                type => "opac",
+                                authnotrequired => 1,
+                                flagsrequired => {borrowers => 1},
+                                debug => 1,
+                                });
+
+$template->{VARS}->{q} = $cgi->param('q');
+$template->{VARS}->{limit} = C4::Context->preference('OPACnumSearchResults') || 20;
+$template->{VARS}->{OPACnumSearchResults} = C4::Context->preference('OPACnumSearchResults') || 20;
+$template->{VARS}->{external_search_targets} = GetExternalSearchTargets( C4::Context->userenv ? C4::Context->userenv->{branch} : '' );
+
+my @xsltResultStylesheets;
+my @xsltDetailStylesheets;
+
+foreach my $syntax ( qw( MARC21 UNIMARC NORMARC ) ) {
+    if ( XSLTGetFilename( $syntax, 'OPACXSLTResultsDisplay' ) =~ m,/opac-tmpl/.*|^https:?.*, ) {
+        push @xsltResultStylesheets, { syntax => $syntax, url => $& };
+    }
+
+    if ( XSLTGetFilename( $syntax, 'OPACXSLTDetailsDisplay' ) =~ m,/opac-tmpl/.*|^https:?.*, ) {
+        push @xsltDetailStylesheets, { syntax => $syntax, url => $& };
+    }
+}
+
+$template->{VARS}->{xslt_result_stylesheets} = \@xsltResultStylesheets;
+$template->{VARS}->{xslt_detail_stylesheets} = \@xsltDetailStylesheets;
+
+output_html_with_http_headers $cgi, $cookie, $template->output;
diff --git a/opac/opac-search.pl b/opac/opac-search.pl
index 617d67e..42e3740 100755
--- a/opac/opac-search.pl
+++ b/opac/opac-search.pl
@@ -74,6 +74,9 @@ if ( $branch_group_limit ) {
             -name => 'multibranchlimit',
             -values => substr($branch_group_limit, 17)
         );
+    } elsif ( $branch_group_limit eq '@overdrive' ) {
+        print $cgi->redirect( '/cgi-bin/koha/opac-overdrive-search.pl?q=' . join( '+', $cgi->param( 'q' ) ) );
+        exit;
     } else {
         $cgi->append(
             -name => 'limit',
@@ -879,10 +882,11 @@ $template->{VARS}->{IDreamBooksReviews} = C4::Context->preference('IDreamBooksRe
 $template->{VARS}->{IDreamBooksReadometer} = C4::Context->preference('IDreamBooksReadometer');
 $template->{VARS}->{IDreamBooksResults} = C4::Context->preference('IDreamBooksResults');
 
-if ($offset == 0 && IsOverDriveEnabled()) {
-    $template->param(OverDriveEnabled => 1);
-    $template->param(OverDriveLibraryID => C4::Context->preference('OverDriveLibraryID'));
-}
+$template->{VARS}->{OPACSearchExternalTargets} = C4::Context->preference('OPACSearchExternalTargets');
+$template->{VARS}->{external_search_targets} = GetExternalSearchTargets( C4::Context->userenv ? C4::Context->userenv->{branch} : '' );
+
+$template->{VARS}->{OverDriveLibraryID} = C4::Context->preference('OverDriveLibraryID');
+$template->{VARS}->{OverDriveEnabled} = ($offset == 0 && IsOverDriveEnabled());
 
     $template->param( borrowernumber    => $borrowernumber);
 output_with_http_headers $cgi, $cookie, $template->output, $content_type;
diff --git a/opac/svc/pazpar2_init b/opac/svc/pazpar2_init
new file mode 100755
index 0000000..3c9fc6d
--- /dev/null
+++ b/opac/svc/pazpar2_init
@@ -0,0 +1,100 @@
+#!/usr/bin/perl
+
+# Copyright 2013 ByWater Solutions
+#
+# This file is part of Koha.
+#
+# Koha 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 3 of the License, or
+# (at your option) any later version.
+#
+# Koha 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 Koha; if not, see <http://www.gnu.org/licenses>.
+
+=head1 NAME
+
+svc/pazpar2_init: Initialize a Pazpar2 session
+
+=head1 SYNOPSIS
+
+svc/pazpar2_init -> ../pazpar2/search.pz2?command=init&...
+
+=head1 DESCRIPTION
+
+This services connects to pazpar2 and authenticates connections on behalf of
+client-side search code. The proxy is necessary to prevent exposing
+authentication information.
+
+=cut
+
+use strict;
+use warnings;
+
+use CGI qw(-oldstyle_urls);
+use HTTP::Request::Common;
+use JSON;
+use URI;
+use XML::Simple;
+
+use C4::Context;
+use C4::Search;
+use C4::Output;
+
+my $dbh = C4::Context->dbh;
+my $query = new CGI;
+
+my %init_opts;
+
+my $targets = GetExternalSearchTargets( C4::Context->userenv ? C4::Context->userenv->{branch} : '' );
+
+foreach my $target ( @$targets ) {
+    my $target_url = $target->{'host'} . ':' . $target->{'port'} . '/' . $target->{'db'};
+    $init_opts{ 'pz:name[' . $target_url . ']' } = $target->{'name'};
+    $init_opts{ 'pz:queryencoding[' . $target_url . ']' } = $target->{'encoding'};
+    $init_opts{ 'pz:xslt[' . $target_url . ']' } = lc( $target->{'syntax'} ) . '-work-groups.xsl';
+    $init_opts{ 'pz:requestsyntax[' . $target_url . ']' } = $target->{'syntax'};
+    $init_opts{ 'pz:nativesyntax[' . $target_url . ']' } = 'iso2709';
+
+    if ( $target->{'userid'} ) {
+        if ( $target->{'password'} ) {
+            $init_opts{ 'pz:authentication[' . $target_url . ']' } = $target->{'userid'} . '/' . $target->{'password'};
+        } else {
+            $init_opts{ 'pz:authentication[' . $target_url . ']' } = $target->{'userid'};
+        }
+    }
+}
+
+my $uri = 'http://' . C4::Context->preference( 'OPACBaseURL' ) . "/pazpar2/search.pz2";
+
+my $request = HTTP::Request::Common::POST( $uri, [ command => 'init', %init_opts ] );
+
+my $ua = LWP::UserAgent->new( "Koha " . C4::Context->KOHAVERSION );
+
+my $response = $ua->request( $request ) ;
+if ( !$response->is_success ) {
+    print $query->header(
+        -status => '500 Internal Server Error'
+    );
+
+    warn "Pazpar2 init failed: " . $response->message;
+    my $content = to_json({
+        error => 'Could not connect to Pazpar2',
+    });
+    output_with_http_headers $query, undef, $content, 'json', '500 Internal Server Error';
+
+    exit;
+} else {
+    my $xs = XML::Simple->new;
+    my $data = $xs->XMLin( $response->content );
+
+    my $content = to_json({
+        sessionID => $data->{'session'}
+    });
+    output_with_http_headers $query, undef, $content, 'json', '200 OK';
+}
diff --git a/rewrite-config.PL b/rewrite-config.PL
index e903d49..1cd7a5a 100644
--- a/rewrite-config.PL
+++ b/rewrite-config.PL
@@ -137,8 +137,8 @@ $prefix = $ENV{'INSTALL_BASE'} || "/usr";
   "__INSTALL_ZEBRA__" => 'yes',
   "__INSTALL_SRU__" => 'yes',
   "__INSTALL_PAZPAR2__" => 'no',
-  "__PAZPAR2_TOGGLE_XML_PRE__" => '<!--',
-  "__PAZPAR2_TOGGLE_XML_POST__" => '-->',
+  "__PAZPAR2_TOGGLE_HTTPD_PRE__" => '<IfDefine PAZPAR2_IS_DISABLED>',
+  "__PAZPAR2_TOGGLE_HTTPD_POST__" => '</IfDefine>',
   "__AUTH_INDEX_MODE__" => 'grs1',
   "__BIB_INDEX_MODE__" => 'grs1',
   "__RUN_DATABASE_TESTS__" => 'no',
@@ -158,8 +158,8 @@ foreach $key (keys %configuration) {
 # munge commenting out the PazPar2 mergeserver
 # entry in koha-conf.xml if necessary
 if ($configuration{'__INSTALL_PAZPAR2__'} eq 'yes') {
-    $configuration{'__PAZPAR2_TOGGLE_XML_PRE__'} = '';
-    $configuration{'__PAZPAR2_TOGGLE_XML_POST__'} = '';
+    $configuration{'__PAZPAR2_TOGGLE_HTTPD_PRE__'} = '';
+    $configuration{'__PAZPAR2_TOGGLE_HTTPD_POST__'} = '';
 }
 
 $fname = $ARGV[0];
diff --git a/t/XSLT.t b/t/XSLT.t
index 64cd597..d9aaf24 100755
--- a/t/XSLT.t
+++ b/t/XSLT.t
@@ -6,7 +6,11 @@
 use strict;
 use warnings;
 
-use Test::More tests => 8;
+use C4::Context;
+use C4::Templates;
+use Test::More tests => 13;
+use Test::MockModule;
+use File::Basename qw/dirname/;
 use File::Temp;
 use File::Path qw/make_path/;
 
@@ -14,36 +18,96 @@ BEGIN {
         use_ok('C4::XSLT');
 }
 
-my $dir = File::Temp->newdir();
+my $opacdir = File::Temp->newdir();
+my $staffdir = File::Temp->newdir();
 my @themes = ('prog', 'test');
 my @langs = ('en', 'es-ES');
 
+sub make_test_file {
+    my ( $filename, $contents ) = @_;
+
+    make_path(dirname($filename));
+    open my $fh, '>', $filename or die "Could not create test file: $filename";
+    print $fh $contents;
+    close $fh;
+}
+
 # create temporary files to be tested later
 foreach my $theme (@themes) {
     foreach my $lang (@langs) {
-        make_path("$dir/$theme/$lang/xslt");
-        open my $fh, '>', "$dir/$theme/$lang/xslt/my_file.xslt";
-        print $fh "Theme $theme, language $lang";
-        close $fh;
+        foreach my $dir ($opacdir, $staffdir) {
+            make_test_file( "$dir/$theme/$lang/xslt/my_file.xslt", "Theme $theme, language $lang" );
+            make_test_file( "$dir/$theme/$lang/xslt/MARC21slim2intranetDetail.xsl", "Theme $theme, language $lang, MARC21slim2intranetDetail" );
+            make_test_file( "$dir/$theme/$lang/xslt/test_en.xsl", "Theme $theme, language $lang, test_en" );
+            make_test_file( "$dir/$theme/$lang/xslt/UNIMARCslim2OPACDetail.xsl", "Theme $theme, language $lang, UNIMARCslim2OPACDetail" );
+            make_test_file( "$dir/$theme/$lang/xslt/nondefault_test.xsl", "Theme $theme, language $lang, nondefault_test" );
+            make_test_file( "$dir/$theme/$lang/xslt/MARC21slim2OPACResults.xsl", "Theme $theme, language $lang, MARC21slim2OPACResults" );
+        }
     }
 }
 
-sub find_and_slurp {
+sub find_and_slurp_default {
     my ($dir, $theme, $lang) = @_;
 
     my $filename = C4::XSLT::_get_best_default_xslt_filename($dir, $theme, $lang, 'my_file.xslt');
-    open my $fh, '<', $filename;
+    open my $fh, '<', $filename or return "Could not open: $filename";
     my $str = <$fh>;
     close $fh;
     return $str;
 }
 
+sub find_and_slurp {
+    my ($marcflavour, $xslsyspref) = @_;
+
+    my $filename = C4::XSLT::XSLTGetFilename( $marcflavour, $xslsyspref );
+    open my $fh, '<', $filename or return "Could not open: $filename";
+    my $str = <$fh>;
+    close $fh;
+    return $str;
+}
+
+my $module_context = new Test::MockModule('C4::Context');
+$module_context->mock(
+    'config',
+    sub {
+        my ( $self, $var ) = @_;
+        my %predefs = (
+            opachtdocs => $opacdir,
+            intrahtdocs => $staffdir,
+        );
+
+        return $predefs{$var} || $module_context->original('config')->(@_);
+    }
+);
+$module_context->mock(
+    'preference',
+    sub {
+        my ( $self, $var ) = @_;
+        my %predefs = (
+            template => 'prog',
+            marcflavour => 'MARC21',
+            opacthemes => 'test',
+            XSLTDetailsDisplay => 'default',
+            XSLTResultsDisplay => "$staffdir/prog/en/xslt/test_en.xsl",
+            OPACXSLTDetailsDisplay => "$opacdir/test/en/xslt/nondefault_test.xsl",
+            OPACXSLTResultsDisplay => '"default"',
+        );
+
+        return $predefs{$var} || $module_context->original('preference')->(@_);
+    }
+);
+
 # These tests verify that we're finding the right XSLT file when present,
 # and falling back to the right XSLT file when an exact match is not present.
-is(find_and_slurp($dir, 'test', 'en'   ), 'Theme test, language en',    'Found test/en');
-is(find_and_slurp($dir, 'test', 'es-ES'), 'Theme test, language es-ES', 'Found test/es-ES');
-is(find_and_slurp($dir, 'prog', 'en',  ), 'Theme prog, language en',    'Found test/en');
-is(find_and_slurp($dir, 'prog', 'es-ES'), 'Theme prog, language es-ES', 'Found test/es-ES');
-is(find_and_slurp($dir, 'test', 'fr-FR'), 'Theme test, language en',    'Fell back to test/en for test/fr-FR');
-is(find_and_slurp($dir, 'nope', 'es-ES'), 'Theme prog, language es-ES', 'Fell back to prog/es-ES for nope/es-ES');
-is(find_and_slurp($dir, 'nope', 'fr-FR'), 'Theme prog, language en',    'Fell back to prog/en for nope/fr-FR');
+is(find_and_slurp_default($opacdir, 'test', 'en'   ), 'Theme test, language en',    'Found test/en');
+is(find_and_slurp_default($opacdir, 'test', 'es-ES'), 'Theme test, language es-ES', 'Found test/es-ES');
+is(find_and_slurp_default($opacdir, 'prog', 'en',  ), 'Theme prog, language en',    'Found test/en');
+is(find_and_slurp_default($opacdir, 'prog', 'es-ES'), 'Theme prog, language es-ES', 'Found test/es-ES');
+is(find_and_slurp_default($opacdir, 'test', 'fr-FR'), 'Theme test, language en',    'Fell back to test/en for test/fr-FR');
+is(find_and_slurp_default($opacdir, 'nope', 'es-ES'), 'Theme prog, language es-ES', 'Fell back to prog/es-ES for nope/es-ES');
+is(find_and_slurp_default($opacdir, 'nope', 'fr-FR'), 'Theme prog, language en',    'Fell back to prog/en for nope/fr-FR');
+is(find_and_slurp('MARC21', 'XSLTDetailsDisplay'), 'Theme prog, language en, MARC21slim2intranetDetail', 'Used default for staff/details');
+is(find_and_slurp('MARC21', 'XSLTResultsDisplay'), 'Theme prog, language en, test_en', 'Used non-default with langcode for staff/results');
+is(find_and_slurp('MARC21', 'OPACXSLTDetailsDisplay'), 'Theme test, language en, nondefault_test', 'not-prog: Used non-default for local marcflavour and opac/details');
+is(find_and_slurp('UNIMARC', 'OPACXSLTDetailsDisplay'), 'Theme test, language en, UNIMARCslim2OPACDetail', 'not-prog: Used default for non-local marcflavour and opac/details');
+is(find_and_slurp('MARC21', 'OPACXSLTResultsDisplay'), 'Theme test, language en, MARC21slim2OPACResults', 'not-prog: Used "default" for opac/results');
-- 
1.8.5.3