From 7d766817ed69ff10546b51f7e6260f63db94a59f Mon Sep 17 00:00:00 2001
From: Demian Katz <demian.katz@villanova.edu>
Date: Wed, 10 Oct 2018 09:22:11 -0400
Subject: [PATCH] BrowZine DOI Linking (#1219)

- Add mechanism to load links by AJAX based on DOI.
- Implement data provider using BrowZine API.
---
 config/vufind/config.ini                      |  14 ++
 languages/en.ini                              |   1 +
 module/VuFind/config/module.config.php        |   2 +
 .../src/VuFind/AjaxHandler/DoiLookup.php      |  86 ++++++++++
 .../VuFind/AjaxHandler/DoiLookupFactory.php   |  69 ++++++++
 .../src/VuFind/AjaxHandler/PluginManager.php  |   2 +
 .../VuFind/src/VuFind/DoiLinker/BrowZine.php  |  94 +++++++++++
 .../src/VuFind/DoiLinker/BrowZineFactory.php  |  68 ++++++++
 .../VuFind/DoiLinker/DoiLinkerInterface.php   |  51 ++++++
 .../src/VuFind/DoiLinker/PluginManager.php    |  69 ++++++++
 .../src/VuFind/View/Helper/Root/Doi.php       | 151 ++++++++++++++++++
 .../VuFind/View/Helper/Root/DoiFactory.php    |  68 ++++++++
 .../Helper/Root/RecordDataFormatterTest.php   |   1 +
 .../Backend/BrowZine/Connector.php            |  17 ++
 themes/bootstrap3/js/doi.js                   |  59 +++++++
 themes/bootstrap3/templates/Helpers/doi.phtml |   2 +
 .../DefaultRecord/data-onlineAccess.phtml     |  15 +-
 .../DefaultRecord/list-entry.phtml            |   9 +-
 .../DefaultRecord/result-grid.phtml           |   7 +-
 .../DefaultRecord/result-list.phtml           |   8 +-
 .../RecordDriver/Pazpar2/result-list.phtml    |   9 +-
 .../templates/RecordTab/holdingsils.phtml     |   5 +-
 themes/root/theme.config.php                  |   2 +
 23 files changed, 797 insertions(+), 12 deletions(-)
 create mode 100644 module/VuFind/src/VuFind/AjaxHandler/DoiLookup.php
 create mode 100644 module/VuFind/src/VuFind/AjaxHandler/DoiLookupFactory.php
 create mode 100644 module/VuFind/src/VuFind/DoiLinker/BrowZine.php
 create mode 100644 module/VuFind/src/VuFind/DoiLinker/BrowZineFactory.php
 create mode 100644 module/VuFind/src/VuFind/DoiLinker/DoiLinkerInterface.php
 create mode 100644 module/VuFind/src/VuFind/DoiLinker/PluginManager.php
 create mode 100644 module/VuFind/src/VuFind/View/Helper/Root/Doi.php
 create mode 100644 module/VuFind/src/VuFind/View/Helper/Root/DoiFactory.php
 create mode 100644 themes/bootstrap3/js/doi.js
 create mode 100644 themes/bootstrap3/templates/Helpers/doi.phtml

diff --git a/config/vufind/config.ini b/config/vufind/config.ini
index 570828c4950..ae1d24c5dd9 100644
--- a/config/vufind/config.ini
+++ b/config/vufind/config.ini
@@ -1012,6 +1012,20 @@ pw               = "Password"
 ;[DPLA]
 ;apiKey = http://dp.la/info/developers/codex/policies/#get-a-key
 
+; These settings affect dynamic DOI-based link inclusion; this can provide links
+; to full text or contextual information.
+[DOI]
+; This setting controls whether or not DOI-based links are enabled, and which
+; API is used to fetch the data. Currently supported options: BrowZine (requires
+; credentials to be configured in BrowZine.ini) or false (to disable). Disabled
+; by default.
+;resolver = BrowZine
+
+; The following settings control where DOI-based links are displayed:
+show_in_results = true      ; include in search results
+show_in_record = false      ; include in core record metadata
+show_in_holdings = false    ; include in holdings tab of record view
+
 ; These settings affect OpenURL generation and presentation; OpenURLs are used to
 ; help users find resources through your link resolver and to manage citations in
 ; Zotero.
diff --git a/languages/en.ini b/languages/en.ini
index a2bc88fa69d..71a2cd7dada 100644
--- a/languages/en.ini
+++ b/languages/en.ini
@@ -1083,6 +1083,7 @@ Video = "Video"
 Video Clips = "Video Clips"
 Videos = "Videos"
 View Book Bag = "View Book Bag"
+View Complete Issue = "View Complete Issue"
 View Full Collection = "View Full Collection"
 View Full Record = "View Full Record"
 View in EDS = "View in EDS"
diff --git a/module/VuFind/config/module.config.php b/module/VuFind/config/module.config.php
index 2628622831f..e765766ab0b 100644
--- a/module/VuFind/config/module.config.php
+++ b/module/VuFind/config/module.config.php
@@ -342,6 +342,7 @@ $config = [
             'VuFind\Db\AdapterFactory' => 'VuFind\Service\ServiceWithConfigIniFactory',
             'VuFind\Db\Row\PluginManager' => 'VuFind\ServiceManager\AbstractPluginManagerFactory',
             'VuFind\Db\Table\PluginManager' => 'VuFind\ServiceManager\AbstractPluginManagerFactory',
+            'VuFind\DoiLinker\PluginManager' => 'VuFind\ServiceManager\AbstractPluginManagerFactory',
             'VuFind\Export' => 'VuFind\ExportFactory',
             'VuFind\Favorites\FavoritesService' => 'VuFind\Favorites\FavoritesServiceFactory',
             'VuFind\GeoFeatures\BasemapConfig' => 'VuFind\GeoFeatures\AbstractConfigFactory',
@@ -515,6 +516,7 @@ $config = [
             'cover_layer' => [ /* see VuFind\Cover\Layer\PluginManager for defaults */ ],
             'db_row' => [ /* see VuFind\Db\Row\PluginManager for defaults */ ],
             'db_table' => [ /* see VuFind\Db\Table\PluginManager for defaults */ ],
+            'doilinker' => [ /* see VuFind\DoiLinker\PluginManager for defaults */ ],
             'hierarchy_driver' => [ /* see VuFind\Hierarchy\Driver\PluginManager for defaults */ ],
             'hierarchy_treedataformatter' => [ /* see VuFind\Hierarchy\TreeDataFormatter\PluginManager for defaults */ ],
             'hierarchy_treedatasource' => [ /* see VuFind\Hierarchy\TreeDataSource\PluginManager for defaults */ ],
diff --git a/module/VuFind/src/VuFind/AjaxHandler/DoiLookup.php b/module/VuFind/src/VuFind/AjaxHandler/DoiLookup.php
new file mode 100644
index 00000000000..deb055ba809
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/DoiLookup.php
@@ -0,0 +1,86 @@
+<?php
+/**
+ * AJAX handler to look up DOI data.
+ *
+ * PHP version 7
+ *
+ * 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\DoiLinker\PluginManager;
+use Zend\Mvc\Controller\Plugin\Params;
+
+/**
+ * AJAX handler to look up DOI data.
+ *
+ * @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 DoiLookup extends AbstractBase
+{
+    /**
+     * DOI Linker Plugin Manager
+     *
+     * @var PluginManager
+     */
+    protected $pluginManager;
+
+    /**
+     * DOI resolver configuration value
+     *
+     * @var string
+     */
+    protected $resolver;
+
+    /**
+     * Constructor
+     *
+     * @param PluginManager $pluginManager DOI Linker Plugin Manager
+     * @param string        $resolver      DOI resolver configuration value
+     */
+    public function __construct(PluginManager $pluginManager, $resolver)
+    {
+        $this->pluginManager = $pluginManager;
+        $this->resolver = $resolver;
+    }
+
+    /**
+     * Handle a request.
+     *
+     * @param Params $params Parameter helper from controller
+     *
+     * @return array [response data, HTTP status code]
+     */
+    public function handleRequest(Params $params)
+    {
+        $response = [];
+        if ($this->pluginManager->has($this->resolver)) {
+            $dois = (array)$params->fromQuery('doi', []);
+            $response = $this->pluginManager->get($this->resolver)->getLinks($dois);
+        }
+        return $this->formatResponse($response);
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/DoiLookupFactory.php b/module/VuFind/src/VuFind/AjaxHandler/DoiLookupFactory.php
new file mode 100644
index 00000000000..fb41749f91e
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/DoiLookupFactory.php
@@ -0,0 +1,69 @@
+<?php
+/**
+ * Factory for DoiLookup AJAX handler.
+ *
+ * PHP version 7
+ *
+ * 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 DoiLookup 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 DoiLookupFactory 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.');
+        }
+        $config = $container->get('VuFind\Config\PluginManager')->get('config');
+        $pluginManager = $container->get('VuFind\DoiLinker\PluginManager');
+        return new $requestedName($pluginManager, $config->DOI->resolver ?? null);
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/PluginManager.php b/module/VuFind/src/VuFind/AjaxHandler/PluginManager.php
index 5f0e3295b8e..793f53e3c84 100644
--- a/module/VuFind/src/VuFind/AjaxHandler/PluginManager.php
+++ b/module/VuFind/src/VuFind/AjaxHandler/PluginManager.php
@@ -47,6 +47,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
         'checkRequestIsValid' => 'VuFind\AjaxHandler\CheckRequestIsValid',
         'commentRecord' => 'VuFind\AjaxHandler\CommentRecord',
         'deleteRecordComment' => 'VuFind\AjaxHandler\DeleteRecordComment',
+        'doiLookup' => 'VuFind\AjaxHandler\DoiLookup',
         'getACSuggestions' => 'VuFind\AjaxHandler\GetACSuggestions',
         'getFacetData' => 'VuFind\AjaxHandler\GetFacetData',
         'getIlsStatus' => 'VuFind\AjaxHandler\GetIlsStatus',
@@ -82,6 +83,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
             'VuFind\AjaxHandler\CommentRecordFactory',
         'VuFind\AjaxHandler\DeleteRecordComment' =>
             'VuFind\AjaxHandler\DeleteRecordCommentFactory',
+        'VuFind\AjaxHandler\DoiLookup' => 'VuFind\AjaxHandler\DoiLookupFactory',
         'VuFind\AjaxHandler\GetACSuggestions' =>
             'VuFind\AjaxHandler\GetACSuggestionsFactory',
         'VuFind\AjaxHandler\GetFacetData' =>
diff --git a/module/VuFind/src/VuFind/DoiLinker/BrowZine.php b/module/VuFind/src/VuFind/DoiLinker/BrowZine.php
new file mode 100644
index 00000000000..f3c05beb8c5
--- /dev/null
+++ b/module/VuFind/src/VuFind/DoiLinker/BrowZine.php
@@ -0,0 +1,94 @@
+<?php
+/**
+ * BrowZine DOI linker
+ *
+ * PHP version 7
+ *
+ * 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  DOI
+ * @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:plugins:record_drivers Wiki
+ */
+namespace VuFind\DoiLinker;
+
+use VuFind\I18n\Translator\TranslatorAwareInterface;
+use VuFindSearch\Backend\BrowZine\Connector;
+
+/**
+ * BrowZine DOI linker
+ *
+ * @category VuFind
+ * @package  DOI
+ * @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:plugins:record_drivers Wiki
+ */
+class BrowZine implements DoiLinkerInterface, TranslatorAwareInterface
+{
+    use \VuFind\I18n\Translator\TranslatorAwareTrait;
+
+    /**
+     * BrowZine connector
+     *
+     * @var Connector
+     */
+    protected $connector;
+
+    /**
+     * Constructor
+     *
+     * @param Connector $connector Connector
+     */
+    public function __construct(Connector $connector)
+    {
+        $this->connector = $connector;
+    }
+
+    /**
+     * Given an array of DOIs, perform a lookup and return an associative array
+     * of arrays, keyed by DOI. Each array contains one or more associative arrays
+     * with 'link' and 'label' keys.
+     *
+     * @param array $doiArray DOIs to look up
+     *
+     * @return array
+     */
+    public function getLinks(array $doiArray)
+    {
+        $response = [];
+        foreach ($doiArray as $doi) {
+            $data = $this->connector->lookupDoi($doi)['data'] ?? null;
+            if (!empty($data['browzineWebLink'])) {
+                $response[$doi][] = [
+                    'link' => $data['browzineWebLink'],
+                    'label' => $this->translate('View Complete Issue'),
+                    'data' => $data,
+                ];
+            }
+            if (!empty($data['fullTextFile'])) {
+                $response[$doi][] = [
+                    'link' => $data['fullTextFile'],
+                    'label' => $this->translate('PDF Full Text'),
+                    'data' => $data,
+                ];
+            }
+        }
+        return $response;
+    }
+}
diff --git a/module/VuFind/src/VuFind/DoiLinker/BrowZineFactory.php b/module/VuFind/src/VuFind/DoiLinker/BrowZineFactory.php
new file mode 100644
index 00000000000..4256b24dae4
--- /dev/null
+++ b/module/VuFind/src/VuFind/DoiLinker/BrowZineFactory.php
@@ -0,0 +1,68 @@
+<?php
+/**
+ * BrowZine DOI linker factory
+ *
+ * PHP version 7
+ *
+ * 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  DOI
+ * @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:plugins:record_drivers Wiki
+ */
+namespace VuFind\DoiLinker;
+
+use Interop\Container\ContainerInterface;
+
+/**
+ * BrowZine DOI linker factory
+ *
+ * @category VuFind
+ * @package  DOI
+ * @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:plugins:record_drivers Wiki
+ */
+class BrowZineFactory 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.');
+        }
+        $backend = $container->get('VuFind\Search\BackendManager')->get('BrowZine');
+        return new $requestedName($backend->getConnector());
+    }
+}
diff --git a/module/VuFind/src/VuFind/DoiLinker/DoiLinkerInterface.php b/module/VuFind/src/VuFind/DoiLinker/DoiLinkerInterface.php
new file mode 100644
index 00000000000..bc967339f70
--- /dev/null
+++ b/module/VuFind/src/VuFind/DoiLinker/DoiLinkerInterface.php
@@ -0,0 +1,51 @@
+<?php
+/**
+ * DOI linker interface
+ *
+ * PHP version 7
+ *
+ * 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  DOI
+ * @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:plugins:record_drivers Wiki
+ */
+namespace VuFind\DoiLinker;
+
+/**
+ * DOI linker interface
+ *
+ * @category VuFind
+ * @package  DOI
+ * @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:plugins:record_drivers Wiki
+ */
+interface DoiLinkerInterface
+{
+    /**
+     * Given an array of DOIs, perform a lookup and return an associative array
+     * of arrays, keyed by DOI. Each array contains one or more associative arrays
+     * with 'link' and 'label' keys.
+     *
+     * @param array $doiArray DOIs to look up
+     *
+     * @return array
+     */
+    public function getLinks(array $doiArray);
+}
diff --git a/module/VuFind/src/VuFind/DoiLinker/PluginManager.php b/module/VuFind/src/VuFind/DoiLinker/PluginManager.php
new file mode 100644
index 00000000000..38dd88ccfcf
--- /dev/null
+++ b/module/VuFind/src/VuFind/DoiLinker/PluginManager.php
@@ -0,0 +1,69 @@
+<?php
+/**
+ * DOI linker plugin manager
+ *
+ * PHP version 7
+ *
+ * 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  DOI
+ * @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:plugins:record_drivers Wiki
+ */
+namespace VuFind\DoiLinker;
+
+/**
+ * DOI linker plugin manager
+ *
+ * @category VuFind
+ * @package  DOI
+ * @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:plugins:record_drivers Wiki
+ */
+class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
+{
+    /**
+     * Default plugin aliases.
+     *
+     * @var array
+     */
+    protected $aliases = [
+        'browzine' => 'VuFind\DoiLinker\BrowZine',
+    ];
+
+    /**
+     * Default plugin factories.
+     *
+     * @var array
+     */
+    protected $factories = [
+        'VuFind\DoiLinker\BrowZine' => 'VuFind\DoiLinker\BrowZineFactory',
+    ];
+
+    /**
+     * Return the name of the base class or interface that plug-ins must conform
+     * to.
+     *
+     * @return string
+     */
+    protected function getExpectedInterface()
+    {
+        return 'VuFind\DoiLinker\DoiLinkerInterface';
+    }
+}
diff --git a/module/VuFind/src/VuFind/View/Helper/Root/Doi.php b/module/VuFind/src/VuFind/View/Helper/Root/Doi.php
new file mode 100644
index 00000000000..de56f1c6734
--- /dev/null
+++ b/module/VuFind/src/VuFind/View/Helper/Root/Doi.php
@@ -0,0 +1,151 @@
+<?php
+/**
+ * DOI view helper
+ *
+ * PHP version 7
+ *
+ * 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  View_Helpers
+ * @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\View\Helper\Root;
+
+/**
+ * DOI view helper
+ *
+ * @category VuFind
+ * @package  View_Helpers
+ * @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 Doi extends \Zend\View\Helper\AbstractHelper
+{
+    /**
+     * Context helper
+     *
+     * @var \VuFind\View\Helper\Root\Context
+     */
+    protected $context;
+
+    /**
+     * VuFind OpenURL configuration
+     *
+     * @var \Zend\Config\Config
+     */
+    protected $config;
+
+    /**
+     * Current RecordDriver
+     *
+     * @var \VuFind\RecordDriver
+     */
+    protected $recordDriver;
+
+    /**
+     * OpenURL context ('results', 'record' or 'holdings')
+     *
+     * @var string
+     */
+    protected $area;
+
+    /**
+     * Constructor
+     *
+     * @param Context             $context Context helper
+     * @param \Zend\Config\Config $config  VuFind OpenURL config
+     */
+    public function __construct(Context $context, $config = null)
+    {
+        $this->context = $context;
+        $this->config = $config;
+    }
+
+    /**
+     * Set up context for helper
+     *
+     * @param \VuFind\RecordDriver $driver The current record driver
+     * @param string               $area   DOI context ('results', 'record'
+     *  or 'holdings'
+     *
+     * @return object
+     */
+    public function __invoke($driver, $area)
+    {
+        $this->recordDriver = $driver;
+        $this->area = $area;
+        return $this;
+    }
+
+    /**
+     * Public method to render the OpenURL template
+     *
+     * @param bool $imagebased Indicates if an image based link
+     * should be displayed or not (null for system default)
+     *
+     * @return string
+     */
+    public function renderTemplate($imagebased = null)
+    {
+        // Build parameters needed to display the control:
+        $doi = $this->recordDriver->tryMethod('getCleanDOI');
+        $params = compact('doi');
+
+        // Render the subtemplate:
+        return $this->context->__invoke($this->getView())->renderInContext(
+            'Helpers/doi.phtml', $params
+        );
+    }
+
+    /**
+     * Does the configuration indicate that we should display DOI links in
+     * the specified context?
+     *
+     * @return bool
+     */
+    protected function checkContext()
+    {
+        // Doesn't matter the target area if no resolver is specified:
+        if (empty($this->config->resolver)) {
+            return false;
+        }
+
+        // If a setting exists, return that:
+        $key = 'show_in_' . $this->area;
+        if (isset($this->config->$key)) {
+            return $this->config->$key;
+        }
+
+        // If we got this far, use the defaults -- true for results, false for
+        // everywhere else.
+        return $this->area == 'results';
+    }
+
+    /**
+     * Public method to check whether OpenURLs are active for current record
+     *
+     * @return bool
+     */
+    public function isActive()
+    {
+        $doi = $this->recordDriver->tryMethod('getCleanDOI');
+        return !empty($doi) && $this->checkContext();
+    }
+}
diff --git a/module/VuFind/src/VuFind/View/Helper/Root/DoiFactory.php b/module/VuFind/src/VuFind/View/Helper/Root/DoiFactory.php
new file mode 100644
index 00000000000..b552966621c
--- /dev/null
+++ b/module/VuFind/src/VuFind/View/Helper/Root/DoiFactory.php
@@ -0,0 +1,68 @@
+<?php
+/**
+ * DOI helper factory.
+ *
+ * PHP version 7
+ *
+ * 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  View_Helpers
+ * @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\View\Helper\Root;
+
+use Interop\Container\ContainerInterface;
+use Zend\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * DOI helper factory.
+ *
+ * @category VuFind
+ * @package  View_Helpers
+ * @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 DoiFactory 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.');
+        }
+        $config = $container->get('VuFind\Config\PluginManager')->get('config');
+        $helpers = $container->get('ViewHelperManager');
+        return new $requestedName($helpers->get('context'), $config->DOI ?? null);
+    }
+}
diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/View/Helper/Root/RecordDataFormatterTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/View/Helper/Root/RecordDataFormatterTest.php
index 0e2622b0258..daa50dbeb2f 100644
--- a/module/VuFind/tests/unit-tests/src/VuFindTest/View/Helper/Root/RecordDataFormatterTest.php
+++ b/module/VuFind/tests/unit-tests/src/VuFindTest/View/Helper/Root/RecordDataFormatterTest.php
@@ -55,6 +55,7 @@ class RecordDataFormatterTest extends \VuFindTest\Unit\ViewHelperTestCase
                 $this->getMockBuilder('VuFind\Auth\ILSAuthenticator')->disableOriginalConstructor()->getMock()
             ),
             'context' => $context,
+            'doi' => new \VuFind\View\Helper\Root\Doi($context),
             'openUrl' => new \VuFind\View\Helper\Root\OpenUrl($context, [], $this->getMockBuilder('VuFind\Resolver\Driver\PluginManager')->disableOriginalConstructor()->getMock()),
             'proxyUrl' => new \VuFind\View\Helper\Root\ProxyUrl(),
             'record' => new \VuFind\View\Helper\Root\Record(),
diff --git a/module/VuFindSearch/src/VuFindSearch/Backend/BrowZine/Connector.php b/module/VuFindSearch/src/VuFindSearch/Backend/BrowZine/Connector.php
index 3a76f84b107..6f45b076177 100644
--- a/module/VuFindSearch/src/VuFindSearch/Backend/BrowZine/Connector.php
+++ b/module/VuFindSearch/src/VuFindSearch/Backend/BrowZine/Connector.php
@@ -87,6 +87,23 @@ class Connector implements \Zend\Log\LoggerAwareInterface
         $this->libraryId = $id;
     }
 
+    /**
+     * Perform a DOI lookup
+     *
+     * @param string $doi            DOI
+     * @param bool   $includeJournal Include journal data in response?
+     *
+     * @return mixed
+     */
+    public function lookupDoi($doi, $includeJournal = false)
+    {
+        // Documentation says URL encoding of DOI is not necessary.
+        return $this->request(
+            'articles/doi/' . $doi,
+            $includeJournal ? ['include' => 'journal'] : []
+        );
+    }
+
     /**
      * Perform a search
      *
diff --git a/themes/bootstrap3/js/doi.js b/themes/bootstrap3/js/doi.js
new file mode 100644
index 00000000000..98287f28612
--- /dev/null
+++ b/themes/bootstrap3/js/doi.js
@@ -0,0 +1,59 @@
+/*global Hunt, VuFind */
+VuFind.register('doi', function Doi() {
+  function embedDoiLinks(el) {
+    var element = $(el);
+    var doi = [];
+    var elements = element.hasClass('doiLink') ? element : element.find('.doiLink');
+    elements.each(function extractDoiData(i, doiLinkEl) {
+      var currentDoi = $(doiLinkEl).data('doi');
+      if (doi.indexOf(currentDoi) === -1) {
+        doi[doi.length] = currentDoi;
+      }
+    });
+    if (doi.length === 0) {
+      return;
+    }
+    var url = VuFind.path + '/AJAX/JSON?' + $.param({
+      method: 'doiLookup',
+      doi: doi,
+    });
+    $.ajax({
+      dataType: 'json',
+      url: url
+    })
+      .done(function embedDoiLinksDone(response) {
+        elements.each(function populateDoiLinks(x, doiEl) {
+          var currentDoi = $(doiEl).data('doi');
+          if ("undefined" !== response.data[currentDoi]) {
+            $(doiEl).empty();
+            for (var i = 0; i < response.data[currentDoi].length; i++) {
+              var newLink = $('<a />');
+              newLink.attr('href', response.data[currentDoi][i].link);
+              newLink.text(response.data[currentDoi][i].label);
+              $(doiEl).append(newLink);
+              $(doiEl).append("<br />");
+            }
+          }
+        });
+      });
+  }
+
+  // Assign actions to the OpenURL links. This can be called with a container e.g. when
+  // combined results fetched with AJAX are loaded.
+  function init(_container) {
+    var container = _container || $('body');
+    // assign action to the openUrlWindow link class
+    if (typeof Hunt === 'undefined') {
+      embedDoiLinks(container);
+    } else {
+      new Hunt(
+        container.find('.doiLink').toArray(),
+        { enter: embedDoiLinks }
+      );
+    }
+  }
+  return {
+    init: init,
+    embedDoiLinks: embedDoiLinks
+  };
+});
diff --git a/themes/bootstrap3/templates/Helpers/doi.phtml b/themes/bootstrap3/templates/Helpers/doi.phtml
new file mode 100644
index 00000000000..55f9676ad35
--- /dev/null
+++ b/themes/bootstrap3/templates/Helpers/doi.phtml
@@ -0,0 +1,2 @@
+<?=$this->inlineScript(\Zend\View\Helper\HeadScript::FILE, 'doi.js', 'SET');?>
+<span class="doiLink" data-doi="<?=$this->escapeHtml($doi)?>"></span>
\ No newline at end of file
diff --git a/themes/bootstrap3/templates/RecordDriver/DefaultRecord/data-onlineAccess.phtml b/themes/bootstrap3/templates/RecordDriver/DefaultRecord/data-onlineAccess.phtml
index 4a545667a7a..4d96751f6f9 100644
--- a/themes/bootstrap3/templates/RecordDriver/DefaultRecord/data-onlineAccess.phtml
+++ b/themes/bootstrap3/templates/RecordDriver/DefaultRecord/data-onlineAccess.phtml
@@ -1,14 +1,15 @@
 <?php
   $openUrl = $this->openUrl($this->driver, 'record');
   $openUrlActive = $openUrl->isActive();
