Subversion Repositories web.active

Rev

Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | Download

<?php namespace ProcessWire;

/**
 * ProcessPageSearch: Live Search (for PW admin)
 * 
 * @method renderList(array $items, $prefix = 'pw-search', $class = 'list')
 * @method renderItem(array $item, $prefix = 'pw-search', $class = 'list')
 * @method string|array execute($getJSON = true)
 * 
 * @todo support searching repeaters
 * 
 */

class ProcessPageSearchLive extends Wire {

    /**
     * Reference to ProcessPageSearch, if available
     * 
     * @var Process|ProcessPageSearch
     * 
     */
    protected $process;

    /**
     * Properties to skip in selectors
     * 
     * @var array
     * 
     */
    protected $skipProperties = array(
        'include',
        'check_access',
        'checkaccess',
    );

    /**
     * Template for live search settings
     * 
     * @var array
     * 
     */
    protected $liveSearchDefaults = array(
        'type' => '', // type of search, if not pages, i.e. "templates", "fields", "modules", "comments", etc. 
        'property' => '', // property to search for within type, or blank if no specific property
        'operator' => '%=',
        'q' => '', // query text to find
        'selectors' => array(),
        'template' => null, 
        'multilang' => true,
        'language' => '', // language name
        'edit' => true,
        'start' => 0, 
        'limit' => 15,
        'verbose' => false, 
        'debug' => false,
        'help' => false,
    );

    /**
     * Template for individual live search result items
     * 
     * @var array
     * 
     */
    protected $itemTemplate = array(
        'id' => 0,
        'url' => '', // required
        'name' => '',
        'title' => '', // required
        'subtitle' => '',
        'summary' => '',
        'icon' => '',
        'group' => '',
        'status' => 0, 
        'modified' => 0, 
    );

    /**
     * Allowed operators
     * 
     * @var array
     * 
     */
    protected $allowOperators = array(
        '=', '==', '!=', '*=', '~=', '%=', '^=', '$=', '<=', '>=', '<', '>'
    );

    /**
     * Operator to use for single-word matches (if not overridden)
     * 
     * @var string
     * 
     */
    protected $singleWordOperator = '%=';

    /**
     * Operator to use for multi-word matches (if not overridden)
     * 
     * @var string
     * 
     */
    protected $multiWordOperator = '~=';

    /**
     * Default fields to search for pages
     * 
     * @var array
     * 
     */
    protected $defaultPageSearchFields = array('title');

    /**
     * Are we currently in “view all” mode?
     * 
     * @var bool
     * 
     */
    protected $isViewAll = false;

    /**
     * Order to render results in, by search type
     * 
     * @var array
     * 
     */
    protected $searchTypesOrder = array();

    /**
     * Search types that are specifically excluded
     * 
     * @var array
     * 
     */
    protected $noSearchTypes = array();

    /**
     * PaginatedArray to use for pagination, when applicable for “view all” mode
     * 
     * @var null|PaginatedArray
     * 
     */
    protected $pagination = null;

    /**
     * Shared translation labels, defined in constructor
     * 
     * @var array
     * 
     */
    protected $labels = array();

    /**
     * Construct
     *
     * @param Process|ProcessPageSearch $process
     * @param array $liveSearch
     * 
     */
    public function __construct(Process $process = null, array $liveSearch = array()) {
        
        if($process) {
            $process->wire($this);
            if($process instanceof ProcessPageSearch) $this->process = $process;
            $a = explode(' ', $process->searchFields2);
            if(count($a)) $this->defaultPageSearchFields = $a;
        }
        
        if(!empty($liveSearch)) {
            $this->liveSearchDefaults = array_merge($this->liveSearchDefaults, $liveSearch);
        }
        
        $findOperators = Selectors::getOperators(array(
            'compareType' => Selector::compareTypeFind, 
            'getIndexType' => 'none',
            'getValueType' => 'operator',
        ));
        
        $this->allowOperators = array_unique(array_merge($this->allowOperators, $findOperators)); 
        
        $this->labels = array(
            'missing-query' => $this->_('No search specified'),
            'pages' => $this->_('Pages'),
            'trash' => $this->_('Trash'),
            'modules' => $this->_('Modules'), 
            'view-all' => $this->_('View All'),
            'search-results' => $this->_('Search Results'),
        );
        
        parent::__construct();
    }

    /**
     * Set order of search types
     * 
     * @param array $types Names of types, in order
     * 
     */
    public function setSearchTypesOrder(array $types) {
        $this->searchTypesOrder = $types; 
    }

    /**
     * Set types that should be excluded unless specifically asked for
     * 
     * @param array $types Names of types to exclude
     * 
     */
    public function setNoSearchTypes(array $types) {
        $this->noSearchTypes = $types;
    }

