From ebc4cda4bfc89d65fee2a8bbe32a27334230ee89 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             |   32 +
 .../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/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                     | 1110 ++++++++++++++++++++
 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       |    3 +
 opac/opac-external-search.pl                       |   63 ++
 opac/opac-search.pl                                |   12 +-
 opac/svc/pazpar2_init                              |   99 ++
 rewrite-config.PL                                  |    8 +-
 32 files changed, 2887 insertions(+), 45 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 bec1f89..01e76ab 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;
@@ -2360,6 +2361,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 d42f041..7ccf215 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 71513ce..0403810 100644
--- a/installer/data/mysql/sysprefs.sql
+++ b/installer/data/mysql/sysprefs.sql
@@ -254,6 +254,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 3d2ca44..a9528e6 100755
--- a/installer/data/mysql/updatedatabase.pl
+++ b/installer/data/mysql/updatedatabase.pl
@@ -7595,6 +7595,38 @@ if ( CheckVersion($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
 
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 aa7310c..90a5c79 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 a5dbe44..f705181 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..92d1595
--- /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 a75c7ef..368b314 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
@@ -424,6 +424,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/ccsr/en/css/opac.css b/koha-tmpl/opac-tmpl/ccsr/en/css/opac.css
index 0901d6b..698968d 100644
--- a/koha-tmpl/opac-tmpl/ccsr/en/css/opac.css
+++ b/koha-tmpl/opac-tmpl/ccsr/en/css/opac.css
@@ -1609,6 +1609,10 @@ strong em, em strong  {
      vertical-align: top;
    width: 10px;
 }
+.sourcecol {
+	vertical-align: top;
+	width: 100px;
+}
 #container {
     color : #000;
 }
@@ -2876,3 +2880,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..9d693b2
--- /dev/null
+++ b/koha-tmpl/opac-tmpl/lib/pz2.js
@@ -0,0 +1,1110 @@
+/*
+ * $Id: 3a9980787bb5d0fe966140b243a4a5eb6768913e $
+** pz2.js - pazpar2's javascript client library.
+*/
+
+//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 c2902c1..e1838b49 100644
--- a/koha-tmpl/opac-tmpl/prog/en/css/opac.css
+++ b/koha-tmpl/opac-tmpl/prog/en/css/opac.css
@@ -1690,6 +1690,10 @@ strong em, em strong  {
 	vertical-align: top;
 	width: 10px;
 }
+.sourcecol {
+	vertical-align: top;
+	width: 100px;
+}
 #container {
 	color : #000;
 }
@@ -3017,6 +3021,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;
 }
@@ -3041,7 +3055,7 @@ padding: 0.1em 0;
     background: #EEEEEE none;
 }
 
-#overdrive-results {
+#overdrive-results, #pazpar2-results {
     font-weight: bold;
     padding-left: 1em;
 }
@@ -3080,3 +3094,14 @@ padding: 0.1em 0;
 .copiesrow {
     clear : both;
 }
+
+.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..c402209
--- /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" /></span>' );
+
+    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 ' ) + data.total + _( ' results in ' ) + $( '#targets-facet input:checked' ).length + _( ' external targets.' ) + '</strong>' );
+
+        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..e3c93ee 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 ( 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 addc7de..8d50984 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>
@@ -1143,6 +1146,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 ebf94b9..d115174 100644
--- a/koha-tmpl/opac-tmpl/prog/en/xslt/UNIMARCslim2OPACResults.xsl
+++ b/koha-tmpl/opac-tmpl/prog/en/xslt/UNIMARCslim2OPACResults.xsl
@@ -12,6 +12,7 @@
 <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 +100,7 @@
 
   <xsl:call-template name="tag_215" />
 
+  <xsl:if test="$showAvailability">
   <span class="results_summary availability">
     <span class="label">Availability: </span>
     <xsl:choose>
@@ -245,6 +247,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..70737e2
--- /dev/null
+++ b/opac/opac-external-search.pl
@@ -0,0 +1,63 @@
+#!/usr/bin/perl
+
+# Copyright 2009 BibLibre SARL
+#
+# 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 strict;
+use warnings;
+
+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 2854da6..ccbb72d 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',
@@ -887,10 +890,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..5a0a472
--- /dev/null
+++ b/opac/svc/pazpar2_init
@@ -0,0 +1,99 @@
+#!/usr/bin/perl
+
+# Copyright 2013 ByWater
+#
+# 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.
+
+=head1 NAME
+
+svc/pazpar2_init: Initialize a Pazpar2 session
+
+=head1 SYNOPSIS
+
+svc/pazpar2_init?targets=3,5,9 -> ../pazpar2/search.pz2?command=init&...
+
+=head1 DESCRIPTION
+
+This services connects to pazpar2 and authenticates connections on behalf of
+client-side search code.
+
+=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];
-- 
1.8.4.rc3