+  $doi = $this->doi($this->driver, 'record');
+  $doiActive = $doi->isActive();
   // Account for replace_other_urls setting
   $urls = $this->record($this->driver)->getLinkDetails($openUrlActive);
 ?>
-<?php if (!empty($urls) || $openUrlActive): ?>
-  <?php foreach ($urls as $current): ?>
-    <a href="<?=$this->escapeHtmlAttr($this->proxyUrl($current['url']))?>"><?=$this->escapeHtml($current['desc'])?></a><br/>
-  <?php endforeach; ?>
-  <?php if ($openUrlActive): ?>
-    <?=$openUrl->renderTemplate()?><br/>
-  <?php endif; ?>
+<?php foreach ($urls as $current): ?>
+  <a href="<?=$this->escapeHtmlAttr($this->proxyUrl($current['url']))?>"><?=$this->escapeHtml($current['desc'])?></a><br/>
+<?php endforeach; ?>
+<?php if ($openUrlActive): ?>
+  <?=$openUrl->renderTemplate()?><br/>
 <?php endif; ?>
+<?php if ($doiActive): ?><?=$doi->renderTemplate()?><?php endif; ?>
diff --git a/themes/bootstrap3/templates/RecordDriver/DefaultRecord/list-entry.phtml b/themes/bootstrap3/templates/RecordDriver/DefaultRecord/list-entry.phtml
index 323229a2109..98d94ee07e4 100644
--- a/themes/bootstrap3/templates/RecordDriver/DefaultRecord/list-entry.phtml
+++ b/themes/bootstrap3/templates/RecordDriver/DefaultRecord/list-entry.phtml
@@ -137,16 +137,23 @@
               */
             $openUrl = $this->openUrl($this->driver, 'results');
             $openUrlActive = $openUrl->isActive();