    /**
     * Set default operators to use for searches (if query does not specify operator)
     * 
     * @param string $singleWordOperator
     * @param string $multiWordOperator
     * 
     */
    public function setDefaultOperators($singleWordOperator, $multiWordOperator = '') {
        $this->singleWordOperator = $singleWordOperator;
        $this->multiWordOperator = empty($multiWordOperator) ? $singleWordOperator : $multiWordOperator;
    }
    
    /**
     * Initialize live search
     *
     * @param array $presets Additional info to populate in liveSearchInfo
     * @return array Current liveSearchInfo
     *
     */
    protected function init(array $presets = array()) {

        /** @var WireInput $input */
        $input = $this->wire('input');
        /** @var Sanitizer $sanitizer */
        $sanitizer = $this->wire('sanitizer');
        /** @var Fields $fields */
        $fields = $this->wire('fields');
        /** @var Templates $templates */
        $templates = $this->wire('templates');
        /** @var User $user */
        $user = $this->wire('user');
        /** @var Languages $languages */
        $languages = $this->wire('languages');
        /** @var AdminTheme|AdminThemeFramework $adminTheme */
        $adminTheme = $this->wire()->adminTheme; 

        $type = isset($presets['type']) ? $presets['type'] : '';
        $language = isset($presets['language']) ? $presets['language'] : '';
        $property = isset($presets['property']) ? $presets['property'] : '';
        $operator = isset($presets['operator']) ? $presets['operator'] : '';
        $template = isset($presets['template']) ? $presets['template'] : '';
        $limit = isset($presets['limit']) ? (int) $presets['limit'] : $this->liveSearchDefaults['limit'];
        $start = isset($presets['start']) ? (int) $presets['start'] : ($input->pageNum() - 1) * $limit;
        $selectors = array();
        $replaceOperator = '';
        $opHolders = array('<=' => '~@LT=', '>=' => '~@GT=', '<' => '~@LT', '>' => '~@GT');  // operator placeholders
        
        $q = empty($presets['q']) ? $input->get('q') : $presets['q'];
        if(empty($q)) $q = $input->get('admin_search'); // legacy name
        if(strpos($q, '~@') !== false) $q = str_replace('~@', '', $q); // disallow placeholder prefix
        if(empty($operator)) $q = str_replace(array_keys($opHolders), array_values($opHolders), $q);
        $q = $sanitizer->text($q, array('reduceSpace' => true));
        
        if($user->isSuperuser() && strpos($q, 'DEBUG') !== false) {
            $q = str_replace('DEBUG', '', $q);
            $presets['debug'] = true;
        }
        
        if(empty($q)) {
            // if no query, we've got nothing to do
            return $this->liveSearchDefaults;
        }

        if(empty($operator)) {
            // operator may be bundled into query: $q
            if(strpos($q, '~@') !== false) {
                foreach($opHolders as $op => $placeholder) { // <=, >=, <, >
                    if(strpos($q, $placeholder) === false) continue;
                    $replaceOperator = $placeholder;
                    $operator = $op;
                    break;
                }
                
            } else if(strpos($q, '==') !== false) {
                // forced equals operator
                $replaceOperator = '==';
                $operator = '=';

            } else if(strpos($q, '=') !== false) {
                // regular equals or other w/equals
                $replaceOperator = '=';
                $opChars = Selectors::getOperatorChars();
                if(preg_match('/([' . preg_quote(implode('', $opChars)) . ']{1,3}=)/', $q, $matches)) {
                    if(in_array($matches[1], $this->allowOperators)) {
                        $operator = $matches[1];
                        $replaceOperator = $operator;
                    } else {
                        $q = str_replace($opChars, ' ', $q);
                    }
                } else {
                    // regular equals, use default operator 
                }
            }
            
            if($replaceOperator) {
                $q = str_replace($replaceOperator, ':', $q);
            }
        }
        
        if(empty($operator) || !in_array($operator, $this->allowOperators)) {
            $operator = strpos($q, ' ') ? $this->multiWordOperator : $this->singleWordOperator;
        }

        // check if type and property may be part of query: $q
        if(empty($type) && empty($property) && strpos($q, ':')) {
            // Search specifies a specific type "type:text", i.e. "users:ryan"
            list($type, $q) = explode(':', $q, 2);
            // live search type: pages, users, modules, fields, templates, comments, etc.
            $type = $sanitizer->name($type);
            if(strpos($type, '.') !== false) {
                // live search type includes a property, i.e. "pages.body", "users.first_name", etc. 
                list($type, $property) = explode('.', $type, 2);
            }
            if($type === 'pages') {
                // ok
            } else if($type) {
                // check if type refers to a template name or language
                $template = true;
                $language = true;
            } else {
                // search all types
            }
        } else if($type) {
            $template = true;
            $language = true;
        }
        
        if($language === true) {
            // check if type refers to a language
            $language = $languages ? $languages->get($type) : null;
            if($language && $language->id) {
                $language = $language->name;
                $template = null;
                $type = '';
            } else {
                $language = '';
            }
        }
        
        if($template === true) {
            // check if type refers to template name or language
            $template = $templates->get($type);
        }
        
        if($template && $template instanceof Template) {
            // does search type match the name of a template?
            $selectors[] = "template=$template->name";
            $type = '';
            // $type = 'pages';
        }
        
        $type = $sanitizer->name($type);
        $property = $sanitizer->fieldName($property);
        $q = trim($q);
        $value = $sanitizer->selectorValue($q);
        $lp = strtolower($property);

        if($property && ($fields->isNative($property) || $fields->get($property)) && !in_array($lp, $this->skipProperties)) {
            // we recognize this property as searchable, so add it to the selector
            if($lp == 'status' && !$user->isSuperuser() && $value > Page::statusHidden) $value = Page::statusHidden;
            $selectors[] = $property . $operator . $value;
        } else {
            // we did not recognize the property, so use field(s) defined in module instead
            $selectors[] = implode('|', $this->defaultPageSearchFields) . $operator . $value;
        }

        $help = strtolower($q) === 'help';
        if(!$help && $adminTheme && $q === $adminTheme->getLabel('search-help')) $help = true;
        
        $liveSearch = array_merge($this->liveSearchDefaults, $presets, array(
            'type' => $type,
            'property' => $property,
            'operator' => $operator,
            'q' => $q,
            'selectors' => $selectors,
            'template' => $template,
            'multilang' => $this->wire('languages') ? true : false,
            'language' => $language, 
            'start' => $start, 
            'limit' => $limit,
            'help' => $help,
        ));
        
        if($this->isViewAll) {
            // variables for pagination
            $input->whitelist('type', $type);
            $input->whitelist('property', $property);
            $input->whitelist('operator', $operator);
            $input->whitelist('q', $q);
            if(!empty($liveSearch['language'])) $input->whitelist('language', $liveSearch['language']); 
        }

        return $liveSearch;
    }

