Subversion Repositories web.active

Rev

Rev 22 | Blame | Compare with Previous | Last modification | View Log | Download

<?php namespace ProcessWire;

/**
 * ProcessWire Page Search Process
 *
 * Provides page searching within the ProcessWire admin
 *
 * For more details about how Process modules work, please see:
 * /wire/core/Process.php
 *
 * ProcessWire 3.x, Copyright 2023 by Ryan Cramer
 * https://processwire.com
 * 
 * @method string findReady($selector)
 * @property string $searchFields
 * @property string $searchFields2
 * @property string $displayField
 * @property string $operator Single-word/partial match operator
 * @property string $operator2 Multi-word operator
 * @property array $searchTypesOrder
 * @property array $noSearchTypes
 * 
 * @property bool|int $adminSearchMode Deprecated/no longer in use?
 * 
 *
 */

class ProcessPageSearch extends Process implements ConfigurableModule {

  static public function getModuleInfo() {
    return array(
      'title' => 'Page Search',
      'summary' => 'Provides a page search engine for admin use.',
      'version' => 108,
      'permanent' => true,
      'permission' => 'page-edit',
    );
  }

  /**
   * Default operator for text searches
   * 
   */
  const defaultOperator = '%=';

  /**
   * Native/system sortable properties
   * 
   * @var array
   * 
   */
  protected $nativeSorts = array(
    'relevance',
    'name',
    'title',
    'id',
    'status',
    'templates_id',
    'parent_id',
    'created',
    'modified',
    'published',
    'modified_users_id',
    'created_users_id',
    'createdUser',
    'modifiedUser',
    'sort',
    'sortfield',
  );

  /**
   * Names of all Field objects in PW
   * 
   * @var array
   * 
   */
  protected $fieldOptions = array();

  /**
   * All operators where key is operator and value is description
   * 
   * @var array
   * 
   */
  protected $operators = array();

  /**
   * Items per pagination
   * 
   * @var int
   * 
   */
  protected $resultLimit = 25;

  /**
   * Lister instance, when applicable 
   * 
   * @var null|ProcessPageLister
   * 
   */
  protected $lister = null;

  /**
   * Debug mode?
   * 
   * @var bool
   * 
   */
  protected $debug = true;
  
  public function __construct() {
    parent::__construct();
    $this->set('searchFields', 'title body');
    $this->set('searchFields2', 'title'); 
    $this->set('displayField', 'name'); 
    $this->set('operator', self::defaultOperator); 
    $this->set('operator2', '~='); 
    $this->set('searchTypesOrder', array('fields', 'templates', 'modules', 'pages', 'trash'));
    $this->set('noSearchTypes', array()); // search types that have been removed

    // make nativeSorts indexed by value
    $sorts = array();
    foreach($this->nativeSorts as $sort) {
      $sorts[$sort] = $sort;
    }
    $this->nativeSorts = $sorts;
  }

  /**
   * Initialize module
   * 
   */
  public function init() {

    foreach($this->fields as $field) {
      if($field->type instanceof FieldtypeFieldsetOpen) continue;
      if($field->type instanceof FieldtypePassword) continue;
      // @todo add field access control checking
      $this->fieldOptions[$field->name] = $field->name;
    }

    ksort($this->fieldOptions);
    parent::init();
  }
  
  public function set($key, $value) {
    if($key == 'searchFields' || $key == 'searchFields2') {
      if(is_array($value)) $value = implode(' ', $value); 
    } else if($key == 'noSearchTypes' && !is_array($value)) {
      $value = explode(' ', $value);
    }
    return parent::set($key, $value);
  }

  /**
   * Get operators used for searches, where key is operator and value is description
   * 
   * @return array
   * 
   */
  static public function getOperators() {
    $operators = Selectors::getOperators(array(
      'getIndexType' => 'operator', 
      'getValueType' => 'label',
    ));
    unset($operators['#=']); // maybe later
    return $operators;
  }

  /**
   * Setup items needed for full execution, as opposed to the regular search input that appears on all pages
   * 
   */
  protected function fullSetup() {
    $sanitizer = $this->wire()->sanitizer;
    $input = $this->wire()->input;
    $headline = $this->_x('Search', 'headline'); // Headline for search page
    if($input->get('processHeadline')) {
      $headline = $sanitizer->entities($sanitizer->text($input->get('processHeadline'))); 
      $this->input->whitelist('processHeadline', $headline); 
    }
    $this->wire('processHeadline', $headline); 
    $this->operators = self::getOperators();
  }

  /**
   * Hookable function to optionally modify selector before it is sent to $pages->find()
   * 
   * Not applicable when Lister is handling the search/render. 
   * 
   * #pw-hooker
   *
   * @param string $selector Selector that will be used to find pages
   * @return string Must return the selector (optionally modified)
   *
   */
  public function ___findReady($selector) {
    return $selector;
  }

  /**
   * Return instance of ProcessPageLister or null if not available
   * 
   * @return ProcessPageLister|null
   * 
   */
  protected function getLister() {
    $modules = $this->wire()->modules;
    if($this->lister) return $this->lister;
    if($this->wire()->user->hasPermission('page-lister')) {
      if($modules->isInstalled('ProcessPageLister')) {
        $this->lister = $modules->get('ProcessPageLister');
      }
    }
    return $this->lister;
  }

  /**
   * Perform an interactive search and provide a search form (default)
   *
   */
  public function ___execute() {
  
    $lister = $this->getLister();
    $ajax = $this->wire()->config->ajax; 
    $bookmark = (int) $this->wire()->input->get('bookmark');
    
    if($lister && ($ajax || $bookmark)) {
      // we will just let Lister do it's thing, since it remembers settings in session
      return $lister->execute(); 
    } else {
      $this->fullSetup();
      $this->processInput();
      list($selector, $displaySelector, $initSelector, $defaultSelector) = $this->buildSelector();
    }
    
    if($lister) {
      if(count($_GET)) $lister->sessionClear();
      $lister->initSelector = $initSelector;
      $lister->defaultSelector = $defaultSelector;
      $lister->defaultSort = 'relevance';
      $lister->set('limit', $this->resultLimit); 
      $lister->preview = false; 
      $lister->columns = $this->getDisplayFields();
      return $lister->execute();
    } else {
      $selector = $this->findReady($selector); 
      $matches = $this->pages->find($selector);
      return $this->render($matches, $displaySelector);
    }
  }
  