+            $doi = $this->doi($this->driver, 'results');
+            $doiActive = $doi->isActive();
             // Account for replace_other_urls setting
             $urls = $this->record($this->driver)->getLinkDetails($openUrlActive);
 
-            if ($openUrlActive || !empty($urls)):
+            if ($openUrlActive || $doiActive || !empty($urls)):
           ?>
             <?php if ($openUrlActive): ?>
               <br/>
               <?=$openUrl->renderTemplate()?>
             <?php endif;?>
 
+            <?php if ($doiActive): ?>
+              <br/>
+              <?=$doi->renderTemplate()?>
+            <?php endif; ?>
+
             <?php if (!is_array($urls)) { $urls = []; }
               if(!$this->driver->isCollection()):
                 foreach ($urls as $current): ?>
diff --git a/themes/bootstrap3/templates/RecordDriver/DefaultRecord/result-grid.phtml b/themes/bootstrap3/templates/RecordDriver/DefaultRecord/result-grid.phtml
index bb091ff369f..e76e183deb6 100644
--- a/themes/bootstrap3/templates/RecordDriver/DefaultRecord/result-grid.phtml
+++ b/themes/bootstrap3/templates/RecordDriver/DefaultRecord/result-grid.phtml
@@ -5,6 +5,8 @@
 */
 $openUrl = $this->openUrl($this->driver, 'results');
 $openUrlActive = $openUrl->isActive();