    /**
     * Execute live search and return JSON result
     * 
     * @param bool $getJSON Get results as JSON string? Specify false to get array instead.
     * @return string|array
     * 
     */
    public function ___execute($getJSON = true) {

        /** @var WireInput $input */
        $input = $this->wire('input');

        $liveSearch = $this->init();

        if((int) $input->get('version') > 1) {
            // version 2+ keep results in native format, for future use
            $items = $this->find($liveSearch);
        } else {
            // version 1 is currently used by PW admin themes
            $items = $this->convertItemsFormat($this->find($liveSearch));
        }
        
        $result = array(
            'matches' => &$items
        );

        return $getJSON ? json_encode($result) : $items;
    }

    /**
     * Render output for landing page to view all items of a particular type
     * 
     * Expects these GET vars to be present: 
     *  - type
     *  - operator
     *  - property
     *  - q
     * 
     * @return string
     * @throws WireException
     * 
     */
    public function executeViewAll() {
    
        /** @var WireInput $input */
        $input = $this->wire('input');
        $this->isViewAll = true;
        
        $type = $input->get->pageName('type');
        $operator = $input->get('operator');
        $property = $input->get->fieldName('property');
        $language = $input->get->pageName('language');
        $q = $input->get->text('q');
        $this->pagination = new PaginatedArray();
        $this->wire($this->pagination);
        
        if(empty($q)) {
            $this->error($this->labels['missing-query']);
            return '';
        }
        
        if(false && ($type == 'pages' || $type == 'trash')) {
            // let Lister handle it
            $results = array();
        } else {
            $liveSearch = $this->init(array(
                'type' => $type,
                'property' => $property,
                'operator' => $operator,
                'q' => $q,
                'limit' => $this->liveSearchDefaults['limit'],
                'verbose' => true,
                'language' => $language, 
            ));
            $results = $this->find($liveSearch);
        }
        
        if($this->process) {
            if($type) {
                $this->process->headline($this->pagination->getPaginationString(array(
                    'label' => $this->labels['search-results'] . " - " . ucfirst($type),
                    'count' => count($results)
                )));
            } else {
                $this->process->headline($this->labels['search-results']); 
            }
        }
    
        $out = $this->renderList($results); 
        
        return $out; 
    }
    