  public function executeReset() {
    $lister = $this->getLister();
    return $lister ? $lister->executeReset() : '';
  }
  
  public function executeEditBookmark() {
    $lister = $this->getLister();
    return $lister ? $lister->executeEditBookmark() : '';
  }
  
  /**
   * Perform a non-interactive search (based on URL GET vars)
   *
   * This is the preferred input method for links and ajax queries.
   *
   * Example /search/for?template=basic-page&body*=example
   *
   */
  public function ___executeFor() {
    
    $languages = $this->wire()->languages;
    $user = $this->wire()->user;
    $input = $this->wire()->input;
    $sanitizer = $this->wire()->sanitizer;
    
    if($input->get('admin_search')) return $this->executeLive();

    $this->fullSetup();
    $selectors = array();
    $limit = $this->resultLimit;
    $start = 0;
    $status = 0;
    $names = array();
    $userLanguage = null;
    $superuser = $user->isSuperuser();
    $checkEditAccess = false;
    $hasInclude = '';
    $n = 0;
    
    $selectorName = $input->get('for_selector_name');
    if($selectorName) {
      $selector = $this->getForSelector($selectorName);
      if(strlen($selector)) $selectors['for'] = $selector;
    }
    
    // names to skip (must be lowercase)
    $skipNames = array(
      'get', 
      'display', 
      'format_name', 
      'for_selector_name',
      'admin_search'
    ); 
  
    // names to convert (keys must be lowercase)
    $convertNames = array(
      'hasparent' => 'has_parent',
      'checkaccess' => 'check_access',
    );
    
    foreach($input->get as $name => $value) {

      $lowerName = strtolower(trim($name));
      
      if(isset($convertNames[$lowerName])) {
        $name = $convertNames[$lowerName];
        $lowerName = strtolower($name);
      }
      
      if(in_array($lowerName, $skipNames)) continue;
      
      if($lowerName == 'lang_id') {
        if($languages) {
          // force results for specific language
          $language = $languages->get((int) $value);
          if(!$language->id) continue;
          if($user->language->id != $language->id) {
            $userLanguage = $user->language;
            $user->language = $language;
          }
        }
        continue;
      }
      
      // operator has no '=', so we'll get the value from the name
      // so that you can do something like: bedrooms>5 rather than bedrooms>=5
      if(!strlen($value) && preg_match('/([^<>]+)\s*([<>])\s*([^<>]+)/', $name, $matches)) {

        $name = $matches[1];
        $operator = $matches[2];
        $value = $matches[3]; 
        
      } else {

        $operator = '=';
        $operatorChars = preg_quote(implode('', Selectors::getOperatorChars()));
        if(preg_match('/^(.+?)([' . $operatorChars . ']+)$/', $name, $matches)) {
          $name = $matches[1];
          $operator = $matches[2] . '=';
          // if unsupported operator requested, substitute '='
          if(!isset($this->operators[$operator])) $operator = '=';
        }
      }

      // replace '-' with '.' since '.' is not allowed in URL variable names
      if(strpos($name, '-')) $name = str_replace('-', '.', $name); 

      if(strpos($name, ',')) {
        $name = $sanitizer->names($name, ',', array('_', '.'));
      } else {
        $name = $sanitizer->fieldSubfield($name, 2); 
      }

      if(!$name) continue; 
      $lowerName = strtolower($name);

      if($lowerName == 'limit') { 
        $limit = (int) $value; 
        $input->whitelist('limit', $value);
        continue; 
      }

      if($lowerName == 'start') { 
        $start = (int) $value;
        $input->whitelist('start', $value); 
        continue; 
      }

      // if dealing with a user other than superuser, only allow include=hidden
      if($lowerName == 'include') {
        $name = $lowerName;
        $value = strtolower($value);
        if($value != 'hidden' && !$superuser) {
          if($user->hasPermission('page-edit') && $input->get('admin_search')) {
            $value = 'unpublished';
            $checkEditAccess = true;
          } else {
            $value = 'hidden';
          }
        }
        $hasInclude = $value; 
      }
    
      // don't allow setting of check_access property, except for superuser
      if($lowerName == 'check_access' && !$superuser) continue; 
      
      // don't allow setting of the 'status' property, except for superuser
      if($lowerName == 'status') {
        if(!$superuser) continue; 
        $status = (int) $value;
      }
      
      // replace URL-compatible comma separators with selector-compatible pipes
      if(strpos($name, ',')) $name = str_replace(',', '|', $name); 

      $name = $this->filterSelectableFieldName($name);
      if(!strlen($name)) continue;
      
      if(strpos($value, ',')) {
        // commas between words: split one key=value, into multiple key=value, key=value
        $valuesAND = explode(',', $value); 
      } else {
        $valuesAND = array($value);
      }

      foreach($valuesAND as $key => $val) {
        if(strpos($val, '|')) {
          $valuesOR = explode('|', $val);
          foreach($valuesOR as $k => $v) {
            $valuesOR[$k] = $sanitizer->selectorValue($v);
          }
          $val = implode('|', $valuesOR);
        } else {
          $val = $sanitizer->selectorValue($val);
        }
        $valuesAND[$key] = $val;
      }
      
      $value = implode(',', $valuesAND); 
      $input->whitelist($name . rtrim($operator, '='), trim($value, '"\''));  
      
      foreach($valuesAND as $val) {
        $n++;
        $selectors["input-$n"] = "$name$operator$val";
      }
      
      $names[] = $name; 
      
    } // foreach input
  
    if($start) $selectors['start'] = "start=$start";
    $selectors['limit'] = "limit=$limit";
    $displaySelector = implode(',', $selectors);

    if(!$status && !$hasInclude && $superuser) {
      // superuser only
      $selectors['superuser'] = "include=all, status<" . Page::statusTrash;
    }

    $selector = implode(', ', $selectors);
    $selector = $this->findReady($selector);
    $items = $this->pages->find($selector);

    if(!$superuser && $checkEditAccess) {
      // filter out non-editable pages, since some may be included via include=unpublished
      foreach($items as $item) {
        if(!$item->editable()) $items->remove($item);
      }
    }
    
    $out = $this->render($items, $displaySelector);
    if($userLanguage) $user->language = $userLanguage;
    
    return $out; 
  }