+$doi = $this->doi($this->driver, 'results');
+$doiActive = $doi->isActive();
 // Account for replace_other_urls setting
 $urls = $this->record($this->driver)->getLinkDetails($openUrlActive);
 ?>
@@ -29,11 +31,14 @@ $urls = $this->record($this->driver)->getLinkDetails($openUrlActive);
       <a class="title" href="<?=$this->recordLink()->getUrl($this->driver)?>">
         <?=$this->record($this->driver)->getTitleHtml(80)?>
       </a>
-      <?php if ($openUrlActive || !empty($urls)): ?>
+      <?php if ($openUrlActive || $doiActive || !empty($urls)): ?>
         <br/><br/>
         <?php if ($openUrlActive): ?>
           <?=$openUrl->renderTemplate()?><br />
         <?php endif; ?>
+        <?php if ($doiActive): ?>
+          <?=$doi->renderTemplate()?><br />
+        <?php endif; ?>
         <?php if (!is_array($urls)) $urls = []; foreach ($urls as $current): ?>
           <a href="<?=$this->escapeHtmlAttr($this->proxyUrl($current['url']))?>" class="fulltext" target="new"><i class="fa fa-external-link" aria-hidden="true"></i> <?=($current['url'] == $current['desc']) ? $this->transEsc('Get full text') : $this->escapeHtml($current['desc'])?></a>
           <br/>