    /**
     * Perform find of types, pages, modules
     * 
     * Result format that this find method expects from modules it calls the search() method from:
     *
     * $result = array(
     *   'title' => 'Title of these items, used as the group label except where overridden item "group" property',
     *   'url' => 'URL to view all items', // if omitted, one will be provided automatically
     *   'total' => 999, // non-paginated total quantity (can be omitted if pagination not supported)
     *   'items' => [
     *     [
     *      // required properties
     *     'title' => 'Title of item',
     *     'url' => 'URL to view or edit the item',
     *      // optional properties:
     *     'id' => 0, 
     *     'name' => 'Name of item', 
     *     'icon' => 'Optional icon name to represent the item, i.e. "gear" or "fa-gear"', 
     *     'group' => 'Optionally group with other items having this group name, overrides $result[title]', 
     *     'status' => int, // if item is a Page, status of page using Page::status* constants 
     *     'summary' => 'Summary or description of item or excerpt of text that matched', // (recommended)
     *     'subtitle' => 'Secondary title of item', // (recommended)
     *     'modified' => int, // last modified date of item 
     *     ], 
     *     [ ... ], [ ... ], etc. 
     *   )
     * );
     *
     * @param array $liveSearch
     * @return array Array of matches
     *
     */
    protected function find(array &$liveSearch) {

        $items = array();
        $user = $this->wire('user');
        $userLanguage = null;
        $q = $liveSearch['q'];
        $type = $liveSearch['type'];
        $foundTypes = array();
        $modulesInfo = array();
        $help = $liveSearch['help'];
        
        /** @var Modules $modules */
        $modules = $this->wire('modules');
        
        /** @var Languages $languages */
        $languages = $this->wire('languages');
        
        if($languages && $liveSearch['language']) {
            // change current user to have requested language, temporarily
            $language = $languages->get($liveSearch['language']);
            if($language && $language->id) {
                $userLanguage = $user->language;    
                $user->language = $language;
            }
        }
        
        if($type != 'pages' && $type != 'trash') {
            $modulesInfo = $modules->getModuleInfo('*', array('verbose' => true));
        }
        
        foreach($modulesInfo as $info) {

            if(empty($info['searchable'])) continue;
            $name = $info['name'];
            $thisType = $info['searchable'];
    
            if(!$this->useType($thisType, $type)) continue;
            if($type && $this->isViewAll && $type != $thisType) continue;
            if($type && stripos($thisType, $type) === false) continue;
            if(!empty($liveSearch['template']) && !empty($liveSearch['property'])) continue;
            if(!$user->isSuperuser() && !$modules->hasPermission($name, $user)) continue;
        
            $foundTypes[] = $thisType;
            $module = null;
            $result = array();
            $timer = null;
            
            try {
                /** @var SearchableModule $module */
                $module = $modules->getModule($name, array('noInit' => true));
                if(!$module) continue;
                $result = $module->search($q, $liveSearch); // see method phpdoc for $result format
                
            } catch(\Exception $e) {
                // ok
            }
            
            if(!$module || (empty($result['items']) && empty($liveSearch['help']))) continue;
            if(empty($result['total'])) $result['total'] = count($result['items']);
        
            if(!in_array($thisType, $this->searchTypesOrder)) $this->searchTypesOrder[] = $thisType;
            $order = array_search($thisType, $this->searchTypesOrder);
            $order = $order * 100;
            
            $title = empty($result['title']) ? "$info[title]" : "$result[title]";
            $n = $liveSearch['start'];
            $item = null;
            
            if($help) {
                foreach($result['items'] as $key => $item) {
                    if($item['name'] != 'help') unset($result['items'][$key]); 
                }
                $result['items'] = array_merge($this->makeHelpItems($result, $thisType), $result['items']); 
            }
            
            foreach($result['items'] as $item) {
                $n++;
                $item = array_merge($this->itemTemplate, $item);
                $item['group'] = empty($item['group']) ? "$title" : "$item[group]";
                if(empty($item['group'])) $item['group'] = $title;
                $item['n'] = "$n/$result[total]";
                $items[$order] = $item;
                $order++;
            }
            
            //if($n && $n < $result['total'] && !$this->isViewAll && !$help) {
            if($n && $n < $result['total'] && !$help) {
                $url = isset($result['url']) ? $result['url'] : '';
                $items[$order] = $this->makeViewAllItem($liveSearch, $thisType, $item['group'], $result['total'], $url); 
            }
            
            if($this->isViewAll && $this->pagination && $type && !$help) {
                $this->pagination->setTotal($result['total']); 
                $this->pagination->setLimit($liveSearch['limit']);
                $this->pagination->setStart($liveSearch['start']); 
            }
        }
        
        if($type && !$help && !count($foundTypes) && !in_array($type, array('pages', 'trash', 'modules'))) {
            if(empty($liveSearch['template']) && !count($foundTypes)) {
                // if no types matched, and it’s going to skip pages, assume type is a property, and do a pages search
                $liveSearch = $this->init(array(
                    'q' => $liveSearch['q'], 
                    'type' => 'pages', 
                    'property' => $type, 
                    'operator' => $liveSearch['operator']
                ));
                $type = 'pages';
            }
        }
        
        if(empty($type) || $type === 'pages' || $type === 'trash' || $liveSearch['template']) {
            // include pages in the search results
            if(!in_array('pages', $this->searchTypesOrder)) $this->searchTypesOrder[] = 'pages';
            $order = array_search('pages', $this->searchTypesOrder) * 100;
            foreach($this->findPages($liveSearch) as $item) {
                $items[$order++] = $item;
            }
        }

        // use built-in modules search when appropriate
        if($this->useType('modules', $type) && $this->wire('user')->isSuperuser()) {
            if(!in_array('modules', $this->searchTypesOrder)) $this->searchTypesOrder[] = 'modules';
            $order = array_search('modules', $this->searchTypesOrder) * 100;
            foreach($this->findModules($liveSearch, $modulesInfo) as $item) {
                $items[$order++] = $item;
            }
        }
        
        // add a debug item if requested to
        if(!empty($liveSearch['debug'])) {
            array_unshift($items, $this->makeDebugItem($liveSearch));
        }
        
        if($userLanguage) {
            // restore original language to user
            $user->language = $userLanguage;
        }
        
        ksort($items);
        
        if($help) $items = array_merge($this->makeHelpItems(array(), 'help'), $items); 

        return $items;
    }