  /**
   * Execute live search
   * 
   * @return string
   * 
   */
  public function executeLive() {
    require_once(dirname(__FILE__) . '/ProcessPageSearchLive.php'); 
    $liveSearch = new ProcessPageSearchLive($this);
    $liveSearch->setSearchTypesOrder($this->searchTypesOrder); 
    $liveSearch->setNoSearchTypes($this->noSearchTypes);
    $liveSearch->setDefaultOperators($this->operator, $this->operator2); 
    if($this->wire()->config->ajax) {
      header('Content-type: application/json'); 
      return $liveSearch->execute();
    } else {
      return $liveSearch->executeViewAll();
    }
  }

  /**
   * Get ID of the repeaters root page ID or 0 if not installed
   * 
   * @return int
   * 
   */
  public function getRepeatersPageID() {
    $session = $this->wire()->session;
    $repeaterID = $session->getFor($this, 'repeaterID');
    if(is_int($repeaterID)) return $repeaterID; 
    if($this->wire()->modules->isInstalled('FieldtypeRepeater')) {
      $repeaterPage = $this->wire()->pages->get(
        "parent_id=" . $this->wire()->config->adminRootPageID . ", " . 
        "name=repeaters, " . 
        "include=all"
      );
      $repeaterID = $repeaterPage->id; 
      $session->setFor($this, 'repeaterID', (int) $repeaterID);
    } else {
      $repeaterID = 0;
    }
    return $repeaterID;
  }

  /**
   * Return array of fields to display in results
   *
   */
  protected function getDisplayFields() {
    $sanitizer = $this->wire()->sanitizer;
    $input = $this->wire()->input;
    
    $display = (string) $input->get('display');
    
    if(!strlen($display)) $display = (string) $input->get('get'); // as required by ProcessPageSearch API 
    if(!strlen($display)) $display = (string) $this->displayField;
    if(!strlen($display)) $display = 'title path';
    
    $display = str_replace(',', ' ', $display);
    $display = explode(' ', $display); // convert to array

    foreach($display as $key => $name) {
      $name = $sanitizer->fieldName($name);
      $display[$key] = $name;
      if($this->isSelectableFieldName($name)) continue;
      if(in_array($name, array('url', 'path', 'httpUrl'))) continue;
      unset($display[$key]);
    }
    
    return array_values($display);
  }

  /**
   * As an alternative to getting specific fields, return a format string
   * 
   * This format string must be pre-populated to session variable:
   * ProcessPageSearch.[format_name] = '{title} - {path}'; // format string
   * 
   * The name the session variable must be provided as a GET var: format_name=[name]
   * 
   * @return array|string
   * 
   */
  protected function getDisplayFormat() {
    $name = $this->wire()->input->get('format_name');
    if(empty($name)) return '';
    $data = $this->wire()->session->getFor($this, "format_" . $name);
    if(empty($data)) return '';
    return array(
      'name' => $name,
      'format' => $data['format'],
      'textOnly' => $data['textOnly']
    );
  }

  /**
   * Set a display format
   * 
   * @param string $name Session var name that will be used, output will be returned in JSON results indexed by $name as well.
   * @param string $format Format string to pass to $page->getMarkup(str)
   * @param bool $textOnly 
   * 
   */
  public function setDisplayFormat($name, $format, $textOnly = false) {
    $this->wire()->session->setFor($this, "format_" . $name, array(
      'format' => $format,
      'textOnly' => $textOnly
    ));
  }

  /**
   * Set a selector to use when $_GET['for_selector_name'] matches given $name
   * 
   * This is for cases where you don't want the selector to pass through user input,
   * and you instead just want to pass the name of it via user input. This enables
   * use of some features that may not be available through user selectors passing
   * only through user input. 
   * 
   * Used in executeFor() mode only. 
   * 
   * @param string $name
   * @param string $selector
   * @return string Returns URL needed to use this selector
   * @since 3.0.223
   * 
   */
  public function setForSelector($name, $selector) {
    $this->wire()->session->setFor($this, "for_selector_$name", $selector);
    return $this->config->urls->admin . 'page/search/for?for_selector_name=' . urlencode($name);
  }

  /**
   * Get selector identified by $name that was previously set with setForSelector()
   * 
   * For executeFor() mode only.
   * 
   * #pw-internal
   * 
   * @param string $name
   * @return string
   * @since 3.0.223
   * 
   */
  public function getForSelector($name) {
    return (string) $this->wire()->session->getFor($this, "for_selector_$name");
  }

  /**
   * Render the search results
   * 
   * @param PageArray $matches
   * @param string $displaySelector
   * @return string
   *
   */
  protected function render(PageArray $matches, $displaySelector = '') {
    
    $input = $this->wire()->input;
    $ajax = $this->wire()->config->ajax;

    $out = '';
    
    if($displaySelector) {
      $this->message(
        sprintf(
          $this->_n('Found %1$d page using selector: %2$s', 'Found %1$d pages using selector: %2$s', $matches->getTotal()), 
          $matches->getTotal(), 
          $displaySelector
        )
      );
    }

    // determine what fields will be displayed
    $display = array();
    if($ajax) $display = $this->getDisplayFormat();
    if(empty($display)) {
      $display = $this->getDisplayFields();
      $input->whitelist('display', implode(',', $display));
    }

    if($ajax) {
      // ajax json output
      header("Content-type: application/json"); 
      $out = $this->renderMatchesAjax($matches, $display, $displaySelector); 

    } else {
      // html output
      $class = '';
      if((int) $input->get('show_options') !== 0 && $input->urlSegment1 != 'find') {
        $out = "\n<div id='ProcessPageSearchOptions'>" . $this->renderFullSearchForm() . "</div>";
        $class = 'show_options';
      } 

      $out .= 
        "\n<div id='ProcessPageSearchResults' class='$class'>" . 
          $this->renderMatchesTable($matches, $display) . 
        "\n</div>";
    }

    return $out;
  }