diff --git a/themes/bootstrap3/templates/RecordDriver/DefaultRecord/result-list.phtml b/themes/bootstrap3/templates/RecordDriver/DefaultRecord/result-list.phtml
index 3f8f07cc084..c97910034a8 100644
--- a/themes/bootstrap3/templates/RecordDriver/DefaultRecord/result-list.phtml
+++ b/themes/bootstrap3/templates/RecordDriver/DefaultRecord/result-list.phtml
@@ -121,14 +121,20 @@
           */
         $openUrl = $this->openUrl($this->driver, 'results');
         $openUrlActive = $openUrl->isActive();
+        $doi = $this->doi($this->driver, 'results');
+        $doiActive = $doi->isActive();
         // Account for replace_other_urls setting
         $urls = $this->record($this->driver)->getLinkDetails($openUrlActive);
 
-        if ($openUrlActive || !empty($urls)): ?>
+        if ($openUrlActive || $doiActive || !empty($urls)): ?>
         <?php if ($openUrlActive): ?>
           <br/>
           <?=$openUrl->renderTemplate()?>
         <?php endif; ?>
+        <?php if ($doiActive): ?>
+          <br/>
+          <?=$doi->renderTemplate()?>
+        <?php endif; ?>
         <?php if (!is_array($urls)) $urls = [];
           if(!$this->driver->isCollection()):
             foreach ($urls as $current): ?>