    /**
     * Find pages for live search
     * 
     * @param array $liveSearch
     * @return array
     * 
     */
    protected function findPages(array &$liveSearch) {

        $user = $this->wire('user');
        $superuser = $user->isSuperuser();
        $pages = $this->wire('pages');
        $config = $this->wire('config');
        
        if(!empty($liveSearch['help'])) {
            $result = array('title' => 'pages', 'items' => array(), 'properties' => array('name', 'title'));
            if($this->wire('fields')->get('body')) $result['properties'][] = 'body';
            $result['properties'][] = $this->_('or any field name');
            return $this->makeHelpItems($result, 'pages');  
        }
        
        // a $pages->find() search will be included in the live search
        $selectors = &$liveSearch['selectors'];
        $selectors[] = "start=$liveSearch[start], limit=$liveSearch[limit]";

        if($this->process) {
            $repeaterID = $this->process->getRepeatersPageID();
            if($repeaterID) $selectors[] = "has_parent!=$repeaterID";
        }

        if($superuser) {
            // superuser only
            $selectors[] = "include=all";
        } else if($user->hasPermission('page-edit')) {
            // admin search mode and user has some kind of page-edit permission
            $selectors[] = "include=unpublished";
            // $selectors[] = "template=$editableTemplates";
            // $selectors[] = "status<" . Page::statusTrash;
        } else {
            // only show regular, non-hidden, non-unpublished pages
        }

        $selector = implode(', ', $selectors);
        if($this->process) $selector = $this->process->findReady($selector);
        
        $titles = array();
        $items = array();
        $matches = array('pages' => array(), 'trash' => array());
    
        try {
            if($this->useType('pages', $liveSearch['type'])) {
                $selector .= ', templates_id!=' . implode('|', $config->userTemplateIDs); // users are searched separately
                $items['pages'] = $pages->find("$selector, status<" . Page::statusTrash);
            }
        } catch(\Exception $e) {
        }
        try {
            if($superuser && $this->useType('trash', $liveSearch['type'])) {
                $items['trash'] = $pages->find("$selector, status>=" . Page::statusTrash);
            }
        } catch(\Exception $e) {
        }

        foreach($items as $type => $pageItems) {
            
            $n = $liveSearch['start'];
            $total = $pageItems->getTotal();
            $item = array();
            
            foreach($pageItems as $page) {
                /** @var Page $page */
                if(!$superuser && $page->isUnpublished() && !$page->editable()) continue;
                $isAdmin = $page->template == 'admin';
                $title = (string) $page->get('title|name');
                
                $item = array(
                    'id' => $page->id,
                    'name' => $page->name,
                    'title' => $title,
                    'subtitle' => $page->template->name,
                    'summary' => $page->path,
                    'url' => !$isAdmin && $page->editable() ? $page->editUrl(array('language' => true)) : $page->url(),
                    'icon' => $page->getIcon(),
                    'group' => '',
                    'n' => (++$n) . '/' . $total,
                    'modified' => $page->modified,
                    'status' => $page->status,
                );
                
                if(!isset($titles[$title])) $titles[$title] = 0;
                $titles[$title]++;
                $item['group'] = $this->labels[$type];
                $matches[$type][] = $item;
            }
            
            if(!empty($item) && $total > count($matches[$type])) {
                $matches[$type][] = $this->makeViewAllItem($liveSearch, $type, $item['group'], $total, '');
            }
        }

        // merge all the matches together
        if(empty($matches['trash'])) {
            $matches = $matches['pages'];
        } else {
            $matches = array_merge($matches['pages'], $matches['trash']);
        }

        // if there any colliding titles, add modified date to the subtitle
        foreach($titles as $title => $qty) {
            if($qty < 2) continue;
            foreach($matches as $key => $item) {
                if($item['title'] !== $title) continue;
                $matches[$key]['subtitle'] .= " (" . wireRelativeTimeStr($item['modified'], true) . ")";
            }
        }

        return $matches;
    }

