text: <?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 2019 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' => 106,
  			'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() {
  		$headline = $this->_x('Search', 'headline'); // Headline for search page
  		if($this->input->get('processHeadline')) {
  			$headline = $this->sanitizer->entities($this->sanitizer->text($this->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() {
  		if($this->lister) return $this->lister;
  		if($this->wire('user')->hasPermission('page-lister')) {
  			if($this->wire('modules')->isInstalled('ProcessPageLister')) {
  				$this->lister = $this->wire('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() {
  		
  		/** @var Languages $languages */
  		$languages = $this->wire('languages');
  		/** @var User $user */
  		$user = $this->wire('user');
  		/** @var WireInput $input */
  		$input = $this->wire('input');
  		/** @var Sanitizer $sanitizer */
  		$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 = '';
  		
  		// names to skip (must be lowercase)
  		$skipNames = array(
  			'get', 
  			'display', 
  			'format_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 = substr($name, -1) . '=';
  
  				if(isset($this->operators[$operator])) {
  					$name = substr($name, 0, -1); 
  				} else {
  					$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); 
  
  			if(!$this->isSelectableFieldName($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) {
  				$selectors[] = "$name$operator$val";
  			}
  			
  			$names[] = $name; 
  			
  		} // foreach input
  	
  		if($start) $selectors[] = "start=$start";
  		$selectors[] = "limit=$limit";
  		$displaySelector = implode(',', $selectors);
  
  		if(!$status && !$hasInclude && $superuser) {
  			// superuser only
  			$selectors[] = "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() {
  		/** @var Session $session */
  		$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() {
  		
  		$display = $this->input->get('display');
  		
  		if(!strlen($display)) $display = $this->input->get('get'); // as required by ProcessPageSearch API 
  		if(!strlen($display)) $display = $this->displayField;
  		if(!strlen($display)) $display = 'title path';
  		
  		$display = str_replace(',', ' ', $display);
  		$display = explode(' ', $display); // convert to array
  
  		foreach($display as $key => $name) {
  			$name = $this->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 mixed|string
  	 * 
  	 */
  	protected function getDisplayFormat() {
  		$name = $this->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
  		));
  	}
  
  	/**
  	 * Render the search results
  	 * 
  	 * @param PageArray $matches
  	 * @param string $displaySelector
  	 * @return string
  	 *
  	 */
  	protected function render(PageArray $matches, $displaySelector = '') {
  
  		$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($this->config->ajax) $display = $this->getDisplayFormat();
  		if(empty($display)) {
  			$display = $this->getDisplayFields();
  			$this->input->whitelist('display', implode(',', $display));
  		}
  
  		if($this->config->ajax) {
  			// ajax json output
  			header("Content-type: application/json"); 
  			$out = $this->renderMatchesAjax($matches, $display, $displaySelector); 
  
  		} else {
  			// html output
  			$class = '';
  			if((int) $this->input->get('show_options') !== 0 && $this->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() {
  		$selector = ''; // for regular ProcessPageSearch
  		$input = $this->wire('input');
  		
  		// search query text
  		$q = $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 = $this->sanitizer->fieldName($fieldName);
  				$selector .= "$fieldName|";
  			}
  			$selector = rtrim($selector, '|') . $this->operator . $this->wire('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 && $this->wire('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 = $this->wire('pages')->get($this->wire('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($this->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($this->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() {
  	
  		/** @var WireInput $input */
  		$input = $this->wire('input');
  		/** @var Sanitizer $sanitizer */
  		$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 && $this->wire('user')->hasPermission('page-view', $template)) {
  				$input->whitelist('template', $template->name);
  			}
  		}
  	
  		// trash (liveSearch)
  		$trash = $input->get('trash');
  		if($trash !== null && $this->wire('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) {
  		
  		/** @var WireInput $input */
  		$input = $this->wire('input');
  		/** @var Sanitizer $sanitizer */
  		$sanitizer = $this->wire('sanitizer');
  
  		$q = trim($sanitizer->text($q));
  		$redirectUrl = '';
  		$operator = '';
  		$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 = $this->wire('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 = $type ? $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', 
  		);
  		
  		$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) {
  			$names = explode('|', $name); 
  			$cnt = 0;
  			foreach($names as $n) {
  				if(!$this->isSelectableFieldName($n, $level + 1)) $cnt++;
  			}
  			return $cnt == 0; 
  
  		} else if(strpos($name, '.')) {
  			list($name, $subname) = explode('.', $name, 2); 
  			if(strpos($subname, '.') !== false) return false;
  			if(in_array(strtolower($subname), $noSubnames)) return false;
  			if(!$this->isSelectableFieldName($subname, $level)) return false;	
  		}
  		
  		$lowerName = strtolower($name);
  		
  		if($lowerName == 'path' && (!$this->wire('modules')->isInstalled('PagePaths') || $this->wire('languages'))) {
  			$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
  			$is = true;
  		}
  
  		if(in_array($lowerName, $notSelectable)) {
  			$is = false;
  		}
  		
  		return $is; 
  	}
  
  	protected function renderFullSearchForm() {
  
  		// 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($this->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 = $this->input->whitelist('template');
  		if($templateName) $advCollapsed = false;
  		foreach($this->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 = $this->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 = $this->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 = $this->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 = $this->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 = $this->modules->get("InputfieldForm");
  		$form->attr('id', 'ProcessPageSearchOptionsForm');
  		$form->method = 'get';
  		$form->action = './';
  
  		/** @var InputfieldMarkup $field */
  		$field = $this->modules->get("InputfieldMarkup");
  		$field->label = $this->_("Search Options");
  		$field->value = $out;
  
  		$form->add($field);
  
  		$field = $this->modules->get("InputfieldMarkup");
  		if($advCollapsed) $field->collapsed = Inputfield::collapsedYes; 
  		$field->label = $this->_("Advanced");
  		$field->value = $out2;
  
  		$form->add($field);
  
  		/* no longer in use
  		$field = $this->modules->get("InputfieldMarkup"); 
  		$field->id = 'ProcessPageSearchShortcuts'; 
  		$field->collapsed = true; 
  		$field->label = $this->_("Shortcuts");
  		$field->value = $this->renderShortcuts();
  		*/
  
  		$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) {
  
  		if(!count($display)) $display = array('path'); 
  		$out = '';
  
  		if(!count($matches)) return $out;
  		/** @var MarkupAdminDataTable $table */
  		$table = $this->modules->get("MarkupAdminDataTable");
  		$table->setSortable(false); 
  		$table->setEncodeEntities(false);
  		$header = $display;
  		$header[] = "";
  		$table->headerRow($header);
  
  		foreach($matches as $match) {
  			$match->setOutputFormatting(true);
  			$editUrl = "{$this->config->urls->admin}page/edit/?id={$match->id}";
  			$viewUrl = $match->url();
  			$row = array();
  			foreach($display as $name) {
  				$value = $match->get($name);
  				if(is_object($value)) {
  					if($value instanceof Page) $value = $value->name;
  				}
  				$value = strip_tags($value);
  				if($name == 'created' || $name == 'modified' || $name == 'published') $value = date(DATE_ISO8601, $value);
  				$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)) {	
  			$pager = $this->wire('modules')->get('MarkupPagerNav');
  			if($this->input->urlSegment1 == 'for') $pager->setBaseUrl($this->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->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() ? $this->config->urls->admin . 'page/edit/?id=' . $page->id : '';
  			}
  			
  			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 $k => $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 = '') {
  
  		$q = substr($this->input->get('q'), 0, 128);
  		$q = $this->wire('sanitizer')->entities($q); 
  		$adminURL = $this->wire('config')->urls->admin; 
  		
  		if($placeholder) {
  			$placeholder = $this->wire('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' />" . //" . $this->_x('Search', 'input') . "' />" . // Text that appears as the placeholder text in the top search submit input
  			"\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) {
  
  		$adminLiveSearchLabel = $this->_('Admin live search');
  		$inputfields = $this->wire(new InputfieldWrapper());
  		$modules = $this->wire('modules');
  		$textFields = array();
  		$allSearchTypes = array('pages', 'trash', 'modules');
  		// $textOperators = array('%=', '~=', '*=', '~*=', '~~=', '~%=', '~|=', '~|*=', '^=', '=');
  		$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($this->wire('modules') as $module) {
  			$info = $this->wire('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);
  
  		$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);
  		
  		$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);
  		
  		$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);
  		
  		$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);
  
  		$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);
  		
  		$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
  		$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;
  	}
  
  
  
  }
  