diff --git a/config/vufind/config.ini b/config/vufind/config.ini index 24f8463989e855921c10d47e00a750c517ef9b8c..80695962fcc03e5f0d4e4c3817b14759e88b9377 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 fde08a082a25c3b513be7c2218e30627c200ea80..cf63a5e970154afb6722f2fc33b5cd555d333d7e 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 99f31e4ccdacc68e0bf4c655b5eb51875b4dfe26..c48bfabd4db71b80d12d94b75a46de79b3c81e9b 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 519b45afc29d8e105f0dc8afc42285d3275826f4..fab18d4528ee094549003f8187619d80b0ea48f7 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 8ad16aa05212ae3560ba84cb265a41387dfc228b..20a0aaf3cd6a934fbede889607f8078fdd94e991 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 0bf9127024d3d95cb17733e2a74e59345985d3c6..6560fc0527853d5c79eace5b261297d93188fa35 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 0173e7b79afcb33c784604f23afcd98bff3edbe7..640a86b41269d297dc656fa57f002d54aa271d66 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">« <?=$this->transEsc('First')?></a> + </li> + <? endif; ?> <li> - <a href="<?=$this->recordLink()->getUrl($this->scrollData['previousRecord'])?>" title="<?=$this->transEsc('Previous Search Result')?>">« <?=$this->transEsc('Prev')?></a> + <a href="<?=$this->recordLink()->getUrl($this->scrollData['previousRecord'])?>" title="<?=$this->transEsc('Previous Search Result')?>" rel="nofollow">« <?=$this->transEsc('Prev')?></a> </li> <? else: ?> + <? if ($this->scrollData['firstRecord']): ?> + <li class="disabled"><a href="#">« <?=$this->transEsc('First')?></a></li> + <? endif; ?> <li class="disabled"><a href="#">« <?=$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')?> »</a> + <a href="<?=$this->recordLink()->getUrl($this->scrollData['nextRecord'])?>" title="<?=$this->transEsc('Next Search Result')?>" rel="nofollow"><?=$this->transEsc('Next')?> »</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')?> »</a> + </li> + <? endif; ?> + <? else: ?> <li class="disabled"><a href="#"><?=$this->transEsc('Next')?> »</a></li> + <? if ($this->scrollData['lastRecord']): ?> + <li class="disabled"><a href="#"><?=$this->transEsc('Last')?> »</a></li> + <? endif; ?> <? endif; ?> </ul> <? endif; ?> diff --git a/themes/bootstrap3/templates/record/view.phtml b/themes/bootstrap3/templates/record/view.phtml index c1ce3ff577992996e63c257d0054351ef73d7c1b..180eaeee6a81f39381ad8f9546b88724e4af6710 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">« <?=$this->transEsc('First')?></a> + </li> + <? endif; ?> <li> <a href="<?=$this->recordLink()->getUrl($this->scrollData['previousRecord'])?>" title="<?=$this->transEsc('Previous Search Result')?>" rel="nofollow">« <?=$this->transEsc('Prev')?></a> </li> <? else: ?> + <? if ($this->scrollData['firstRecord']): ?> + <li class="disabled"><a href="#">« <?=$this->transEsc('First')?></a></li> + <? endif; ?> <li class="disabled"><a href="#">« <?=$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')?> »</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')?> »</a> + </li> + <? endif; ?> + <? else: ?> <li class="disabled"><a href="#"><?=$this->transEsc('Next')?> »</a></li> + <? if ($this->scrollData['lastRecord']): ?> + <li class="disabled"><a href="#"><?=$this->transEsc('Last')?> »</a></li> + <? endif; ?> <? endif; ?> </ul> <? endif; ?>