    /**
     * Allow this search type?
     * 
     * @param string $type Type to check
     * @param string $requestType Type specifically requested by user
     * @return bool
     * 
     */
    protected function useType($type, $requestType = '') {
        if($requestType) return $type === $requestType; 
        return !in_array($type, $this->noSearchTypes); 
    }


    /**
     * Find modules matching query
     * 
     * @param array $liveSearch
     * @param array $modulesInfo
     * @return array
     * 
     */
    protected function findModules(array &$liveSearch, array &$modulesInfo) {
        
        $q = $liveSearch['q'];
        $groupLabel = $this->labels['modules'];
        $items = array();
        $forceMatch = false;
        
        if(!empty($liveSearch['help'])) {
            $info = $this->wire('modules')->getModuleInfoVerbose('ProcessPageSearch');
            $properties = array();
            foreach(array_keys($info) as $property) {
                $value = $info[$property];
                if(!is_array($value)) $properties[$property] = $property;
            }
            $exclude = array('id', 'file', 'versionStr', 'core');
            foreach($exclude as $key) unset($properties[$key]); 
            $result = array(
                'title' => 'Modules',
                'items' => array(),
                'properties' => $properties
            );
            $items = $this->makeHelpItems($result, 'modules');
            return $items;
        }

        if($liveSearch['type'] === 'modules' && !empty($liveSearch['property'])) {
            // searching for custom module property
            $forceMatch = true;
            $infos = $this->wire('modules')->findByInfo(
                $liveSearch['property'] . $liveSearch['operator'] . 
                $this->wire('sanitizer')->selectorValue($q), 2
            );
        } else {
            // text-matching for all modules
            $infos = &$modulesInfo;
        }
            
        foreach($infos as $info) {
            $id = isset($info['id']) ? $info['id'] : 0;
            $name = $info['name'];
            $title = $info['title'];
            $summary = isset($info['summary']) ? $info['summary'] : '';
            if(!$forceMatch) {
                $searchText = "$name $title $summary";
                if(stripos($searchText, $q) === false) continue;
            }
            $item = array(
                'id' => $id,
                'name' => $name,
                'title' => $title,
                'subtitle' => $name,
                'summary' => $summary, 
                'url' => $this->wire('config')->urls->admin . "module/edit?name=$name",
                'group' => $groupLabel,
            );
            $item = array_merge($this->itemTemplate, $item);
            $items[] = $item;
        }
        
        $total = count($items); 
        $n = 0;
        foreach($items as $key => $item) {
            $n++;
            $items[$key]['n'] = "$n/$total";
        }
        
        return $items;
    }
    
    /**
     * Convert items from native live search format (v2) to v1 format
     * 
     * v1 format is used by ProcessWire admin themes. 
     * 
     * @param array $items
     * @return array
     * 
     */
    protected function convertItemsFormat(array $items) {
        
        $converted = array();
        $sanitizer = $this->wire('sanitizer');
        
        foreach($items as $item) {
            $a = array(
                'id' => $item['id'],
                'name' => (string) $item['name'],
                'title' => (string) $item['title'],
                'template_label' => (string) $item['subtitle'],
                'tip' => (string) $item['summary'],
                'editUrl' => (string) $item['url'],
                'type' => (string) $sanitizer->entities($item['group']),
                'icon' => isset($item['icon']) ? $item['icon'] : '',
            );
            
            if(!empty($item['status'])) {
                if($item['status'] & Page::statusUnpublished) $a['unpublished'] = true; 
                if($item['status'] & Page::statusHidden) $a['hidden'] = true;
                if($item['status'] & Page::statusLocked) $a['locked'] = true;
            }
            
            $converted[] = $a;
        }
    
        return $converted;
    }