  /**
   * Build a selector based upon interactive choices from the search form 
   * 
   * Only used by execute(), not used by executeFor()
   * 
   * ~~~~~
   * Returns array(
   *   0 => $selector,         // string, main selector for search
   *   1 => $displaySelector,  // string, selector for display purposes
   *   2 => $initSelector,     // string, selector for initialization in Lister (the part user cannot change)
   *   3 => $defaultSelector   // string default selector used by Lister (the part user can change)
   * );
   * ~~~~~
   * @return array
   *
   */
  protected function buildSelector() {
    
    $input = $this->wire()->input;
    $sanitizer = $this->wire()->sanitizer;
    $user = $this->wire()->user;
    $pages = $this->wire()->pages;
    $config = $this->wire()->config;
    
    $selector = ''; // for regular ProcessPageSearch
    
    // search query text
    $q = (string) $input->whitelist('q');
    if(strlen($q)) { 
      // GET vars "property" or "field" can used interchangably
      if($input->whitelist('property')) {
        $searchFields = array($input->whitelist('property'));
      } else if($input->whitelist('field')) {
        $searchFields = explode(' ', $input->whitelist('field'));
      } else {  
        $searchFields = $input->get('live') ? $this->searchFields2 : $this->searchFields;
        if(is_string($searchFields)) $searchFields = explode(' ', $searchFields);
      }
      foreach($searchFields as $fieldName) {
        $fieldName = $sanitizer->fieldName($fieldName);
        $selector .= "$fieldName|";
      }
      $selector = rtrim($selector, '|') . $this->operator . $sanitizer->selectorValue($q);
    } 

    // determine if results are sorted by something other than relevance
    $sort = $input->whitelist('sort');
    if($sort && $sort != 'relevance') {
      $reverse = $input->whitelist('reverse') ? "-" : '';
      $selector .= ", sort=$reverse$sort";

      // if a specific template isn't requested, then locate the templates that use this field and confine the search to them
      if(!$input->whitelist('template') && !isset($this->nativeSorts[$sort])) {
        $templates = array();
        foreach($this->templates as $template) {
          if($template->fieldgroup->has($sort)) $templates[] = $template->name;
        }
        if(count($templates)) $selector .= ", template=" . implode("|", $templates);
      }
    }

    // determine if search limited to a specific template
    if($input->whitelist('template')) {
      $selector .= ", template=" . $input->whitelist('template');
    }
    
    $trash = $input->whitelist('trash');
    if($trash !== null && $user->isSuperuser()) {
      if($trash === 0) {
        $selector .= ", status!=trash";
      } else if($trash === 1) {
        $selector .= ", status=trash, include=all";
      }
    }

    if(!$selector) {
      if(!$this->lister) $this->error($this->_("No search specified"));
      return array('','','','');
    }

    $selector = trim($selector, ", ");

    $displaySelector = $selector; // highlight the selector that was used for display purposes
    $defaultSelector = $selector; // user changable selector in Lister
    $initSelector = '' ; // non-user changable selector in Lister
    $s = ''; // anything added to this will be populated to both $selector and $initSelector below

    // limit results for pagination
    $s .= ", limit=$this->resultLimit";
    
    $adminRootPage = $pages->get($config->adminRootPageID); 

    // exclude admin repeater pages unless the admin template is chosen
    if(!$input->whitelist('template')) {
      // but only for superuser, as we're excluding all admin pages for non-superusers
      if($this->user->isSuperuser()) {
        $repeaters = $adminRootPage->child('name=repeaters, include=all');
        if($repeaters->id) $s .= ", has_parent!=$repeaters->id";
      }
    }

    // include hidden pages
    if($user->isSuperuser()) {
      $s .= ", include=all";
    } else {
      // non superuser doesn't get any admin pages in their results
      $s .= ", has_parent!=$adminRootPage"; 
      // if user has any kind of edit access, allow unpublished pages to be included
      if($user->hasPermission('page-edit')) $s .= ", include=unpublished";
    }
    
    $selector .= $s; 
    $initSelector .= $s; 
    
    return array($selector, $displaySelector, trim($initSelector, ', '), $defaultSelector); 
  }

  /**
   * Process input from the search form
   *
   */
  protected function processInput() {

    $user = $this->wire()->user;
    $input = $this->wire()->input;
    $sanitizer = $this->wire()->sanitizer;

    // search query
    $q = $input->get('q');
    if($q !== null) $this->processInputQuery($q);

    // search fields (can optionally contain multiple CSV field names)
    $field = $input->get('field');
    if($field) {
      $field = str_replace(',', ' ', $field);
      $fieldArray = explode(' ', $field);
      $field = '';
      foreach($fieldArray as $f) {
        $f = $sanitizer->fieldName($f);
        if(!isset($this->fieldOptions[$f]) && !isset($this->nativeSorts[$f])) continue;
        $field .= $f . " ";
      }
      $field = rtrim($field, " ");
      if($field) {
        $this->searchFields = $field;
        $input->whitelist('field', $field);
      }
    } else if($input->get('live')) {
      $input->whitelist('field', $this->searchFields2);
    } else {
      $input->whitelist('field', $this->searchFields);
    }

    // operator, search type
    if(empty($this->operator)) $this->operator = self::defaultOperator; 
    $operator = $input->get('operator'); 
    if(!is_null($operator)) {
      if(array_key_exists($operator, $this->operators)) {
        $this->operator = substr($this->input->get('operator'), 0, 3);
      } else if(ctype_digit("$operator")) { 
        $operators = array_keys($this->operators); 
        if(isset($operators[$operator])) $this->operator = $operators[$operator]; 
      }
      $input->whitelist('operator', $this->operator);
    }

    // sort
    $input->whitelist('sort', 'relevance');
    $sort = $input->get('sort');
    if($sort) {
      $sort = $sanitizer->fieldName($sort);
      if($sort && (isset($this->nativeSorts[$sort]) || isset($this->fieldOptions[$sort]))) {
        $input->whitelist('sort', $sort);
      }
      if($input->get('reverse')) {
        $input->whitelist('reverse', 1);
      }
    }

    // template
    $template = $input->get('template');
    if($template) {
      $template = $sanitizer->templateName($template);
      $template = $this->wire()->templates->get($template);
      if($template && $user->hasPermission('page-view', $template)) {
        $input->whitelist('template', $template->name);
      }
    }
  
    // trash (liveSearch)
    $trash = $input->get('trash');
    if($trash !== null && $user->isSuperuser()) {
      $trash = (int) $trash;
      if($trash === 0 || $trash === 1) {
        $input->whitelist('trash', $trash);
      }
    }
  
    // custom property (like 'field', except can contain only one name)
    $property = $input->get('property');
    if($property !== null) {
      $property = $sanitizer->fieldName($property);
      if($this->isSelectableFieldName($property)) {
        $input->whitelist('property', $property);
      }
    }
  }

