From 0d5cb5c4b172fdd20e9a8f925ff8cdf37ce34817 Mon Sep 17 00:00:00 2001
From: Leila Gonzales <lmg@agiweb.org>
Date: Tue, 12 Jul 2016 10:47:54 -0700
Subject: [PATCH] Added First/Last navigation for Record scroller (#732)

---
 config/vufind/config.ini                      |   6 +
 .../Controller/Plugin/ResultScroller.php      | 139 +++++++++++++++-
 .../VuFind/src/VuFind/Search/Base/Options.php |  17 ++
 .../VuFind/src/VuFind/Search/Solr/Options.php |   7 +
 .../VuFindTest/Search/TestHarness/Options.php |  17 ++
 .../Controller/Plugin/ResultScrollerTest.php  | 154 +++++++++++++++++-
 .../templates/collection/view.phtml           |  22 ++-
 themes/bootstrap3/templates/record/view.phtml |  18 +-
 8 files changed, 366 insertions(+), 14 deletions(-)

diff --git a/config/vufind/config.ini b/config/vufind/config.ini
index 24f8463989e..80695962fcc 100644
--- a/config/vufind/config.ini
+++ b/config/vufind/config.ini
@@ -1220,6 +1220,12 @@ callnumber_handler = false
 ; through the current result set from within the record view.
 next_prev_navigation = false
 
+; Set this to true in order to enable "first" and "last" links to navigate
+; through the content result set from within the record view. Note, this 
+; may cause slow behavior with some installations. The option will only work
+; when next_prev_navigation is also set to true.
+first_last_navigation = false
+
 ; Setting this to true will cause VuFind to skip the results page and
 ; proceed directly to the record page when a search has only one hit.
 jump_to_single_search_result = false
diff --git a/module/VuFind/src/VuFind/Controller/Plugin/ResultScroller.php b/module/VuFind/src/VuFind/Controller/Plugin/ResultScroller.php
index fde08a082a2..cf63a5e9701 100644
--- a/module/VuFind/src/VuFind/Controller/Plugin/ResultScroller.php
+++ b/module/VuFind/src/VuFind/Controller/Plugin/ResultScroller.php
@@ -89,6 +89,8 @@ class ResultScroller extends AbstractPlugin
         $this->data->page = $searchObject->getParams()->getPage();
         $this->data->limit = $searchObject->getParams()->getLimit();
         $this->data->total = $searchObject->getResultTotal();
+        $this->data->firstlast = $searchObject->getOptions()
+            ->supportsFirstLastNavigation();
 
         // save the IDs of records on the current page to the session
         // so we can "slide" from one record to the next/previous records
@@ -98,6 +100,8 @@ class ResultScroller extends AbstractPlugin
         // clear the previous/next page
         unset($this->data->prevIds);
         unset($this->data->nextIds);
+        unset($this->data->firstId);
+        unset($this->data->lastId);
 
         return (bool)$this->data->currIds;
     }
@@ -286,6 +290,125 @@ class ResultScroller extends AbstractPlugin
         return $retVal;
     }
 