    /**
     * Make a search result item that displays debugging info
     * 
     * @param array $liveSearch
     * @return array
     * 
     */
    protected function makeDebugItem($liveSearch) {
        $liveSearch['user_language'] = $this->wire('user')->language->name;
        $summary = print_r($liveSearch, true);
        return array_merge($this->itemTemplate, array(
            'id' => 0,
            'name' => 'debug',
            'title' => implode(', ', $liveSearch['selectors']),
            'subtitle' => $liveSearch['q'],
            'summary' => $summary,
            'url' => '#',
            'group' => 'Debug',
        ));
    }
    
    /**
     * Make a search result item that displays property info
     *
     * @param array $result Result array returned by a SearchableModule::search() method
     * @param string $type
     * @return array
     *
     */
    protected function makeHelpItems(array $result, $type) {
        
        $items = array();
        $helloLabel = $this->_('test');
        $usage1desc = $this->wire('sanitizer')->unentities($this->_('Searches %1$s for: %2$s')); 
        $usage2desc = $this->wire('sanitizer')->unentities($this->_('Searches “%1$s” property of %2$s for: %3$s')); 
        
        if($type === 'help') {
            $operators = ProcessPageSearch::getOperators();
            $summary = 
                $this->_('Examples use the “=” equals operator.') . " \n" . 
                $this->_('In some cases you can also use these:') . "\n";
            foreach($operators as $op => $label) {
                $summary .= "$op " . rtrim($label, '*') . "\n";
            }
            $items[] = array(
                'title' => $this->_('operators:'),
                'subtitle' => implode(', ', array_keys($operators)), 
                'summary' => $summary, 
                'group' => 'help',
                'url' => 'https://processwire.com/api/selectors/#operators'
            );
            if($this->wire('user')->isSuperuser() && $this->process) {
                $items[] = array(
                    'title' => $this->_('configure'),
                    'subtitle' => $this->_('Click here to configure search settings'),
                    'url' => $url = $this->wire('modules')->getModuleEditUrl('ProcessPageSearch'),
                    'group' => 'help',
                );
            }
            foreach($items as $key => $item) $items[$key] = array_merge($this->itemTemplate, $item); 
            return $items;
        }
        
        // include any items from result that had the name "help"
        foreach($result['items'] as $item) {
            if($item['name'] == 'help') $items[] = $item;
        }

        $items[] = array(
            'title' => "$type=$helloLabel",
            'subtitle' => sprintf($usage1desc, $type, $helloLabel),
        );
        
        if(!empty($result['properties'])) {
        
            if($type == 'pages' || $type == 'modules') {
                $property = 'title';
            } else if($type == 'fields' || $type == 'templates') {
                $property = 'label';
            } else {
                $property = reset($result['properties']);
            }
            
            $items[] = array(
                'title' => "$type.$property=$helloLabel",
                'subtitle' => sprintf($usage2desc, $property, $type, $helloLabel)
            );
            
            if($type === 'pages') {
                $items[] = array(
                    'title' => "$property=$helloLabel", 
                    'subtitle' => $this->_('Same as above (shorter syntax if no names collide)')
                );
                $templateName = 'basic-page';
                $items[] = array(
                    'title' => "$templateName=$helloLabel",
                    'subtitle' => sprintf($this->_('Limit results to template: %s'), $templateName),
                );
                $items[] = array(
                    'title' => "$templateName.$property=$helloLabel",
                    'subtitle' => sprintf($this->_('Limit results to %s field on template'), $property)
                );
                
            } else if($type === 'templates') {
                $fieldName = 'images';
                $items[] = array(
                    'title' => "templates.fields=$fieldName",
                    'subtitle' => sprintf($this->_('Find templates that have field: %s'), $fieldName)
                );
            } else if($type === 'fields') {
                $items[] = array(
                    'title' => "fields.settings=ckeditor",
                    'subtitle' => $this->_('Find fields with “ckeditor” in settings'),
                );
            }
            
            $properties = implode(', ', $result['properties']);

            if(strlen($properties) > 50) {
                $properties = $this->wire('sanitizer')->truncate($properties, 50) . ' ' . $this->_('(hover for more)');
            }

            $summary =
                sprintf($this->_('The examples use the “%s” property.'), $property) . "\n" .
                $this->_('You can also use any of these properties:') . "\n • " .
                implode("\n • ", $result['properties']);

            $items[] = array(
                'title' => $this->_('properties'),
                'subtitle' => $properties,
                'summary' => $summary
            );
        }
        
        $group = sprintf($this->_('%s help'), $type);
        
        foreach($items as $key => $item) {
            $item['name'] = 'help';
            $item['group'] = $group;
            $items[$key] = array_merge($this->itemTemplate, $item); 
        }
    
        return $items;
    }