  /**
   * Process input for the $q query variable
   * 
   * Since $q can also have type, field/property, operator and search text embedded within it,
   * this function separates all of those out and populates GET variables for them, when present.
   * 
   * @param $q
   * 
   */
  protected function processInputQuery($q) {
    
    $input = $this->wire()->input;
    $sanitizer = $this->wire()->sanitizer;

    $q = trim($sanitizer->text($q));
    $redirectUrl = '';
    $operators = $this->operators;
    $type = '';
    
    $operators['=='] = 'Equals';
    $operators[':'] = 'Auto'; // alternative to '='

    // handle cases where search type (template), property, and operator are bundled in with the $q
    if(!$this->operator) $this->operator = '%=';

    // deetermine which operator (if any) is present in $q
    foreach($operators as $operator => $description) {
      if(strpos($q, $operator) === false) continue;
      if(!preg_match('/^([^=%$*+<>~^:]+)' . $operator . '([^=%$*+<>~^:]+)$/', $q, $matches)) continue;
      if($operator === '=') $operator = '?'; // operator to be determined on factors search text
      if($operator === '==') $operator = '=';
      $type = $sanitizer->name($matches[1]);
      $q = trim($matches[2]);
      break;
    }

    if($operator === '?') {
      // operator was '=': use 'contains words' operator if there is more than one word in $q
      $operator = strpos($q, ' ') ? '~=' : $this->operator;
    } else if(empty($operator)) {
      // operator was not present, only query text was, so use default operator
      $operator = $this->operator;
    }
    $input->get->set('operator', $operator);

    if(strpos($type, '.')) {
      // type with property/field
      list($type, $field) = explode('.', $type, 2);
      $field = $sanitizer->fieldName(trim($field));
      $input->get->set('field', $field);
    } else {
      $field = '';
    }

    if($type == 'pages') {
      // okay
    } else if($type == 'trash') {
      $input->get->set('trash', 1);
    } else if($type) {
      $template = $this->wire()->templates->get($type);
      if($template) {
        // defined template
        $input->get->set('template', $template->name);
      } else {
        // some other non-page type
        $redirectUrl = $this->wire()->page->url . 'live/' .
          '?q=' . urlencode($q) .
          '&type=' . urlencode($type) .
          '&property=' . urlencode($field) .
          '&operator=' . urlencode($operator);
      }
    }
    
    if($redirectUrl) $this->wire()->session->redirect($redirectUrl);
    
    $input->whitelist('q', $q);
  }


  /**
   * Is the given field name selectable?
   * 
   * @param string $name Field "name" or "name1|name2|name3"
   * @param int $level Greater than 0 when recursive
   * @return bool
   *
   */
  protected function isSelectableFieldName($name, $level = 0) {
    
    $selectable = array(
      'parent', 
      'template', 
      'template_label', 
      'has_parent', 
      'hasParent', 
      'children', 
      'numChildren', 
      'num_children', 
      'count', 
      'path', 
      'owner',
    );
    
    $notSelectable = array(
      // must be lowercase
      'pass',
      'config',
      'it',
      'display',
    );
    
    $noSubnames = array(
      // must be lowercase
      'include',
      'check_access',
      'checkaccess',
    );

    $is = false;

    if(!$level && strpos($name, '|') !== false) {
      // a|b|c
      // note: use filterSelectableFieldName to instead remove non-selectable fields
      $names = explode('|', $name); 
      $cnt = 0;
      foreach($names as $n) {
        if(!$this->isSelectableFieldName($n, $level + 1)) $cnt++;
      }
      return $cnt == 0; 
    } 
    
    if(strpos($name, '.')) {
      // field.subfield
      list($name, $subname) = explode('.', $name, 2);
      if(strpos($subname, '.') !== false && !$this->isSelectableFieldName($subname, $level + 1)) return false;
      if(in_array(strtolower($subname), $noSubnames)) return false;
      if(in_array(strtolower($subname), $notSelectable)) return false;
      if(!$this->isSelectableFieldName($name, $level + 1)) return false;
      $field = isset($this->fieldOptions[$name]) ? $this->wire()->fields->get($name) : null;
      if($field && $field->type) {
        if(!$field->viewable()) return false;
        if(strpos($subname, 'owner.') === 0 && wireInstanceOf($field->type, array('FieldtypePage', 'FieldtypeRepeater'))) {
          list(, $tername) = explode('.', $subname, 2); 
          if($this->isSelectableFieldName($tername, $level + 1)) return true;
        }
        $info = $field->type->getSelectorInfo($field);
        if(isset($info['subfields'][$subname])) return true;
        if($field->type instanceof FieldtypePage) return $this->isSelectableFieldName($subname, $level + 1);
      } else if($name === 'parent' || $name === 'children' || $name === 'owner') {
        if(in_array($subname, $selectable)) return true;
        if(isset($this->nativeSorts[$subname])) return true;
        return $this->isSelectableFieldName($subname, $level + 1);
      }
      return false;
    }
    
    $lowerName = strtolower($name);
    
    if($lowerName == 'path') {
      if($this->wire()->languages || !$this->wire()->modules->isInstalled('PagePaths')) {
        $name = 'name';
        $lowerName = $name;
      }
    }

    if(isset($this->nativeSorts[$name])) {
      // native sort properties
      $is = true;
    } else if(in_array($name, $selectable)) {
      // always selectable properties
      $is = true;
    } else if(!$level && in_array($name, array('include', 'status', 'check_access'))) {
      // selectable, but only if not OR’d with other fields (level=0), and must be access checked outside this method
      $is = true;
    } else if(isset($this->fieldOptions[$name])) {
      // custom fields
      $field = $this->wire()->fields->get($name);
      $is = $field && $field->viewable();
    }

    if($is && in_array($lowerName, $notSelectable)) {
      $is = false;
    }
    
    return $is; 
  }
  
