diff --git a/module/VuFind/config/module.config.php b/module/VuFind/config/module.config.php
index e70cda5b0b04f0cd990b31eb1fa7a65ba5287a49..d5b271c12e6db7eddb0e34474b613eef0361181d 100644
--- a/module/VuFind/config/module.config.php
+++ b/module/VuFind/config/module.config.php
@@ -105,7 +105,7 @@ $config = [
     ],
     'controllers' => [
         'factories' => [
-            'VuFind\Controller\AjaxController' => 'VuFind\Controller\AbstractBaseFactory',
+            'VuFind\Controller\AjaxController' => 'VuFind\Controller\AjaxControllerFactory',
             'VuFind\Controller\AlphabrowseController' => 'VuFind\Controller\AbstractBaseFactory',
             'VuFind\Controller\AuthorController' => 'VuFind\Controller\AbstractBaseFactory',
             'VuFind\Controller\AuthorityController' => 'VuFind\Controller\AbstractBaseFactory',
@@ -280,6 +280,7 @@ $config = [
         'allow_override' => true,
         'factories' => [
             'ProxyManager\Configuration' => 'VuFind\Service\Factory::getProxyConfig',
+            'VuFind\AjaxHandler\PluginManager' => 'VuFind\ServiceManager\AbstractPluginManagerFactory',
             'VuFind\Auth\ILSAuthenticator' => 'VuFind\Auth\ILSAuthenticatorFactory',
             'VuFind\Auth\Manager' => 'VuFind\Auth\ManagerFactory',
             'VuFind\Auth\PluginManager' => 'VuFind\ServiceManager\AbstractPluginManagerFactory',
@@ -458,6 +459,7 @@ $config = [
         // This section contains service manager configurations for all VuFind
         // pluggable components:
         'plugin_managers' => [
+            'ajaxhandler' => [ /* see VuFind\AjaxHandler\PluginManager for defaults */ ],
             'auth' => [ /* see VuFind\Auth\PluginManager for defaults */ ],
             'autocomplete' => [ /* see VuFind\Autocomplete\PluginManager for defaults */ ],
             'channelprovider' => [ /* see VuFind\ChannelProvider\PluginManager for defaults */ ],
diff --git a/module/VuFind/src/VuFind/AjaxHandler/AbstractBase.php b/module/VuFind/src/VuFind/AjaxHandler/AbstractBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..3122fdc2f9c25210457f51562915c5806001d584
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/AbstractBase.php
@@ -0,0 +1,85 @@
+<?php
+/**
+ * Abstract base AJAX handler
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use VuFind\Session\Settings as SessionSettings;
+
+/**
+ * Abstract base AJAX handler
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+abstract class AbstractBase implements AjaxHandlerInterface
+{
+    /**
+     * Session settings
+     *
+     * @var SessionSettings
+     */
+    protected $sessionSettings = null;
+
+    /**
+     * Prevent session writes -- this is designed to be called prior to time-
+     * consuming AJAX operations to help reduce the odds of a timing-related bug
+     * that causes the wrong version of session data to be written to disk (see
+     * VUFIND-716 for more details).
+     *
+     * @return void
+     */
+    protected function disableSessionWrites()
+    {
+        if (null === $this->sessionSettings) {
+            throw new \Exception('Session settings object missing.');
+        }
+        $this->sessionSettings->disableWrite();
+    }
+
+    /**
+     * Format a response array.
+     *
+     * @param mixed  $response Response data
+     * @param string $status   Internal status code (see constants in interface;
+     * defaults to OK)
+     * @param int    $httpCode HTTP status code (omit for default)
+     *
+     * @return array
+     */
+    protected function formatResponse($response, $status = self::STATUS_OK,
+        $httpCode = null
+    ) {
+        $arr = [$response, $status];
+        if ($httpCode !== null) {
+            $arr[] = $httpCode;
+        }
+        return $arr;
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/AbstractIlsAndUserAction.php b/module/VuFind/src/VuFind/AjaxHandler/AbstractIlsAndUserAction.php
new file mode 100644
index 0000000000000000000000000000000000000000..9692e2964239ee45c81ecb5eaa1cf37c6716bc7c
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/AbstractIlsAndUserAction.php
@@ -0,0 +1,87 @@
+<?php
+/**
+ * Abstract base class for handlers depending on the ILS and a logged-in user.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use VuFind\Auth\ILSAuthenticator;
+use VuFind\Db\Row\User;
+use VuFind\I18n\Translator\TranslatorAwareInterface;
+use VuFind\ILS\Connection;
+use VuFind\Session\Settings as SessionSettings;
+
+/**
+ * Abstract base class for handlers depending on the ILS and a logged-in user.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+abstract class AbstractIlsAndUserAction extends AbstractBase
+    implements TranslatorAwareInterface
+{
+    use \VuFind\I18n\Translator\TranslatorAwareTrait;
+
+    /**
+     * ILS connection
+     *
+     * @var Connection
+     */
+    protected $ils;
+
+    /**
+     * ILS authenticator
+     *
+     * @var ILSAuthenticator
+     */
+    protected $ilsAuthenticator;
+
+    /**
+     * Logged in user (or false)
+     *
+     * @var User|bool
+     */
+    protected $user;
+
+    /**
+     * Constructor
+     *
+     * @param SessionSettings  $ss               Session settings
+     * @param Connection       $ils              ILS connection
+     * @param ILSAuthenticator $ilsAuthenticator ILS authenticator
+     * @param User|bool        $user             Logged in user (or false)
+     */
+    public function __construct(SessionSettings $ss, Connection $ils,
+        ILSAuthenticator $ilsAuthenticator, $user
+    ) {
+        $this->sessionSettings = $ss;
+        $this->ils = $ils;
+        $this->ilsAuthenticator = $ilsAuthenticator;
+        $this->user = $user;
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/AbstractIlsAndUserActionFactory.php b/module/VuFind/src/VuFind/AjaxHandler/AbstractIlsAndUserActionFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..aa6b52517bd9f5dce9b1c8ea6b3ed00e9dc8e612
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/AbstractIlsAndUserActionFactory.php
@@ -0,0 +1,73 @@
+<?php
+/**
+ * Factory for AbstractIlsAndUserAction AJAX handlers.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use Interop\Container\ContainerInterface;
+
+/**
+ * Factory for AbstractIlsAndUserAction AJAX handlers.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class AbstractIlsAndUserActionFactory
+    implements \Zend\ServiceManager\Factory\FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options passed to factory.');
+        }
+        return new $requestedName(
+            $container->get('VuFind\Session\Settings'),
+            $container->get('VuFind\ILS\Connection'),
+            $container->get('VuFind\Auth\ILSAuthenticator'),
+            $container->get('VuFind\Auth\Manager')->isLoggedIn()
+        );
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/AjaxHandlerInterface.php b/module/VuFind/src/VuFind/AjaxHandler/AjaxHandlerInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..70eff2f6e8e17a610e351db8d3132240c09c38a1
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/AjaxHandlerInterface.php
@@ -0,0 +1,56 @@
+<?php
+/**
+ * AJAX handler interface
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use Zend\Mvc\Controller\Plugin\Params;
+
+/**
+ * AJAX handler interface
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+interface AjaxHandlerInterface
+{
+    // define some status constants
+    const STATUS_OK = 'OK';                  // good
+    const STATUS_ERROR = 'ERROR';            // bad
+    const STATUS_NEED_AUTH = 'NEED_AUTH';    // must login first
+
+    /**
+     * Handle a request.
+     *
+     * @param Params $params Parameter helper from controller
+     *
+     * @return array [response data, internal status code, HTTP status code]
+     */
+    public function handleRequest(Params $params);
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/CheckRequestIsValid.php b/module/VuFind/src/VuFind/AjaxHandler/CheckRequestIsValid.php
new file mode 100644
index 0000000000000000000000000000000000000000..88fdc30634f72a39ed14bf2965db745a2860fd7c
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/CheckRequestIsValid.php
@@ -0,0 +1,145 @@
+<?php
+/**
+ * "Check Request is Valid" AJAX handler
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use Zend\Mvc\Controller\Plugin\Params;
+
+/**
+ * "Check Request is Valid" AJAX handler
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class CheckRequestIsValid extends AbstractIlsAndUserAction
+{
+    /**
+     * Status messages
+     *
+     * @var array
+     */
+    protected $statuses = [
+        'ILLRequest' => [
+            'success' =>  'ill_request_place_text',
+            'failure' => 'ill_request_error_blocked',
+        ],
+        'StorageRetrievalRequest' => [
+            'success' => 'storage_retrieval_request_place_text',
+            'failure' => 'storage_retrieval_request_error_blocked',
+        ],
+    ];
+
+    /**
+     * Given a request type and a boolean success status, return an appropriate
+     * message.
+     *
+     * @param string $requestType Type of request being made
+     * @param bool   $results     Result status
+     *
+     * @return string
+     */
+    protected function getStatusMessage($requestType, $results)
+    {
+        // If successful, return success message:
+        if ($results) {
+            return isset($this->statuses[$requestType]['success'])
+                ? $this->statuses[$requestType]['success']
+                : 'request_place_text';
+        }
+        // If unsuccessful, return failure message:
+        return isset($this->statuses[$requestType]['failure'])
+            ? $this->statuses[$requestType]['failure']
+            : 'hold_error_blocked';
+    }
+
+    /**
+     * Handle a request.
+     *
+     * @param Params $params Parameter helper from controller
+     *
+     * @return array [response data, internal status code, HTTP status code]
+     */
+    public function handleRequest(Params $params)
+    {
+        $this->disableSessionWrites();  // avoid session write timing bug
+        $id = $params->fromQuery('id');
+        $data = $params->fromQuery('data');
+        $requestType = $params->fromQuery('requestType');
+        if (empty($id) || empty($data)) {
+            return $this->formatResponse(
+                $this->translate('bulk_error_missing'),
+                self::STATUS_ERROR,
+                400
+            );
+        }
+        // check if user is logged in
+        if (!$this->user) {
+            return $this->formatResponse(
+                $this->translate('You must be logged in first'),
+                self::STATUS_NEED_AUTH,
+                401
+            );
+        }
+
+        try {
+            $patron = $this->ilsAuthenticator->storedCatalogLogin();
+            if ($patron) {
+                switch ($requestType) {
+                case 'ILLRequest':
+                    $results = $this->ils
+                        ->checkILLRequestIsValid($id, $data, $patron);
+                    break;
+                case 'StorageRetrievalRequest':
+                    $results = $this->ils
+                        ->checkStorageRetrievalRequestIsValid($id, $data, $patron);
+                    break;
+                default:
+                    $results = $this->ils->checkRequestIsValid($id, $data, $patron);
+                    break;
+                }
+                if (is_array($results)) {
+                    $msg = $results['status'];
+                    $results = $results['valid'];
+                } else {
+                    $msg = $this->getStatusMessage($requestType, $results);
+                }
+                return $this->formatResponse(
+                    ['status' => $results, 'msg' => $this->translate($msg)]
+                );
+            }
+        } catch (\Exception $e) {
+            // Do nothing -- just fail through to the error message below.
+        }
+
+        return $this->formatResponse(
+            $this->translate('An error has occurred'), self::STATUS_ERROR, 500
+        );
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/CommentRecord.php b/module/VuFind/src/VuFind/AjaxHandler/CommentRecord.php
new file mode 100644
index 0000000000000000000000000000000000000000..496cf84c74ac1b765f06cd36955bec5197a187d5
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/CommentRecord.php
@@ -0,0 +1,159 @@
+<?php
+/**
+ * AJAX handler to comment on a record.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use VuFind\Controller\Plugin\Recaptcha;
+use VuFind\Db\Row\User;
+use VuFind\Db\Table\Resource;
+use VuFind\I18n\Translator\TranslatorAwareInterface;
+use Zend\Mvc\Controller\Plugin\Params;
+
+/**
+ * AJAX handler to comment on a record.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class CommentRecord extends AbstractBase implements TranslatorAwareInterface
+{
+    use \VuFind\I18n\Translator\TranslatorAwareTrait;
+
+    /**
+     * Resource database table
+     *
+     * @var Resource
+     */
+    protected $table;
+
+    /**
+     * Recaptcha controller plugin
+     *
+     * @var Recaptcha
+     */
+    protected $recaptcha;
+
+    /**
+     * Logged in user (or false)
+     *
+     * @var User|bool
+     */
+    protected $user;
+
+    /**
+     * Are comments enabled?
+     *
+     * @var bool
+     */
+    protected $enabled;
+
+    /**
+     * Constructor
+     *
+     * @param Resource  $table     Resource database table
+     * @param Recaptcha $recaptcha Recaptcha controller plugin
+     * @param User|bool $user      Logged in user (or false)
+     * @param bool      $enabled   Are comments enabled?
+     */
+    public function __construct(Resource $table, Recaptcha $recaptcha, $user,
+        $enabled = true
+    ) {
+        $this->table = $table;
+        $this->recaptcha = $recaptcha;
+        $this->user = $user;
+        $this->enabled = $enabled;
+    }
+
+    /**
+     * Is CAPTCHA valid? (Also returns true if CAPTCHA is disabled).
+     *
+     * @param Params $params Parameter helper from controller
+     *
+     * @return bool
+     */
+    protected function checkCaptcha(Params $params)
+    {
+        // Not enabled? Report success!
+        if (!$this->recaptcha->active('userComments')) {
+            return true;
+        }
+        $this->recaptcha->setErrorMode('none');
+        return $this->recaptcha->validate();
+    }
+
+    /**
+     * Handle a request.
+     *
+     * @param Params $params Parameter helper from controller
+     *
+     * @return array [response data, internal status code, HTTP status code]
+     */
+    public function handleRequest(Params $params)
+    {
+        // Make sure comments are enabled:
+        if (!$this->enabled) {
+            return $this->formatResponse(
+                $this->translate('Comments disabled'),
+                self::STATUS_ERROR,
+                403
+            );
+        }
+
+        if ($this->user === false) {
+            return $this->formatResponse(
+                $this->translate('You must be logged in first'),
+                self::STATUS_NEED_AUTH,
+                401
+            );
+        }
+
+        $id = $params->fromPost('id');
+        $source = $params->fromPost('source', DEFAULT_SEARCH_BACKEND);
+        $comment = $params->fromPost('comment');
+        if (empty($id) || empty($comment)) {
+            return $this->formatResponse(
+                $this->translate('bulk_error_missing'),
+                self::STATUS_ERROR,
+                400
+            );
+        }
+
+        if (!$this->checkCaptcha($params)) {
+            return $this->formatResponse(
+                $this->translate('recaptcha_not_passed'),
+                self::STATUS_ERROR,
+                403
+            );
+        }
+
+        $resource = $this->table->findResource($id, $source);
+        return $this->formatResponse($resource->addComment($comment, $this->user));
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/CommentRecordFactory.php b/module/VuFind/src/VuFind/AjaxHandler/CommentRecordFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..35a9ff1cec087c12efc368596c06b5c3f7fbc285
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/CommentRecordFactory.php
@@ -0,0 +1,75 @@
+<?php
+/**
+ * Factory for CommentRecord AJAX handler.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use Interop\Container\ContainerInterface;
+
+/**
+ * Factory for CommentRecord AJAX handler.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class CommentRecordFactory implements \Zend\ServiceManager\Factory\FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options passed to factory.');
+        }
+        $tablePluginManager = $container->get('VuFind\Db\Table\PluginManager');
+        $controllerPluginManager = $container->get('ControllerPluginManager');
+        $capabilities = $container->get('VuFind\Config\AccountCapabilities');
+        return new $requestedName(
+            $tablePluginManager->get('VuFind\Db\Table\Resource'),
+            $controllerPluginManager->get('VuFind\Controller\Plugin\Recaptcha'),
+            $container->get('VuFind\Auth\Manager')->isLoggedIn(),
+            $capabilities->getCommentSetting() !== 'disabled'
+        );
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/DeleteRecordComment.php b/module/VuFind/src/VuFind/AjaxHandler/DeleteRecordComment.php
new file mode 100644
index 0000000000000000000000000000000000000000..327d8cd8b3fc6795c212c9dc99b65d96975edd9a
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/DeleteRecordComment.php
@@ -0,0 +1,127 @@
+<?php
+/**
+ * AJAX handler to delete a comment on a record.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use VuFind\Db\Row\User;
+use VuFind\Db\Table\Comments;
+use VuFind\I18n\Translator\TranslatorAwareInterface;
+use Zend\Mvc\Controller\Plugin\Params;
+
+/**
+ * AJAX handler to delete a comment on a record.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class DeleteRecordComment extends AbstractBase implements TranslatorAwareInterface
+{
+    use \VuFind\I18n\Translator\TranslatorAwareTrait;
+
+    /**
+     * Comments database table
+     *
+     * @var Comments
+     */
+    protected $table;
+
+    /**
+     * Logged in user (or false)
+     *
+     * @var User|bool
+     */
+    protected $user;
+
+    /**
+     * Are comments enabled?
+     *
+     * @var bool
+     */
+    protected $enabled;
+
+    /**
+     * Constructor
+     *
+     * @param Comments  $table   Comments database table
+     * @param User|bool $user    Logged in user (or false)
+     * @param bool      $enabled Are comments enabled?
+     */
+    public function __construct(Comments $table, $user, $enabled = true)
+    {
+        $this->table = $table;
+        $this->user = $user;
+        $this->enabled = $enabled;
+    }
+
+    /**
+     * Handle a request.
+     *
+     * @param Params $params Parameter helper from controller
+     *
+     * @return array [response data, internal status code, HTTP status code]
+     */
+    public function handleRequest(Params $params)
+    {
+        // Make sure comments are enabled:
+        if (!$this->enabled) {
+            return $this->formatResponse(
+                $this->translate('Comments disabled'),
+                self::STATUS_ERROR,
+                403
+            );
+        }
+
+        if ($this->user === false) {
+            return $this->formatResponse(
+                $this->translate('You must be logged in first'),
+                self::STATUS_NEED_AUTH,
+                401
+            );
+        }
+
+        $id = $params->fromQuery('id');
+        if (empty($id)) {
+            return $this->formatResponse(
+                $this->translate('bulk_error_missing'),
+                self::STATUS_ERROR,
+                400
+            );
+        }
+        if (!$this->table->deleteIfOwnedByUser($id, $this->user)) {
+            return $this->formatResponse(
+                $this->translate('edit_list_fail'),
+                self::STATUS_ERROR,
+                403
+            );
+        }
+
+        return $this->formatResponse($this->translate('Done'));
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/DeleteRecordCommentFactory.php b/module/VuFind/src/VuFind/AjaxHandler/DeleteRecordCommentFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..33432dcc72e33d5c58aa3ebcd39497e9a4059d6b
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/DeleteRecordCommentFactory.php
@@ -0,0 +1,74 @@
+<?php
+/**
+ * Factory for DeleteRecordComment AJAX handler.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use Interop\Container\ContainerInterface;
+
+/**
+ * Factory for DeleteRecordComment AJAX handler.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class DeleteRecordCommentFactory
+    implements \Zend\ServiceManager\Factory\FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options passed to factory.');
+        }
+        $tablePluginManager = $container->get('VuFind\Db\Table\PluginManager');
+        $capabilities = $container->get('VuFind\Config\AccountCapabilities');
+        return new $requestedName(
+            $tablePluginManager->get('VuFind\Db\Table\Comments'),
+            $container->get('VuFind\Auth\Manager')->isLoggedIn(),
+            $capabilities->getCommentSetting() !== 'disabled'
+        );
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/GetACSuggestions.php b/module/VuFind/src/VuFind/AjaxHandler/GetACSuggestions.php
new file mode 100644
index 0000000000000000000000000000000000000000..e2b82a834e4b2f5385687889864ba9319d2d6103
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/GetACSuggestions.php
@@ -0,0 +1,78 @@
+<?php
+/**
+ * "Get Autocomplete Suggestions" AJAX handler
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use VuFind\Autocomplete\Suggester;
+use VuFind\Session\Settings as SessionSettings;
+use Zend\Mvc\Controller\Plugin\Params;
+use Zend\Stdlib\Parameters;
+
+/**
+ * "Get Autocomplete Suggestions" AJAX handler
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class GetACSuggestions extends AbstractBase
+{
+    /**
+     * Autocomplete suggester
+     *
+     * @var Suggester
+     */
+    protected $suggester;
+
+    /**
+     * Constructor
+     *
+     * @param SessionSettings $ss        Session settings
+     * @param Suggester       $suggester Autocomplete suggester
+     */
+    public function __construct(SessionSettings $ss, Suggester $suggester)
+    {
+        $this->sessionSettings = $ss;
+        $this->suggester = $suggester;
+    }
+
+    /**
+     * Handle a request.
+     *
+     * @param Params $params Parameter helper from controller
+     *
+     * @return array [response data, internal status code, HTTP status code]
+     */
+    public function handleRequest(Params $params)
+    {
+        $this->disableSessionWrites();  // avoid session write timing bug
+        $query = new Parameters($params->fromQuery());
+        return $this->formatResponse($this->suggester->getSuggestions($query));
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/GetACSuggestionsFactory.php b/module/VuFind/src/VuFind/AjaxHandler/GetACSuggestionsFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..5a5889631ae7a55b00e20f3d8e1b2c37042a86ec
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/GetACSuggestionsFactory.php
@@ -0,0 +1,71 @@
+<?php
+/**
+ * Factory for GetACSuggestions AJAX handler.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use Interop\Container\ContainerInterface;
+
+/**
+ * Factory for GetACSuggestions AJAX handler.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class GetACSuggestionsFactory implements
+    \Zend\ServiceManager\Factory\FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options passed to factory.');
+        }
+        return new $requestedName(
+            $container->get('VuFind\Session\Settings'),
+            $container->get('VuFind\Autocomplete\Suggester')
+        );
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/GetFacetData.php b/module/VuFind/src/VuFind/AjaxHandler/GetFacetData.php
new file mode 100644
index 0000000000000000000000000000000000000000..b5080c6c610f3e836cc294442a593a8ad5e2d24f
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/GetFacetData.php
@@ -0,0 +1,121 @@
+<?php
+/**
+ * "Get Facet Data" AJAX handler
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use VuFind\Search\Solr\HierarchicalFacetHelper;
+use VuFind\Search\Solr\Results;
+use VuFind\Session\Settings as SessionSettings;
+use Zend\Mvc\Controller\Plugin\Params;
+use Zend\Stdlib\Parameters;
+
+/**
+ * "Get Facet Data" AJAX handler
+ *
+ * Get hierarchical facet data for jsTree
+ *
+ * Parameters:
+ * facetName  The facet to retrieve
+ * facetSort  By default all facets are sorted by count. Two values are available
+ * for alternative sorting:
+ *   top = sort the top level alphabetically, rest by count
+ *   all = sort all levels alphabetically
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class GetFacetData extends AbstractBase
+{
+    /**
+     * Hierarchical facet helper
+     *
+     * @var HierarchicalFacetHelper
+     */
+    protected $facetHelper;
+
+    /**
+     * Solr search results object
+     *
+     * @var Results
+     */
+    protected $results;
+
+    /**
+     * Constructor
+     *
+     * @param SessionSettings         $ss      Session settings
+     * @param HierarchicalFacetHelper $fh      Facet helper
+     * @param Results                 $results Solr results object
+     */
+    public function __construct(SessionSettings $ss, HierarchicalFacetHelper $fh,
+        Results $results
+    ) {
+        $this->sessionSettings = $ss;
+        $this->facetHelper = $fh;
+        $this->results = $results;
+    }
+
+    /**
+     * Handle a request.
+     *
+     * @param Params $params Parameter helper from controller
+     *
+     * @return array [response data, internal status code, HTTP status code]
+     */
+    public function handleRequest(Params $params)
+    {
+        $this->disableSessionWrites();  // avoid session write timing bug
+
+        $facet = $params->fromQuery('facetName');
+        $sort = $params->fromQuery('facetSort');
+        $operator = $params->fromQuery('facetOperator');
+
+        $paramsObj = $this->results->getParams();
+        $paramsObj->addFacet($facet, null, $operator === 'OR');
+        $paramsObj->initFromRequest(new Parameters($params->fromQuery()));
+
+        $facets = $this->results->getFullFieldFacets([$facet], false, -1, 'count');
+        if (empty($facets[$facet]['data']['list'])) {
+            return $this->formatResponse([]);
+        }
+
+        $facetList = $facets[$facet]['data']['list'];
+
+        if (!empty($sort)) {
+            $this->facetHelper->sortFacetList($facetList, $sort == 'top');
+        }
+
+        return $this->formatResponse(
+            $this->facetHelper->buildFacetArray(
+                $facet, $facetList, $this->results->getUrlQuery()
+            )
+        );
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/GetFacetDataFactory.php b/module/VuFind/src/VuFind/AjaxHandler/GetFacetDataFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..d8096535fc7c870e01c09ab250c55ca2486749eb
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/GetFacetDataFactory.php
@@ -0,0 +1,71 @@
+<?php
+/**
+ * Factory for GetFacetData AJAX handler.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use Interop\Container\ContainerInterface;
+
+/**
+ * Factory for GetFacetData AJAX handler.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class GetFacetDataFactory implements \Zend\ServiceManager\Factory\FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options passed to factory.');
+        }
+        return new $requestedName(
+            $container->get('VuFind\Session\Settings'),
+            $container->get('VuFind\Search\Solr\HierarchicalFacetHelper'),
+            $container->get('VuFind\Search\Results\PluginManager')->get('Solr')
+        );
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/GetIlsStatus.php b/module/VuFind/src/VuFind/AjaxHandler/GetIlsStatus.php
new file mode 100644
index 0000000000000000000000000000000000000000..b9d3c8c1b4e561f0979ad63bc7d37c9d7e16eb3a
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/GetIlsStatus.php
@@ -0,0 +1,99 @@
+<?php
+/**
+ * "Get ILS Status" AJAX handler
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @author   André Lahmann <lahmann@ub.uni-leipzig.de>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use VuFind\ILS\Connection;
+use VuFind\Session\Settings as SessionSettings;
+use Zend\Mvc\Controller\Plugin\Params;
+use Zend\View\Renderer\RendererInterface;
+
+/**
+ * "Get ILS Status" AJAX handler
+ *
+ * This will check the ILS for being online and will return the ils-offline
+ * template upon failure.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @author   André Lahmann <lahmann@ub.uni-leipzig.de>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class GetIlsStatus extends AbstractBase
+{
+    /**
+     * ILS connection
+     *
+     * @var Connection
+     */
+    protected $ils;
+
+    /**
+     * View renderer
+     *
+     * @var RendererInterface
+     */
+    protected $renderer;
+
+    /**
+     * Constructor
+     *
+     * @param SessionSettings   $ss       Session settings
+     * @param Connection        $ils      ILS connection
+     * @param RendererInterface $renderer View renderer
+     */
+    public function __construct(SessionSettings $ss, Connection $ils,
+        RendererInterface $renderer
+    ) {
+        $this->sessionSettings = $ss;
+        $this->ils = $ils;
+        $this->renderer = $renderer;
+    }
+
+    /**
+     * Handle a request.
+     *
+     * @param Params $params Parameter helper from controller
+     *
+     * @return array [response data, internal status code, HTTP status code]
+     */
+    public function handleRequest(Params $params)
+    {
+        $this->disableSessionWrites();
+        if ($this->ils->getOfflineMode(true) == 'ils-offline') {
+            $offlineModeMsg = $params->fromPost(
+                'offlineModeMsg', $params->fromQuery('offlineModeMsg')
+            );
+            $html = $this->renderer
+                ->render('Helpers/ils-offline.phtml', compact('offlineModeMsg'));
+        }
+        return $this->formatResponse($html ?? '');
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/GetIlsStatusFactory.php b/module/VuFind/src/VuFind/AjaxHandler/GetIlsStatusFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..3481046ce7d0a5b686982078be7745a0f1194497
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/GetIlsStatusFactory.php
@@ -0,0 +1,71 @@
+<?php
+/**
+ * Factory for GetIlsStatus AJAX handler.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use Interop\Container\ContainerInterface;
+
+/**
+ * Factory for GetIlsStatus AJAX handler.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class GetIlsStatusFactory implements \Zend\ServiceManager\Factory\FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options passed to factory.');
+        }
+        return new $requestedName(
+            $container->get('VuFind\Session\Settings'),
+            $container->get('VuFind\ILS\Connection'),
+            $container->get('ViewRenderer')
+        );
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/GetItemStatuses.php b/module/VuFind/src/VuFind/AjaxHandler/GetItemStatuses.php
new file mode 100644
index 0000000000000000000000000000000000000000..4c74903066e7fe0e029e56f23284d7ea642db233
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/GetItemStatuses.php
@@ -0,0 +1,491 @@
+<?php
+/**
+ * "Get Item Status" AJAX handler
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @author   Chris Delis <cedelis@uillinois.edu>
+ * @author   Tuan Nguyen <tuan@yorku.ca>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use VuFind\I18n\Translator\TranslatorAwareInterface;
+use VuFind\ILS\Connection;
+use VuFind\ILS\Logic\Holds;
+use VuFind\Session\Settings as SessionSettings;
+use Zend\Config\Config;
+use Zend\Mvc\Controller\Plugin\Params;
+use Zend\View\Renderer\RendererInterface;
+
+/**
+ * "Get Item Status" AJAX handler
+ *
+ * This is responsible for printing the holdings information for a
+ * collection of records in JSON format.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @author   Chris Delis <cedelis@uillinois.edu>
+ * @author   Tuan Nguyen <tuan@yorku.ca>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class GetItemStatuses extends AbstractBase implements TranslatorAwareInterface
+{
+    use \VuFind\I18n\Translator\TranslatorAwareTrait;
+
+    /**
+     * Top-level configuration
+     *
+     * @var Config
+     */
+    protected $config;
+
+    /**
+     * ILS connection
+     *
+     * @var Connection
+     */
+    protected $ils;
+
+    /**
+     * View renderer
+     *
+     * @var RendererInterface
+     */
+    protected $renderer;
+
+    /**
+     * Holds logic
+     *
+     * @var Holds
+     */
+    protected $holdLogic;
+
+    /**
+     * Constructor
+     *
+     * @param SessionSettings   $ss        Session settings
+     * @param Config            $config    Top-level configuration
+     * @param Connection        $ils       ILS connection
+     * @param RendererInterface $renderer  View renderer
+     * @param Holds             $holdLogic Holds logic
+     */
+    public function __construct(SessionSettings $ss, Config $config, Connection $ils,
+        RendererInterface $renderer, Holds $holdLogic
+    ) {
+        $this->sessionSettings = $ss;
+        $this->config = $config;
+        $this->ils = $ils;
+        $this->renderer = $renderer;
+        $this->holdLogic = $holdLogic;
+    }
+
+    /**
+     * Support method for getItemStatuses() -- filter suppressed locations from the
+     * array of item information for a particular bib record.
+     *
+     * @param array $record Information on items linked to a single bib record
+     *
+     * @return array        Filtered version of $record
+     */
+    protected function filterSuppressedLocations($record)
+    {
+        static $hideHoldings = false;
+        if ($hideHoldings === false) {
+            $hideHoldings = $this->holdLogic->getSuppressedLocations();
+        }
+
+        $filtered = [];
+        foreach ($record as $current) {
+            if (!in_array($current['location'], $hideHoldings)) {
+                $filtered[] = $current;
+            }
+        }
+        return $filtered;
+    }
+
+    /**
+     * Translate an array of strings using a prefix.
+     *
+     * @param string $transPrefix Translation prefix
+     * @param array  $list        List of values to translate
+     *
+     * @return array
+     */
+    protected function translateList($transPrefix, $list)
+    {
+        $transList = [];
+        foreach ($list as $current) {
+            $transList[] = $this->translate(
+                $transPrefix . $current, [], $current
+            );
+        }
+        return $transList;
+    }
+
+    /**
+     * Support method for getItemStatuses() -- when presented with multiple values,
+     * pick which one(s) to send back via AJAX.
+     *
+     * @param array  $rawList     Array of values to choose from.
+     * @param string $mode        config.ini setting -- first, all or msg
+     * @param string $msg         Message to display if $mode == "msg"
+     * @param string $transPrefix Translator prefix to apply to values (false to
+     * omit translation of values)
+     *
+     * @return string
+     */
+    protected function pickValue($rawList, $mode, $msg, $transPrefix = false)
+    {
+        // Make sure array contains only unique values:
+        $list = array_unique($rawList);
+
+        // If there is only one value in the list, or if we're in "first" mode,
+        // send back the first list value:
+        if ($mode == 'first' || count($list) == 1) {
+            return $transPrefix
+                ? $this->translate($transPrefix . $list[0], [], $list[0])
+                : $list[0];
+        } elseif (count($list) == 0) {
+            // Empty list?  Return a blank string:
+            return '';
+        } elseif ($mode == 'all') {
+            // All values mode?  Return comma-separated values:
+            return implode(
+                ",\t",
+                $transPrefix ? $this->translateList($transPrefix, $list) : $list
+            );
+        } else {
+            // Message mode?  Return the specified message, translated to the
+            // appropriate language.
+            return $this->translate($msg);
+        }
+    }
+
+    /**
+     * Based on settings and the number of callnumbers, return callnumber handler
+     * Use callnumbers before pickValue is run.
+     *
+     * @param array  $list           Array of callnumbers.
+     * @param string $displaySetting config.ini setting -- first, all or msg
+     *
+     * @return string
+     */
+    protected function getCallnumberHandler($list = null, $displaySetting = null)
+    {
+        if ($displaySetting == 'msg' && count($list) > 1) {
+            return false;
+        }
+        return isset($this->config->Item_Status->callnumber_handler)
+            ? $this->config->Item_Status->callnumber_handler
+            : false;
+    }
+
+    /**
+     * Reduce an array of service names to a human-readable string.
+     *
+     * @param array $rawServices Names of available services.
+     *
+     * @return string
+     */
+    protected function reduceServices(array $rawServices)
+    {
+        // Normalize, dedup and sort available services
+        $normalize = function ($in) {
+            return strtolower(preg_replace('/[^A-Za-z]/', '', $in));
+        };
+        $services = array_map($normalize, array_unique($rawServices));
+        sort($services);
+
+        // Do we need to deal with a preferred service?
+        $preferred = isset($this->config->Item_Status->preferred_service)
+            ? $normalize($this->config->Item_Status->preferred_service) : false;
+        if (false !== $preferred && in_array($preferred, $services)) {
+            $services = [$preferred];
+        }
+
+        return $this->renderer->render(
+            'ajax/status-available-services.phtml',
+            ['services' => $services]
+        );
+    }
+
+    /**
+     * Support method for getItemStatuses() -- process a single bibliographic record
+     * for location settings other than "group".
+     *
+     * @param array  $record            Information on items linked to a single bib
+     *                                  record
+     * @param array  $messages          Custom status HTML
+     *                                  (keys = available/unavailable)
+     * @param string $locationSetting   The location mode setting used for
+     *                                  pickValue()
+     * @param string $callnumberSetting The callnumber mode setting used for
+     *                                  pickValue()
+     *
+     * @return array                    Summarized availability information
+     */
+    protected function getItemStatus($record, $messages, $locationSetting,
+        $callnumberSetting
+    ) {
+        // Summarize call number, location and availability info across all items:
+        $callNumbers = $locations = [];
+        $use_unknown_status = $available = false;
+        $services = [];
+
+        foreach ($record as $info) {
+            // Find an available copy
+            if ($info['availability']) {
+                $available = true;
+            }
+            // Check for a use_unknown_message flag
+            if (isset($info['use_unknown_message'])
+                && $info['use_unknown_message'] == true
+            ) {
+                $use_unknown_status = true;
+            }
+            // Store call number/location info:
+            $callNumbers[] = $info['callnumber'];
+            $locations[] = $info['location'];
+            // Store all available services
+            if (isset($info['services'])) {
+                $services = array_merge($services, $info['services']);
+            }
+        }
+
+        $callnumberHandler = $this->getCallnumberHandler(
+            $callNumbers, $callnumberSetting
+        );
+
+        // Determine call number string based on findings:
+        $callNumber = $this->pickValue(
+            $callNumbers, $callnumberSetting, 'Multiple Call Numbers'
+        );
+
+        // Determine location string based on findings:
+        $location = $this->pickValue(
+            $locations, $locationSetting, 'Multiple Locations', 'location_'
+        );
+
+        if (!empty($services)) {
+            $availability_message = $this->reduceServices($services);
+        } else {
+            $availability_message = $use_unknown_status
+                ? $messages['unknown']
+                : $messages[$available ? 'available' : 'unavailable'];
+        }
+
+        // Send back the collected details:
+        return [
+            'id' => $record[0]['id'],
+            'availability' => ($available ? 'true' : 'false'),
+            'availability_message' => $availability_message,
+            'location' => htmlentities($location, ENT_COMPAT, 'UTF-8'),
+            'locationList' => false,
+            'reserve' =>
+                ($record[0]['reserve'] == 'Y' ? 'true' : 'false'),
+            'reserve_message' => $record[0]['reserve'] == 'Y'
+                ? $this->translate('on_reserve')
+                : $this->translate('Not On Reserve'),
+            'callnumber' => htmlentities($callNumber, ENT_COMPAT, 'UTF-8'),
+            'callnumber_handler' => $callnumberHandler
+        ];
+    }
+
+    /**
+     * Support method for getItemStatuses() -- process a single bibliographic record
+     * for "group" location setting.
+     *
+     * @param array  $record            Information on items linked to a single
+     *                                  bib record
+     * @param array  $messages          Custom status HTML
+     *                                  (keys = available/unavailable)
+     * @param string $callnumberSetting The callnumber mode setting used for
+     *                                  pickValue()
+     *
+     * @return array                    Summarized availability information
+     */
+    protected function getItemStatusGroup($record, $messages, $callnumberSetting)
+    {
+        // Summarize call number, location and availability info across all items:
+        $locations =  [];
+        $use_unknown_status = $available = false;
+        foreach ($record as $info) {
+            // Find an available copy
+            if ($info['availability']) {
+                $available = $locations[$info['location']]['available'] = true;
+            }
+            // Check for a use_unknown_message flag
+            if (isset($info['use_unknown_message'])
+                && $info['use_unknown_message'] == true
+            ) {
+                $use_unknown_status = true;
+                $locations[$info['location']]['status_unknown'] = true;
+            }
+            // Store call number/location info:
+            $locations[$info['location']]['callnumbers'][] = $info['callnumber'];
+        }
+
+        // Build list split out by location:
+        $locationList = false;
+        foreach ($locations as $location => $details) {
+            $locationCallnumbers = array_unique($details['callnumbers']);
+            // Determine call number string based on findings:
+            $callnumberHandler = $this->getCallnumberHandler(
+                $locationCallnumbers, $callnumberSetting
+            );
+            $locationCallnumbers = $this->pickValue(
+                $locationCallnumbers, $callnumberSetting, 'Multiple Call Numbers'
+            );
+            $locationInfo = [
+                'availability' =>
+                    $details['available'] ?? false,
+                'location' => htmlentities(
+                    $this->translate('location_' . $location, [], $location),
+                    ENT_COMPAT, 'UTF-8'
+                ),
+                'callnumbers' =>
+                    htmlentities($locationCallnumbers, ENT_COMPAT, 'UTF-8'),
+                'status_unknown' => $details['status_unknown'] ?? false,
+                'callnumber_handler' => $callnumberHandler
+            ];
+            $locationList[] = $locationInfo;
+        }
+
+        $availability_message = $use_unknown_status
+            ? $messages['unknown']
+            : $messages[$available ? 'available' : 'unavailable'];
+
+        // Send back the collected details:
+        return [
+            'id' => $record[0]['id'],
+            'availability' => ($available ? 'true' : 'false'),
+            'availability_message' => $availability_message,
+            'location' => false,
+            'locationList' => $locationList,
+            'reserve' =>
+                ($record[0]['reserve'] == 'Y' ? 'true' : 'false'),
+            'reserve_message' => $record[0]['reserve'] == 'Y'
+                ? $this->translate('on_reserve')
+                : $this->translate('Not On Reserve'),
+            'callnumber' => false
+        ];
+    }
+
+    /**
+     * Handle a request.
+     *
+     * @param Params $params Parameter helper from controller
+     *
+     * @return array [response data, internal status code, HTTP status code]
+     */
+    public function handleRequest(Params $params)
+    {
+        $this->disableSessionWrites();  // avoid session write timing bug
+        $ids = $params->fromPost('id', $params->fromQuery('id', []));
+        $results = $this->ils->getStatuses($ids);
+
+        if (!is_array($results)) {
+            // If getStatuses returned garbage, let's turn it into an empty array
+            // to avoid triggering a notice in the foreach loop below.
+            $results = [];
+        }
+
+        // In order to detect IDs missing from the status response, create an
+        // array with a key for every requested ID.  We will clear keys as we
+        // encounter IDs in the response -- anything left will be problems that
+        // need special handling.
+        $missingIds = array_flip($ids);
+
+        // Load messages for response:
+        $messages = [
+            'available' => $this->renderer->render('ajax/status-available.phtml'),
+            'unavailable' =>
+                $this->renderer->render('ajax/status-unavailable.phtml'),
+            'unknown' => $this->renderer->render('ajax/status-unknown.phtml')
+        ];
+
+        // Load callnumber and location settings:
+        $callnumberSetting = isset($this->config->Item_Status->multiple_call_nos)
+            ? $this->config->Item_Status->multiple_call_nos : 'msg';
+        $locationSetting = isset($this->config->Item_Status->multiple_locations)
+            ? $this->config->Item_Status->multiple_locations : 'msg';
+        $showFullStatus = isset($this->config->Item_Status->show_full_status)
+            ? $this->config->Item_Status->show_full_status : false;
+
+        // Loop through all the status information that came back
+        $statuses = [];
+        foreach ($results as $recordNumber => $record) {
+            // Filter out suppressed locations:
+            $record = $this->filterSuppressedLocations($record);
+
+            // Skip empty records:
+            if (count($record)) {
+                if ($locationSetting == "group") {
+                    $current = $this->getItemStatusGroup(
+                        $record, $messages, $callnumberSetting
+                    );
+                } else {
+                    $current = $this->getItemStatus(
+                        $record, $messages, $locationSetting, $callnumberSetting
+                    );
+                }
+                // If a full status display has been requested, append the HTML:
+                if ($showFullStatus) {
+                    $current['full_status'] = $this->renderer->render(
+                        'ajax/status-full.phtml', [
+                            'statusItems' => $record,
+                            'callnumberHandler' => $this->getCallnumberHandler()
+                         ]
+                    );
+                }
+                $current['record_number'] = array_search($current['id'], $ids);
+                $statuses[] = $current;
+
+                // The current ID is not missing -- remove it from the missing list.
+                unset($missingIds[$current['id']]);
+            }
+        }
+
+        // If any IDs were missing, send back appropriate dummy data
+        foreach ($missingIds as $missingId => $recordNumber) {
+            $statuses[] = [
+                'id'                   => $missingId,
+                'availability'         => 'false',
+                'availability_message' => $messages['unavailable'],
+                'location'             => $this->translate('Unknown'),
+                'locationList'         => false,
+                'reserve'              => 'false',
+                'reserve_message'      => $this->translate('Not On Reserve'),
+                'callnumber'           => '',
+                'missing_data'         => true,
+                'record_number'        => $recordNumber
+            ];
+        }
+
+        // Done
+        return $this->formatResponse($statuses);
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/GetItemStatusesFactory.php b/module/VuFind/src/VuFind/AjaxHandler/GetItemStatusesFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..c0608b72dd34cb1eb2888ae8eefbf3d5b1b735d3
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/GetItemStatusesFactory.php
@@ -0,0 +1,73 @@
+<?php
+/**
+ * Factory for GetItemStatus AJAX handler.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use Interop\Container\ContainerInterface;
+
+/**
+ * Factory for GetItemStatus AJAX handler.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class GetItemStatusesFactory implements \Zend\ServiceManager\Factory\FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options passed to factory.');
+        }
+        return new $requestedName(
+            $container->get('VuFind\Session\Settings'),
+            $container->get('VuFind\Config\PluginManager')->get('config'),
+            $container->get('VuFind\ILS\Connection'),
+            $container->get('ViewRenderer'),
+            $container->get('VuFind\ILS\Logic\Holds')
+        );
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/GetLibraryPickupLocations.php b/module/VuFind/src/VuFind/AjaxHandler/GetLibraryPickupLocations.php
new file mode 100644
index 0000000000000000000000000000000000000000..eb71b1f5f63773499814f7077e883280c39e2bd9
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/GetLibraryPickupLocations.php
@@ -0,0 +1,97 @@
+<?php
+/**
+ * "Get Library Pickup Locations" AJAX handler
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use Zend\Mvc\Controller\Plugin\Params;
+
+/**
+ * "Get Library Pickup Locations" AJAX handler
+ *
+ * Get pick up locations for a library
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class GetLibraryPickupLocations extends AbstractIlsAndUserAction
+{
+    /**
+     * Handle a request.
+     *
+     * @param Params $params Parameter helper from controller
+     *
+     * @return array [response data, internal status code, HTTP status code]
+     */
+    public function handleRequest(Params $params)
+    {
+        $this->disableSessionWrites();  // avoid session write timing bug
+        $id = $params->fromQuery('id');
+        $pickupLib = $params->fromQuery('pickupLib');
+        if (null === $id || null === $pickupLib) {
+            return $this->formatResponse(
+                $this->translate('bulk_error_missing'),
+                self::STATUS_ERROR,
+                400
+            );
+        }
+        // check if user is logged in
+        if (!$this->user) {
+            return $this->formatResponse(
+                $this->translate('You must be logged in first'),
+                self::STATUS_NEED_AUTH,
+                401
+            );
+        }
+
+        try {
+            $patron = $this->ilsAuthenticator->storedCatalogLogin();
+            if ($patron) {
+                $results = $this->ils
+                    ->getILLPickupLocations($id, $pickupLib, $patron);
+                foreach ($results as &$result) {
+                    if (isset($result['name'])) {
+                        $result['name'] = $this->translate(
+                            'location_' . $result['name'],
+                            [],
+                            $result['name']
+                        );
+                    }
+                }
+                return $this->formatResponse(['locations' => $results]);
+            }
+        } catch (\Exception $e) {
+            // Do nothing -- just fail through to the error message below.
+        }
+
+        return $this->formatResponse(
+            $this->translate('An error has occurred'), self::STATUS_ERROR, 500
+        );
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/GetRecordCommentsAsHTML.php b/module/VuFind/src/VuFind/AjaxHandler/GetRecordCommentsAsHTML.php
new file mode 100644
index 0000000000000000000000000000000000000000..0241796f7983f4c6f252391fe2a9f1ba233b686e
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/GetRecordCommentsAsHTML.php
@@ -0,0 +1,88 @@
+<?php
+/**
+ * AJAX handler to get list of comments for a record as HTML.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use VuFind\Record\Loader;
+use Zend\Mvc\Controller\Plugin\Params;
+use Zend\View\Renderer\RendererInterface;
+
+/**
+ * AJAX handler to get list of comments for a record as HTML.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class GetRecordCommentsAsHTML extends AbstractBase
+{
+    /**
+     * Record loader
+     *
+     * @var Loader
+     */
+    protected $loader;
+
+    /**
+     * View renderer
+     *
+     * @var RendererInterface
+     */
+    protected $renderer;
+
+    /**
+     * Constructor
+     *
+     * @param Connection        $loader   Record loader
+     * @param RendererInterface $renderer View renderer
+     */
+    public function __construct(Loader $loader, RendererInterface $renderer)
+    {
+        $this->loader = $loader;
+        $this->renderer = $renderer;
+    }
+
+    /**
+     * Handle a request.
+     *
+     * @param Params $params Parameter helper from controller
+     *
+     * @return array [response data, internal status code, HTTP status code]
+     */
+    public function handleRequest(Params $params)
+    {
+        $driver = $this->loader->load(
+            $params->fromQuery('id'),
+            $params->fromQuery('source', DEFAULT_SEARCH_BACKEND)
+        );
+        return $this->formatResponse(
+            $this->renderer->render('record/comments-list.phtml', compact('driver'))
+        );
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/GetRecordCommentsAsHTMLFactory.php b/module/VuFind/src/VuFind/AjaxHandler/GetRecordCommentsAsHTMLFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..22ac0945b305038ed6abe57fccc3573638c1e56f
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/GetRecordCommentsAsHTMLFactory.php
@@ -0,0 +1,71 @@
+<?php
+/**
+ * Factory for GetRecordCommentsAsHTML AJAX handler.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use Interop\Container\ContainerInterface;
+
+/**
+ * Factory for GetRecordCommentsAsHTML AJAX handler.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class GetRecordCommentsAsHTMLFactory
+    implements \Zend\ServiceManager\Factory\FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options passed to factory.');
+        }
+        return new $requestedName(
+            $container->get('VuFind\Record\Loader'),
+            $container->get('ViewRenderer')
+        );
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/GetRecordDetails.php b/module/VuFind/src/VuFind/AjaxHandler/GetRecordDetails.php
new file mode 100644
index 0000000000000000000000000000000000000000..6c2d975bee05d40fb8254c861c0310e458087a85
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/GetRecordDetails.php
@@ -0,0 +1,138 @@
+<?php
+/**
+ * "Get Record Details" AJAX handler
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use VuFind\Record\Loader;
+use VuFind\RecordTab\PluginManager;
+use Zend\Http\PhpEnvironment\Request;
+use Zend\Mvc\Controller\Plugin\Params;
+use Zend\View\Renderer\RendererInterface;
+
+/**
+ * "Get Record Details" AJAX handler
+ *
+ * Get record for integrated list view.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class GetRecordDetails extends AbstractBase
+{
+    /**
+     * ZF configuration
+     *
+     * @var array
+     */
+    protected $config;
+
+    /**
+     * Request
+     *
+     * @var Request
+     */
+    protected $request;
+
+    /**
+     * Record loader
+     *
+     * @var Loader
+     */
+    protected $recordLoader;
+
+    /**
+     * Record tab plugin manager
+     *
+     * @var PluginManager
+     */
+    protected $pluginManager;
+
+    /**
+     * View renderer
+     *
+     * @var RendererInterface
+     */
+    protected $renderer;
+
+    /**
+     * Constructor
+     *
+     * @param array             $config   ZF configuration
+     * @param Request           $request  HTTP request
+     * @param Loader            $loader   Record loader
+     * @param PluginManager     $pm       RecordTab plugin manager
+     * @param RendererInterface $renderer Renderer
+     */
+    public function __construct(array $config, Request $request, Loader $loader,
+        PluginManager $pm, RendererInterface $renderer
+    ) {
+        $this->config = $config;
+        $this->request = $request;
+        $this->recordLoader = $loader;
+        $this->pluginManager = $pm;
+        $this->renderer = $renderer;
+    }
+
+    /**
+     * Handle a request.
+     *
+     * @param Params $params Parameter helper from controller
+     *
+     * @return array [response data, internal status code, HTTP status code]
+     */
+    public function handleRequest(Params $params)
+    {
+        $driver = $this->recordLoader
+            ->load($params->fromQuery('id'), $params->fromQuery('source'));
+        $viewtype = preg_replace(
+            '/\W/', '', trim(strtolower($params->fromQuery('type')))
+        );
+
+        $details = $this->pluginManager->getTabDetailsForRecord(
+            $driver,
+            $this->config['vufind']['recorddriver_tabs'],
+            $this->request,
+            'Information'
+        );
+
+        $html = $this->renderer->render(
+            "record/ajaxview-" . $viewtype . ".phtml",
+            [
+                'defaultTab' => $details['default'],
+                'driver' => $driver,
+                'tabs' => $details['tabs'],
+                'backgroundTabs' => $this->pluginManager->getBackgroundTabNames(
+                    $driver, $this->config['vufind']['recorddriver_tabs']
+                )
+            ]
+        );
+        return $this->formatResponse($html);
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/GetRecordDetailsFactory.php b/module/VuFind/src/VuFind/AjaxHandler/GetRecordDetailsFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..7eda291fa3feb589ee1ea1f4d0d0dc6811184ae5
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/GetRecordDetailsFactory.php
@@ -0,0 +1,74 @@
+<?php
+/**
+ * Factory for GetRecordDetails AJAX handler.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use Interop\Container\ContainerInterface;
+
+/**
+ * Factory for GetRecordDetails AJAX handler.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class GetRecordDetailsFactory
+    implements \Zend\ServiceManager\Factory\FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options passed to factory.');
+        }
+        return new $requestedName(
+            $container->get('Config'),
+            $container->get('Request'),
+            $container->get('VuFind\Record\Loader'),
+            $container->get('VuFind\RecordTab\PluginManager'),
+            $container->get('ViewRenderer')
+        );
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/GetRecordTags.php b/module/VuFind/src/VuFind/AjaxHandler/GetRecordTags.php
new file mode 100644
index 0000000000000000000000000000000000000000..db774f93dfcc29fbf0669b2dd35e839582d239ac
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/GetRecordTags.php
@@ -0,0 +1,113 @@
+<?php
+/**
+ * AJAX handler to get all tags for a record as HTML.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use VuFind\Db\Row\User;
+use VuFind\Db\Table\Tags;
+use Zend\Mvc\Controller\Plugin\Params;
+use Zend\View\Renderer\RendererInterface;
+
+/**
+ * AJAX handler to get all tags for a record as HTML.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class GetRecordTags extends AbstractBase
+{
+    /**
+     * Tags database table
+     *
+     * @var Tags
+     */
+    protected $table;
+
+    /**
+     * Logged in user (or false)
+     *
+     * @var User|bool
+     */
+    protected $user;
+
+    /**
+     * View renderer
+     *
+     * @var RendererInterface
+     */
+    protected $renderer;
+
+    /**
+     * Constructor
+     *
+     * @param Tags              $table    Tags table
+     * @param User|bool         $user     Logged in user (or false)
+     * @param RendererInterface $renderer View renderer
+     */
+    public function __construct(Tags $table, $user, RendererInterface $renderer)
+    {
+        $this->table = $table;
+        $this->user = $user;
+        $this->renderer = $renderer;
+    }
+
+    /**
+     * Handle a request.
+     *
+     * @param Params $params Parameter helper from controller
+     *
+     * @return array [response data, internal status code, HTTP status code]
+     */
+    public function handleRequest(Params $params)
+    {
+        $is_me_id = !$this->user ? null : $this->user->id;
+
+        // Retrieve from database:
+        $tags = $this->table->getForResource(
+            $params->fromQuery('id'),
+            $params->fromQuery('source', DEFAULT_SEARCH_BACKEND),
+            0, null, null, 'count', $is_me_id
+        );
+
+        // Build data structure for return:
+        $tagList = [];
+        foreach ($tags as $tag) {
+            $tagList[] = [
+                'tag'   => $tag->tag,
+                'cnt'   => $tag->cnt,
+                'is_me' => !empty($tag->is_me)
+            ];
+        }
+
+        $viewParams = ['tagList' => $tagList, 'loggedin' => (bool)$this->user];
+        $html = $this->renderer->render('record/taglist', $viewParams);
+        return $this->formatResponse($html);
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/GetRecordTagsFactory.php b/module/VuFind/src/VuFind/AjaxHandler/GetRecordTagsFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..4e751054acf9aebb2313ac13f80d6da47a465a0a
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/GetRecordTagsFactory.php
@@ -0,0 +1,72 @@
+<?php
+/**
+ * Factory for GetRecordTags AJAX handler.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use Interop\Container\ContainerInterface;
+
+/**
+ * Factory for GetRecordTags AJAX handler.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class GetRecordTagsFactory implements \Zend\ServiceManager\Factory\FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options passed to factory.');
+        }
+        $tablePluginManager = $container->get('VuFind\Db\Table\PluginManager');
+        return new $requestedName(
+            $tablePluginManager->get('VuFind\Db\Table\Tags'),
+            $container->get('VuFind\Auth\Manager')->isLoggedIn(),
+            $container->get('ViewRenderer')
+        );
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/GetRequestGroupPickupLocations.php b/module/VuFind/src/VuFind/AjaxHandler/GetRequestGroupPickupLocations.php
new file mode 100644
index 0000000000000000000000000000000000000000..117baafeb57db92a2da5e095a901e3980e5d960c
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/GetRequestGroupPickupLocations.php
@@ -0,0 +1,99 @@
+<?php
+/**
+ * "Get Request Group Pickup Locations" AJAX handler
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use Zend\Mvc\Controller\Plugin\Params;
+
+/**
+ * "Get Request Group Pickup Locations" AJAX handler
+ *
+ * Get pick up locations for a request group
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class GetRequestGroupPickupLocations extends AbstractIlsAndUserAction
+{
+    /**
+     * Handle a request.
+     *
+     * @param Params $params Parameter helper from controller
+     *
+     * @return array [response data, internal status code, HTTP status code]
+     */
+    public function handleRequest(Params $params)
+    {
+        $this->disableSessionWrites();  // avoid session write timing bug
+        $id = $params->fromQuery('id');
+        $requestGroupId = $params->fromQuery('requestGroupId');
+        if (null === $id || null === $requestGroupId) {
+            return $this->formatResponse(
+                $this->translate('bulk_error_missing'),
+                self::STATUS_ERROR,
+                400
+            );
+        }
+        // check if user is logged in
+        if (!$this->user) {
+            return $this->formatResponse(
+                $this->translate('You must be logged in first'),
+                self::STATUS_NEED_AUTH,
+                401
+            );
+        }
+
+        try {
+            if ($patron = $this->ilsAuthenticator->storedCatalogLogin()) {
+                $details = [
+                    'id' => $id,
+                    'requestGroupId' => $requestGroupId
+                ];
+                $results = $this->ils->getPickupLocations($patron, $details);
+                foreach ($results as &$result) {
+                    if (isset($result['locationDisplay'])) {
+                        $result['locationDisplay'] = $this->translate(
+                            'location_' . $result['locationDisplay'],
+                            [],
+                            $result['locationDisplay']
+                        );
+                    }
+                }
+                return $this->formatResponse(['locations' => $results]);
+            }
+        } catch (\Exception $e) {
+            // Do nothing -- just fail through to the error message below.
+        }
+
+        return $this->formatResponse(
+            $this->translate('An error has occurred'), self::STATUS_ERROR, 500
+        );
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/GetResolverLinks.php b/module/VuFind/src/VuFind/AjaxHandler/GetResolverLinks.php
new file mode 100644
index 0000000000000000000000000000000000000000..b172940b7d3fc93116093ea366b2fcada4cde4af
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/GetResolverLinks.php
@@ -0,0 +1,167 @@
+<?php
+/**
+ * "Get Resolver Links" AJAX handler
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @author   Graham Seaman <Graham.Seaman@rhul.ac.uk>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use VuFind\I18n\Translator\TranslatorAwareInterface;
+use VuFind\Resolver\Connection;
+use VuFind\Resolver\Driver\PluginManager;
+use VuFind\Session\Settings as SessionSettings;
+use Zend\Config\Config;
+use Zend\Mvc\Controller\Plugin\Params;
+use Zend\View\Renderer\RendererInterface;
+
+/**
+ * "Get Resolver Links" AJAX handler
+ *
+ * Fetch Links from resolver given an OpenURL and format as HTML
+ * and output the HTML content in JSON object.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @author   Graham Seaman <Graham.Seaman@rhul.ac.uk>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class GetResolverLinks extends AbstractBase implements TranslatorAwareInterface
+{
+    use \VuFind\I18n\Translator\TranslatorAwareTrait;
+
+    /**
+     * Resolver driver plugin manager
+     *
+     * @var PluginManager
+     */
+    protected $pluginManager;
+
+    /**
+     * View renderer
+     *
+     * @var RendererInterface
+     */
+    protected $renderer;
+
+    /**
+     * Top-level VuFind configuration (config.ini)
+     *
+     * @var Config
+     */
+    protected $config;
+
+    /**
+     * Constructor
+     *
+     * @param SessionSettings   $ss       Session settings
+     * @param PluginManager     $pm       Resolver driver plugin manager
+     * @param RendererInterface $renderer View renderer
+     * @param Config            $config   Top-level VuFind configuration (config.ini)
+     */
+    public function __construct(SessionSettings $ss, PluginManager $pm,
+        RendererInterface $renderer, Config $config
+    ) {
+        $this->sessionSettings = $ss;
+        $this->pluginManager = $pm;
+        $this->renderer = $renderer;
+        $this->config = $config;
+    }
+
+    /**
+     * Handle a request.
+     *
+     * @param Params $params Parameter helper from controller
+     *
+     * @return array [response data, internal status code, HTTP status code]
+     */
+    public function handleRequest(Params $params)
+    {
+        $this->disableSessionWrites();  // avoid session write timing bug
+        $openUrl = $params->fromQuery('openurl', '');
+        $searchClassId = $params->fromQuery('searchClassId', '');
+
+        $resolverType = isset($this->config->OpenURL->resolver)
+            ? $this->config->OpenURL->resolver : 'other';
+        if (!$this->pluginManager->has($resolverType)) {
+            return $this->formatResponse(
+                $this->translate("Could not load driver for $resolverType"),
+                self::STATUS_ERROR,
+                500
+            );
+        }
+        $resolver = new Connection($this->pluginManager->get($resolverType));
+        if (isset($this->config->OpenURL->resolver_cache)) {
+            $resolver->enableCache($this->config->OpenURL->resolver_cache);
+        }
+        $result = $resolver->fetchLinks($openUrl);
+
+        // Sort the returned links into categories based on service type:
+        $electronic = $print = $services = [];
+        foreach ($result as $link) {
+            switch ($link['service_type'] ?? '') {
+            case 'getHolding':
+                $print[] = $link;
+                break;
+            case 'getWebService':
+                $services[] = $link;
+                break;
+            case 'getDOI':
+                // Special case -- modify DOI text for special display:
+                $link['title'] = $this->translate('Get full text');
+                $link['coverage'] = '';
+            case 'getFullTxt':
+            default:
+                $electronic[] = $link;
+                break;
+            }
+        }
+
+        // Get the OpenURL base:
+        if (isset($this->config->OpenURL->url)) {
+            // Trim off any parameters (for legacy compatibility -- default config
+            // used to include extraneous parameters):
+            list($base) = explode('?', $this->config->OpenURL->url);
+        } else {
+            $base = false;
+        }
+
+        $moreOptionsLink = $resolver->supportsMoreOptionsLink()
+            ? $resolver->getResolverUrl($openUrl) : '';
+
+        // Render the links using the view:
+        $view = [
+            'openUrlBase' => $base, 'openUrl' => $openUrl, 'print' => $print,
+            'electronic' => $electronic, 'services' => $services,
+            'searchClassId' => $searchClassId,
+            'moreOptionsLink' => $moreOptionsLink
+        ];
+        $html = $this->renderer->render('ajax/resolverLinks.phtml', $view);
+
+        // output HTML encoded in JSON object
+        return $this->formatResponse($html);
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/GetResolverLinksFactory.php b/module/VuFind/src/VuFind/AjaxHandler/GetResolverLinksFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..4aed4717d5d844967cb4053ee74aa55569cc72a0
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/GetResolverLinksFactory.php
@@ -0,0 +1,73 @@
+<?php
+/**
+ * Factory for GetResolverLinks AJAX handler.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use Interop\Container\ContainerInterface;
+
+/**
+ * Factory for GetResolverLinks AJAX handler.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class GetResolverLinksFactory
+    implements \Zend\ServiceManager\Factory\FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options passed to factory.');
+        }
+        return new $requestedName(
+            $container->get('VuFind\Session\Settings'),
+            $container->get('VuFind\Resolver\Driver\PluginManager'),
+            $container->get('ViewRenderer'),
+            $container->get('VuFind\Config\PluginManager')->get('config')
+        );
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/GetSaveStatuses.php b/module/VuFind/src/VuFind/AjaxHandler/GetSaveStatuses.php
new file mode 100644
index 0000000000000000000000000000000000000000..b223fefc561070ae2a20f84443dfad9acd9e7777
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/GetSaveStatuses.php
@@ -0,0 +1,154 @@
+<?php
+/**
+ * "Get Save Statuses" AJAX handler
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use VuFind\Db\Row\User;
+use VuFind\I18n\Translator\TranslatorAwareInterface;
+use VuFind\Session\Settings as SessionSettings;
+use Zend\Mvc\Controller\Plugin\Params;
+use Zend\Mvc\Controller\Plugin\Url;
+
+/**
+ * "Get Save Statuses" AJAX handler
+ *
+ * Check one or more records to see if they are saved in one of the user's list.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class GetSaveStatuses extends AbstractBase implements TranslatorAwareInterface
+{
+    use \VuFind\I18n\Translator\TranslatorAwareTrait;
+
+    /**
+     * Logged in user (or false)
+     *
+     * @var User|bool
+     */
+    protected $user;
+
+    /**
+     * URL helper
+     *
+     * @var Url
+     */
+    protected $urlHelper;
+
+    /**
+     * Constructor
+     *
+     * @param SessionSettings $ss        Session settings
+     * @param User|bool       $user      Logged in user (or false)
+     * @param Url             $urlHelper URL helper
+     */
+    public function __construct(SessionSettings $ss, $user, Url $urlHelper)
+    {
+        $this->sessionSettings = $ss;
+        $this->user = $user;
+        $this->urlHelper = $urlHelper;
+    }
+
+    /**
+     * Format list object into array.
+     *
+     * @param array $list List data
+     *
+     * @return array
+     */
+    protected function formatListData($list)
+    {
+        return [
+            'list_url' =>
+                $this->urlHelper->fromRoute('userList', ['id' => $list['list_id']]),
+            'list_title' => $list['list_title'],
+        ];
+    }
+
+    /**
+     * Obtain status data from the current logged-in user.
+     *
+     * @param array $ids     IDs to retrieve
+     * @param array $sources Source data for IDs (parallel-indexed)
+     *
+     * @return array
+     */
+    protected function getDataFromUser($ids, $sources)
+    {
+        $result = $checked = [];
+        foreach ($ids as $i => $id) {
+            $source = $sources[$i] ?? DEFAULT_SEARCH_BACKEND;
+            $selector = $source . '|' . $id;
+
+            // We don't want to bother checking the same ID more than once, so
+            // use the $checked flag array to avoid duplicates:
+            if (!isset($checked[$selector])) {
+                $checked[$selector] = true;
+
+                $data = $this->user->getSavedData($id, null, $source);
+                $result[$selector] = ($data && count($data) > 0)
+                    ? array_map([$this, 'formatListData'], $data->toArray()) : [];
+            }
+        }
+        return $result;
+    }
+
+    /**
+     * Handle a request.
+     *
+     * @param Params $params Parameter helper from controller
+     *
+     * @return array [response data, internal status code, HTTP status code]
+     */
+    public function handleRequest(Params $params)
+    {
+        $this->disableSessionWrites();  // avoid session write timing bug
+        // check if user is logged in
+        if (!$this->user) {
+            return $this->formatResponse(
+                $this->translate('You must be logged in first'),
+                self::STATUS_NEED_AUTH,
+                401
+            );
+        }
+
+        // loop through each ID check if it is saved to any of the user's lists
+        $ids = $params->fromPost('id', $params->fromQuery('id', []));
+        $sources = $params->fromPost('source', $params->fromQuery('source', []));
+        if (!is_array($ids) || !is_array($sources)) {
+            return $this->formatResponse(
+                $this->translate('Argument must be array.'),
+                self::STATUS_ERROR,
+                400
+            );
+        }
+        return $this->formatResponse($this->getDataFromUser($ids, $sources));
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/GetSaveStatusesFactory.php b/module/VuFind/src/VuFind/AjaxHandler/GetSaveStatusesFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..9d966a549f28aa44b63cb1a3ae232341e76b0ab6
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/GetSaveStatusesFactory.php
@@ -0,0 +1,71 @@
+<?php
+/**
+ * Factory for GetSaveStatuses AJAX handler.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use Interop\Container\ContainerInterface;
+
+/**
+ * Factory for GetSaveStatuses AJAX handler.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class GetSaveStatusesFactory implements \Zend\ServiceManager\Factory\FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options passed to factory.');
+        }
+        return new $requestedName(
+            $container->get('VuFind\Session\Settings'),
+            $container->get('VuFind\Auth\Manager')->isLoggedIn(),
+            $container->get('ControllerPluginManager')->get('url')
+        );
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/GetVisData.php b/module/VuFind/src/VuFind/AjaxHandler/GetVisData.php
new file mode 100644
index 0000000000000000000000000000000000000000..8c31a27b1e9e1e7b34eb72fe3fcd20c169561e00
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/GetVisData.php
@@ -0,0 +1,157 @@
+<?php
+/**
+ * "Get Visualization Data" AJAX handler
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @author   Chris Hallberg <crhallberg@gmail.com>
+ * @author   Till Kinstler <kinstler@gbv.de>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use VuFind\Search\Solr\Results;
+use VuFind\Session\Settings as SessionSettings;
+use Zend\Mvc\Controller\Plugin\Params;
+use Zend\Stdlib\Parameters;
+
+/**
+ * "Get Visualization Data" AJAX handler
+ *
+ * AJAX for timeline feature (PubDateVisAjax)
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @author   Chris Hallberg <crhallberg@gmail.com>
+ * @author   Till Kinstler <kinstler@gbv.de>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class GetVisData extends AbstractBase
+{
+    /**
+     * Solr search results object
+     *
+     * @var Results
+     */
+    protected $results;
+
+    /**
+     * Constructor
+     *
+     * @param SessionSettings $ss      Session settings
+     * @param Results         $results Solr search results object
+     */
+    public function __construct(SessionSettings $ss, Results $results)
+    {
+        $this->sessionSettings = $ss;
+        $this->results = $results;
+    }
+
+    /**
+     * Extract details from applied filters.
+     *
+     * @param array $filters    Current filter list
+     * @param array $dateFacets Objects containing the date ranges
+     *
+     * @return array
+     */
+    protected function processDateFacets($filters, $dateFacets)
+    {
+        $result = [];
+        foreach ($dateFacets as $current) {
+            $from = $to = '';
+            if (isset($filters[$current])) {
+                foreach ($filters[$current] as $filter) {
+                    if (preg_match('/\[[\d\*]+ TO [\d\*]+\]/', $filter)) {
+                        $range = explode(' TO ', trim($filter, '[]'));
+                        $from = $range[0] == '*' ? '' : $range[0];
+                        $to = $range[1] == '*' ? '' : $range[1];
+                        break;
+                    }
+                }
+            }
+            $result[$current] = [$from, $to];
+            $result[$current]['label']
+                = $this->results->getParams()->getFacetLabel($current);
+        }
+        return $result;
+    }
+
+    /**
+     * Filter bad values from facet lists and add useful data fields.
+     *
+     * @param array $filters Current filter list
+     * @param array $fields  Processed date information from processDateFacets
+     *
+     * @return array
+     */
+    protected function processFacetValues($filters, $fields)
+    {
+        $facets = $this->results->getFullFieldFacets(array_keys($fields));
+        $retVal = [];
+        foreach ($facets as $field => $values) {
+            $filter = $filters[$field][0] ?? null;
+            $newValues = [
+                'data' => [],
+                'min' => $fields[$field][0] > 0 ? $fields[$field][0] : 0,
+                'max' => $fields[$field][1] > 0 ? $fields[$field][1] : 0,
+                'removalURL' => $this->results->getUrlQuery()
+                    ->removeFacet($field, $filter)->getParams(false),
+            ];
+            foreach ($values['data']['list'] as $current) {
+                // Only retain numeric values!
+                if (preg_match("/^[0-9]+$/", $current['value'])) {
+                    $newValues['data'][]
+                        = [$current['value'], $current['count']];
+                }
+            }
+            $retVal[$field] = $newValues;
+        }
+        return $retVal;
+    }
+
+    /**
+     * Handle a request.
+     *
+     * @param Params $params Parameter helper from controller
+     *
+     * @return array [response data, internal status code, HTTP status code]
+     */
+    public function handleRequest(Params $params)
+    {
+        $this->disableSessionWrites();  // avoid session write timing bug
+        $paramsObj = $this->results->getParams();
+        $paramsObj->initFromRequest(new Parameters($params->fromQuery()));
+        foreach ($params->fromQuery('hf', []) as $hf) {
+            $paramsObj->addHiddenFilter($hf);
+        }
+        $paramsObj->getOptions()->disableHighlighting();
+        $paramsObj->getOptions()->spellcheckEnabled(false);
+        $filters = $paramsObj->getFilters();
+        $rawDateFacets = $params->fromQuery('facetFields');
+        $dateFacets = empty($rawDateFacets) ? [] : explode(':', $rawDateFacets);
+        $fields = $this->processDateFacets($filters, $dateFacets);
+        return $this->formatResponse($this->processFacetValues($filters, $fields));
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/GetVisDataFactory.php b/module/VuFind/src/VuFind/AjaxHandler/GetVisDataFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..e05b92167b2c4734d77afd114739b139ded07dbc
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/GetVisDataFactory.php
@@ -0,0 +1,70 @@
+<?php
+/**
+ * Factory for GetVisData AJAX handler.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use Interop\Container\ContainerInterface;
+
+/**
+ * Factory for GetVisData AJAX handler.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class GetVisDataFactory implements \Zend\ServiceManager\Factory\FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options passed to factory.');
+        }
+        return new $requestedName(
+            $container->get('VuFind\Session\Settings'),
+            $container->get('VuFind\Search\Results\PluginManager')->get('Solr')
+        );
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/KeepAlive.php b/module/VuFind/src/VuFind/AjaxHandler/KeepAlive.php
new file mode 100644
index 0000000000000000000000000000000000000000..d7fe8f4c85213b81a4f71d4c33eb23ea9f609242
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/KeepAlive.php
@@ -0,0 +1,79 @@
+<?php
+/**
+ * "Keep Alive" AJAX handler
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use Zend\Mvc\Controller\Plugin\Params;
+use Zend\Session\SessionManager;
+
+/**
+ * "Keep Alive" AJAX handler
+ *
+ * This is responsible for keeping the session alive whenever called
+ * (via JavaScript)
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class KeepAlive extends AbstractBase
+{
+    /**
+     * Session Manager
+     *
+     * @var SessionManager
+     */
+    protected $sessionManager;
+
+    /**
+     * Constructor
+     *
+     * @param SessionManager $sm Session manager
+     */
+    public function __construct(SessionManager $sm)
+    {
+        $this->sessionManager = $sm;
+    }
+
+    /**
+     * Handle a request.
+     *
+     * @param Params $params Parameter helper from controller
+     *
+     * @return array [response data, internal status code, HTTP status code]
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function handleRequest(Params $params)
+    {
+        // Request ID from session to mark it active
+        $this->sessionManager->getId();
+        return $this->formatResponse(true);
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/KeepAliveFactory.php b/module/VuFind/src/VuFind/AjaxHandler/KeepAliveFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..88f7391db5029886ad46e9e4d678576ad2b9731b
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/KeepAliveFactory.php
@@ -0,0 +1,67 @@
+<?php
+/**
+ * Factory for KeepAlive AJAX handler.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use Interop\Container\ContainerInterface;
+
+/**
+ * Factory for KeepAlive AJAX handler.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class KeepAliveFactory implements \Zend\ServiceManager\Factory\FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options passed to factory.');
+        }
+        return new $requestedName($container->get('Zend\Session\SessionManager'));
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/PluginManager.php b/module/VuFind/src/VuFind/AjaxHandler/PluginManager.php
new file mode 100644
index 0000000000000000000000000000000000000000..b78caad5750af768df8e03c28986dfac7e0152d0
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/PluginManager.php
@@ -0,0 +1,122 @@
+<?php
+/**
+ * AJAX handler plugin manager
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+/**
+ * AJAX handler plugin manager
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
+{
+    /**
+     * Default plugin aliases.
+     *
+     * @var array
+     */
+    protected $aliases = [
+        'checkRequestIsValid' => 'VuFind\AjaxHandler\CheckRequestIsValid',
+        'commentRecord' => 'VuFind\AjaxHandler\CommentRecord',
+        'deleteRecordComment' => 'VuFind\AjaxHandler\DeleteRecordComment',
+        'getACSuggestions' => 'VuFind\AjaxHandler\GetACSuggestions',
+        'getFacetData' => 'VuFind\AjaxHandler\GetFacetData',
+        'getIlsStatus' => 'VuFind\AjaxHandler\GetIlsStatus',
+        'getItemStatuses' => 'VuFind\AjaxHandler\GetItemStatuses',
+        'getLibraryPickupLocations' =>
+            'VuFind\AjaxHandler\GetLibraryPickupLocations',
+        'getRecordCommentsAsHTML' => 'VuFind\AjaxHandler\GetRecordCommentsAsHTML',
+        'getRecordDetails' => 'VuFind\AjaxHandler\GetRecordDetails',
+        'getRecordTags' => 'VuFind\AjaxHandler\GetRecordTags',
+        'getRequestGroupPickupLocations' =>
+            'VuFind\AjaxHandler\GetRequestGroupPickupLocations',
+        'getResolverLinks' => 'VuFind\AjaxHandler\GetResolverLinks',
+        'getSaveStatuses' => 'VuFind\AjaxHandler\GetSaveStatuses',
+        'getVisData' => 'VuFind\AjaxHandler\GetVisData',
+        'keepAlive' => 'VuFind\AjaxHandler\KeepAlive',
+        'recommend' => 'VuFind\AjaxHandler\Recommend',
+        'systemStatus' => 'VuFind\AjaxHandler\SystemStatus',
+        'tagRecord' => 'VuFind\AjaxHandler\TagRecord',
+    ];
+
+    /**
+     * Default plugin factories.
+     *
+     * @var array
+     */
+    protected $factories = [
+        'VuFind\AjaxHandler\CheckRequestIsValid' =>
+            'VuFind\AjaxHandler\AbstractIlsAndUserActionFactory',
+        'VuFind\AjaxHandler\CommentRecord' =>
+            'VuFind\AjaxHandler\CommentRecordFactory',
+        'VuFind\AjaxHandler\DeleteRecordComment' =>
+            'VuFind\AjaxHandler\DeleteRecordCommentFactory',
+        'VuFind\AjaxHandler\GetACSuggestions' =>
+            'VuFind\AjaxHandler\GetACSuggestionsFactory',
+        'VuFind\AjaxHandler\GetFacetData' =>
+            'VuFind\AjaxHandler\GetFacetDataFactory',
+        'VuFind\AjaxHandler\GetIlsStatus' =>
+            'VuFind\AjaxHandler\GetIlsStatusFactory',
+        'VuFind\AjaxHandler\GetItemStatuses' =>
+            'VuFind\AjaxHandler\GetItemStatusesFactory',
+        'VuFind\AjaxHandler\GetLibraryPickupLocations' =>
+            'VuFind\AjaxHandler\AbstractIlsAndUserActionFactory',
+        'VuFind\AjaxHandler\GetRecordCommentsAsHTML' =>
+            'VuFind\AjaxHandler\GetRecordCommentsAsHTMLFactory',
+        'VuFind\AjaxHandler\GetRecordDetails' =>
+            'VuFind\AjaxHandler\GetRecordDetailsFactory',
+        'VuFind\AjaxHandler\GetRecordTags' =>
+            'VuFind\AjaxHandler\GetRecordTagsFactory',
+        'VuFind\AjaxHandler\GetRequestGroupPickupLocations' =>
+            'VuFind\AjaxHandler\AbstractIlsAndUserActionFactory',
+        'VuFind\AjaxHandler\GetResolverLinks' =>
+            'VuFind\AjaxHandler\GetResolverLinksFactory',
+        'VuFind\AjaxHandler\GetSaveStatuses' =>
+            'VuFind\AjaxHandler\GetSaveStatusesFactory',
+        'VuFind\AjaxHandler\GetVisData' => 'VuFind\AjaxHandler\GetVisDataFactory',
+        'VuFind\AjaxHandler\KeepAlive' => 'VuFind\AjaxHandler\KeepAliveFactory',
+        'VuFind\AjaxHandler\Recommend' => 'VuFind\AjaxHandler\RecommendFactory',
+        'VuFind\AjaxHandler\SystemStatus' =>
+            'VuFind\AjaxHandler\SystemStatusFactory',
+        'VuFind\AjaxHandler\TagRecord' => 'VuFind\AjaxHandler\TagRecordFactory',
+    ];
+
+    /**
+     * Return the name of the base class or interface that plug-ins must conform
+     * to.
+     *
+     * @return string
+     */
+    protected function getExpectedInterface()
+    {
+        return 'VuFind\AjaxHandler\AjaxHandlerInterface';
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/Recommend.php b/module/VuFind/src/VuFind/AjaxHandler/Recommend.php
new file mode 100644
index 0000000000000000000000000000000000000000..1608ac6fc2c5abe5a6294c856c90a6c4599eeb74
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/Recommend.php
@@ -0,0 +1,109 @@
+<?php
+/**
+ * Load a recommendation module via AJAX.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use VuFind\Recommend\PluginManager;
+use VuFind\Search\Solr\Results;
+use VuFind\Session\Settings as SessionSettings;
+use Zend\Mvc\Controller\Plugin\Params;
+use Zend\Stdlib\Parameters;
+use Zend\View\Renderer\RendererInterface;
+
+/**
+ * Load a recommendation module via AJAX.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class Recommend extends AbstractBase
+{
+    /**
+     * Recommendation plugin manager
+     *
+     * @var PluginManager
+     */
+    protected $pluginManager;
+
+    /**
+     * Solr search results object
+     *
+     * @var Results
+     */
+    protected $results;
+
+    /**
+     * View renderer
+     *
+     * @var RendererInterface
+     */
+    protected $renderer;
+
+    /**
+     * Constructor
+     *
+     * @param SessionSettings   $ss       Session settings
+     * @param PluginManager     $pm       Recommendation plugin manager
+     * @param Results           $results  Solr results object
+     * @param RendererInterface $renderer View renderer
+     */
+    public function __construct(SessionSettings $ss, PluginManager $pm,
+        Results $results, RendererInterface $renderer
+    ) {
+        $this->sessionSettings = $ss;
+        $this->pluginManager = $pm;
+        $this->results = $results;
+        $this->renderer = $renderer;
+    }
+
+    /**
+     * Handle a request.
+     *
+     * @param Params $params Parameter helper from controller
+     *
+     * @return array [response data, internal status code, HTTP status code]
+     */
+    public function handleRequest(Params $params)
+    {
+        $this->disableSessionWrites();  // avoid session write timing bug
+        // Process recommendations -- for now, we assume Solr-based search objects,
+        // since deferred recommendations work best for modules that don't care about
+        // the details of the search objects anyway:
+        $module = $this->pluginManager->get($params->fromQuery('mod'));
+        $module->setConfig($params->fromQuery('params'));
+        $paramsObj = $this->results->getParams();
+        $module->init($paramsObj, new Parameters($params->fromQuery()));
+        $module->process($this->results);
+
+        // Render recommendations:
+        $recommend = $this->renderer->plugin('recommend');
+        return $this->formatResponse($recommend($module));
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/RecommendFactory.php b/module/VuFind/src/VuFind/AjaxHandler/RecommendFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..87c525189a6ae7de1ea8183689ff959a46fdeb9a
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/RecommendFactory.php
@@ -0,0 +1,72 @@
+<?php
+/**
+ * Factory for Recommend AJAX handler.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use Interop\Container\ContainerInterface;
+
+/**
+ * Factory for Recommend AJAX handler.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class RecommendFactory implements \Zend\ServiceManager\Factory\FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options passed to factory.');
+        }
+        return new $requestedName(
+            $container->get('VuFind\Session\Settings'),
+            $container->get('VuFind\Recommend\PluginManager'),
+            $container->get('VuFind\Search\Results\PluginManager')->get('Solr'),
+            $container->get('ViewRenderer')
+        );
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/SystemStatus.php b/module/VuFind/src/VuFind/AjaxHandler/SystemStatus.php
new file mode 100644
index 0000000000000000000000000000000000000000..46a1cc387e6ca2e58de1fde145709897bbcb94c7
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/SystemStatus.php
@@ -0,0 +1,141 @@
+<?php
+/**
+ * "Keep Alive" AJAX handler
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use VuFind\Db\Table\Session;
+use VuFind\Search\Results\PluginManager;
+use Zend\Config\Config;
+use Zend\Mvc\Controller\Plugin\Params;
+use Zend\Session\SessionManager;
+
+/**
+ * "Keep Alive" AJAX handler
+ *
+ * This is responsible for keeping the session alive whenever called
+ * (via JavaScript)
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class SystemStatus extends AbstractBase
+{
+    /**
+     * Session Manager
+     *
+     * @var SessionManager
+     */
+    protected $sessionManager;
+
+    /**
+     * Session database table
+     *
+     * @var Session
+     */
+    protected $sessionTable;
+
+    /**
+     * Results manager
+     *
+     * @var PluginManager
+     */
+    protected $resultsManager;
+
+    /**
+     * Top-level VuFind configuration (config.ini)
+     *
+     * @var Config
+     */
+    protected $config;
+
+    /**
+     * Constructor
+     *
+     * @param SessionManager $sm     Session manager
+     * @param PluginManager  $rm     Results manager
+     * @param Config         $config Top-level VuFind configuration (config.ini)
+     * @param Session        $table  Session database table
+     */
+    public function __construct(SessionManager $sm, PluginManager $rm,
+        Config $config, Session $table
+    ) {
+        $this->sessionManager = $sm;
+        $this->resultsManager = $rm;
+        $this->config = $config;
+        $this->sessionTable = $table;
+    }
+
+    /**
+     * Handle a request.
+     *
+     * @param Params $params Parameter helper from controller
+     *
+     * @return array [response data, internal status code, HTTP status code]
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function handleRequest(Params $params)
+    {
+        // Check system status
+        if (!empty($this->config->System->healthCheckFile)
+            && file_exists($this->config->System->healthCheckFile)
+        ) {
+            return $this->formatResponse(
+                'Health check file exists', self::STATUS_ERROR, 503
+            );
+        }
+
+        // Test search index
+        try {
+            $results = $this->resultsManager->get('Solr');
+            $paramsObj = $results->getParams();
+            $paramsObj->setQueryIDs(['healthcheck']);
+            $results->performAndProcessSearch();
+        } catch (\Exception $e) {
+            return $this->formatResponse(
+                'Search index error: ' . $e->getMessage(), self::STATUS_ERROR, 500
+            );
+        }
+
+        // Test database connection
+        try {
+            $this->sessionTable->getBySessionId('healthcheck', false);
+        } catch (\Exception $e) {
+            return $this->formatResponse(
+                'Database error: ' . $e->getMessage(), self::STATUS_ERROR, 500
+            );
+        }
+
+        // This may be called frequently, don't leave sessions dangling
+        $this->sessionManager->destroy();
+
+        return $this->formatResponse('');
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/SystemStatusFactory.php b/module/VuFind/src/VuFind/AjaxHandler/SystemStatusFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..0261892d5c2d9fb9d62a4747dae2d838538c5744
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/SystemStatusFactory.php
@@ -0,0 +1,73 @@
+<?php
+/**
+ * Factory for SystemStatus AJAX handler.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use Interop\Container\ContainerInterface;
+
+/**
+ * Factory for SystemStatus AJAX handler.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class SystemStatusFactory implements \Zend\ServiceManager\Factory\FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options passed to factory.');
+        }
+        $tablePluginManager = $container->get('VuFind\Db\Table\PluginManager');
+        return new $requestedName(
+            $container->get('Zend\Session\SessionManager'),
+            $container->get('VuFind\Search\Results\PluginManager'),
+            $container->get('VuFind\Config\PluginManager')->get('config'),
+            $tablePluginManager->get('VuFind\Db\Table\Session')
+        );
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/TagRecord.php b/module/VuFind/src/VuFind/AjaxHandler/TagRecord.php
new file mode 100644
index 0000000000000000000000000000000000000000..6511e6c1472df8e98f58877fd53b4bbf0380e783
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/TagRecord.php
@@ -0,0 +1,114 @@
+<?php
+/**
+ * AJAX handler to tag/untag a record.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use VuFind\Db\Row\User;
+use VuFind\I18n\Translator\TranslatorAwareInterface;
+use VuFind\Record\Loader;
+use VuFind\Tags;
+use Zend\Mvc\Controller\Plugin\Params;
+
+/**
+ * AJAX handler to tag/untag a record.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class TagRecord extends AbstractBase implements TranslatorAwareInterface
+{
+    use \VuFind\I18n\Translator\TranslatorAwareTrait;
+
+    /**
+     * Record loader
+     *
+     * @var Loader
+     */
+    protected $loader;
+
+    /**
+     * Tag parser
+     *
+     * @var Tags
+     */
+    protected $tagParser;
+
+    /**
+     * Logged in user (or false)
+     *
+     * @var User|bool
+     */
+    protected $user;
+
+    /**
+     * Constructor
+     *
+     * @param Loader    $loader Record loader
+     * @param Tags      $parser Tag parser
+     * @param User|bool $user   Logged in user (or false)
+     */
+    public function __construct(Loader $loader, Tags $parser, $user)
+    {
+        $this->loader = $loader;
+        $this->tagParser = $parser;
+        $this->user = $user;
+    }
+
+    /**
+     * Handle a request.
+     *
+     * @param Params $params Parameter helper from controller
+     *
+     * @return array [response data, internal status code, HTTP status code]
+     */
+    public function handleRequest(Params $params)
+    {
+        if (!$this->user) {
+            return $this->formatResponse(
+                $this->translate('You must be logged in first'),
+                self::STATUS_NEED_AUTH,
+                401
+            );
+        }
+
+        $id = $params->fromPost('id');
+        $source = $params->fromPost('source', DEFAULT_SEARCH_BACKEND);
+        $tag = $params->fromPost('tag', '');
+
+        if (strlen($tag) > 0) { // don't add empty tags
+            $driver = $this->loader->load($id, $source);
+            ('false' === $params->fromPost('remove', 'false'))
+                ? $driver->addTags($this->user, $this->tagParser->parse($tag))
+                : $driver->deleteTags($this->user, $this->tagParser->parse($tag));
+        }
+
+        return $this->formatResponse($this->translate('Done'));
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/TagRecordFactory.php b/module/VuFind/src/VuFind/AjaxHandler/TagRecordFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..3a11f517018034d939771e9e33fb3d63e1709315
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/TagRecordFactory.php
@@ -0,0 +1,71 @@
+<?php
+/**
+ * Factory for TagRecord AJAX handler.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use Interop\Container\ContainerInterface;
+
+/**
+ * Factory for TagRecord AJAX handler.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class TagRecordFactory implements \Zend\ServiceManager\Factory\FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options passed to factory.');
+        }
+        return new $requestedName(
+            $container->get('VuFind\Record\Loader'),
+            $container->get('VuFind\Tags'),
+            $container->get('VuFind\Auth\Manager')->isLoggedIn()
+        );
+    }
+}
diff --git a/module/VuFind/src/VuFind/Controller/AjaxController.php b/module/VuFind/src/VuFind/Controller/AjaxController.php
index 3f8a1367a78cda71cc1efd11fc5d10f363e54027..ea796681bb31fc7a5d5bd5ccaad44a658cbbce33 100644
--- a/module/VuFind/src/VuFind/Controller/AjaxController.php
+++ b/module/VuFind/src/VuFind/Controller/AjaxController.php
@@ -27,7 +27,9 @@
  */
 namespace VuFind\Controller;
 
-use Zend\ServiceManager\ServiceLocatorInterface;
+use VuFind\AjaxHandler\PluginManager;
+use VuFind\I18n\Translator\TranslatorAwareInterface;
+use Zend\Mvc\Controller\AbstractActionController;
 
 /**
  * This controller handles global AJAX functionality
@@ -38,69 +40,32 @@ use Zend\ServiceManager\ServiceLocatorInterface;
  * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
  * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
  */
-class AjaxController extends AbstractBase
+class AjaxController extends AbstractActionController
+    implements TranslatorAwareInterface
 {
-    // define some status constants
-    const STATUS_OK = 'OK';                  // good
-    const STATUS_ERROR = 'ERROR';            // bad
-    const STATUS_NEED_AUTH = 'NEED_AUTH';    // must login first
-
-    /**
-     * Type of output to use
-     *
-     * @var string
-     */
-    protected $outputMode;
-
-    /**
-     * Array of PHP errors captured during execution
-     *
-     * @var array
-     */
-    protected static $php_errors = [];
+    use AjaxResponseTrait;
+    use \VuFind\I18n\Translator\TranslatorAwareTrait;
 
     /**
      * Constructor
      *
-     * @param ServiceLocatorInterface $sm Service locator
+     * @param PluginManager $am AJAX Handler Plugin Manager
      */
-    public function __construct(ServiceLocatorInterface $sm)
+    public function __construct(PluginManager $am)
     {
         // Add notices to a key in the output
-        set_error_handler(['VuFind\Controller\AjaxController', "storeError"]);
-        parent::__construct($sm);
+        set_error_handler([static::class, 'storeError']);
+        $this->ajaxManager = $am;
     }
 
     /**
-     * Handles passing data to the class
+     * Make an AJAX call with a JSON-formatted response.
      *
-     * @return mixed
+     * @return \Zend\Http\Response
      */
     public function jsonAction()
     {
-        // Set the output mode to JSON:
-        $this->outputMode = 'json';
-
-        // Call the method specified by the 'method' parameter; append Ajax to
-        // the end to avoid access to arbitrary inappropriate methods.
-        $callback = [$this, $this->params()->fromQuery('method') . 'Ajax'];
-        if (is_callable($callback)) {
-            try {
-                return call_user_func($callback);
-            } catch (\Exception $e) {
-                $debugMsg = ('development' == APPLICATION_ENV)
-                    ? ': ' . $e->getMessage() : '';
-                return $this->output(
-                    $this->translate('An error has occurred') . $debugMsg,
-                    self::STATUS_ERROR,
-                    500
-                );
-            }
-        } else {
-            return $this->output(
-                $this->translate('Invalid Method'), self::STATUS_ERROR, 400
-            );
-        }
+        return $this->callAjaxMethod($this->params()->fromQuery('method'));
     }
 
     /**
@@ -110,1205 +75,7 @@ class AjaxController extends AbstractBase
      */
     public function recommendAction()
     {
-        $this->disableSessionWrites();  // avoid session write timing bug
-        // Process recommendations -- for now, we assume Solr-based search objects,
-        // since deferred recommendations work best for modules that don't care about
-        // the details of the search objects anyway:
-        $rm = $this->serviceLocator->get('VuFind\Recommend\PluginManager');
-        $module = $rm->get($this->params()->fromQuery('mod'));
-        $module->setConfig($this->params()->fromQuery('params'));
-        $results = $this->getResultsManager()->get('Solr');
-        $params = $results->getParams();
-        $module->init($params, $this->getRequest()->getQuery());
-        $module->process($results);
-
-        // Set headers:
-        $response = $this->getResponse();
-        $headers = $response->getHeaders();
-        $headers->addHeaderLine('Content-type', 'text/html');
-        $headers->addHeaderLine('Cache-Control', 'no-cache, must-revalidate');
-        $headers->addHeaderLine('Expires', 'Mon, 26 Jul 1997 05:00:00 GMT');
-
-        // Render recommendations:
-        $recommend = $this->getViewRenderer()->plugin('recommend');
-        $response->setContent($recommend($module));
-        return $response;
-    }
-
-    /**
-     * Support method for getItemStatuses() -- filter suppressed locations from the
-     * array of item information for a particular bib record.
-     *
-     * @param array $record Information on items linked to a single bib record
-     *
-     * @return array        Filtered version of $record
-     */
-    protected function filterSuppressedLocations($record)
-    {
-        static $hideHoldings = false;
-        if ($hideHoldings === false) {
-            $logic = $this->serviceLocator->get('VuFind\ILS\Logic\Holds');
-            $hideHoldings = $logic->getSuppressedLocations();
-        }
-
-        $filtered = [];
-        foreach ($record as $current) {
-            if (!in_array($current['location'], $hideHoldings)) {
-                $filtered[] = $current;
-            }
-        }
-        return $filtered;
-    }
-
-    /**
-     * Get Item Statuses
-     *
-     * This is responsible for printing the holdings information for a
-     * collection of records in JSON format.
-     *
-     * @return \Zend\Http\Response
-     * @author Chris Delis <cedelis@uillinois.edu>
-     * @author Tuan Nguyen <tuan@yorku.ca>
-     */
-    protected function getItemStatusesAjax()
-    {
-        $this->disableSessionWrites();  // avoid session write timing bug
-        $catalog = $this->getILS();
-        $ids = $this->params()->fromPost('id', $this->params()->fromQuery('id'));
-        $results = $catalog->getStatuses($ids);
-
-        if (!is_array($results)) {
-            // If getStatuses returned garbage, let's turn it into an empty array
-            // to avoid triggering a notice in the foreach loop below.
-            $results = [];
-        }
-
-        // In order to detect IDs missing from the status response, create an
-        // array with a key for every requested ID.  We will clear keys as we
-        // encounter IDs in the response -- anything left will be problems that
-        // need special handling.
-        $missingIds = array_flip($ids);
-
-        // Get access to PHP template renderer for partials:
-        $renderer = $this->getViewRenderer();
-
-        // Load messages for response:
-        $messages = [
-            'available' => $renderer->render('ajax/status-available.phtml'),
-            'unavailable' => $renderer->render('ajax/status-unavailable.phtml'),
-            'unknown' => $renderer->render('ajax/status-unknown.phtml')
-        ];
-
-        // Load callnumber and location settings:
-        $config = $this->getConfig();
-        $callnumberSetting = isset($config->Item_Status->multiple_call_nos)
-            ? $config->Item_Status->multiple_call_nos : 'msg';
-        $locationSetting = isset($config->Item_Status->multiple_locations)
-            ? $config->Item_Status->multiple_locations : 'msg';
-        $showFullStatus = isset($config->Item_Status->show_full_status)
-            ? $config->Item_Status->show_full_status : false;
-
-        // Loop through all the status information that came back
-        $statuses = [];
-        foreach ($results as $recordNumber => $record) {
-            // Filter out suppressed locations:
-            $record = $this->filterSuppressedLocations($record);
-
-            // Skip empty records:
-            if (count($record)) {
-                if ($locationSetting == "group") {
-                    $current = $this->getItemStatusGroup(
-                        $record, $messages, $callnumberSetting
-                    );
-                } else {
-                    $current = $this->getItemStatus(
-                        $record, $messages, $locationSetting, $callnumberSetting
-                    );
-                }
-                // If a full status display has been requested, append the HTML:
-                if ($showFullStatus) {
-                    $current['full_status'] = $renderer->render(
-                        'ajax/status-full.phtml', [
-                            'statusItems' => $record,
-                            'callnumberHandler' => $this->getCallnumberHandler()
-                         ]
-                    );
-                }
-                $current['record_number'] = array_search($current['id'], $ids);
-                $statuses[] = $current;
-
-                // The current ID is not missing -- remove it from the missing list.
-                unset($missingIds[$current['id']]);
-            }
-        }
-
-        // If any IDs were missing, send back appropriate dummy data
-        foreach ($missingIds as $missingId => $recordNumber) {
-            $statuses[] = [
-                'id'                   => $missingId,
-                'availability'         => 'false',
-                'availability_message' => $messages['unavailable'],
-                'location'             => $this->translate('Unknown'),
-                'locationList'         => false,
-                'reserve'              => 'false',
-                'reserve_message'      => $this->translate('Not On Reserve'),
-                'callnumber'           => '',
-                'missing_data'         => true,
-                'record_number'        => $recordNumber
-            ];
-        }
-
-        // Done
-        return $this->output($statuses, self::STATUS_OK);
-    }
-
-    /**
-     * Support method for getItemStatuses() -- when presented with multiple values,
-     * pick which one(s) to send back via AJAX.
-     *
-     * @param array  $list        Array of values to choose from.
-     * @param string $mode        config.ini setting -- first, all or msg
-     * @param string $msg         Message to display if $mode == "msg"
-     * @param string $transPrefix Translator prefix to apply to values (false to
-     * omit translation of values)
-     *
-     * @return string
-     */
-    protected function pickValue($list, $mode, $msg, $transPrefix = false)
-    {
-        // Make sure array contains only unique values:
-        $list = array_unique($list);
-
-        // If there is only one value in the list, or if we're in "first" mode,
-        // send back the first list value:
-        if ($mode == 'first' || count($list) == 1) {
-            if (!$transPrefix) {
-                return $list[0];
-            } else {
-                return $this->translate($transPrefix . $list[0], [], $list[0]);
-            }
-        } elseif (count($list) == 0) {
-            // Empty list?  Return a blank string:
-            return '';
-        } elseif ($mode == 'all') {
-            // Translate values if necessary:
-            if ($transPrefix) {
-                $transList = [];
-                foreach ($list as $current) {
-                    $transList[] = $this->translate(
-                        $transPrefix . $current, [], $current
-                    );
-                }
-                $list = $transList;
-            }
-            // All values mode?  Return comma-separated values:
-            return implode(",\t", $list);
-        } else {
-            // Message mode?  Return the specified message, translated to the
-            // appropriate language.
-            return $this->translate($msg);
-        }
-    }
-
-    /**
-     * Based on settings and the number of callnumbers, return callnumber handler
-     * Use callnumbers before pickValue is run.
-     *
-     * @param array  $list           Array of callnumbers.
-     * @param string $displaySetting config.ini setting -- first, all or msg
-     *
-     * @return string
-     */
-    protected function getCallnumberHandler($list = null, $displaySetting = null)
-    {
-        if ($displaySetting == 'msg' && count($list) > 1) {
-            return false;
-        }
-        $config = $this->getConfig();
-        return isset($config->Item_Status->callnumber_handler)
-            ? $config->Item_Status->callnumber_handler
-            : false;
-    }
-
-    /**
-     * Reduce an array of service names to a human-readable string.
-     *
-     * @param array $services Names of available services.
-     *
-     * @return string
-     */
-    protected function reduceServices(array $services)
-    {
-        // Normalize, dedup and sort available services
-        $normalize = function ($in) {
-            return strtolower(preg_replace('/[^A-Za-z]/', '', $in));
-        };
-        $services = array_map($normalize, array_unique($services));
-        sort($services);
-
-        // Do we need to deal with a preferred service?
-        $config = $this->getConfig();
-        $preferred = isset($config->Item_Status->preferred_service)
-            ? $normalize($config->Item_Status->preferred_service) : false;
-        if (false !== $preferred && in_array($preferred, $services)) {
-            $services = [$preferred];
-        }
-
-        return $this->getViewRenderer()->render(
-            'ajax/status-available-services.phtml',
-            ['services' => $services]
-        );
-    }
-
-    /**
-     * Support method for getItemStatuses() -- process a single bibliographic record
-     * for location settings other than "group".
-     *
-     * @param array  $record            Information on items linked to a single bib
-     *                                  record
-     * @param array  $messages          Custom status HTML
-     *                                  (keys = available/unavailable)
-     * @param string $locationSetting   The location mode setting used for
-     *                                  pickValue()
-     * @param string $callnumberSetting The callnumber mode setting used for
-     *                                  pickValue()
-     *
-     * @return array                    Summarized availability information
-     */
-    protected function getItemStatus($record, $messages, $locationSetting,
-        $callnumberSetting
-    ) {
-        // Summarize call number, location and availability info across all items:
-        $callNumbers = $locations = [];
-        $use_unknown_status = $available = false;
-        $services = [];
-
-        foreach ($record as $info) {
-            // Find an available copy
-            if ($info['availability']) {
-                $available = true;
-            }
-            // Check for a use_unknown_message flag
-            if (isset($info['use_unknown_message'])
-                && $info['use_unknown_message'] == true
-            ) {
-                $use_unknown_status = true;
-            }
-            // Store call number/location info:
-            $callNumbers[] = $info['callnumber'];
-            $locations[] = $info['location'];
-            // Store all available services
-            if (isset($info['services'])) {
-                $services = array_merge($services, $info['services']);
-            }
-        }
-
-        $callnumberHandler = $this->getCallnumberHandler(
-            $callNumbers, $callnumberSetting
-        );
-
-        // Determine call number string based on findings:
-        $callNumber = $this->pickValue(
-            $callNumbers, $callnumberSetting, 'Multiple Call Numbers'
-        );
-
-        // Determine location string based on findings:
-        $location = $this->pickValue(
-            $locations, $locationSetting, 'Multiple Locations', 'location_'
-        );
-
-        if (!empty($services)) {
-            $availability_message = $this->reduceServices($services);
-        } else {
-            $availability_message = $use_unknown_status
-                ? $messages['unknown']
-                : $messages[$available ? 'available' : 'unavailable'];
-        }
-
-        // Send back the collected details:
-        return [
-            'id' => $record[0]['id'],
-            'availability' => ($available ? 'true' : 'false'),
-            'availability_message' => $availability_message,
-            'location' => htmlentities($location, ENT_COMPAT, 'UTF-8'),
-            'locationList' => false,
-            'reserve' =>
-                ($record[0]['reserve'] == 'Y' ? 'true' : 'false'),
-            'reserve_message' => $record[0]['reserve'] == 'Y'
-                ? $this->translate('on_reserve')
-                : $this->translate('Not On Reserve'),
-            'callnumber' => htmlentities($callNumber, ENT_COMPAT, 'UTF-8'),
-            'callnumber_handler' => $callnumberHandler
-        ];
-    }
-
-    /**
-     * Support method for getItemStatuses() -- process a single bibliographic record
-     * for "group" location setting.
-     *
-     * @param array  $record            Information on items linked to a single
-     *                                  bib record
-     * @param array  $messages          Custom status HTML
-     *                                  (keys = available/unavailable)
-     * @param string $callnumberSetting The callnumber mode setting used for
-     *                                  pickValue()
-     *
-     * @return array                    Summarized availability information
-     */
-    protected function getItemStatusGroup($record, $messages, $callnumberSetting)
-    {
-        // Summarize call number, location and availability info across all items:
-        $locations =  [];
-        $use_unknown_status = $available = false;
-        foreach ($record as $info) {
-            // Find an available copy
-            if ($info['availability']) {
-                $available = $locations[$info['location']]['available'] = true;
-            }
-            // Check for a use_unknown_message flag
-            if (isset($info['use_unknown_message'])
-                && $info['use_unknown_message'] == true
-            ) {
-                $use_unknown_status = true;
-                $locations[$info['location']]['status_unknown'] = true;
-            }
-            // Store call number/location info:
-            $locations[$info['location']]['callnumbers'][] = $info['callnumber'];
-        }
-
-        // Build list split out by location:
-        $locationList = false;
-        foreach ($locations as $location => $details) {
-            $locationCallnumbers = array_unique($details['callnumbers']);
-            // Determine call number string based on findings:
-            $callnumberHandler = $this->getCallnumberHandler(
-                $locationCallnumbers, $callnumberSetting
-            );
-            $locationCallnumbers = $this->pickValue(
-                $locationCallnumbers, $callnumberSetting, 'Multiple Call Numbers'
-            );
-            $locationInfo = [
-                'availability' => $details['available'] ?? false,
-                'location' => htmlentities(
-                    $this->translate('location_' . $location, [], $location),
-                    ENT_COMPAT, 'UTF-8'
-                ),
-                'callnumbers' =>
-                    htmlentities($locationCallnumbers, ENT_COMPAT, 'UTF-8'),
-                'status_unknown' => $details['status_unknown'] ?? false,
-                'callnumber_handler' => $callnumberHandler
-            ];
-            $locationList[] = $locationInfo;
-        }
-
-        $availability_message = $use_unknown_status
-            ? $messages['unknown']
-            : $messages[$available ? 'available' : 'unavailable'];
-
-        // Send back the collected details:
-        return [
-            'id' => $record[0]['id'],
-            'availability' => ($available ? 'true' : 'false'),
-            'availability_message' => $availability_message,
-            'location' => false,
-            'locationList' => $locationList,
-            'reserve' =>
-                ($record[0]['reserve'] == 'Y' ? 'true' : 'false'),
-            'reserve_message' => $record[0]['reserve'] == 'Y'
-                ? $this->translate('on_reserve')
-                : $this->translate('Not On Reserve'),
-            'callnumber' => false
-        ];
-    }
-
-    /**
-     * Check one or more records to see if they are saved in one of the user's list.
-     *
-     * @return \Zend\Http\Response
-     */
-    protected function getSaveStatusesAjax()
-    {
-        $this->disableSessionWrites();  // avoid session write timing bug
-        // check if user is logged in
-        $user = $this->getUser();
-        if (!$user) {
-            return $this->output(
-                $this->translate('You must be logged in first'),
-                self::STATUS_NEED_AUTH,
-                401
-            );
-        }
-
-        // loop through each ID check if it is saved to any of the user's lists
-        $ids = $this->params()->fromPost('id', $this->params()->fromQuery('id', []));
-        $sources = $this->params()->fromPost(
-            'source', $this->params()->fromQuery('source', [])
-        );
-        if (!is_array($ids) || !is_array($sources)) {
-            return $this->output(
-                $this->translate('Argument must be array.'),
-                self::STATUS_ERROR,
-                400
-            );
-        }
-        $result = $checked = [];
-        foreach ($ids as $i => $id) {
-            $source = $sources[$i] ?? DEFAULT_SEARCH_BACKEND;
-            $selector = $source . '|' . $id;
-
-            // We don't want to bother checking the same ID more than once, so
-            // use the $checked flag array to avoid duplicates:
-            if (isset($checked[$selector])) {
-                continue;
-            }
-            $checked[$selector] = true;
-
-            $data = $user->getSavedData($id, null, $source);
-            $result[$selector] = [];
-            if ($data && count($data) > 0) {
-                // if this item was saved, add it to the list of saved items.
-                foreach ($data as $list) {
-                    $result[$selector][] = [
-                        'list_url' => $this->url()->fromRoute(
-                            'userList',
-                            ['id' => $list->list_id]
-                        ),
-                        'list_title' => $list->list_title
-                    ];
-                }
-            }
-        }
-        return $this->output($result, self::STATUS_OK);
-    }
-
-    /**
-     * Send output data and exit.
-     *
-     * @param mixed  $data     The response data
-     * @param string $status   Status of the request
-     * @param int    $httpCode A custom HTTP Status Code
-     *
-     * @return \Zend\Http\Response
-     * @throws \Exception
-     */
-    protected function output($data, $status, $httpCode = null)
-    {
-        $response = $this->getResponse();
-        $headers = $response->getHeaders();
-        $headers->addHeaderLine('Cache-Control', 'no-cache, must-revalidate');
-        $headers->addHeaderLine('Expires', 'Mon, 26 Jul 1997 05:00:00 GMT');
-        if ($httpCode !== null) {
-            $response->setStatusCode($httpCode);
-        }
-        if ($this->outputMode == 'json') {
-            $headers->addHeaderLine('Content-type', 'application/javascript');
-            $output = ['data' => $data, 'status' => $status];
-            if ('development' == APPLICATION_ENV && count(self::$php_errors) > 0) {
-                $output['php_errors'] = self::$php_errors;
-            }
-            $response->setContent(json_encode($output));
-            return $response;
-        } elseif ($this->outputMode == 'plaintext') {
-            $headers->addHeaderLine('Content-type', 'text/plain');
-            $response->setContent($data ? $status . " $data" : $status);
-            return $response;
-        } else {
-            throw new \Exception('Unsupported output mode: ' . $this->outputMode);
-        }
-    }
-
-    /**
-     * Store the errors for later, to be added to the output
-     *
-     * @param string $errno   Error code number
-     * @param string $errstr  Error message
-     * @param string $errfile File where error occurred
-     * @param string $errline Line number of error
-     *
-     * @return bool           Always true to cancel default error handling
-     */
-    public static function storeError($errno, $errstr, $errfile, $errline)
-    {
-        self::$php_errors[] = "ERROR [$errno] - " . $errstr . "<br />\n"
-            . " Occurred in " . $errfile . " on line " . $errline . ".";
-        return true;
-    }
-
-    /**
-     * Tag a record.
-     *
-     * @return \Zend\Http\Response
-     */
-    protected function tagRecordAjax()
-    {
-        $user = $this->getUser();
-        if ($user === false) {
-            return $this->output(
-                $this->translate('You must be logged in first'),
-                self::STATUS_NEED_AUTH,
-                401
-            );
-        }
-        // empty tag
-        try {
-            $driver = $this->getRecordLoader()->load(
-                $this->params()->fromPost('id'),
-                $this->params()->fromPost('source', DEFAULT_SEARCH_BACKEND)
-            );
-            $tag = $this->params()->fromPost('tag', '');
-            $tagParser = $this->serviceLocator->get('VuFind\Tags');
-            if (strlen($tag) > 0) { // don't add empty tags
-                if ('false' === $this->params()->fromPost('remove', 'false')) {
-                    $driver->addTags($user, $tagParser->parse($tag));
-                } else {
-                    $driver->deleteTags($user, $tagParser->parse($tag));
-                }
-            }
-        } catch (\Exception $e) {
-            return $this->output(
-                ('development' == APPLICATION_ENV) ? $e->getMessage() : 'Failed',
-                self::STATUS_ERROR,
-                500
-            );
-        }
-
-        return $this->output($this->translate('Done'), self::STATUS_OK);
-    }
-
-    /**
-     * Get all tags for a record.
-     *
-     * @return \Zend\Http\Response
-     */
-    protected function getRecordTagsAjax()
-    {
-        $user = $this->getUser();
-        $is_me_id = null === $user ? null : $user->id;
-        // Retrieve from database:
-        $tagTable = $this->getTable('Tags');
-        $tags = $tagTable->getForResource(
-            $this->params()->fromQuery('id'),
-            $this->params()->fromQuery('source', DEFAULT_SEARCH_BACKEND),
-            0, null, null, 'count', $is_me_id
-        );
-
-        // Build data structure for return:
-        $tagList = [];
-        foreach ($tags as $tag) {
-            $tagList[] = [
-                'tag'   => $tag->tag,
-                'cnt'   => $tag->cnt,
-                'is_me' => !empty($tag->is_me)
-            ];
-        }
-
-        // Set layout to render content only:
-        $this->layout()->setTemplate('layout/lightbox');
-        $view = $this->createViewModel(
-            [
-                'tagList' => $tagList,
-                'loggedin' => null !== $user
-            ]
-        );
-        $view->setTemplate('record/taglist');
-        return $view;
-    }
-
-    /**
-     * Get record for integrated list view.
-     *
-     * @return \Zend\Http\Response
-     */
-    protected function getRecordDetailsAjax()
-    {
-        $driver = $this->getRecordLoader()->load(
-            $this->params()->fromQuery('id'),
-            $this->params()->fromQuery('source')
-        );
-        $viewtype = preg_replace(
-            '/\W/', '',
-            trim(strtolower($this->params()->fromQuery('type')))
-        );
-        $request = $this->getRequest();
-        $config = $this->serviceLocator->get('Config');
-
-        $recordTabPlugin = $this->serviceLocator
-            ->get('VuFind\RecordTab\PluginManager');
-        $details = $recordTabPlugin
-            ->getTabDetailsForRecord(
-                $driver,
-                $config['vufind']['recorddriver_tabs'],
-                $request,
-                'Information'
-            );
-
-        $rtpm = $this->serviceLocator->get('VuFind\RecordTab\PluginManager');
-        $html = $this->getViewRenderer()
-            ->render(
-                "record/ajaxview-" . $viewtype . ".phtml",
-                [
-                    'defaultTab' => $details['default'],
-                    'driver' => $driver,
-                    'tabs' => $details['tabs'],
-                    'backgroundTabs' => $rtpm->getBackgroundTabNames(
-                        $driver, $this->getRecordTabConfig()
-                    )
-                ]
-            );
-        return $this->output($html, self::STATUS_OK);
-    }
-
-    /**
-     * AJAX for timeline feature (PubDateVisAjax)
-     *
-     * @param array $fields Solr fields to retrieve data from
-     *
-     * @author Chris Hallberg <crhallberg@gmail.com>
-     * @author Till Kinstler <kinstler@gbv.de>
-     *
-     * @return \Zend\Http\Response
-     */
-    protected function getVisDataAjax($fields = ['publishDate'])
-    {
-        $this->disableSessionWrites();  // avoid session write timing bug
-        $results = $this->getResultsManager()->get('Solr');
-        $params = $results->getParams();
-        $params->initFromRequest($this->getRequest()->getQuery());
-        foreach ($this->params()->fromQuery('hf', []) as $hf) {
-            $params->addHiddenFilter($hf);
-        }
-        $params->getOptions()->disableHighlighting();
-        $params->getOptions()->spellcheckEnabled(false);
-        $filters = $params->getFilters();
-        $dateFacets = $this->params()->fromQuery('facetFields');
-        $dateFacets = empty($dateFacets) ? [] : explode(':', $dateFacets);
-        $fields = $this->processDateFacets($filters, $dateFacets, $results);
-        $facets = $this->processFacetValues($fields, $results);
-        foreach ($fields as $field => $val) {
-            $facets[$field]['min'] = $val[0] > 0 ? $val[0] : 0;
-            $facets[$field]['max'] = $val[1] > 0 ? $val[1] : 0;
-            $facets[$field]['removalURL']
-                = $results->getUrlQuery()->removeFacet(
-                    $field,
-                    $filters[$field][0] ?? null
-                )->getParams(false);
-        }
-        return $this->output($facets, self::STATUS_OK);
-    }
-
-    /**
-     * Support method for getVisData() -- extract details from applied filters.
-     *
-     * @param array                       $filters    Current filter list
-     * @param array                       $dateFacets Objects containing the date
-     * ranges
-     * @param \VuFind\Search\Solr\Results $results    Search results object
-     *
-     * @return array
-     */
-    protected function processDateFacets($filters, $dateFacets, $results)
-    {
-        $result = [];
-        foreach ($dateFacets as $current) {
-            $from = $to = '';
-            if (isset($filters[$current])) {
-                foreach ($filters[$current] as $filter) {
-                    if (preg_match('/\[[\d\*]+ TO [\d\*]+\]/', $filter)) {
-                        $range = explode(' TO ', trim($filter, '[]'));
-                        $from = $range[0] == '*' ? '' : $range[0];
-                        $to = $range[1] == '*' ? '' : $range[1];
-                        break;
-                    }
-                }
-            }
-            $result[$current] = [$from, $to];
-            $result[$current]['label']
-                = $results->getParams()->getFacetLabel($current);
-        }
-        return $result;
-    }
-
-    /**
-     * Support method for getVisData() -- filter bad values from facet lists.
-     *
-     * @param array                       $fields  Processed date information from
-     * processDateFacets
-     * @param \VuFind\Search\Solr\Results $results Search results object
-     *
-     * @return array
-     */
-    protected function processFacetValues($fields, $results)
-    {
-        $facets = $results->getFullFieldFacets(array_keys($fields));
-        $retVal = [];
-        foreach ($facets as $field => $values) {
-            $newValues = ['data' => []];
-            foreach ($values['data']['list'] as $current) {
-                // Only retain numeric values!
-                if (preg_match("/^[0-9]+$/", $current['value'])) {
-                    $newValues['data'][]
-                        = [$current['value'], $current['count']];
-                }
-            }
-            $retVal[$field] = $newValues;
-        }
-        return $retVal;
-    }
-
-    /**
-     * Get Autocomplete suggestions.
-     *
-     * @return \Zend\Http\Response
-     */
-    protected function getACSuggestionsAjax()
-    {
-        $this->disableSessionWrites();  // avoid session write timing bug
-        $query = $this->getRequest()->getQuery();
-        $suggester = $this->serviceLocator->get('VuFind\Autocomplete\Suggester');
-        return $this->output($suggester->getSuggestions($query), self::STATUS_OK);
-    }
-
-    /**
-     * Check Request is Valid
-     *
-     * @return \Zend\Http\Response
-     */
-    protected function checkRequestIsValidAjax()
-    {
-        $this->disableSessionWrites();  // avoid session write timing bug
-        $id = $this->params()->fromQuery('id');
-        $data = $this->params()->fromQuery('data');
-        $requestType = $this->params()->fromQuery('requestType');
-        if (empty($id) || empty($data)) {
-            return $this->output(
-                $this->translate('bulk_error_missing'),
-                self::STATUS_ERROR,
-                400
-            );
-        }
-        // check if user is logged in
-        $user = $this->getUser();
-        if (!$user) {
-            return $this->output(
-                $this->translate('You must be logged in first'),
-                self::STATUS_NEED_AUTH,
-                401
-            );
-        }
-
-        try {
-            $catalog = $this->getILS();
-            $patron = $this->getILSAuthenticator()->storedCatalogLogin();
-            if ($patron) {
-                switch ($requestType) {
-                case 'ILLRequest':
-                    $results = $catalog->checkILLRequestIsValid($id, $data, $patron);
-                    if (is_array($results)) {
-                        $msg = $results['status'];
-                        $results = $results['valid'];
-                    } else {
-                        $msg = $results
-                            ? 'ill_request_place_text' : 'ill_request_error_blocked';
-                    }
-                    break;
-                case 'StorageRetrievalRequest':
-                    $results = $catalog->checkStorageRetrievalRequestIsValid(
-                        $id, $data, $patron
-                    );
-                    if (is_array($results)) {
-                        $msg = $results['status'];
-                        $results = $results['valid'];
-                    } else {
-                        $msg = $results ? 'storage_retrieval_request_place_text'
-                            : 'storage_retrieval_request_error_blocked';
-                    }
-                    break;
-                default:
-                    $results = $catalog->checkRequestIsValid($id, $data, $patron);
-                    if (is_array($results)) {
-                        $msg = $results['status'];
-                        $results = $results['valid'];
-                    } else {
-                        $msg = $results ? 'request_place_text'
-                            : 'hold_error_blocked';
-                        break;
-                    }
-                }
-                return $this->output(
-                    ['status' => $results, 'msg' => $this->translate($msg)],
-                    self::STATUS_OK
-                );
-            }
-        } catch (\Exception $e) {
-            // Do nothing -- just fail through to the error message below.
-        }
-
-        return $this->output(
-            $this->translate('An error has occurred'), self::STATUS_ERROR, 500
-        );
-    }
-
-    /**
-     * Comment on a record.
-     *
-     * @return \Zend\Http\Response
-     */
-    protected function commentRecordAjax()
-    {
-        // Make sure comments are enabled:
-        if (!$this->commentsEnabled()) {
-            return $this->output(
-                $this->translate('Comments disabled'),
-                self::STATUS_ERROR,
-                403
-            );
-        }
-
-        $user = $this->getUser();
-        if ($user === false) {
-            return $this->output(
-                $this->translate('You must be logged in first'),
-                self::STATUS_NEED_AUTH,
-                401
-            );
-        }
-
-        $id = $this->params()->fromPost('id');
-        $comment = $this->params()->fromPost('comment');
-        if (empty($id) || empty($comment)) {
-            return $this->output(
-                $this->translate('bulk_error_missing'),
-                self::STATUS_ERROR,
-                400
-            );
-        }
-
-        $useCaptcha = $this->recaptcha()->active('userComments');
-        $this->recaptcha()->setErrorMode('none');
-        if (!$this->formWasSubmitted('comment', $useCaptcha)) {
-            return $this->output(
-                $this->translate('recaptcha_not_passed'),
-                self::STATUS_ERROR,
-                403
-            );
-        }
-
-        $table = $this->getTable('Resource');
-        $resource = $table->findResource(
-            $id, $this->params()->fromPost('source', DEFAULT_SEARCH_BACKEND)
-        );
-        $id = $resource->addComment($comment, $user);
-
-        return $this->output($id, self::STATUS_OK);
-    }
-
-    /**
-     * Delete a comment on a record.
-     *
-     * @return \Zend\Http\Response
-     */
-    protected function deleteRecordCommentAjax()
-    {
-        // Make sure comments are enabled:
-        if (!$this->commentsEnabled()) {
-            return $this->output(
-                $this->translate('Comments disabled'),
-                self::STATUS_ERROR,
-                403
-            );
-        }
-
-        $user = $this->getUser();
-        if ($user === false) {
-            return $this->output(
-                $this->translate('You must be logged in first'),
-                self::STATUS_NEED_AUTH,
-                401
-            );
-        }
-
-        $id = $this->params()->fromQuery('id');
-        if (empty($id)) {
-            return $this->output(
-                $this->translate('bulk_error_missing'),
-                self::STATUS_ERROR,
-                400
-            );
-        }
-        $table = $this->getTable('Comments');
-        if (!$table->deleteIfOwnedByUser($id, $user)) {
-            return $this->output(
-                $this->translate('edit_list_fail'),
-                self::STATUS_ERROR,
-                403
-            );
-        }
-
-        return $this->output($this->translate('Done'), self::STATUS_OK);
-    }
-
-    /**
-     * Get list of comments for a record as HTML.
-     *
-     * @return \Zend\Http\Response
-     */
-    protected function getRecordCommentsAsHTMLAjax()
-    {
-        $driver = $this->getRecordLoader()->load(
-            $this->params()->fromQuery('id'),
-            $this->params()->fromQuery('source', DEFAULT_SEARCH_BACKEND)
-        );
-        $html = $this->getViewRenderer()
-            ->render('record/comments-list.phtml', ['driver' => $driver]);
-        return $this->output($html, self::STATUS_OK);
-    }
-
-    /**
-     * Fetch Links from resolver given an OpenURL and format as HTML
-     * and output the HTML content in JSON object.
-     *
-     * @return \Zend\Http\Response
-     * @author Graham Seaman <Graham.Seaman@rhul.ac.uk>
-     */
-    protected function getResolverLinksAjax()
-    {
-        $this->disableSessionWrites();  // avoid session write timing bug
-        $openUrl = $this->params()->fromQuery('openurl', '');
-        $searchClassId = $this->params()->fromQuery('searchClassId', '');
-
-        $config = $this->getConfig();
-        $resolverType = isset($config->OpenURL->resolver)
-            ? $config->OpenURL->resolver : 'other';
-        $pluginManager = $this->serviceLocator
-            ->get('VuFind\Resolver\Driver\PluginManager');
-        if (!$pluginManager->has($resolverType)) {
-            return $this->output(
-                $this->translate("Could not load driver for $resolverType"),
-                self::STATUS_ERROR,
-                500
-            );
-        }
-        $resolver = new \VuFind\Resolver\Connection(
-            $pluginManager->get($resolverType)
-        );
-        if (isset($config->OpenURL->resolver_cache)) {
-            $resolver->enableCache($config->OpenURL->resolver_cache);
-        }
-        $result = $resolver->fetchLinks($openUrl);
-
-        // Sort the returned links into categories based on service type:
-        $electronic = $print = $services = [];
-        foreach ($result as $link) {
-            switch ($link['service_type'] ?? '') {
-            case 'getHolding':
-                $print[] = $link;
-                break;
-            case 'getWebService':
-                $services[] = $link;
-                break;
-            case 'getDOI':
-                // Special case -- modify DOI text for special display:
-                $link['title'] = $this->translate('Get full text');
-                $link['coverage'] = '';
-            case 'getFullTxt':
-            default:
-                $electronic[] = $link;
-                break;
-            }
-        }
-
-        // Get the OpenURL base:
-        if (isset($config->OpenURL) && isset($config->OpenURL->url)) {
-            // Trim off any parameters (for legacy compatibility -- default config
-            // used to include extraneous parameters):
-            list($base) = explode('?', $config->OpenURL->url);
-        } else {
-            $base = false;
-        }
-
-        $moreOptionsLink = $resolver->supportsMoreOptionsLink()
-            ? $resolver->getResolverUrl($openUrl) : '';
-
-        // Render the links using the view:
-        $view = [
-            'openUrlBase' => $base, 'openUrl' => $openUrl, 'print' => $print,
-            'electronic' => $electronic, 'services' => $services,
-            'searchClassId' => $searchClassId,
-            'moreOptionsLink' => $moreOptionsLink
-        ];
-        $html = $this->getViewRenderer()->render('ajax/resolverLinks.phtml', $view);
-
-        // output HTML encoded in JSON object
-        return $this->output($html, self::STATUS_OK);
-    }
-
-    /**
-     * Keep Alive
-     *
-     * This is responsible for keeping the session alive whenever called
-     * (via JavaScript)
-     *
-     * @return \Zend\Http\Response
-     */
-    protected function keepAliveAjax()
-    {
-        // Request ID from session to mark it active
-        $this->serviceLocator->get('Zend\Session\SessionManager')->getId();
-        return $this->output(true, self::STATUS_OK);
-    }
-
-    /**
-     * Get pick up locations for a library
-     *
-     * @return \Zend\Http\Response
-     */
-    protected function getLibraryPickupLocationsAjax()
-    {
-        $this->disableSessionWrites();  // avoid session write timing bug
-        $id = $this->params()->fromQuery('id');
-        $pickupLib = $this->params()->fromQuery('pickupLib');
-        if (null === $id || null === $pickupLib) {
-            return $this->output(
-                $this->translate('bulk_error_missing'),
-                self::STATUS_ERROR,
-                400
-            );
-        }
-        // check if user is logged in
-        $user = $this->getUser();
-        if (!$user) {
-            return $this->output(
-                $this->translate('You must be logged in first'),
-                self::STATUS_NEED_AUTH,
-                401
-            );
-        }
-
-        try {
-            $catalog = $this->getILS();
-            $patron = $this->getILSAuthenticator()->storedCatalogLogin();
-            if ($patron) {
-                $results = $catalog->getILLPickupLocations($id, $pickupLib, $patron);
-                foreach ($results as &$result) {
-                    if (isset($result['name'])) {
-                        $result['name'] = $this->translate(
-                            'location_' . $result['name'],
-                            [],
-                            $result['name']
-                        );
-                    }
-                }
-                return $this->output(['locations' => $results], self::STATUS_OK);
-            }
-        } catch (\Exception $e) {
-            // Do nothing -- just fail through to the error message below.
-        }
-
-        return $this->output(
-            $this->translate('An error has occurred'), self::STATUS_ERROR, 500
-        );
-    }
-
-    /**
-     * Get pick up locations for a request group
-     *
-     * @return \Zend\Http\Response
-     */
-    protected function getRequestGroupPickupLocationsAjax()
-    {
-        $this->disableSessionWrites();  // avoid session write timing bug
-        $id = $this->params()->fromQuery('id');
-        $requestGroupId = $this->params()->fromQuery('requestGroupId');
-        if (null === $id || null === $requestGroupId) {
-            return $this->output(
-                $this->translate('bulk_error_missing'),
-                self::STATUS_ERROR,
-                400
-            );
-        }
-        // check if user is logged in
-        $user = $this->getUser();
-        if (!$user) {
-            return $this->output(
-                $this->translate('You must be logged in first'),
-                self::STATUS_NEED_AUTH,
-                401
-            );
-        }
-
-        try {
-            $catalog = $this->getILS();
-            $patron = $this->getILSAuthenticator()->storedCatalogLogin();
-            if ($patron) {
-                $details = [
-                    'id' => $id,
-                    'requestGroupId' => $requestGroupId
-                ];
-                $results = $catalog->getPickupLocations($patron, $details);
-                foreach ($results as &$result) {
-                    if (isset($result['locationDisplay'])) {
-                        $result['locationDisplay'] = $this->translate(
-                            'location_' . $result['locationDisplay'],
-                            [],
-                            $result['locationDisplay']
-                        );
-                    }
-                }
-                return $this->output(['locations' => $results], self::STATUS_OK);
-            }
-        } catch (\Exception $e) {
-            // Do nothing -- just fail through to the error message below.
-        }
-
-        return $this->output(
-            $this->translate('An error has occurred'), self::STATUS_ERROR, 500
-        );
-    }
-
-    /**
-     * Get hierarchical facet data for jsTree
-     *
-     * Parameters:
-     * facetName  The facet to retrieve
-     * facetSort  By default all facets are sorted by count. Two values are available
-     * for alternative sorting:
-     *   top = sort the top level alphabetically, rest by count
-     *   all = sort all levels alphabetically
-     *
-     * @return \Zend\Http\Response
-     */
-    protected function getFacetDataAjax()
-    {
-        $this->disableSessionWrites();  // avoid session write timing bug
-
-        $facet = $this->params()->fromQuery('facetName');
-        $sort = $this->params()->fromQuery('facetSort');
-        $operator = $this->params()->fromQuery('facetOperator');
-
-        $results = $this->getResultsManager()->get('Solr');
-        $params = $results->getParams();
-        $params->addFacet($facet, null, $operator === 'OR');
-        $params->initFromRequest($this->getRequest()->getQuery());
-
-        $facets = $results->getFullFieldFacets([$facet], false, -1, 'count');
-        if (empty($facets[$facet]['data']['list'])) {
-            return $this->output([], self::STATUS_OK);
-        }
-
-        $facetList = $facets[$facet]['data']['list'];
-
-        $facetHelper = $this->serviceLocator
-            ->get('VuFind\Search\Solr\HierarchicalFacetHelper');
-        if (!empty($sort)) {
-            $facetHelper->sortFacetList($facetList, $sort == 'top');
-        }
-
-        return $this->output(
-            $facetHelper->buildFacetArray(
-                $facet, $facetList, $results->getUrlQuery()
-            ),
-            self::STATUS_OK
-        );
+        return $this->callAjaxMethod('recommend', 'text/html');
     }
 
     /**
@@ -1318,83 +85,8 @@ class AjaxController extends AbstractBase
      *
      * @return \Zend\Http\Response
      */
-    protected function systemStatusAction()
-    {
-        $this->outputMode = 'plaintext';
-
-        // Check system status
-        $config = $this->getConfig();
-        if (!empty($config->System->healthCheckFile)
-            && file_exists($config->System->healthCheckFile)
-        ) {
-            return $this->output(
-                'Health check file exists', self::STATUS_ERROR, 503
-            );
-        }
-
-        // Test search index
-        try {
-            $results = $this->getResultsManager()->get('Solr');
-            $params = $results->getParams();
-            $params->setQueryIDs(['healthcheck']);
-            $results->performAndProcessSearch();
-        } catch (\Exception $e) {
-            return $this->output(
-                'Search index error: ' . $e->getMessage(), self::STATUS_ERROR, 500
-            );
-        }
-
-        // Test database connection
-        try {
-            $sessionTable = $this->getTable('Session');
-            $sessionTable->getBySessionId('healthcheck', false);
-        } catch (\Exception $e) {
-            return $this->output(
-                'Database error: ' . $e->getMessage(), self::STATUS_ERROR, 500
-            );
-        }
-
-        // This may be called frequently, don't leave sessions dangling
-        $this->serviceLocator->get('Zend\Session\SessionManager')->destroy();
-
-        return $this->output('', self::STATUS_OK);
-    }
-
-    /**
-     * Convenience method for accessing results
-     *
-     * @return \VuFind\Search\Results\PluginManager
-     */
-    protected function getResultsManager()
-    {
-        return $this->serviceLocator->get('VuFind\Search\Results\PluginManager');
-    }
-
-    /**
-     * Get Ils Status
-     *
-     * This will check the ILS for being online and will return the ils-offline
-     * template upon failure.
-     *
-     * @return \Zend\Http\Response
-     * @author André Lahmann <lahmann@ub.uni-leipzig.de>
-     */
-    protected function getIlsStatusAjax()
+    public function systemStatusAction()
     {
-        $this->disableSessionWrites();  // avoid session write timing bug
-        if ($this->getILS()->getOfflineMode(true) == 'ils-offline') {
-            $offlineModeMsg = $this->params()->fromPost(
-                'offlineModeMsg',
-                $this->params()->fromQuery('offlineModeMsg')
-            );
-            return $this->output(
-                $this->getViewRenderer()->render(
-                    'Helpers/ils-offline.phtml',
-                    compact('offlineModeMsg')
-                ),
-                self::STATUS_OK
-            );
-        }
-        return $this->output('', self::STATUS_OK);
+        return $this->callAjaxMethod('systemStatus', 'text/plain');
     }
 }
diff --git a/module/VuFind/src/VuFind/Controller/AjaxControllerFactory.php b/module/VuFind/src/VuFind/Controller/AjaxControllerFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..ec10195410780fb3523119875594cde8e0c39819
--- /dev/null
+++ b/module/VuFind/src/VuFind/Controller/AjaxControllerFactory.php
@@ -0,0 +1,67 @@
+<?php
+/**
+ * Ajax controller factory.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Controller
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\Controller;
+
+use Interop\Container\ContainerInterface;
+use Zend\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * Ajax controller factory.
+ *
+ * @category VuFind
+ * @package  Controller
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class AjaxControllerFactory implements FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options sent to factory.');
+        }
+        $pm = $container->get('VuFind\AjaxHandler\PluginManager');
+        return new $requestedName($pm);
+    }
+}
diff --git a/module/VuFind/src/VuFind/Controller/AjaxResponseTrait.php b/module/VuFind/src/VuFind/Controller/AjaxResponseTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..3d3c066f88da96cfb09e5ca645b4c9173479db5b
--- /dev/null
+++ b/module/VuFind/src/VuFind/Controller/AjaxResponseTrait.php
@@ -0,0 +1,188 @@
+<?php
+/**
+ * Trait to allow AJAX response generation.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Controller
+ * @author   Chris Hallberg <challber@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
+ */
+namespace VuFind\Controller;
+
+use VuFind\AjaxHandler\AjaxHandlerInterface as Ajax;
+use VuFind\AjaxHandler\PluginManager;
+
+/**
+ * Trait to allow AJAX response generation.
+ *
+ * Dependencies:
+ * - \VuFind\I18n\Translator\TranslatorAwareTrait
+ * - Injection of $this->ajaxManager (for some functionality)
+ *
+ * @category VuFind
+ * @package  Controller
+ * @author   Chris Hallberg <challber@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
+ */
+trait AjaxResponseTrait
+{
+    /**
+     * Array of PHP errors captured during execution. Add this code to your
+     * constructor in order to populate the array:
+     *     set_error_handler([static::class, 'storeError']);
+     *
+     * @var array
+     */
+    protected static $php_errors = [];
+
+    /**
+     * AJAX Handler plugin manager
+     *
+     * @var PluginManager;
+     */
+    protected $ajaxManager = null;
+
+    /**
+     * Format the content of the AJAX response based on the response type.
+     *
+     * @param string $type   Content-type of output
+     * @param mixed  $data   The response data
+     * @param string $status Status of the request
+     *
+     * @return string
+     * @throws \Exception
+     */
+    protected function formatContent($type, $data, $status)
+    {
+        switch ($type) {
+        case 'application/javascript':
+            $output = compact('data', 'status');
+            if ('development' == APPLICATION_ENV && count(self::$php_errors) > 0) {
+                $output['php_errors'] = self::$php_errors;
+            }
+            return json_encode($output);
+        case 'text/plain':
+            return $data ? $status . " $data" : $status;
+        case 'text/html':
+            return $data ?: '';
+        default:
+            throw new \Exception("Unsupported content type: $type");
+        }
+    }
+
+    /**
+     * Send output data and exit.
+     *
+     * @param string $type     Content type to output
+     * @param mixed  $data     The response data
+     * @param string $status   Status of the request
+     * @param int    $httpCode A custom HTTP Status Code
+     *
+     * @return \Zend\Http\Response
+     * @throws \Exception
+     */
+    protected function getAjaxResponse($type, $data, $status = Ajax::STATUS_OK,
+        $httpCode = null
+    ) {
+        $response = $this->getResponse();
+        $headers = $response->getHeaders();
+        $headers->addHeaderLine('Content-type', $type);
+        $headers->addHeaderLine('Cache-Control', 'no-cache, must-revalidate');
+        $headers->addHeaderLine('Expires', 'Mon, 26 Jul 1997 05:00:00 GMT');
+        if ($httpCode !== null) {
+            $response->setStatusCode($httpCode);
+        }
+        $response->setContent($this->formatContent($type, $data, $status));
+        return $response;
+    }
+
+    /**
+     * Turn an exception into error response.
+     *
+     * @param string     $type Content type to output
+     * @param \Exception $e    Exception to output.
+     *
+     * @return \Zend\Http\Response
+     */
+    protected function getExceptionResponse($type, \Exception $e)
+    {
+        $debugMsg = ('development' == APPLICATION_ENV)
+            ? ': ' . $e->getMessage() : '';
+        return $this->getAjaxResponse(
+            $type,
+            $this->translate('An error has occurred') . $debugMsg,
+            Ajax::STATUS_ERROR,
+            500
+        );
+    }
+
+    /**
+     * Call an AJAX method and turn the result into a response.
+     *
+     * @param string $method AJAX method to call
+     * @param string $type   Content type to output
+     *
+     * @return \Zend\Http\Response
+     */
+    protected function callAjaxMethod($method, $type = 'application/javascript')
+    {
+        // Check the AJAX handler plugin manager for the method.
+        if (!$this->ajaxManager) {
+            throw new \Exception('AJAX Handler Plugin Manager missing.');
+        }
+        if ($this->ajaxManager->has($method)) {
+            try {
+                $handler = $this->ajaxManager->get($method);
+                return $this->getAjaxResponse(
+                    $type, ...$handler->handleRequest($this->params())
+                );
+            } catch (\Exception $e) {
+                return $this->getExceptionResponse($type, $e);
+            }
+        }
+
+        // If we got this far, we can't handle the requested method:
+        return $this->getAjaxResponse(
+            $type,
+            $this->translate('Invalid Method'),
+            Ajax::STATUS_ERROR,
+            400
+        );
+    }
+
+    /**
+     * Store the errors for later, to be added to the output
+     *
+     * @param string $errno   Error code number
+     * @param string $errstr  Error message
+     * @param string $errfile File where error occurred
+     * @param string $errline Line number of error
+     *
+     * @return bool           Always true to cancel default error handling
+     */
+    public static function storeError($errno, $errstr, $errfile, $errline)
+    {
+        self::$php_errors[] = "ERROR [$errno] - " . $errstr . "<br />\n"
+            . " Occurred in " . $errfile . " on line " . $errline . ".";
+        return true;
+    }
+}
diff --git a/module/VuFind/src/VuFind/Controller/CombinedController.php b/module/VuFind/src/VuFind/Controller/CombinedController.php
index 02fb5c01caee808a686c7e07a5d4b8eccce653d6..5566e6df1a81edc1f2253989903813c6eaa0e068 100644
--- a/module/VuFind/src/VuFind/Controller/CombinedController.php
+++ b/module/VuFind/src/VuFind/Controller/CombinedController.php
@@ -40,6 +40,8 @@ use Zend\ServiceManager\ServiceLocatorInterface;
  */
 class CombinedController extends AbstractSearch
 {
+    use AjaxResponseTrait;
+
     /**
      * Constructor
      *
@@ -94,13 +96,6 @@ class CombinedController extends AbstractSearch
         $this->adjustQueryForSettings($settings);
         $settings['view'] = $this->forwardTo($controller, $action);
 
-        // Send response:
-        $response = $this->getResponse();
-        $headers = $response->getHeaders();
-        $headers->addHeaderLine('Content-type', 'text/html');
-        $headers->addHeaderLine('Cache-Control', 'no-cache, must-revalidate');
-        $headers->addHeaderLine('Expires', 'Mon, 26 Jul 1997 05:00:00 GMT');
-
         // Should we suppress content due to emptiness?
         if (isset($settings['hide_if_empty']) && $settings['hide_if_empty']
             && $settings['view']->results->getResultTotal() == 0
@@ -127,8 +122,7 @@ class CombinedController extends AbstractSearch
                 $viewParams
             );
         }
-        $response->setContent($html);
-        return $response;
+        return $this->getAjaxResponse('text/html', $html);
     }
 
     /**
diff --git a/module/VuFind/src/VuFindTest/Unit/AjaxHandlerTest.php b/module/VuFind/src/VuFindTest/Unit/AjaxHandlerTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..e8ddeef1177828c42b4bc28eb9268ba01356587f
--- /dev/null
+++ b/module/VuFind/src/VuFindTest/Unit/AjaxHandlerTest.php
@@ -0,0 +1,123 @@
+<?php
+/**
+ * Base class for AjaxHandler tests.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Page
+ */
+namespace VuFindTest\Unit;
+
+use Zend\Http\Request;
+use Zend\Mvc\Controller\Plugin\Params;
+use Zend\ServiceManager\ServiceManager;
+use Zend\Stdlib\Parameters;
+
+/**
+ * Base class for AjaxHandler tests.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Page
+ */
+abstract class AjaxHandlerTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Create a mock service.
+     *
+     * @param string $name    Name of class implementing service
+     * @param array  $methods Methods to mock
+     *
+     * @return object
+     */
+    protected function getMockService($name, $methods = [])
+    {
+        return $this->getMockBuilder($name)
+            ->disableOriginalConstructor()
+            ->setMethods($methods)
+            ->getMock();
+    }
+
+    /**
+     * Create mock user object.
+     *
+     * @return \VuFind\Db\Row\User
+     */
+    protected function getMockUser()
+    {
+        return $this->getMockService('VuFind\Db\Row\User');
+    }
+
+    /**
+     * Add a service to a container.
+     *
+     * @param ServiceManager $container Container to populate
+     * @param string         $name      Name of service to create
+     * @param mixed          $value     Value of service (or null to create mock)
+     *
+     * @return void
+     */
+    protected function addServiceToContainer($container, $name, $value = null)
+    {
+        $container->setService($name, $value ?? $this->getMockService($name));
+    }
+
+    /**
+     * Get an auth manager with a value set for isLoggedIn.
+     *
+     * @param \VuFind\Db\Row\User $user Return value for isLoggedIn()
+     *
+     * @return \VuFind\Auth\Manager
+     */
+    protected function getMockAuthManager($user)
+    {
+        $authManager = $this->getMockService('VuFind\Auth\Manager', ['isLoggedIn']);
+        $authManager->expects($this->any())->method('isLoggedIn')
+            ->will($this->returnValue($user));
+        return $authManager;
+    }
+
+    /**
+     * Build a Params helper for testing.
+     *
+     * @param array $get  GET parameters
+     * @param array $post POST parameters
+     *
+     * @return Params
+     */
+    protected function getParamsHelper($get = [], $post = [])
+    {
+        $params = new Params();
+        $request = new Request();
+        $request->setQuery(new Parameters($get));
+        $request->setPost(new Parameters($post));
+        $controller = $this->getMockService(
+            'Zend\Mvc\Controller\AbstractActionController', ['getRequest']
+        );
+        $controller->expects($this->any())->method('getRequest')
+            ->will($this->returnValue($request));
+        $params->setController($controller);
+        return $params;
+    }
+}
diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/AjaxHandler/CheckRequestIsValidTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/AjaxHandler/CheckRequestIsValidTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..e680a81025cfc886c4de4faae76e829e5529b115
--- /dev/null
+++ b/module/VuFind/tests/unit-tests/src/VuFindTest/AjaxHandler/CheckRequestIsValidTest.php
@@ -0,0 +1,172 @@
+<?php
+/**
+ * CheckRequestIsValid test class.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Page
+ */
+namespace VuFindTest\AjaxHandler;
+
+use VuFind\AjaxHandler\AbstractIlsAndUserActionFactory;
+use VuFind\AjaxHandler\CheckRequestIsValid;
+use VuFind\Auth\ILSAuthenticator;
+use VuFind\ILS\Connection;
+use VuFind\Session\Settings;
+use Zend\ServiceManager\ServiceManager;
+
+/**
+ * CheckRequestIsValid test class.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Page
+ */
+class CheckRequestIsValidTest extends \VuFindTest\Unit\AjaxHandlerTest
+{
+    /**
+     * Set up a CheckRequestIsValid handler for testing.
+     *
+     * @param Settings         $ss      Session settings (or null for default)
+     * @param Connection       $ils     ILS connection (or null for default)
+     * @param ILSAuthenticator $ilsAuth ILS authenticator (or null for default)
+     * @param User|bool        $user    Return value for isLoggedIn() in auth manager
+     *
+     * @return CheckRequestIsValid
+     */
+    protected function getHandler($ss = null, $ils = null, $ilsAuth = null,
+        $user = false
+    ) {
+        // Create container
+        $container = new ServiceManager();
+
+        // Install or mock up services:
+        $this->addServiceToContainer($container, 'VuFind\Session\Settings', $ss);
+        $this->addServiceToContainer($container, 'VuFind\ILS\Connection', $ils);
+        $this->addServiceToContainer(
+            $container, 'VuFind\Auth\ILSAuthenticator', $ilsAuth
+        );
+
+        // Set up auth manager with user:
+        $authManager = $this->getMockAuthManager($user);
+        $container->setService('VuFind\Auth\Manager', $authManager);
+
+        // Build the handler:
+        $factory = new AbstractIlsAndUserActionFactory();
+        return $factory($container, CheckRequestIsValid::class);
+    }
+
+    /**
+     * Test the AJAX handler's response when no one is logged in.
+     *
+     * @return void
+     */
+    public function testLoggedOutUser()
+    {
+        $handler = $this->getHandler();
+        $this->assertEquals(
+            ['You must be logged in first', 'NEED_AUTH', 401],
+            $handler->handleRequest($this->getParamsHelper(['id' => 1, 'data' => 1]))
+        );
+    }
+
+    /**
+     * Test the AJAX handler's response when the query is empty.
+     *
+     * @return void
+     */
+    public function testEmptyQuery()
+    {
+        $handler = $this->getHandler(null, null, null, $this->getMockUser());
+        $this->assertEquals(
+            ['bulk_error_missing', 'ERROR', 400],
+            $handler->handleRequest($this->getParamsHelper())
+        );
+    }
+
+    /**
+     * Generic support function for successful request tests.
+     *
+     * @return void
+     */
+    protected function runSuccessfulTest($ilsMethod, $requestType = null)
+    {
+        $ilsAuth = $this->getMockService(
+            'VuFind\Auth\ILSAuthenticator', ['storedCatalogLogin']
+        );
+        $ilsAuth->expects($this->once())->method('storedCatalogLogin')
+            ->will($this->returnValue([3]));
+        $ils = $this
+            ->getMockService('VuFind\ILS\Connection', [$ilsMethod]);
+        $ils->expects($this->once())->method($ilsMethod)
+            ->with($this->equalTo(1), $this->equalTo(2), $this->equalTo([3]))
+            ->will($this->returnValue(true));
+        $handler = $this->getHandler(null, $ils, $ilsAuth, $this->getMockUser());
+        $params = ['id' => 1, 'data' => 2, 'requestType' => $requestType];
+        return $handler->handleRequest($this->getParamsHelper($params));
+    }
+
+    /**
+     * Test a successful hold response.
+     *
+     * @return void
+     */
+    public function testHoldResponse()
+    {
+        $this->assertEquals(
+            [['status' => true, 'msg' => 'request_place_text'], 'OK'],
+            $this->runSuccessfulTest('checkRequestIsValid')
+        );
+    }
+
+    /**
+     * Test a successful ILL response.
+     *
+     * @return void
+     */
+    public function testILLResponse()
+    {
+        $this->assertEquals(
+            [['status' => true, 'msg' => 'ill_request_place_text'], 'OK'],
+            $this->runSuccessfulTest('checkILLRequestIsValid', 'ILLRequest')
+        );
+    }
+
+    /**
+     * Test a successful storage retrieval response.
+     *
+     * @return void
+     */
+    public function testStorageResponse()
+    {
+        $this->assertEquals(
+            [
+                ['status' => true, 'msg' => 'storage_retrieval_request_place_text'],
+                'OK'
+            ], $this->runSuccessfulTest(
+                'checkStorageRetrievalRequestIsValid', 'StorageRetrievalRequest'
+            )
+        );
+    }
+}
diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/AjaxHandler/CommentRecordTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/AjaxHandler/CommentRecordTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..22eaad9f48ba9b24658bc9197aaa16844f2bfac9
--- /dev/null
+++ b/module/VuFind/tests/unit-tests/src/VuFindTest/AjaxHandler/CommentRecordTest.php
@@ -0,0 +1,175 @@
+<?php
+/**
+ * CommentRecord test class.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Page
+ */
+namespace VuFindTest\AjaxHandler;
+
+use VuFind\AjaxHandler\CommentRecord;
+use VuFind\AjaxHandler\CommentRecordFactory;
+use VuFind\Controller\Plugin\Recaptcha;
+use VuFind\Db\Row\Resource;
+use VuFind\Db\Row\User;
+use VuFind\Db\Table\Resource as ResourceTable;
+use Zend\ServiceManager\ServiceManager;
+
+/**
+ * CommentRecord test class.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Page
+ */
+class CommentRecordTest extends \VuFindTest\Unit\AjaxHandlerTest
+{
+    /**
+     * Set up a CommentRecord handler for testing.
+     *
+     * @param ResourceTable $table     Resource table mock (or null for default)
+     * @param Recaptcha     $recaptcha Recaptcha plugin mock (or null for default)
+     * @param bool          $enabled   Are comments enabled?
+     * @param User|bool     $user      Return value for isLoggedIn() in auth manager
+     *
+     * @return CommentRecord
+     */
+    protected function getHandler($table = null, $recaptcha = null, $enabled = true,
+        $user = false
+    ) {
+        // Create container
+        $container = new ServiceManager();
+
+        // For simplicity, let the top-level container stand in for the plugin
+        // managers:
+        $container->setService('VuFind\Db\Table\PluginManager', $container);
+        $container->setService('ControllerPluginManager', $container);
+
+        // Install or mock up remaining services:
+        $this->addServiceToContainer(
+            $container, 'VuFind\Db\Table\Resource', $table
+        );
+        $this->addServiceToContainer(
+            $container, 'VuFind\Controller\Plugin\Recaptcha', $recaptcha
+        );
+
+        // Set up auth manager with user:
+        $authManager = $this->getMockAuthManager($user);
+        $container->setService('VuFind\Auth\Manager', $authManager);
+
+        // Set up capability configuration:
+        $cfg = new \Zend\Config\Config(
+            ['Social' => ['comments' => $enabled ? 'enabled' : 'disabled']]
+        );
+        $capabilities = new \VuFind\Config\AccountCapabilities($cfg, $authManager);
+        $container->setService('VuFind\Config\AccountCapabilities', $capabilities);
+
+        // Build the handler:
+        $factory = new CommentRecordFactory();
+        return $factory($container, CommentRecord::class);
+    }
+
+    /**
+     * Return a mock resource row that expects a specific user and comment.
+     *
+     * @param string $comment Comment to expect
+     * @param User   $user    User to expect
+     *
+     * @return Resource
+     */
+    protected function getMockResource($comment, $user)
+    {
+        $row = $this->getMockService('VuFind\Db\Row\Resource', ['addComment']);
+        $row->expects($this->once())->method('addComment')
+            ->with($this->equalTo($comment), $this->equalTo($user))
+            ->will($this->returnValue(true));
+        return $row;
+    }
+
+    /**
+     * Test the AJAX handler's response when comments are disabled.
+     *
+     * @return void
+     */
+    public function testDisabledResponse()
+    {
+        $handler = $this->getHandler(null, null, false);
+        $this->assertEquals(
+            ['Comments disabled', 'ERROR', 403],
+            $handler->handleRequest($this->getParamsHelper())
+        );
+    }
+
+    /**
+     * Test the AJAX handler's response when no one is logged in.
+     *
+     * @return void
+     */
+    public function testLoggedOutUser()
+    {
+        $handler = $this->getHandler(null, null, true);
+        $this->assertEquals(
+            ['You must be logged in first', 'NEED_AUTH', 401],
+            $handler->handleRequest($this->getParamsHelper())
+        );
+    }
+
+    /**
+     * Test the AJAX handler's response when the query is empty.
+     *
+     * @return void
+     */
+    public function testEmptyQuery()
+    {
+        $handler = $this->getHandler(null, null, true, $this->getMockUser());
+        $this->assertEquals(
+            ['bulk_error_missing', 'ERROR', 400],
+            $handler->handleRequest($this->getParamsHelper())
+        );
+    }
+
+    /**
+     * Test a successful scenario.
+     *
+     * @return void
+     */
+    public function testSuccessfulTransaction()
+    {
+        $user = $this->getMockUser();
+        $table = $this->getMockService('VuFind\Db\Table\Resource', ['findResource']);
+        $table->expects($this->once())->method('findResource')
+            ->with($this->equalTo('foo'), $this->equalTo('Solr'))
+            ->will($this->returnValue($this->getMockResource('bar', $user)));
+        $handler = $this->getHandler($table, null, true, $user);
+        $post = [
+            'id' => 'foo',
+            'comment' => 'bar',
+        ];
+        $this->assertEquals(
+            [true, 'OK'],
+            $handler->handleRequest($this->getParamsHelper([], $post))
+        );
+    }
+}
diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/AjaxHandler/KeepAliveTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/AjaxHandler/KeepAliveTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..aaf28ca538252d443ae7f0a3957d6409fa2444c9
--- /dev/null
+++ b/module/VuFind/tests/unit-tests/src/VuFindTest/AjaxHandler/KeepAliveTest.php
@@ -0,0 +1,60 @@
+<?php
+/**
+ * ChoiceAuth test class.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2018.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Page
+ */
+namespace VuFindTest\AjaxHandler;
+
+use VuFind\AjaxHandler\KeepAlive;
+use VuFind\AjaxHandler\KeepAliveFactory;
+
+/**
+ * ChoiceAuth test class.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Page
+ */
+class KeepAliveTest extends \VuFindTest\Unit\AjaxHandlerTest
+{
+    /**
+     * Test the AJAX handler's basic response.
+     *
+     * @return void
+     */
+    public function testResponse()
+    {
+        $sm = $this->getMockService('Zend\Session\SessionManager', ['getId']);
+        $sm->expects($this->once())->method('getId');
+        $container = new \Zend\ServiceManager\ServiceManager();
+        $container->setService('Zend\Session\SessionManager', $sm);
+        $factory = new KeepAliveFactory();
+        $handler = $factory($container, KeepAlive::class);
+        $params = new \Zend\Mvc\Controller\Plugin\Params();
+        $this->assertEquals([true, 'OK'], $handler->handleRequest($params));
+    }
+}
diff --git a/themes/bootstrap3/js/record.js b/themes/bootstrap3/js/record.js
index 10abb8eaefb53788f451dfb339e65e08fc26ca8b..96d7be709142455ee1e4bc304b82ec845ae4d8b8 100644
--- a/themes/bootstrap3/js/record.js
+++ b/themes/bootstrap3/js/record.js
@@ -198,12 +198,12 @@ function refreshTagList(_target, _loggedin) {
       source: recordSource
     });
     $.ajax({
-      dataType: 'html',
+      dataType: 'json',
       url: url
     })
     .done(function getRecordTagsDone(response) {
       $tagList.empty();
-      $tagList.replaceWith(response);
+      $tagList.replaceWith(response.data);
       if (loggedin) {
         $tagList.addClass('loggedin');
       } else {