From b83f4a721a50e79271f9518d1d30202d4684cb08 Mon Sep 17 00:00:00 2001 From: Demian Katz <demian.katz@villanova.edu> Date: Wed, 18 Jul 2012 14:53:44 -0400 Subject: [PATCH] Implemented browse. --- module/VuFind/config/module.config.php | 1 + .../VuFind/Controller/BrowseController.php | 672 ++++++++++++++++++ module/VuFind/src/VuFind/Db/Table/Tags.php | 114 +-- 3 files changed, 733 insertions(+), 54 deletions(-) create mode 100644 module/VuFind/src/VuFind/Controller/BrowseController.php diff --git a/module/VuFind/config/module.config.php b/module/VuFind/config/module.config.php index 6e21b58347d..6425b12c887 100644 --- a/module/VuFind/config/module.config.php +++ b/module/VuFind/config/module.config.php @@ -67,6 +67,7 @@ $config = array( 'alphabrowse' => 'VuFind\Controller\AlphabrowseController', 'author' => 'VuFind\Controller\AuthorController', 'authority' => 'VuFind\Controller\AuthorityController', + 'browse' => 'VuFind\Controller\BrowseController', 'cover' => 'VuFind\Controller\CoverController', 'error' => 'VuFind\Controller\ErrorController', 'index' => 'VuFind\Controller\IndexController', diff --git a/module/VuFind/src/VuFind/Controller/BrowseController.php b/module/VuFind/src/VuFind/Controller/BrowseController.php new file mode 100644 index 00000000000..e9b92619bb2 --- /dev/null +++ b/module/VuFind/src/VuFind/Controller/BrowseController.php @@ -0,0 +1,672 @@ +<?php +/** + * Browse Module Controller + * + * PHP Version 5 + * + * Copyright (C) Villanova University 2011. + * + * 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., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + * @category VuFind2 + * @package Controller + * @author Chris Hallberg <challber@villanova.edu> + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link http://vufind.org/wiki/alphabetical_heading_browse Wiki + */ +namespace VuFind\Controller; +use VuFind\Config\Reader as ConfigReader, VuFind\Db\Table\Tags as TagsTable, + VuFind\Search\Solr\Params as SolrParams, + VuFind\Search\Solr\Results as SolrResults; + +/** + * BrowseController Class + * + * Controls the alphabetical browsing feature + * + * @category VuFind2 + * @package Controller + * @author Chris Hallberg <challber@villanova.edu> + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link http://vufind.org/wiki/alphabetical_heading_browse Wiki + */ +class BrowseController extends AbstractBase +{ + + protected $config, $currentAction = null, $disabledFacets; + + /** + * Constructor + */ + public function __construct() + { + $this->config = ConfigReader::getConfig(); + + $this->disabledFacets = array(); + foreach ($this->config->Browse as $key => $setting) { + if ($setting == false) { + $this->disabledFacets[] = $key; + } + } + } + + /** + * Set the name of the current action. + * + * @param string $name Name of the current action + * + * @return void + */ + protected function setCurrentAction($name) + { + $this->currentAction = $name; + } + + /** + * Get the name of the current action. + * + * @return string + */ + protected function getCurrentAction() + { + return $this->currentAction; + } + + /** + * Create a new ViewModel. + * + * @param array $params Parameters to pass to ViewModel constructor. + * + * @return \Zend\View\Model\ViewModel + */ + protected function createViewModel($params = null) + { + $view = parent::createViewModel($params); + + // Set the current action. + $currentAction = $this->getCurrentAction(); + if (!empty($currentAction)) { + $view->currentAction = $currentAction; + } + + // Initialize the array of top-level browse options. + $browseOptions = array(); + + // First option: tags -- is it enabled in config.ini? If no setting is + // found, assume it is active. + if (!isset($this->config->Browse->tag) + || $this->config->Browse->tag == true + ) { + $browseOptions[] = $this->buildBrowseOption('Tag', 'Tag'); + $view->tagEnabled = true; + } + + // Read configuration settings for LC / Dewey call number display; default + // to LC only if no settings exist in config.ini. + if (!isset($this->config->Browse->dewey) + && !isset($this->config->Browse->lcc) + ) { + $lcc = true; + $dewey = false; + } else { + $lcc = (isset($this->config->Browse->lcc) + && $this->config->Browse->lcc); + $dewey = (isset($this->config->Browse->dewey) + && $this->config->Browse->dewey); + } + + // Add the call number options as needed -- note that if both options exist, + // we need to use special text to disambiguate them. + if ($dewey) { + $browseOptions[] = $this->buildBrowseOption( + 'Dewey', ($lcc ? 'browse_dewey' : 'Call Number') + ); + $view->deweyEnabled = true; + } + if ($lcc) { + $browseOptions[] = $this->buildBrowseOption( + 'LCC', ($dewey ? 'browse_lcc' : 'Call Number') + ); + $view->lccEnabled = true; + } + + // Loop through remaining browse options. All may be individually disabled + // in config.ini, but if no settings are found, they are assumed to be on. + $remainingOptions = array( + 'Author', 'Topic', 'Genre', 'Region', 'Era' + ); + foreach ($remainingOptions as $current) { + $option = strToLower($current); + if (!isset($this->config->Browse->$option) + || $this->config->Browse->$option == true + ) { + $browseOptions[] = $this->buildBrowseOption($current, $current); + $option .= 'Enabled'; + $view->$option = true; + } + } + + // CARRY + if ($findby = $this->params()->fromQuery('findby')) { + $view->findby = $findby; + } + if ($query = $this->params()->fromQuery('query')) { + $view->query = $query; + } + if ($category = $this->params()->fromQuery('category')) { + $view->category = $category; + } + $view->browseOptions = $browseOptions; + + return $view; + } + + /** + * Build an array containing options describing a top-level Browse option. + * + * @param string $action The name of the Action for this option + * @param string $description A description of this Browse option + * + * @return array The Browse option array + */ + protected function buildBrowseOption($action, $description) + { + return array('action' => $action, 'description' => $description); + } + + /** + * Gathers data for the view of the AlphaBrowser and does some initialization + * + * @return \Zend\View\Model\ViewModel + */ + public function homeAction() + { + $this->setCurrentAction('Home'); + return $this->createViewModel(); + } + + /** + * Perform the search + * + * @param \Zend\View\Model\ViewModel $view View model to modify + * + * @return \Zend\View\Model\ViewModel + */ + protected function performSearch($view) + { + // Remove disabled facets + $facets = $view->categoryList; + foreach ($this->disabledFacets as $facet) { + unset($facets[$facet]); + } + $view->categoryList = $facets; + + // SEARCH (Tag does its own search) + if ($this->params()->fromQuery('query') + && $this->getCurrentAction() != 'Tag' + ) { + $results = $this->getFacetList( + $this->params()->fromQuery('facet_field'), + $this->params()->fromQuery('query_field'), + 'count', $this->params()->fromQuery('query') + ); + $resultList = array(); + foreach ($results as $result) { + $resultList[] = array( + 'result' => $result['displayText'], + 'count' => $result['count'] + ); + } + // Don't make a second filter if it would be the same facet + $view->paramTitle + = ($this->params()->fromQuery('query_field') != $this->getCategory()) + ? 'filter[]=' . $this->params()->fromQuery('query_field') . ':' + . urlencode($this->params()->fromQuery('query')) . '&' + : ''; + switch($this->getCurrentAction()) { + case 'LCC': + $view->paramTitle .= 'filter[]=callnumber-subject:'; + break; + case 'Dewey': + $view->paramTitle .= 'filter[]=dewey-ones:'; + break; + default: + $view->paramTitle .= 'filter[]='.$this->getCategory().':'; + } + $view->paramTitle = str_replace( + '+AND+', + '&filter[]=', + $view->paramTitle + ); + $view->resultList = $resultList; + } + + $view->setTemplate('browse/home'); + return $view; + } + + /** + * Browse tags + * + * @return void + */ + public function tagAction() + { + $this->setCurrentAction('Tag'); + $view = $this->createViewModel(); + + $view->categoryList = array( + 'alphabetical' => 'By Alphabetical', + 'popularity' => 'By Popularity', + 'recent' => 'By Recent' + ); + + if ($this->params()->fromQuery('findby')) { + $params = $this->getRequest()->getQuery()->toArray(); + $tagTable = new TagsTable(); + // Special case -- display alphabet selection if necessary: + if ($params['findby'] == 'alphabetical') { + $legalLetters = $this->getAlphabetList(); + $view->secondaryList = $legalLetters; + // Only display tag list when a valid letter is selected: + if (isset($params['query'])) { + // Note -- this does not need to be escaped because + // $params['query'] has already been validated against + // the _getAlphabetList() method below! + $tags = $tagTable->matchText($params['query']); + $tagList = array(); + foreach ($tags as $tag) { + $count = $tagTable->getCount($tag['id']); + if ($count > 0) { + $tagList[] = array( + 'result' => $tag['tag'], + 'count' => $count + ); + } + } + $view->resultList = array_slice( + $tagList, 0, $this->config->Browse->result_limit + ); + } + } else { + // Default case: always display tag list for non-alphabetical modes: + $tagList = $tagTable->getTagList( + $params['findby'], + $this->config->Browse->result_limit + ); + $resultList = array(); + foreach ($tagList as $i=>$tag) { + $resultList[$i] = array( + 'result' => $tag['tag'], + 'count' => $tag['cnt'] + ); + } + $view->resultList = $resultList; + } + $view->paramTitle = 'lookfor='; + $view->searchParams = array(); + } + + return $this->performSearch($view); + } + + /** + * Browse LCC + * + * @return void + */ + public function lccAction() + { + $this->setCurrentAction('LCC'); + $view = $this->createViewModel(); + list($view->filter, $view->secondaryList) = $this->getSecondaryList('lcc'); + $view->secondaryParams = array( + 'query_field' => 'callnumber-first', + 'facet_field' => 'callnumber-subject' + ); + $view->searchParams = array(); + return $this->performSearch($view); + } + + /** + * Browse Dewey + * + * @return void + */ + public function deweyAction() + { + $this->setCurrentAction('Dewey'); + $view = $this->createViewModel(); + list($view->filter, $hundredsList) = $this->getSecondaryList('dewey'); + $categoryList = array(); + foreach ($hundredsList as $dewey) { + $categoryList[$dewey['value']] = $dewey['displayText'] + . ' (' . $dewey['count'] . ')'; + } + $view->categoryList = $categoryList; + if ($this->params()->fromQuery('findby')) { + $secondaryList = $this->quoteValues( + $this->getFacetList( + 'dewey-tens', + 'dewey-hundreds', + 'count', + $this->params()->fromQuery('findby') + ) + ); + foreach ($secondaryList as $index=>$item) { + $secondaryList[$index]['value'] .= + ' AND dewey-hundreds:' + . $this->params()->fromQuery('findby'); + } + $view->secondaryList = $secondaryList; + $view->secondaryParams = array( + 'query_field' => 'dewey-tens', + 'facet_field' => 'dewey-ones' + ); + } + return $this->performSearch($view); + } + + /** + * Generic action function that handles all the common parts of the below actions + * + * @param string $currentAction name of the current action. profound stuff. + * @param array $categoryList category options + * @param string $facetPrefix if this is true and we're looking + * alphabetically, add a facet_prefix to the URL + * + * @return void + */ + protected function performBrowse($currentAction, $categoryList, $facetPrefix) + { + $this->setCurrentAction($currentAction); + $view = $this->createViewModel(); + $view->categoryList = $categoryList; + + $findby = $this->params()->fromQuery('findby'); + if ($findby) { + $view->secondaryParams = array( + 'query_field' => $this->getCategory($findby), + 'facet_field' => $this->getCategory($currentAction) + ); + $view->facetPrefix = $facetPrefix && $findby == 'alphabetical'; + list($view->filter, $view->secondaryList) + = $this->getSecondaryList($findby); + } + + return $this->performSearch($view); + } + + /** + * Browse Author + * + * @return void + */ + public function authorAction() + { + $categoryList = array( + 'alphabetical' => 'By Alphabetical', + 'lcc' => 'By Call Number', + 'topic' => 'By Topic', + 'genre' => 'By Genre', + 'region' => 'By Region', + 'era' => 'By Era' + ); + + return $this->performBrowse('Author', $categoryList, false); + } + + /** + * Browse Topic + * + * @return void + */ + public function topicAction() + { + $categoryList = array( + 'alphabetical' => 'By Alphabetical', + 'genre' => 'By Genre', + 'region' => 'By Region', + 'era' => 'By Era' + ); + + return $this->performBrowse('Topic', $categoryList, true); + } + + /** + * Browse Genre + * + * @return void + */ + public function genreAction() + { + $categoryList = array( + 'alphabetical' => 'By Alphabetical', + 'topic' => 'By Topic', + 'region' => 'By Region', + 'era' => 'By Era' + ); + + return $this->performBrowse('Genre', $categoryList, true); + } + + /** + * Browse Region + * + * @return void + */ + public function regionAction() + { + $categoryList = array( + 'alphabetical' => 'By Alphabetical', + 'topic' => 'By Topic', + 'genre' => 'By Genre', + 'era' => 'By Era' + ); + + return $this->performBrowse('Region', $categoryList, true); + } + + /** + * Browse Era + * + * @return void + */ + public function eraAction() + { + $categoryList = array( + 'alphabetical' => 'By Alphabetical', + 'topic' => 'By Topic', + 'genre' => 'By Genre', + 'region' => 'By Region' + ); + + return $this->performBrowse('Era', $categoryList, true); + } + + /** + * Get array with two values: a filter name and a secondary list based on facets + * + * @param string $facet the facet we need the contents of + * + * @return array + */ + protected function getSecondaryList($facet) + { + $category = $this->getCategory(); + switch($facet) { + case 'alphabetical': + return $this->getAlphabetList(); + case 'dewey': + return array( + 'dewey-tens', $this->quoteValues( + $this->getFacetList('dewey-hundreds', $category, 'index') + ) + ); + case 'lcc': + return array( + 'callnumber-first', $this->quoteValues( + $this->getFacetList('callnumber-first', $category, 'index') + ) + ); + case 'topic': + return array( + 'topic_facet', $this->quoteValues( + $this->getFacetList('topic_facet', $category) + ) + ); + case 'genre': + return array( + 'genre_facet', $this->quoteValues( + $this->getFacetList('genre_facet', $category) + ) + ); + case 'region': + return array( + 'geographic_facet', $this->quoteValues( + $this->getFacetList('geographic_facet', $category) + ) + ); + case 'era': + return array( + 'era_facet', $this->quoteValues( + $this->getFacetList('era_facet', $category) + ) + ); + } + } + + /** + * Get a list of items from a facet. + * + * @param string $facet which facet we're searching in + * @param string $category which subfacet the search applies to + * @param string $sort how are we ranking these? || 'index' + * @param string $query is there a specific query? No = wildcard + * + * @return array, indexed by value with text of displayText and count + */ + protected function getFacetList($facet, $category = null, + $sort = 'count', $query = '[* TO *]' + ) { + $params = new SolrParams(); + $params->addFacet($facet); + if ($category != null) { + $query = $category . ':' . $query; + } else { + $query = $facet . ':' . $query; + } + $params->setOverrideQuery($query); + $searchObject = new SolrResults($params); + // Get limit from config + $params->setFacetLimit($this->config->Browse->result_limit); + $params->setLimit(0); + // Facet prefix + if ($this->params()->fromQuery('facet_prefix')) { + $params->setFacetPrefix($this->params()->fromQuery('facet_prefix')); + } + $params->setFacetSort($sort); + $result = $searchObject->getFacetList(); + //var_dump($result[$facet]['list']); + if (isset($result[$facet])) { + return $result[$facet]['list']; + } else { + return array(); + } + } + + /** + * Helper class that adds quotes around the values of an array + * + * @param array $array object array where each item has a value param + * + * @return array, indexed by value with text of displayText and count + */ + protected function quoteValues($array) + { + foreach ($array as $i=>$result) { + $result['value'] = '"'.$result['value'].'"'; + $array[$i] = $result; + } + return $array; + } + + /** + * Get the facet search term for an action + * + * @param string $action action to be translated + * + * @return string + */ + protected function getCategory($action = null) + { + if ($action == null) { + $action = $this->getCurrentAction(); + } + switch(strToLower($action)) { + case 'alphabetical': + return $this->getCategory(); + case 'dewey': + return 'dewey-hundreds'; + case 'lcc': + return 'callnumber-first'; + case 'author': + return 'authorStr'; + case 'topic': + return 'topic_facet'; + case 'genre': + return 'genre_facet'; + case 'region': + return 'geographic_facet'; + case 'era': + return 'era_facet'; + } + return $action; + } + + /** + * Get a list of letters to display in alphabetical mode. + * + * @return array + */ + protected function getAlphabetList() + { + // ALPHABET TO ['value','displayText'] + $alphabet = str_split('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'); + foreach ($alphabet as $index=>$letter) { + $alphabet[$index] = array( + 'value' => $letter, + 'displayText' => $letter + ); + } + if ($this->getCurrentAction() == 'Tag') { + return $alphabet; + } + // ADD ASTERISK FOR THOSE THAT NEED IT + foreach ($alphabet as $index=>$letter) { + $letter['value'] .= '*'; + $alphabet[$index] = $letter; + } + if ($this->getCurrentAction() != 'Era') { + return $alphabet; + } + // PUT NUMBERS FIRST FOR YEARS + array_unshift($alphabet, $alphabet[count($alphabet)-10]); + unset($alphabet[count($alphabet)-10]); + for ($i=0;$i<9;$i++) { + array_unshift($alphabet, array_pop($alphabet)); + } + return $alphabet; + } +} \ No newline at end of file diff --git a/module/VuFind/src/VuFind/Db/Table/Tags.php b/module/VuFind/src/VuFind/Db/Table/Tags.php index 9bed8868034..99b63ea140d 100644 --- a/module/VuFind/src/VuFind/Db/Table/Tags.php +++ b/module/VuFind/src/VuFind/Db/Table/Tags.php @@ -26,6 +26,7 @@ * @link http://vufind.org Main Site */ namespace VuFind\Db\Table; +use Zend\Db\Sql\Expression; /** * Table Definition for tags @@ -79,13 +80,11 @@ class Tags extends Gateway */ public function matchText($text) { - /* TODO - $select = $this->select(); - $select->where('lower(tag) LIKE lower(?)', $text . '%'); - $select->order('tag'); - $result = $this->fetchAll($select); - return $result->toArray(); - */ + $callback = function ($select) use ($text) { + $select->where->literal('lower(tag) like lower(?)', array($text . '%')); + $select->order('tag'); + }; + return $this->select($callback); } /** @@ -118,18 +117,20 @@ class Tags extends Gateway */ public function getCount($tag_id) { - /* TODO - $resourceTagTable = new VuFind_Model_Db_ResourceTags(); - $select = $resourceTagTable - ->select() - ->from( - array('resource_tags'), - array('cnt' => 'COUNT(*)') - ) - ->where('tag_id = ?', $tag_id); - $count = $resourceTagTable->fetchRow($select); - return $count['cnt']; - */ + $resourceTagTable = new ResourceTags(); + $callback = function ($select) use ($tag_id) { + $select->columns( + array( + 'cnt' => new Expression( + 'COUNT(DISTINCT(?))', array('resource_id'), + array(Expression::TYPE_IDENTIFIER) + ) + ) + ); + $select->where->equalTo('tag_id', $tag_id); + }; + $count = $resourceTagTable->select($callback)->current(); + return isset($count['cnt']) ? $count['cnt'] : 0; } /** @@ -190,52 +191,57 @@ class Tags extends Gateway * * @param string $sort Sort/search parameter * @param int $limit Maximum number of tags - * @param string $extra_where Additional select parameters * * @return array Tag details. */ - public function getTagList($sort, $limit = 100, $extra_where = '') + public function getTagList($sort, $limit = 100) { - /* TODO - $tagList = array(); - $select = $this->select(); - $select->from( - array('tags'), - array('tags.tag', 'COUNT(resource_tags.id) AS cnt') - ); - $select->join( - array('resource_tags'), - 'tags.id = resource_tags.tag_id', - array() - ); - if (strlen($extra_where) > 0) { - $select->where($extra_where); - } - $select->group('tags.tag'); - switch ($sort) { - case 'alphabetical': - $select->order(array('tags.tag', 'cnt DESC')); - break; - case 'popularity': - $select->order(array('cnt DESC', 'tags.tag')); - break; - case 'recent': - $select->order( - array('max(resource_tags.posted) DESC', 'cnt DESC', 'tags.tag') + $callback = function($select) use ($sort, $limit) { + $select->columns( + array( + 'tag', + 'cnt' => new Expression( + 'COUNT(DISTINCT(?))', array('resource_tags.resource_id'), + array(Expression::TYPE_IDENTIFIER) + ), + 'posted' => new Expression( + 'MAX(?)', array('resource_tags.posted'), + array(Expression::TYPE_IDENTIFIER) + ) + ) ); - break; - } - // Limit the size of our results based on the ini browse limit setting - $select->limit($limit); - $tags = $this->fetchAll($select); - foreach ($tags as $t) { + $select->join( + 'resource_tags', 'tags.id = resource_tags.tag_id', array() + ); + if (strlen($extra_where) > 0) { + $select->where($extra_where); + } + $select->group('tags.tag'); + switch ($sort) { + case 'alphabetical': + $select->order(array('tags.tag', 'cnt DESC')); + break; + case 'popularity': + $select->order(array('cnt DESC', 'tags.tag')); + break; + case 'recent': + $select->order( + array('posted DESC', 'cnt DESC', 'tags.tag') + ); + break; + } + // Limit the size of our results based on the ini browse limit setting + //$select->limit($limit); + }; + + $tagList = array(); + foreach ($this->select($callback) as $t) { $tagList[] = array( 'tag' => $t->tag, 'cnt' => $t->cnt ); } return $tagList; - */ } /** -- GitLab