  /**
   * Given string 'name' or 'name1|name2|name3' remove any 'name(s)' that are not selectable and return
   * 
   * @param string $name
   * @return string
   * @since 3.0.190
   * 
   */
  protected function filterSelectableFieldName($name) {
    if(!strlen($name)) return '';
    $onlySingles = array('include', 'check_access', 'checkaccess', 'status');
    $names = strpos($name, '|') !== false ? explode('|', $name) : array($name);
    $qty = count($names);
    foreach($names as $key => $name) {
      $lowerName = strtolower($name); 
      if(empty($name) || ($qty > 1 && in_array($lowerName,  $onlySingles))) {
        unset($names[$key]); 
      } else if(!$this->isSelectableFieldName($name)) {
        unset($names[$key]);
      }
    }
    return count($names) ? implode('|', $names) : '';
  }

  protected function renderFullSearchForm() {
    
    $input = $this->wire()->input;
    $modules = $this->wire()->modules;

    // Search options

    $out  = "\n\t<p id='wrap_search_query'>";

    $out .= 
      "\n\t<p id='wrap_search_field'>" .
      "\n\t<label for='search_field'>" . $this->_('Search in field(s):') . "</label>" .
      "\n\t<input type='text' name='field' value='" . htmlentities($this->searchFields, ENT_QUOTES) . "' />" .
      "\n\t</p>";

    $out .= 
      "\n\t<p id='wrap_search_operator'>" .
      "\n\t<label for='search_operator'>" . $this->_('Type of search:') . "</label>" .
      "\n\t<select id='search_operator' name='operator'>";

    $n = 0;
    foreach($this->operators as $operator => $desc) {
      $attrs = $this->operator === $operator ? " selected='selected'" : '';
      $out .= "\n\t\t<option$attrs value='$n'>$desc (a" . htmlentities($operator) . "b)</option>";
      $n++;
    }
    $out .= 
      "\n\t</select>" .
      "\n\t</p>";

    $out .= 
      "\n\t<label class='ui-priority-primary' for='search_query'>" . $this->_('Search for:') . "</label>" .
      "\n\t<input id='search_query' type='text' name='q' value='" . htmlentities($input->whitelist('q'), ENT_QUOTES, "UTF-8") . "' />" .
      "\n\t<input type='hidden' name='show_options' value='1' />" . 
      "\n\t</p>";


    // Advanced

    $advCollapsed = true; 

    $out2 = 
      "\n\t<p id='wrap_search_template'>" .
      "\n\t<label for='search_template'>" . $this->_('Limit to template:') . "</label>" .
      "\n\t<select id='search_template' name='template'>" .
      "\n\t\t<option></option>";

    $templateName = $input->whitelist('template');
    if($templateName) $advCollapsed = false;
    foreach($this->wire()->templates as $template) {
      $attrs = $template->name === $templateName ? " selected='selected'" : '';
      $out2 .= "\n\t<option$attrs>$template->name</option>";
    }

    $out2 .= 
      "\n\t</select>" .
      "\n\t</p>";


    $out2.= 
      "\n\t<p id='wrap_search_sort'>" .
      "\n\t<label for='search_sort'>" . $this->_('Sort by:') . "</label>" .
      "\n\t<select id='search_sort' name='sort'>";

    $sorts = $this->nativeSorts + $this->fieldOptions;

    $sort = $input->whitelist('sort');
    if($sort && $sort != 'relevance') $advCollapsed = false;
    foreach($sorts as $s) {
      if(strpos($s, ' ')) continue; // skip over multi fields
      $attrs = '';
      if($s === $sort) $attrs = " selected='selected'";
      $out2 .= "\n\t\t<option$attrs>$s</option>";
    }

    $out2 .= 
      "\n\t</select>" .
      "\n\t</p>";

    if($sort != 'relevance') {
      $reverse = $input->whitelist('reverse'); 
      $out2 .= 
        "\n\t<p id='wrap_search_options'>" .
        "\n\t<label><input type='checkbox' name='reverse' value='1' " . ($reverse ? "checked='checked' " : '') . "/> " . $this->_('Reverse sort?') . "</label>" .
        "\n\t</p>";
      if($reverse) $advCollapsed = false;
    }

    $display = $input->whitelist('display'); 
    $out2 .= 
      "\n\t<p id='wrap_search_display'>" .
      "\n\t<label for='search_display'>" . $this->_('Display field(s):') . "</label>" .
      "\n\t<input type='text' name='display' value='" . htmlentities($display, ENT_QUOTES) . "' />" .
      "\n\t</p>";
    if($display && $display != 'title,path') $advCollapsed = false;


    /** @var InputfieldSubmit $submit */
    $submit = $modules->get("InputfieldSubmit");
    $submit->attr('name', 'submit');
    $submit->attr('value', $this->_x('Search', 'submit')); // Search submit button for advanced search
    $out .= "<p>" . $submit->render() . "</p>";

    /** @var InputfieldForm $form */
    $form = $modules->get("InputfieldForm");
    $form->attr('id', 'ProcessPageSearchOptionsForm');
    $form->method = 'get';
    $form->action = './';

    /** @var InputfieldMarkup $field */
    $field = $modules->get("InputfieldMarkup");
    $field->label = $this->_("Search Options");
    $field->value = $out;

    $form->add($field);

    /** @var InputfieldMarkup $field */
    $field = $modules->get("InputfieldMarkup");
    if($advCollapsed) $field->collapsed = Inputfield::collapsedYes; 
    $field->label = $this->_("Advanced");
    $field->value = $out2;

    $form->add($field);

    return $form->render();
  }