diff --git a/themes/bootstrap3/templates/RecordDriver/Pazpar2/result-list.phtml b/themes/bootstrap3/templates/RecordDriver/Pazpar2/result-list.phtml
index a836656fef6..014b44758b2 100644
--- a/themes/bootstrap3/templates/RecordDriver/Pazpar2/result-list.phtml
+++ b/themes/bootstrap3/templates/RecordDriver/Pazpar2/result-list.phtml
@@ -79,13 +79,20 @@
           */
         $openUrl = $this->openUrl($this->driver, 'results');
         $openUrlActive = $openUrl->isActive();
+        $doi = $this->doi($this->driver, 'results');
+        $doiActive = $doi->isActive();
         // Account for replace_other_urls setting
         $urls = $this->record($this->driver)->getLinkDetails($openUrlActive);
-        if ($openUrlActive || !empty($urls)): ?>
+
+        if ($openUrlActive || $doiActive || !empty($urls)): ?>
         <?php if ($openUrlActive): ?>
           <br/>
           <?=$openUrl->renderTemplate()?>
         <?php endif; ?>
+        <?php if ($doiActive): ?>
+          <br/>
+          <?=$doi->renderTemplate()?>
+        <?php endif; ?>
         <?php if (!is_array($urls)) $urls = [];
           if(!$this->driver->isCollection()):
             foreach ($urls as $current): ?>