+    /**
+     * Return a modified results array for the case where we need to retrieve data
+     * from the the first page of results
+     *
+     * @param array                       $retVal     Return values (in progress)
+     * @param \VuFind\Search\Base\Results $lastSearch Representation of last search
+     *
+     * @return array
+     */
+    protected function scrollToFirstRecord($retVal, $lastSearch)
+    {
+        // Set page in session to First Page
+        $this->data->page = 1;
+        // update the search URL in the session
+        $lastSearch->getParams()->setPage($this->data->page);
+        $this->rememberSearch($lastSearch);
+
+        // update current, next and prev Ids
+        $this->data->currIds = $this->fetchPage($lastSearch, $this->data->page);
+        $this->data->nextIds = $this->fetchPage($lastSearch, $this->data->page + 1);
+        $this->data->prevIds = null;
+
+        // now we can set the previous/next record
+        $retVal['previousRecord'] = null;
+        $retVal['nextRecord'] = isset($this->data->currIds[1])
+            ? $this->data->currIds[1] : null;
+        // cover extremely unlikely edge case -- page size of 1:
+        if (null === $retVal['nextRecord'] && isset($this->data->nextIds[0])) {
+            $retVal['nextRecord'] = $this->data->nextIds[0];
+        }
+
+        // recalculate the current position
+        $retVal['currentPosition'] = 1;
+
+        // and we're done
+        return $retVal;
+    }
+
+    /**
+     * Return a modified results array for the case where we need to retrieve data
+     * from the the last page of results
+     *
+     * @param array                       $retVal     Return values (in progress)
+     * @param \VuFind\Search\Base\Results $lastSearch Representation of last search
+     *
+     * @return array
+     */
+    protected function scrollToLastRecord($retVal, $lastSearch)
+    {
+        // Set page in session to Last Page
+        $this->data->page = $this->getLastPageNumber();
+        // update the search URL in the session
+        $lastSearch->getParams()->setPage($this->data->page);
+        $this->rememberSearch($lastSearch);
+
+        // update current, next and prev Ids
+        $this->data->currIds = $this->fetchPage($lastSearch, $this->data->page);
+        $this->data->prevIds = $this->fetchPage($lastSearch, $this->data->page - 1);
+        $this->data->nextIds = null;
+
+        // recalculate the current position
+        $retVal['currentPosition'] = $this->data->total;
+
+        // now we can set the previous/next record
+        $retVal['nextRecord'] = null;
+        if (count($this->data->currIds) > 1) {
+            $pos = count($this->data->currIds) - 2;
+            $retVal['previousRecord'] = $this->data->currIds[$pos];
+        } else if (count($this->data->prevIds) > 0) {
+            $prevPos = count($this->data->prevIds) - 1;
+            $retVal['previousRecord'] = $this->data->prevIds[$prevPos];
+        }
+
+        // and we're done
+        return $retVal;
+    }
+
+    /**
+     * Get the ID of the first record in the result set.
+     *
+     * @param \VuFind\Search\Base\Results $lastSearch Representation of last search
+     *
+     * @return string
+     */
+    protected function getFirstRecordId($lastSearch)
+    {
+        if (!isset($this->data->firstId)) {
+            $firstPage = $this->fetchPage($lastSearch, 1);
+            $this->data->firstId = $firstPage[0];
+        }
+        return $this->data->firstId;
+    }
+
+    /**
+     * Calculate the last page number in the result set.
+     *
+     * @return int
+     */
+    protected function getLastPageNumber()
+    {
+        return ceil($this->data->total / $this->data->limit);
+    }
+
+    /**
+     * Get the ID of the last record in the result set.
+     *
+     * @param \VuFind\Search\Base\Results $lastSearch Representation of last search
+     *
+     * @return string
+     */
+    protected function getLastRecordId($lastSearch)
+    {
+        if (!isset($this->data->lastId)) {
+            $results = $this->fetchPage($lastSearch, $this->getLastPageNumber());
+            $this->data->lastId = array_pop($results);
+        }
+        return $this->data->lastId;
+    }
+
     /**
      * Get the previous/next record in the last search
      * result set relative to the current one, also return
@@ -301,6 +424,7 @@ class ResultScroller extends AbstractPlugin
     public function getScrollData($driver)
     {
         $retVal = [
+            'firstRecord' => null, 'lastRecord' => null,
             'previousRecord' => null, 'nextRecord' => null,
             'currentPosition' => null, 'resultTotal' => null
         ];
@@ -322,6 +446,12 @@ class ResultScroller extends AbstractPlugin
             $retVal['resultTotal']
                 = isset($this->data->total) ? $this->data->total : 0;
 
+            // Set first and last record IDs
+            if ($this->data->firstlast) {
+                $retVal['firstRecord'] = $this->getFirstRecordId($lastSearch);
+                $retVal['lastRecord'] = $this->getLastRecordId($lastSearch);
+            }
+
             // build a full ID string using the driver:
             $id = $driver->getSourceIdentifier() . '|' . $driver->getUniqueId();
 
@@ -361,7 +491,6 @@ class ResultScroller extends AbstractPlugin
                             ->scrollToPreviousPage($retVal, $lastSearch, $pos);
                     }
                 }
-
                 // if there is something on the next page
                 if (!empty($this->data->nextIds)) {
                     // check if current record is on the next page
@@ -371,6 +500,14 @@ class ResultScroller extends AbstractPlugin
                         return $this->scrollToNextPage($retVal, $lastSearch, $pos);
                     }
                 }
+                if ($this->data->firstlast) {
+                    if ($id == $retVal['firstRecord']) {
+                        return $this->scrollToFirstRecord($retVal, $lastSearch);
+                    }
+                    if ($id == $retVal['lastRecord']) {
+                        return $this->scrollToLastRecord($retVal, $lastSearch);
+                    }
+                }
             }
         }
         return $retVal;
diff --git a/module/VuFind/src/VuFind/Search/Base/Options.php b/module/VuFind/src/VuFind/Search/Base/Options.php
index 99f31e4ccda..c48bfabd4db 100644
--- a/module/VuFind/src/VuFind/Search/Base/Options.php
+++ b/module/VuFind/src/VuFind/Search/Base/Options.php
@@ -260,6 +260,13 @@ abstract class Options implements TranslatorAwareInterface
      */
     protected $resultLimit = -1;
 
