'Page Search', 'summary' => 'Provides a page search engine for admin use.', 'version' => 105, 'permanent' => true, 'permission' => 'page-edit', ); } const defaultOperator = '%='; protected $nativeSorts = array( 'relevance', 'name', 'title', 'id', 'status', 'templates_id', 'parent_id', 'created', 'modified', 'modified_users_id', 'created_users_id', 'createdUser', 'modifiedUser', 'sort', 'sortfield', ); protected $fieldOptions = array(); protected $customSorts = array(); protected $operators = array(); protected $resultLimit = 25; protected $maxLimit = 250; protected $lister = null; /** * Mode indicating admin ajax search, set by GET var admin_search=1 * * This mode typically focuses on just searching the 'title' field. * This mode also includes data specific to ajax requests. * */ protected $adminSearchMode = false; public function init() { foreach($this->fields as $field) { if($field->type instanceof FieldtypeFieldsetOpen) continue; if($field->type instanceof FieldtypePassword) continue; $this->fieldOptions[] = $field->name; } sort($this->fieldOptions); parent::init(); } static public function getOperators() { $f = __FILE__; return array( '=' => __('Equals', $f), '!=' => __('Does not equal', $f), '>' => __('Greater than', $f), '>=' => __('Greater than or equal to', $f), '<' => __('Less than', $f), '<=' => __('Less than or equal to', $f), '*=' => __('Contains phrase or partial word', $f), '%=' => __('Contains phrase/word using LIKE', $f), '~=' => __('Contains all the words', $f), ); } /** * 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(); } /** * Perform an interactive search and provide a search form (default) * */ public function ___execute() { if($this->wire('user')->hasPermission('lister')) { if($this->wire('modules')->isInstalled('ProcessPageLister')) { $this->lister = $this->wire('modules')->get('ProcessPageLister'); } } $ajax = $this->wire('config')->ajax; if($this->lister && $ajax) { // we will just let Lister do it's thing, since it remembers settings in session return $this->lister->execute(); } else { $this->fullSetup(); $this->processInput(); list($selector, $displaySelector, $initSelector, $defaultSelector) = $this->buildSelector(); } if($this->lister) { $lister = $this->lister; if(count($_GET)) $lister->sessionClear(); $lister->initSelector = $initSelector; $lister->defaultSelector = $defaultSelector; $lister->defaultSort = 'relevance'; $lister->limit = $this->resultLimit; $lister->preview = false; $lister->columns = $this->getDisplayFields(); return $lister->execute(); } else { $matches = $this->pages->find($selector); return $this->render($matches, $displaySelector); } } /** * 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() { $this->fullSetup(); $selector = ''; $displaySelector = ''; $limit = $this->resultLimit; $start = 0; $status = 0; $names = array(); $adminSearchStr = ''; foreach($this->input->get as $name => $value) { // 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, '-')) { list($name, $subname) = explode('-', $name); $name = "$name.$subname"; } if(strpos($name, ',')) $name = $this->sanitizer->names($name, ',', array('_', '.')); else $name = $this->sanitizer->pageName($name); // note: switch to pageName over fieldName to support "." if(!$name) continue; if($name == 'limit') { $limit = (int) $value; $this->input->whitelist('limit', $value); continue; } if($name == 'start') { $start = (int) $value; $this->input->whitelist('start', $value); continue; } // if dealing with a user other than superuser, only allow include=hidden if($name == 'include' && $value != 'hidden' && !$this->user->isSuperuser()) $value = 'hidden'; // don't allow setting of check_access property, except for superuser if($name == 'check_access' && !$this->user->isSuperuser()) continue; // don't allow setting of the 'status' property, except for superuser if($name == 'status') { if(!$this->user->isSuperuser()) continue; $status = (int) $value; } // check if adminSearchMode should be enabled (for ajax search) if($name == 'admin_search') { $adminSearchStr = $this->sanitizer->selectorValue($value); $this->adminSearchMode = true; continue; } // replace URL-compatible comma separators with selector-compatible pipes if(strpos($name, ',')) $name = str_replace(',', '|', $name); if(!$this->isSelectableFieldName($name)) continue; if(strpos($value, '|')) { $values = explode('|', $value); foreach($values as $k => $v) $values[$k] = $this->sanitizer->selectorValue($v); $value = implode('|', $values); } else { $value = $this->sanitizer->selectorValue($value); } $this->input->whitelist($name . rtrim($operator, '='), trim($value, '"\'')); $selector .= "$name$operator$value, "; $names[] = $name; } if(strlen($adminSearchStr)) { // adminSearchMode active, auto populate a search, like title%=value $fields = $this->searchFields2 ? explode(' ', $this->searchFields2) : array('title'); $operator = strlen($adminSearchStr) < 4 ? '%=' : '*='; $selector .= implode('|', $fields) . $operator . $adminSearchStr . ", "; } if($start) $selector .= "start=$start, "; $selector .= "limit=$limit, "; $selector = rtrim($selector, ", "); $displaySelector = $selector; if($this->adminSearchMode && !in_array('has_parent', $names)) { // exclude repeaters from matching, when present $admin = $this->wire('pages')->get($this->wire('config')->adminRootPageID); $repeaters = $admin->children("name=repeaters, include=all"); if(count($repeaters)) $selector .= ", has_parent!=" . $repeaters->first()->id; } if($this->user->isSuperuser() && !$status && !preg_match('/\binclude=/', $selector)) { $selector .= ", include=all, status<" . Page::statusTrash; } return $this->render($this->pages->find($selector), $displaySelector); } /** * 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 $display; } /** * Render the search results * */ 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 = $this->getDisplayFields(); $this->input->whitelist('display', implode(',', $display)); if($this->config->ajax) { // ajax json output $out = $this->renderMatchesAjax($matches, $display, $displaySelector); } else { // html output $class = ''; if((int) $this->input->get->show_options !== 0 && $this->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 * */ protected function buildSelector() { $selector = ''; // for regular ProcessPageSearch $initSelector = ''; // for Lister, non-changable part of the selector $defaultSelector = ''; // for Lister, changeable filters // search query text $q = $this->input->whitelist('q'); if(strlen($q)) { $searchFields = $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 = $this->input->whitelist('sort'); if($sort && $sort != 'relevance') { $reverse = $this->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(!$this->input->whitelist('template') && !in_array($sort, $this->nativeSorts)) { $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($this->input->whitelist('template')) { $selector .= ", template=" . $this->input->whitelist('template'); } if(!$selector) { $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(!$this->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() { // search fields if($this->input->get->field) { $field = str_replace(',', ' ', $this->input->get->field); $fieldArray = explode(' ', $field); $field = ''; foreach($fieldArray as $f) { $f = $this->sanitizer->fieldName($f); if(!in_array($f, $this->fieldOptions) && !in_array($f, $this->nativeSorts)) continue; $field .= $f . " "; } $field = rtrim($field, " "); if($field) { $this->searchFields = $field; $this->input->whitelist('field', $field); } } else { $this->input->whitelist('field', $this->searchFields); } // operator, search type if(empty($this->operator)) $this->operator = self::defaultOperator; $operator = $this->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]; } $this->input->whitelist('operator', $this->operator); } // search query $q = $this->sanitizer->text(substr($this->input->get->q, 0, 128)); $this->input->whitelist('q', $q); // sort $this->input->whitelist('sort', 'relevance'); if($this->input->get->sort) { $sort = $this->sanitizer->fieldName($this->input->get->sort); if($sort && (in_array($sort, $this->nativeSorts) || in_array($sort, $this->fieldOptions))) $this->input->whitelist('sort', $sort); if($this->input->get->reverse) $this->input->whitelist('reverse', 1); } // template if($this->input->get->template) { $template = $this->sanitizer->name($this->input->get->template); if(!$this->templates->get($template)) $template = ''; if($template) $this->input->whitelist('template', $template); } } /** * Is the given field name selectable? * */ protected function isSelectableFieldName($name, $level = 0) { $is = false; if(!$level && strpos($name, '|')) { $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); if(!$this->isSelectableFieldName($subname, $level)) return false; } if(in_array($name, $this->nativeSorts)) $is = true; else if(in_array($name, array('parent', 'template', 'template_label', 'has_parent', 'hasParent', 'children', 'numChildren', 'num_children', 'count'))) $is = true; else if(!$level && in_array($name, array('include', 'status', 'check_access'))) $is = true; else if(in_array($name, $this->fieldOptions)) $is = true; if($name == 'pass' || $name == 'config') $is = false; return $is; } protected function renderFullSearchForm() { // 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 = $this->input->whitelist('reverse'); $out2 .= "\n\t

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

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

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

"; if($display && $display != 'title,path') $advCollapsed = false; $submit = $this->modules->get("InputfieldSubmit"); $submit->attr('name', 'submit'); $submit->attr('value', $this->_x('Search', 'submit')); // Search submit button for advanced search $out .= "

" . $submit->render() . "

"; $form = $this->modules->get("InputfieldForm"); $form->attr('id', 'ProcessPageSearchOptionsForm'); $form->method = 'get'; $form->action = './'; $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); /* Remove temporarily $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(); } protected function renderShortcuts() { $out = ''; $links = array( 'Quick Links', "All by creation date" => '?q=&submit=Search&display=title+path+created&sort=created&reverse=1' , "All by latest edit date" => '?q=&submit=Search&display=title+path+created&sort=modified&reverse=1', "Users by creation date" => '?q=&template=user&submit=Search&operator=~%3D&display=name+email+created&sort=created&reverse=1', 'New pages by template', ); foreach($this->templates as $template) { // Quick links only for content with more than one page // if($template->getNumPages() < 2) continue; // Users get own quick link earlier, others are rather irrelevant if($template->flags & Template::flagSystem) continue; $links[$template->name] = "?q=&template={$template->name}&submit=Search&operator=~%3D&display=title+path+created&sort=created&reverse=1"; } foreach($links as $label => $value) { if(is_int($label)) { $out .= "

$value

"; } else { $value .= "&show_options=1"; $value = htmlspecialchars($value); $out .= "$label"; } } return $out; } protected function renderMatchesTable(PageArray $matches, array $display, $id = 'ProcessPageSearchResultsList') { if(!count($display)) $display = array('path'); $out = ''; if(!count($matches)) return $out; $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') $value = date(DATE_ISO8601, $value); $row[] = "$value"; } $row[] = $match->editable() ? "" . $this->_('edit') . "" : ' '; $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; } /** * Find other types of ProcessWire assets that may be useful in search * * Applicable to adminSearchMode only. * * @param $q Text to find * @return array Array of matches * */ protected function findOtherTypes($q) { $language = $this->wire('user')->language; $language = $language && $language->id && !$language->isDefault() ? $language->id : ''; $results = array(); foreach($this->wire('fields') as $field) { $name = $field->name; $label = $field->{"label$language"}; if(stripos($name . $label, $q) !== false) $results[] = array( 'id' => $field->id, 'template_label' => str_replace('Fieldtype', '', $field->type), 'title' => $field->name, 'editUrl' => $this->wire('config')->urls->admin . "setup/field/edit?id=$field->id", 'type' => $this->_x('Fields', 'match-type') ); } foreach($this->wire('templates') as $template) { $name = $template->name; $label = $template->{"label$language"}; if(stripos($name . $label, $q) !== false) $results[] = array( 'id' => $template->id, 'template_label' => $template->name, 'title' => $label ? $label : $template->name, 'editUrl' => $this->wire('config')->urls->admin . "setup/template/edit?id=$template->id", 'type' => $this->_x('Templates', 'match-type') ); } foreach($this->wire('modules') as $module) { //if($module instanceof ModulePlaceholder) continue; $name = $module->className(); $info = $this->wire('modules')->getModuleInfo($module); $title = $module instanceof ModulePlaceholder ? $name : $info['title']; if(stripos($name . $title, $q) !== false) $results[] = array( 'id' => $module->id, 'template_label' => $name, 'title' => $title, 'editUrl' => $this->wire('config')->urls->admin . "module/edit?name=$name", 'type' => $this->_x('Modules', 'match-type') ); } return $results; } /** * Render the provided matches as a JSON string for AJAX use * */ protected function renderMatchesAjax(PageArray $matches, array $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(wire('languages')) { $language = $this->user->language; if($language && !$language->isDefault()) $templateLabel = "label$language"; } if($this->adminSearchMode && $this->user->isSuperuser()) { // enable search to include users when adminSearchMode and superuser $a['matches'] = $this->findOtherTypes($this->input->get('admin_search')); $users = $this->wire('users')->find("name%=" . $this->wire('sanitizer')->pageName($this->input->get('admin_search'))); if(count($users)) $matches->prepend($users); } foreach($matches as $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 : ''; } 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 * */ 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 * */ 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; } 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 = ''; } $out = "\n
" . "\n\t" . "\n\t" . "\n\t" . //" . $this->_x('Search', 'input') . "' />" . // Text that appears as the placeholder text in the top search submit input "\n\t" . "\n\t" . "\n
"; return $out; } static public function getModuleConfigInputfields(array $data) { $inputfields = new InputfieldWrapper(); $inputfield = Wire::getFuel('modules')->get("InputfieldText"); $inputfield->attr('name', 'searchFields'); if(!isset($data['searchFields'])) $data['searchFields'] = 'title body'; if(is_array($data['searchFields'])) $data['searchFields'] = implode(' ', $data['searchFields']); $inputfield->attr('value', $data['searchFields']); $inputfield->label = __("Default fields to search"); $description = __("Enter the names for one or more text-based fields that you want to search, separating each by a space."); // Default fields description $inputfield->description = $description; $inputfields->append($inputfield); $inputfield = Wire::getFuel('modules')->get("InputfieldText"); $inputfield->attr('name', 'searchFields2'); if(!isset($data['searchFields2'])) $data['searchFields2'] = 'title'; if(is_array($data['searchFields2'])) $data['searchFields2'] = implode(' ', $data['searchFields2']); $inputfield->attr('value', $data['searchFields2']); $inputfield->label = __("Field(s) to search in admin search (ajax) mode"); $inputfield->description = $description; $inputfield->notes = __("We recommend limiting this to 1 or 2 fields at the most since results populate a live autocomplete field. Typically you would just search the title."); // Fields to search description $inputfields->append($inputfield); $inputfield = Wire::getFuel('modules')->get("InputfieldText"); $inputfield->attr('name', 'displayField'); $inputfield->attr('value', isset($data['displayField']) ? $data['displayField'] : 'name'); $inputfield->label = __("Default field name(s) to display in search results"); $inputfield->description = __("If specifying more than one field, separate each with a space."); $inputfields->append($inputfield); $inputfield = Wire::getFuel('modules')->get("InputfieldSelect"); $inputfield->attr('name', 'operator'); $inputfield->attr('value', isset($data['operator']) ? $data['operator'] : self::defaultOperator); $inputfield->label = __("Default search operator"); foreach(self::getOperators() as $operator => $label) { $inputfield->addOption($operator, "$operator $label"); } $inputfields->append($inputfield); return $inputfields; } }