diff --git a/themes/bootstrap3/templates/RecordTab/holdingsils.phtml b/themes/bootstrap3/templates/RecordTab/holdingsils.phtml
index 3d4f71b9831..fefcb15662d 100644
--- a/themes/bootstrap3/templates/RecordTab/holdingsils.phtml
+++ b/themes/bootstrap3/templates/RecordTab/holdingsils.phtml
@@ -4,6 +4,8 @@
     $user = $account->isLoggedIn();
     $openUrl = $this->openUrl($this->driver, 'holdings');
     $openUrlActive = $openUrl->isActive();
+    $doi = $this->doi($this->driver, 'holdings');
+    $doiActive = $doi->isActive();
     // Account for replace_other_urls setting
     $urls = $this->record($this->driver)->getLinkDetails($openUrlActive);
     $offlineMode = $this->ils()->getOfflineMode();
@@ -42,7 +44,7 @@
 <?php $holdingTitleHold = $this->driver->tryMethod('getRealTimeTitleHold'); if (!empty($holdingTitleHold)): ?>
   <a class="placehold" data-lightbox title="<?=$this->transEsc('request_place_text')?>" href="<?=$this->recordLink()->getRequestUrl($holdingTitleHold)?>"><i class="fa fa-flag" aria-hidden="true"></i>&nbsp;<?=$this->transEsc('title_hold_place')?></a>
 <?php endif; ?>