+    /**
+     * Is the first/last navigation scroller enabled?
+     *
+     * @var bool
+     */
+    protected $firstlastNavigation = false;
+
     /**
      * Constructor
      *
@@ -901,4 +908,14 @@ abstract class Options implements TranslatorAwareInterface
         $vars = array_keys($vars);
         return $vars;
     }
+
+    /**
+     * Should we include first/last options in result scroller navigation?
+     *
+     * @return bool
+     */
+    public function supportsFirstLastNavigation()
+    {
+        return $this->firstlastNavigation;
+    }
 }
diff --git a/module/VuFind/src/VuFind/Search/Solr/Options.php b/module/VuFind/src/VuFind/Search/Solr/Options.php
index 519b45afc29..fab18d4528e 100644
--- a/module/VuFind/src/VuFind/Search/Solr/Options.php
+++ b/module/VuFind/src/VuFind/Search/Solr/Options.php
@@ -198,6 +198,13 @@ class Options extends \VuFind\Search\Base\Options
             $this->spellcheck = $config->Spelling->enabled;
         }
 
+        // Turn on first/last navigation if configured:
+        if (isset($config->Record->first_last_navigation)
+            && $config->Record->first_last_navigation
+        ) {
+            $this->firstlastNavigation = true;
+        }
+
         // Turn on highlighting if the user has requested highlighting or snippet
         // functionality:
         $highlight = !isset($searchSettings->General->highlighting)