    /**
     * Make a search result item that displays a “view all” link
     * 
     * @param array $liveSearch
     * @param string $type
     * @param string $group
     * @param int $total
     * @param string $url If module provides its own view-all URL
     * @return array
     * 
     */
    protected function makeViewAllItem(&$liveSearch, $type, $group, $total, $url = '') {
        
        if(!empty($url)) {
            // use provided url
        } else if($type == 'pages' || $type == 'trash' || !empty($liveSearch['template'])) {
            $url = $this->wire('page')->url();
            $url .= "?q=" . urlencode($liveSearch['q']) . "&live=1";
            if($type == 'trash') $url .= "&trash=1";
            if(!empty($liveSearch['template'])) {
                $url .= "&template=" . $liveSearch['template']->name;
            }
            if(!empty($liveSearch['property'])) {
                $url .= "&field=" . urlencode($liveSearch['property']);
            }
            if(!empty($liveSearch['operator'])) {
                $url .= "&operator=" . urlencode($liveSearch['operator']);
            }
        } else {
            $url = $this->wire('page')->url() . 'live/' . 
                '?q=' . urlencode($liveSearch['q']) .
                '&type=' . urlencode($type) .
                '&property=' . urlencode($liveSearch['property']) .
                '&operator=' . urlencode($liveSearch['operator']);
        }
        
        return array_merge($this->itemTemplate, array(
            'id' => 0,
            'name' => 'view-all',
            'title' => $this->labels['view-all'],
            'subtitle' => sprintf($this->_('%d items'), $total),
            'summary' => '',
            'url' => $url,
            'group' => $group,
        ));
    }

    /**
     * Render “view all” list
     * 
     * @param array $items
     * @param string $prefix For CSS classes, default is "pw-search"
     * @param string $class Class name for list, default is "list" which translates to "pw-search-list"
     * @return string HTML markup
     * 
     */
    protected function ___renderList(array $items, $prefix = 'pw-search', $class = 'list') {

        $pagination = $this->pagination->renderPager();
        $group = '';
        $groups = array();
        $totals = array();
        $counts = array();
        $btn = $this->modules->get('InputfieldButton'); /** @var InputfieldButton $btn */
        $btn->aclass = "$prefix-view-all";
    
        foreach($items as $item) {
            if($item['group'] != $group) {
                $group = $item['group'];
                $groups[$group] = ''; 
            }
            $counts[$group] = isset($counts[$group]) ? $counts[$group] + 1 : 1;
            if(empty($totals[$group]) && isset($item['n'])) {
                list(, $total) = explode('/', $item['n']);
                $totals[$group] = (int) $total;
            }
            if($item['name'] === 'view-all') {
                if($pagination) continue;
                $btn->href = $item['url'];
                $btn->value = "$item[title] > $group (" . $totals[$group] . ")";
                $groups[$group] .= $btn->render();
            } else {
                $groups[$group] .= $this->renderItem($item, $prefix) . '<hr />';
            }
        }
        
        $totalGroups = array();
        foreach($groups as $group => $content) {
            $total = empty($totals[$group]) ? $counts[$group] : (int) $totals[$group];
            $totalGroups["$group ($total)"] = $content;
            unset($groups[$group]); 
        }
        
        $wireTabs = $this->wire('modules')->get('JqueryWireTabs');
        
        return
            "<div class='pw-search-$class'>" . 
                $pagination . 
                $wireTabs->render($totalGroups) . 
                $pagination . 
            "</div>";
    } 
    /**
     * Render an item for the “view all” list
     * 
     * @param array $item
     * @param string $prefix For CSS classes, default is "pw-search"
     * @param string $class Class name for item, default is "item" which translates to "pw-search-item"
     * @return string HTML markup
     * 
     */
    protected function ___renderItem(array $item, $prefix = 'pw-search', $class = 'item') {
    
        /** @var Sanitizer $sanitizer */
        $sanitizer = $this->wire('sanitizer');
        
        foreach(array('title', 'subtitle', 'summary', 'url') as $key) {
            if(isset($item[$key])) {
                $item[$key] = $sanitizer->entities($item[$key]);
            } else {
                $item[$key] = '';
            }
        }
        
        $title = "<strong class='$prefix-title'>$item[title]</strong>";
        $subtitle = empty($item['subtitle']) ? '' : "<br /><em class='$prefix-subtitle'>$item[subtitle]</em> ";
        $summary = empty($item['summary']) ? '' : "<br /><span class='$prefix-summary'>$item[summary]</span> ";
        
        return "\n\t<div class='$prefix-$class'><p><a href='$item[url]'>$title</a> $subtitle $summary</p></div>";
    }

}