-<?php if (!empty($urls) || $openUrlActive): ?>
+<?php if (!empty($urls) || $openUrlActive || $doiActive): ?>
   <h3><?=$this->transEsc("Internet")?></h3>
   <?php if (!empty($urls)): ?>
     <?php foreach ($urls as $current): ?>
@@ -50,6 +52,7 @@
     <?php endforeach; ?>
   <?php endif; ?>
   <?php if ($openUrlActive): ?><?=$openUrl->renderTemplate()?><?php endif; ?>
+  <?php if ($doiActive): ?><?=$doi->renderTemplate()?><?php endif; ?>
 <?php endif; ?>
 <?php foreach ($holdings['holdings'] ?? [] as $holding): ?>
 <h3>
diff --git a/themes/root/theme.config.php b/themes/root/theme.config.php
index d175736cfc7..0f2215e799b 100644
--- a/themes/root/theme.config.php
+++ b/themes/root/theme.config.php
@@ -18,6 +18,7 @@ return [
             'VuFind\View\Helper\Root\CurrentPath' => 'Zend\ServiceManager\Factory\InvokableFactory',
             'VuFind\View\Helper\Root\DateTime' => 'VuFind\View\Helper\Root\DateTimeFactory',
             'VuFind\View\Helper\Root\DisplayLanguageOption' => 'VuFind\View\Helper\Root\DisplayLanguageOptionFactory',
+            'VuFind\View\Helper\Root\Doi' => 'VuFind\View\Helper\Root\DoiFactory',
             'VuFind\View\Helper\Root\Export' => 'VuFind\View\Helper\Root\ExportFactory',
             'VuFind\View\Helper\Root\Feedback' => 'VuFind\View\Helper\Root\FeedbackFactory',
             'VuFind\View\Helper\Root\Flashmessages' => 'VuFind\View\Helper\Root\FlashmessagesFactory',
@@ -77,6 +78,7 @@ return [
             'currentPath' => 'VuFind\View\Helper\Root\CurrentPath',
             'dateTime' => 'VuFind\View\Helper\Root\DateTime',
             'displayLanguageOption' => 'VuFind\View\Helper\Root\DisplayLanguageOption',
+            'doi' => 'VuFind\View\Helper\Root\Doi',
             'export' => 'VuFind\View\Helper\Root\Export',
             'feedback' => 'VuFind\View\Helper\Root\Feedback',
             'flashmessages' => 'VuFind\View\Helper\Root\Flashmessages',
-- 
GitLab