diff --git a/module/VuFind/src/VuFindTest/Search/TestHarness/Options.php b/module/VuFind/src/VuFindTest/Search/TestHarness/Options.php
index 8ad16aa0521..20a0aaf3cd6 100644
--- a/module/VuFind/src/VuFindTest/Search/TestHarness/Options.php
+++ b/module/VuFind/src/VuFindTest/Search/TestHarness/Options.php
@@ -40,6 +40,23 @@ namespace VuFindTest\Search\TestHarness;
  */
 class Options extends \VuFind\Search\Base\Options
 {
+    /**
+     * Constructor
+     *
+     * @param \VuFind\Config\PluginManager $configLoader Config loader
+     */
+    public function __construct(\VuFind\Config\PluginManager $configLoader)
+    {
+        parent::__construct($configLoader);
+        // Turn on first/last navigation if configured:
+        $config = $configLoader->get('config');
+        if (isset($config->Record->first_last_navigation)
+            && $config->Record->first_last_navigation
+        ) {
+            $this->firstlastNavigation = true;
+        }
+    }
+
     /**
      * Return the route name for the search results action.
      *
diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/Controller/Plugin/ResultScrollerTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/Controller/Plugin/ResultScrollerTest.php
index 0bf9127024d..6560fc05278 100644
--- a/module/VuFind/tests/unit-tests/src/VuFindTest/Controller/Plugin/ResultScrollerTest.php
+++ b/module/VuFind/tests/unit-tests/src/VuFindTest/Controller/Plugin/ResultScrollerTest.php
@@ -54,12 +54,31 @@ class ResultScrollerTest extends TestCase
         $results = $this->getMockResults();
         $this->assertFalse($plugin->init($results));
         $expected = [
+            'firstRecord' => null, 'lastRecord' => null,
             'previousRecord' => null, 'nextRecord' => null,
             'currentPosition' => null, 'resultTotal' => null
         ];
         $this->assertEquals($expected, $plugin->getScrollData($results->getMockRecordDriver(1)));
     }
 
+    /**
+     * Test scrolling on single-record set
+     *
+     * @return void
+     */
+    public function testScrollingOnSingleRecord()
+    {
+        $results = $this->getMockResults(1, 10, 1);
+        $plugin = $this->getMockResultScroller($results);
+        $this->assertTrue($plugin->init($results));
+        $expected = [
+            'firstRecord' => 'Solr|1', 'lastRecord' => 'Solr|1',
+            'previousRecord' => null, 'nextRecord' => null,
+            'currentPosition' => 1, 'resultTotal' => 1
+        ];
+        $this->assertEquals($expected, $plugin->getScrollData($results->getMockRecordDriver(1)));
+    }
+
     /**
      * Test scrolling for a record in the middle of the page
      *
@@ -71,6 +90,100 @@ class ResultScrollerTest extends TestCase
         $plugin = $this->getMockResultScroller($results);
         $this->assertTrue($plugin->init($results));
         $expected = [
+            'firstRecord' => 'Solr|1', 'lastRecord' => 'Solr|10',
+            'previousRecord' => 'Solr|4', 'nextRecord' => 'Solr|6',
+            'currentPosition' => 5, 'resultTotal' => 10
+        ];
+        $this->assertEquals($expected, $plugin->getScrollData($results->getMockRecordDriver(5)));
+    }
+
+    /**
+     * Test scrolling to the first record in a set.
+     *
+     * @return void
+     */
+    public function testScrollingToFirstRecord()
+    {
+        $results = $this->getMockResults(5, 2, 10);
+        $plugin = $this->getMockResultScroller($results);
+        $this->assertTrue($plugin->init($results));
+        $expected = [
+            'firstRecord' => 'Solr|1', 'lastRecord' => 'Solr|10',
+            'previousRecord' => null, 'nextRecord' => 'Solr|2',
+            'currentPosition' => 1, 'resultTotal' => 10
+        ];
+        $this->assertEquals($expected, $plugin->getScrollData($results->getMockRecordDriver(1)));
+    }
+
+    /**
+     * Test scrolling to the first record in a set (with page size set to 1).
+     *
+     * @return void
+     */
+    public function testScrollingToFirstRecordWithPageSize1()
+    {
+        $results = $this->getMockResults(10, 1, 10);
+        $plugin = $this->getMockResultScroller($results);
+        $this->assertTrue($plugin->init($results));
+        $expected = [
+            'firstRecord' => 'Solr|1', 'lastRecord' => 'Solr|10',
+            'previousRecord' => null, 'nextRecord' => 'Solr|2',
+            'currentPosition' => 1, 'resultTotal' => 10
+        ];
+        $this->assertEquals($expected, $plugin->getScrollData($results->getMockRecordDriver(1)));
+    }
+
+    /**
+     * Test scrolling to the last record in a set (with multiple records on the
+     * last page of results).
+     *
+     * @return void
+     */
+    public function testScrollingToLastRecord()
+    {
+        $results = $this->getMockResults(1, 2, 10);
+        $plugin = $this->getMockResultScroller($results);
+        $this->assertTrue($plugin->init($results));
+        $expected = [
+            'firstRecord' => 'Solr|1', 'lastRecord' => 'Solr|10',
+            'previousRecord' => 'Solr|9', 'nextRecord' => null,
+            'currentPosition' => 10, 'resultTotal' => 10
+        ];
+        $this->assertEquals($expected, $plugin->getScrollData($results->getMockRecordDriver(10)));
+    }
+
+    /**
+     * Test scrolling to the last record in a set (with only one record on the
+     * last page of results).
+     *
+     * @return void
+     */
+    public function testScrollingToLastRecordAcrossPageBoundaries()
+    {
+        $results = $this->getMockResults(1, 2, 9);
+        $plugin = $this->getMockResultScroller($results);
+        $this->assertTrue($plugin->init($results));
+        $expected = [
+            'firstRecord' => 'Solr|1', 'lastRecord' => 'Solr|9',
+            'previousRecord' => 'Solr|8', 'nextRecord' => null,
+            'currentPosition' => 9, 'resultTotal' => 9
+        ];
+        $this->assertEquals($expected, $plugin->getScrollData($results->getMockRecordDriver(9)));
+    }
+
+    /**
+     * Test that first/last results can be disabled (this is the same as the
+     * testScrollingInMiddleOfPage() test, but with first/last setting off).
+     *
+     * @return void
+     */
+    public function testDisabledFirstLast()
+    {
+        $results = $this->getMockResults(1, 10, 10, false);
+        $plugin = $this->getMockResultScroller($results);
+        $this->assertTrue($plugin->init($results));
+        $expected = [
+            'firstRecord' => null, 'lastRecord' => null,
             'previousRecord' => 'Solr|4', 'nextRecord' => 'Solr|6',
             'currentPosition' => 5, 'resultTotal' => 10
         ];
@@ -88,6 +201,7 @@ class ResultScrollerTest extends TestCase
         $plugin = $this->getMockResultScroller($results);
         $this->assertTrue($plugin->init($results));
         $expected = [
+            'firstRecord' => 'Solr|1', 'lastRecord' => 'Solr|10',
             'previousRecord' => null, 'nextRecord' => 'Solr|2',
             'currentPosition' => 1, 'resultTotal' => 10
         ];
@@ -105,6 +219,7 @@ class ResultScrollerTest extends TestCase
         $plugin = $this->getMockResultScroller($results);
         $this->assertTrue($plugin->init($results));
         $expected = [
+            'firstRecord' => 'Solr|1', 'lastRecord' => 'Solr|10',
             'previousRecord' => 'Solr|9', 'nextRecord' => null,
             'currentPosition' => 10, 'resultTotal' => 10
         ];
@@ -122,6 +237,7 @@ class ResultScrollerTest extends TestCase
         $plugin = $this->getMockResultScroller($results);
         $this->assertTrue($plugin->init($results));
         $expected = [
+            'firstRecord' => 'Solr|1', 'lastRecord' => 'Solr|17',
             'previousRecord' => 'Solr|16', 'nextRecord' => null,
             'currentPosition' => 17, 'resultTotal' => 17
         ];
@@ -139,12 +255,23 @@ class ResultScrollerTest extends TestCase
         $plugin = $this->getMockResultScroller($results);
         $this->assertTrue($plugin->init($results));
         $expected = [
+            'firstRecord' => 'Solr|1', 'lastRecord' => 'Solr|30',
             'previousRecord' => 'Solr|10', 'nextRecord' => 'Solr|12',
             'currentPosition' => 11, 'resultTotal' => 30
         ];
         $this->assertEquals($expected, $plugin->getScrollData($results->getMockRecordDriver(11)));
     }
 
+    /**
+     * Get a configuration array to turn on first/last setting.
+     *
+     * @return array
+     */
+    protected function getFirstLastConfig()
+    {
+        return ['Record' => ['first_last_navigation' => true]];
+    }
+
     /**
      * Test scrolling at end of middle page.
      *
@@ -156,6 +283,7 @@ class ResultScrollerTest extends TestCase
         $plugin = $this->getMockResultScroller($results);
         $this->assertTrue($plugin->init($results));
         $expected = [
+            'firstRecord' => 'Solr|1', 'lastRecord' => 'Solr|30',
             'previousRecord' => 'Solr|19', 'nextRecord' => 'Solr|21',
             'currentPosition' => 20, 'resultTotal' => 30
         ];
@@ -165,15 +293,21 @@ class ResultScrollerTest extends TestCase
     /**
      * Get mock search results
      *
-     * @param int $page  Current page number
-     * @param int $limit Page size
-     * @param int $total Total size of fake result set
+     * @param int  $page      Current page number
+     * @param int  $limit     Page size
+     * @param int  $total     Total size of fake result set
+     * @param bool $firstLast Turn on first/last config?
      *
      * @return \VuFind\Search\Base\Results
      */
-    protected function getMockResults($page = 1, $limit = 20, $total = 0)
-    {
+    protected function getMockResults($page = 1, $limit = 20, $total = 0,
+        $firstLast = true
+    ) {
         $pm = $this->getMockBuilder('VuFind\Config\PluginManager')->disableOriginalConstructor()->getMock();
+        $config = new \Zend\Config\Config(
+            $firstLast ? $this->getFirstLastConfig() : []
+        );
+        $pm->expects($this->any())->method('get')->will($this->returnValue($config));
         $options = new \VuFindTest\Search\TestHarness\Options($pm);
         $params = new \VuFindTest\Search\TestHarness\Params($options, $pm);
         $params->setPage($page);
@@ -185,13 +319,15 @@ class ResultScrollerTest extends TestCase
     /**
      * Get mock result scroller
      *
-     * @param \VuFind\Search\Base\Results restoreLastSearch results (null to ignore)
-     * @param array                                                                  $methods Methods to mock
+     * @param \VuFind\Search\Base\Results $results restoreLastSearch results
+     * (null to ignore)
+     * @param array                       $methods Methods to mock
      *
      * @return ResultScroller
      */
-    protected function getMockResultScroller($results = null, $methods = ['restoreLastSearch', 'rememberSearch'])
-    {
+    protected function getMockResultScroller($results = null,
+        $methods = ['restoreLastSearch', 'rememberSearch']
+    ) {
         $mock = $this->getMock(
             'VuFind\Controller\Plugin\ResultScroller', $methods, [new Container('test')]
         );
diff --git a/themes/bootstrap3/templates/collection/view.phtml b/themes/bootstrap3/templates/collection/view.phtml
index 0173e7b79af..640a86b4126 100644
--- a/themes/bootstrap3/templates/collection/view.phtml
+++ b/themes/bootstrap3/templates/collection/view.phtml
@@ -27,19 +27,35 @@
 <? if (isset($this->scrollData) && ($this->scrollData['previousRecord'] || $this->scrollData['nextRecord'])): ?>
   <ul class="pager">
     <? if ($this->scrollData['previousRecord']): ?>
+      <? if ($this->scrollData['firstRecord']): ?>
+        <li>
+          <a href="<?=$this->recordLink()->getUrl($this->scrollData['firstRecord'])?>" title="<?=$this->transEsc('First Search Result')?>" rel="nofollow">&laquo; <?=$this->transEsc('First')?></a>
+        </li>
+      <? endif; ?>
       <li>
-        <a href="<?=$this->recordLink()->getUrl($this->scrollData['previousRecord'])?>" title="<?=$this->transEsc('Previous Search Result')?>">&laquo; <?=$this->transEsc('Prev')?></a>
+        <a href="<?=$this->recordLink()->getUrl($this->scrollData['previousRecord'])?>" title="<?=$this->transEsc('Previous Search Result')?>" rel="nofollow">&laquo; <?=$this->transEsc('Prev')?></a>
       </li>
     <? else: ?>
+      <? if ($this->scrollData['firstRecord']): ?>
+        <li class="disabled"><a href="#">&laquo; <?=$this->transEsc('First')?></a></li>
+      <? endif; ?>
       <li class="disabled"><a href="#">&laquo; <?=$this->transEsc('Prev')?></a></li>
     <? endif; ?>
     #<?=$this->localizedNumber($this->scrollData['currentPosition']) . ' ' . $this->transEsc('of') . ' ' . $this->localizedNumber($this->scrollData['resultTotal']) . ' ' . $this->transEsc('results') ?>
     <? if ($this->scrollData['nextRecord']): ?>
       <li>
-        <a href="<?=$this->recordLink()->getUrl($this->scrollData['nextRecord'])?>" title="<?=$this->transEsc('Next Search Result')?>"><?=$this->transEsc('Next')?> &raquo;</a>
+        <a href="<?=$this->recordLink()->getUrl($this->scrollData['nextRecord'])?>" title="<?=$this->transEsc('Next Search Result')?>" rel="nofollow"><?=$this->transEsc('Next')?> &raquo;</a>
       </li>
-    <? else: ?>
+      <? if ($this->scrollData['lastRecord']): ?>
+        <li>
+          <a href="<?=$this->recordLink()->getUrl($this->scrollData['lastRecord'])?>" title="<?=$this->transEsc('Last Search Result')?>" rel="nofollow"><?=$this->transEsc('Last')?> &raquo;</a>
+        </li>
+      <? endif; ?>
+     <? else: ?>
       <li class="disabled"><a href="#"><?=$this->transEsc('Next')?> &raquo;</a></li>
+      <? if ($this->scrollData['lastRecord']): ?>
+        <li class="disabled"><a href="#"><?=$this->transEsc('Last')?> &raquo;</a></li>
+      <? endif; ?>
     <? endif; ?>
   </ul>
 <? endif; ?>
diff --git a/themes/bootstrap3/templates/record/view.phtml b/themes/bootstrap3/templates/record/view.phtml
index c1ce3ff5779..180eaeee6a8 100644
--- a/themes/bootstrap3/templates/record/view.phtml
+++ b/themes/bootstrap3/templates/record/view.phtml
@@ -21,10 +21,18 @@
 <? if (isset($this->scrollData) && ($this->scrollData['previousRecord'] || $this->scrollData['nextRecord'])): ?>
   <ul class="pager hidden-print">
     <? if ($this->scrollData['previousRecord']): ?>
+      <? if ($this->scrollData['firstRecord']): ?>
+        <li>
+          <a href="<?=$this->recordLink()->getUrl($this->scrollData['firstRecord'])?>" title="<?=$this->transEsc('First Search Result')?>" rel="nofollow">&laquo; <?=$this->transEsc('First')?></a>
+        </li>
+      <? endif; ?>
       <li>
         <a href="<?=$this->recordLink()->getUrl($this->scrollData['previousRecord'])?>" title="<?=$this->transEsc('Previous Search Result')?>" rel="nofollow">&laquo; <?=$this->transEsc('Prev')?></a>
       </li>
     <? else: ?>
+      <? if ($this->scrollData['firstRecord']): ?>
+        <li class="disabled"><a href="#">&laquo; <?=$this->transEsc('First')?></a></li>
+      <? endif; ?>
       <li class="disabled"><a href="#">&laquo; <?=$this->transEsc('Prev')?></a></li>
     <? endif; ?>
     #<?=$this->localizedNumber($this->scrollData['currentPosition']) . ' ' . $this->transEsc('of') . ' ' . $this->localizedNumber($this->scrollData['resultTotal']) . ' ' . $this->transEsc('results') ?>
@@ -32,8 +40,16 @@
       <li>
         <a href="<?=$this->recordLink()->getUrl($this->scrollData['nextRecord'])?>" title="<?=$this->transEsc('Next Search Result')?>" rel="nofollow"><?=$this->transEsc('Next')?> &raquo;</a>
       </li>
-    <? else: ?>
+      <? if ($this->scrollData['lastRecord']): ?>
+        <li>
+          <a href="<?=$this->recordLink()->getUrl($this->scrollData['lastRecord'])?>" title="<?=$this->transEsc('Last Search Result')?>" rel="nofollow"><?=$this->transEsc('Last')?> &raquo;</a>
+        </li>
+      <? endif; ?>
+     <? else: ?>
       <li class="disabled"><a href="#"><?=$this->transEsc('Next')?> &raquo;</a></li>
+      <? if ($this->scrollData['lastRecord']): ?>
+        <li class="disabled"><a href="#"><?=$this->transEsc('Last')?> &raquo;</a></li>
+      <? endif; ?>
     <? endif; ?>
   </ul>
 <? endif; ?>
-- 
GitLab