  /**
   * Render a table of matches
   * 
   * @param PageArray $matches
   * @param array $display Fields to display (from getDisplayFields method)
   * @return string
   * 
   */
  protected function renderMatchesTable(PageArray $matches, array $display) {
    
    $input = $this->wire()->input;
    $config = $this->wire()->config;
    $modules = $this->wire()->modules;

    if(!count($matches)) return '';
    if(!count($display)) $display = array('path'); 
    
    /** @var MarkupAdminDataTable $table */
    $table = $modules->get("MarkupAdminDataTable");
    $table->setSortable(false); 
    $table->setEncodeEntities(false);
    $header = $display;
    $header[] = "";
    $table->headerRow($header);

    foreach($matches as $match) {
      $match->setOutputFormatting(true);
      $editUrl = "{$config->urls->admin}page/edit/?id={$match->id}";
      $viewUrl = $match->url();
      $row = array();
      foreach($display as $name) {
        $value = $match->get($name);
        if($value instanceof Page) $value = $value->name;
        $value = strip_tags($value);
        if($name == 'created' || $name == 'modified' || $name == 'published') $value = date('Y-m-d H:i:s', $value);
        $value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
        $row[] = "<a href='$viewUrl'>$value</a>";
      }
      $row[] = $match->editable() ? "<a class='action' href='$editUrl'>" . $this->_('edit') . "</a>" : '&nbsp;';
      $table->row($row);

    }

    if($matches->getTotal() > count($matches)) {  
      /** @var MarkupPagerNav $pager */
      $pager = $modules->get('MarkupPagerNav');
      if($input->urlSegment1 == 'for') $pager->setBaseUrl($this->wire()->page->url . "for/"); 
      $pager = $pager->render($matches); 
    } else {
      $pager = '';
    }
    
    $out = $pager . $table->render() . $pager;

    return $out;
  }

  /**
   * Render the provided matches as a JSON string for AJAX use
   * 
   * @param PageArray $matches
   * @param array $display Array of fields to display, or display format associative array
   * @param string $selector
   * @return string
   *
   */
  protected function renderMatchesAjax(PageArray $matches, $display, $selector) {

    $a = array(
      'selector' => $selector, 
      'total' => $matches->getTotal(),
      'limit' => $matches->getLimit(),
      'start' => $matches->getStart(),
      'matches' => array(),
    );

    // determine which template label we'll be asking for (for multi-language support)
    $templateLabel = 'label';
    if($this->wire()->languages) {
      $language = $this->wire()->user->language; 
      if($language && !$language->isDefault()) $templateLabel = "label$language";
    }
    
    foreach($matches as $page) {
      /** @var Page $page */

      $p = array(
        'id' => $page->id, 
        'parent_id' => $page->parent_id, 
        'template' => $page->template->name, 
        'path' => $page->path, 
        'name' => $page->name, 
      );
      
      if($this->adminSearchMode) {
        // don't include non-editable pages in admin search mode
        if(!$page->editable()) {
          $a['total']--;
          continue; 
        }
        // include the type of match and URL to edit, when in adminSearchMode
        $p['type'] = $this->_x('Pages', 'match-type');
        $p['editUrl'] = $page->editable() ? $page->editUrl() : '';
      }
      
      if(isset($display['name']) && isset($display['format'])) {
        // use display format, returning a 'value' property containing the formatted value
        if($display['textOnly']) {
          $value = $page->getText($display['format'], true, false);
        } else {
          $value = $page->getMarkup($display['format']);
        }
        $p[$display['name']] = $value; 
        
      } else {
        // use display fields
        foreach($display as $key) {

          if($key == 'template_label') {
            $p['template_label'] = $page->template->$templateLabel ? $page->template->$templateLabel : $page->template->label;
            if(empty($p['template_label'])) $p['template_label'] = $page->template->name;
            continue;
          }

          $value = $page->get($key);
          if(empty($value) && $this->adminSearchMode) {
            if($key == 'title') $value = $page->name; // prevent empty title
          }

          if(is_object($value)) $value = $this->setupObjectMatch($value);
          if(is_array($value)) $value = $this->setupArrayMatch($value);

          $p[$key] = $value;
        }
      }

      $a['matches'][] = $p;
    }

    return json_encode($a);   
  }

  /**
   * Convert object to an array where possible, otherwise convert to a string
   *
   * For use by renderMatchesAjax
   * 
   * @param Page|WireData|WireArray|Wire|object $o
   * @return array|string
   *
   */
  protected function setupObjectMatch($o) {
    if($o instanceof Page) {
      return array(
        'id' => $o->id,
        'parent_id' => $o->parent_id,
        'template' => $o->template->name,
        'name' => $o->name,
        'path' => $o->path,
        'title' => $o->title
      ); 
    }
    if($o instanceof WireData || $o instanceof WireArray) return $o->getArray();
    return (string) $o;
  }

  /**
   * Filter an array converting any indexes containing objects to arrays or strings
   *
   * For use by renderMatchesAjax
   * 
   * @param array $a
   * @return array
   *
   */
  protected function setupArrayMatch(array $a) {
    foreach($a as $key => $value) {
      if(is_object($value)) $a[$key] = $this->setupObjectMatch($value);
        else if(is_array($value)) $a[$key] = $this->setupArrayMatch($value); 
    }
    return $a; 
  }

  /**
   * Render search for that submits to this process
   * 
   * @param string $placeholder Value for placeholder attribute in search input
   * @return string
   * 
   */
  public function renderSearchForm($placeholder = '') {
    $sanitizer = $this->wire()->sanitizer;

    $q = substr((string) $this->wire()->input->get('q'), 0, 128);
    $q = $sanitizer->entities($q); 
    $adminURL = $this->wire()->config->urls->admin; 
    
    if($placeholder) {
      $placeholder = $sanitizer->entities1($placeholder); 
      $placeholder = " placeholder='$placeholder'";
    } else {
      $placeholder = '';
    }
    
    $action = $adminURL . 'page/search/live/';
    
    $out =  
      "\n<form id='ProcessPageSearchForm' data-action='$action' action='$action' method='get'>" .
      "\n\t<label for='ProcessPageSearchQuery'><i class='fa fa-search'></i></label>" . 
      "\n\t<input type='text' id='ProcessPageSearchQuery' name='q' value='$q' $placeholder />" .
      "\n\t<input type='submit' id='ProcessPageSearchSubmit' name='search' value='Search' />" . 
      "\n\t<input type='hidden' name='show_options' value='1' />" .
      "\n\t<span id='ProcessPageSearchStatus'></span>" .
      "\n</form>";

    return $out;

  }

