'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
" . $this->renderFullSearchForm() . "
"; $class = 'show_options'; } $out .= "\n
" . $this->renderMatchesTable($matches, $display) . "\n
"; } 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

"; $out .= "\n\t

" . "\n\t" . "\n\t" . "\n\t

"; $out .= "\n\t

" . "\n\t" . "\n\t" . "\n\t

"; $out .= "\n\t" . "\n\t" . "\n\t" . "\n\t

"; // Advanced $advCollapsed = true; $out2 = "\n\t

" . "\n\t" . "\n\t" . "\n\t

"; $out2.= "\n\t

" . "\n\t" . "\n\t" . "\n\t

"; if($sort != 'relevance') { $reverse = $input->whitelist('reverse'); $out2 .= "\n\t

" . "\n\t" . "\n\t

"; if($reverse) $advCollapsed = false; } $display = $input->whitelist('display'); $out2 .= "\n\t

" . "\n\t" . "\n\t" . "\n\t

"; 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 .= "

" . $submit->render() . "

"; /** @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[] = "$value"; } $row[] = $match->editable() ? "" . $this->_('edit') . "" : ' '; $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
" . "\n\t" . "\n\t" . "\n\t" . "\n\t" . "\n\t" . "\n
"; 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; } }