  public function getModuleConfigInputfields(array $data) {
    
    $modules = $this->wire()->modules;

    $adminLiveSearchLabel = $this->_('Admin live search');
    $inputfields = $this->wire(new InputfieldWrapper());
    $textFields = array();
    $allSearchTypes = array('pages', 'trash', 'modules');
    $textOperators = Selectors::getOperators(array(
      'compareType' => Selector::compareTypeFind,
      'getIndexType' => 'operator',
      'getValueType' => 'label',
    )); 
    $textOperators['='] = SelectorEqual::getLabel();
    unset($textOperators['#=']);
    
    if(!isset($data['searchTypesOrder'])) $data['searchTypesOrder'] = array();
    if(!isset($data['noSearchTypes'])) $data['noSearchTypes'] = array();
    
    $searchTypesOrder = &$data['searchTypesOrder'];
    $noSearchTypes = &$data['noSearchTypes'];
  
    // find all text fields
    foreach($this->wire()->fields as $field) {
      if(!$field->type instanceof FieldtypeText) continue;
      $textFields[$field->name] = $field;
    }
  
    // ensure that base/built-in search types are present 
    foreach($allSearchTypes as $key) {
      if(!in_array($key, $searchTypesOrder)) $searchTypesOrder[] = $key;
    }
  
    // find searchable modules
    foreach($modules as $module) {
      $info = $modules->getModuleInfoVerbose($module);
      if(empty($info['searchable'])) continue;
      $name = $info['searchable'];
      if(is_bool($name) || ctype_digit($name)) $name = $info['name'];
      $allSearchTypes[$name] = $name;
      if(!in_array($name, $searchTypesOrder)) $searchTypesOrder[] = $name;
    }
  
    /** @var InputfieldFieldset $fieldset */
    $fieldset = $modules->get('InputfieldFieldset'); 
    $fieldset->label = $adminLiveSearchLabel; 
    $fieldset->icon = 'search';
    $inputfields->add($fieldset);

    /** @var InputfieldAsmSelect $f */
    $f = $modules->get('InputfieldAsmSelect');
    $f->attr('name', 'searchTypesOrder');
    $f->label = $this->_('Search order');
    $f->description = 
      $this->_('These are the types of searches that will be performed during an admin live search.') . ' ' . 
      $this->_('Drag them to the order you want the search results to be listed in.');
    foreach($allSearchTypes as $name) {
      $label = $name;
      if(in_array($name, $noSearchTypes)) $label .= ' ' . $this->_('(excluded)');
      $f->addOption($name, $label);
    }
    $f->attr('value', $searchTypesOrder); 
    $f->setAsmSelectOption('deletable', false);
    $f->setAsmSelectOption('addable', false); 
    $fieldset->add($f);
  
    /** @var InputfieldAsmSelect $f */
    $f = $modules->get('InputfieldAsmSelect');
    $f->attr('name', 'noSearchTypes');
    $f->label = $this->_('Exclude search types');
    $f->description =
      $this->_('Select any search types that you want to exclude from live search. These might be types you don’t often need to search.') . ' ' .
      $this->_('The more types excluded, the faster the live search will perform.') . ' ' . 
      $this->_('Any selected types can still be searched if asked for specifically in the search.') . ' ' .
      $this->_('For example, if you excluded the “trash” type, it could still be searched if you prefixed your search with “trash=”, like “trash=hello”.');
    foreach($allSearchTypes as $name) {
      $f->addOption($name);
    }
    $f->attr('value', $noSearchTypes);
    $fieldset->add($f);
    
    /** @var InputfieldFieldset $fieldset */
    $fieldset = $modules->get('InputfieldFieldset');
    $fieldset->label = $adminLiveSearchLabel . ' ' . $this->_('(settings for pages type)'); 
    $fieldset->icon = 'search';
    $fieldset->themeOffset = 'm';
    $inputfields->add($fieldset);
  
    /** @var InputfieldAsmSelect $f */
    $f = $modules->get('InputfieldAsmSelect');
    $f->attr('name', 'searchFields2');
    $f->label = $this->_('Page fields to search');
    $f->description = 
      $this->_('This applies to search results from “pages” and “trash” only.') . ' ' . 
      $this->_("We recommend limiting this to 1 or 2 fields at the most to ensure the live search is fast. Typically you would just search the “title” field."); // Fields to search description
    foreach($textFields as $field) $f->addOption($field->name); 
    $value = isset($data['searchFields2']) ? $data['searchFields2'] : array('title');
    $value = !is_array($value) ? explode(' ', $value) : $value;
    $f->value = $value;
    $fieldset->add($f);

    /** @var InputfieldAsmSelect $f */
    $f = $modules->get('InputfieldAsmSelect');
    $f->attr('name', 'searchFields');
    $f->label = $this->_('Page fields to search if user hits “enter” in the search box');
    $f->description = 
      $this->_('Typically this would be the same as above, but you might also want to add additional field(s).') . ' ' . 
      $this->_('For instance, rather than just searching the “title” field, you might want to also search a “body” field as well.'); 
    foreach($textFields as $field) $f->addOption($field->name);
    $value = isset($data['searchFields']) ? $data['searchFields'] : array('title', 'body');
    $value = !is_array($value) ? explode(' ', $value) : $value;
    $f->value = $value;
    $fieldset->append($f);

    /** @var InputfieldSelect $f */
    $f = $modules->get("InputfieldSelect");
    $f->attr('name', 'operator');
    $f->attr('value', isset($data['operator']) ? $data['operator'] : self::defaultOperator);
    $f->label = $this->_('Default search operator for single and partial word searches');
    $f->columnWidth = 50;
    foreach($textOperators as $operator => $label) {
      $f->addOption($operator, "$operator $label");
    }
    $fieldset->append($f);

    /** @var InputfieldSelect $f */
    $f = $modules->get("InputfieldSelect");
    $f->attr('name', 'operator2');
    $f->attr('value', isset($data['operator2']) ? $data['operator2'] : '~=');
    $f->label = $this->_('Default search operator for multi-word (phrase) searches');
    $f->columnWidth = 50;
    foreach($textOperators as $operator => $label) {
      $f->addOption($operator, "$operator $label");
    }
    $fieldset->append($f);
    
    // displayField: no longer used, except if user lacks page-lister permission
    /** @var InputfieldHidden $f */
    $f = $modules->get("InputfieldHidden");
    $f->attr('name', 'displayField');
    $f->attr('value', isset($data['displayField']) ? $data['displayField'] : 'name');
    $f->label = $this->_("Default field name(s) to display in search results");
    $f->description = $this->_("If specifying more than one field, separate each with a space.");
    $inputfields->append($f);

    return $inputfields;
  }
}