artabro/wire/modules/Process/ProcessPageSearch/ProcessPageSearch.module
2024-08-27 11:35:37 +02:00

1584 lines
47 KiB
Text
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php namespace ProcessWire;
/**
* ProcessWire Page Search Process
*
* Provides page searching within the ProcessWire admin
*
* For more details about how Process modules work, please see:
* /wire/core/Process.php
*
* ProcessWire 3.x, Copyright 2023 by Ryan Cramer
* https://processwire.com
*
* @method string findReady($selector)
* @property string $searchFields
* @property string $searchFields2
* @property string $displayField
* @property string $operator Single-word/partial match operator
* @property string $operator2 Multi-word operator
* @property array $searchTypesOrder
* @property array $noSearchTypes
*
* @property bool|int $adminSearchMode Deprecated/no longer in use?
*
*
*/
class ProcessPageSearch extends Process implements ConfigurableModule {
static public function getModuleInfo() {
return array(
'title' => 'Page Search',
'summary' => 'Provides a page search engine for admin use.',
'version' => 108,
'permanent' => true,
'permission' => 'page-edit',
);
}
/**
* Default operator for text searches
*
*/
const defaultOperator = '%=';
/**
* Native/system sortable properties
*
* @var array
*
*/
protected $nativeSorts = array(
'relevance',
'name',
'title',
'id',
'status',
'templates_id',
'parent_id',
'created',
'modified',
'published',
'modified_users_id',
'created_users_id',
'createdUser',
'modifiedUser',
'sort',
'sortfield',
);
/**
* Names of all Field objects in PW
*
* @var array
*
*/
protected $fieldOptions = array();
/**
* All operators where key is operator and value is description
*
* @var array
*
*/
protected $operators = array();
/**
* Items per pagination
*
* @var int
*
*/
protected $resultLimit = 25;
/**
* Lister instance, when applicable
*
* @var null|ProcessPageLister
*
*/
protected $lister = null;
/**
* Debug mode?
*
* @var bool
*
*/
protected $debug = true;
public function __construct() {
parent::__construct();
$this->set('searchFields', 'title body');
$this->set('searchFields2', 'title');
$this->set('displayField', 'name');
$this->set('operator', self::defaultOperator);
$this->set('operator2', '~=');
$this->set('searchTypesOrder', array('fields', 'templates', 'modules', 'pages', 'trash'));
$this->set('noSearchTypes', array()); // search types that have been removed
// make nativeSorts indexed by value
$sorts = array();
foreach($this->nativeSorts as $sort) {
$sorts[$sort] = $sort;
}
$this->nativeSorts = $sorts;
}
/**
* Initialize module
*
*/
public function init() {
foreach($this->fields as $field) {
if($field->type instanceof FieldtypeFieldsetOpen) continue;
if($field->type instanceof FieldtypePassword) continue;
// @todo add field access control checking
$this->fieldOptions[$field->name] = $field->name;
}
ksort($this->fieldOptions);
parent::init();
}
public function set($key, $value) {
if($key == 'searchFields' || $key == 'searchFields2') {
if(is_array($value)) $value = implode(' ', $value);
} else if($key == 'noSearchTypes' && !is_array($value)) {
$value = explode(' ', $value);
}
return parent::set($key, $value);
}
/**
* Get operators used for searches, where key is operator and value is description
*
* @return array
*
*/
static public function getOperators() {
$operators = Selectors::getOperators(array(
'getIndexType' => 'operator',
'getValueType' => 'label',
));
unset($operators['#=']); // maybe later
return $operators;
}
/**
* Setup items needed for full execution, as opposed to the regular search input that appears on all pages
*
*/
protected function fullSetup() {
$sanitizer = $this->wire()->sanitizer;
$input = $this->wire()->input;
$headline = $this->_x('Search', 'headline'); // Headline for search page
if($input->get('processHeadline')) {
$headline = $sanitizer->entities($sanitizer->text($input->get('processHeadline')));
$this->input->whitelist('processHeadline', $headline);
}
$this->wire('processHeadline', $headline);
$this->operators = self::getOperators();
}
/**
* Hookable function to optionally modify selector before it is sent to $pages->find()
*
* Not applicable when Lister is handling the search/render.
*
* #pw-hooker
*
* @param string $selector Selector that will be used to find pages
* @return string Must return the selector (optionally modified)
*
*/
public function ___findReady($selector) {
return $selector;
}
/**
* Return instance of ProcessPageLister or null if not available
*
* @return ProcessPageLister|null
*
*/
protected function getLister() {
$modules = $this->wire()->modules;
if($this->lister) return $this->lister;
if($this->wire()->user->hasPermission('page-lister')) {
if($modules->isInstalled('ProcessPageLister')) {
$this->lister = $modules->get('ProcessPageLister');
}
}
return $this->lister;
}
/**
* Perform an interactive search and provide a search form (default)
*
*/
public function ___execute() {
$lister = $this->getLister();
$ajax = $this->wire()->config->ajax;
$bookmark = (int) $this->wire()->input->get('bookmark');
if($lister && ($ajax || $bookmark)) {
// we will just let Lister do it's thing, since it remembers settings in session
return $lister->execute();
} else {
$this->fullSetup();
$this->processInput();
list($selector, $displaySelector, $initSelector, $defaultSelector) = $this->buildSelector();
}
if($lister) {
if(count($_GET)) $lister->sessionClear();
$lister->initSelector = $initSelector;
$lister->defaultSelector = $defaultSelector;
$lister->defaultSort = 'relevance';
$lister->set('limit', $this->resultLimit);
$lister->preview = false;
$lister->columns = $this->getDisplayFields();
return $lister->execute();
} else {
$selector = $this->findReady($selector);
$matches = $this->pages->find($selector);
return $this->render($matches, $displaySelector);
}
}
public function executeReset() {
$lister = $this->getLister();
return $lister ? $lister->executeReset() : '';
}
public function executeEditBookmark() {
$lister = $this->getLister();
return $lister ? $lister->executeEditBookmark() : '';
}
/**
* Perform a non-interactive search (based on URL GET vars)
*
* This is the preferred input method for links and ajax queries.
*
* Example /search/for?template=basic-page&body*=example
*
*/
public function ___executeFor() {
$languages = $this->wire()->languages;
$user = $this->wire()->user;
$input = $this->wire()->input;
$sanitizer = $this->wire()->sanitizer;
if($input->get('admin_search')) return $this->executeLive();
$this->fullSetup();
$selectors = array();
$limit = $this->resultLimit;
$start = 0;
$status = 0;
$names = array();
$userLanguage = null;
$superuser = $user->isSuperuser();
$checkEditAccess = false;
$hasInclude = '';
$n = 0;
$selectorName = $input->get('for_selector_name');
if($selectorName) {
$selector = $this->getForSelector($selectorName);
if(strlen($selector)) $selectors['for'] = $selector;
}
// names to skip (must be lowercase)
$skipNames = array(
'get',
'display',
'format_name',
'for_selector_name',
'admin_search'
);
// names to convert (keys must be lowercase)
$convertNames = array(
'hasparent' => 'has_parent',
'checkaccess' => 'check_access',
);
foreach($input->get as $name => $value) {
$lowerName = strtolower(trim($name));
if(isset($convertNames[$lowerName])) {
$name = $convertNames[$lowerName];
$lowerName = strtolower($name);
}
if(in_array($lowerName, $skipNames)) continue;
if($lowerName == 'lang_id') {
if($languages) {
// force results for specific language
$language = $languages->get((int) $value);
if(!$language->id) continue;
if($user->language->id != $language->id) {
$userLanguage = $user->language;
$user->language = $language;
}
}
continue;
}
// operator has no '=', so we'll get the value from the name
// so that you can do something like: bedrooms>5 rather than bedrooms>=5
if(!strlen($value) && preg_match('/([^<>]+)\s*([<>])\s*([^<>]+)/', $name, $matches)) {
$name = $matches[1];
$operator = $matches[2];
$value = $matches[3];
} else {
$operator = '=';
$operatorChars = preg_quote(implode('', Selectors::getOperatorChars()));
if(preg_match('/^(.+?)([' . $operatorChars . ']+)$/', $name, $matches)) {
$name = $matches[1];
$operator = $matches[2] . '=';
// if unsupported operator requested, substitute '='
if(!isset($this->operators[$operator])) $operator = '=';
}
}
// replace '-' with '.' since '.' is not allowed in URL variable names
if(strpos($name, '-')) $name = str_replace('-', '.', $name);
if(strpos($name, ',')) {
$name = $sanitizer->names($name, ',', array('_', '.'));
} else {
$name = $sanitizer->fieldSubfield($name, 2);
}
if(!$name) continue;
$lowerName = strtolower($name);
if($lowerName == 'limit') {
$limit = (int) $value;
$input->whitelist('limit', $value);
continue;
}
if($lowerName == 'start') {
$start = (int) $value;
$input->whitelist('start', $value);
continue;
}
// if dealing with a user other than superuser, only allow include=hidden
if($lowerName == 'include') {
$name = $lowerName;
$value = strtolower($value);
if($value != 'hidden' && !$superuser) {
if($user->hasPermission('page-edit') && $input->get('admin_search')) {
$value = 'unpublished';
$checkEditAccess = true;
} else {
$value = 'hidden';
}
}
$hasInclude = $value;
}
// don't allow setting of check_access property, except for superuser
if($lowerName == 'check_access' && !$superuser) continue;
// don't allow setting of the 'status' property, except for superuser
if($lowerName == 'status') {
if(!$superuser) continue;
$status = (int) $value;
}
// replace URL-compatible comma separators with selector-compatible pipes
if(strpos($name, ',')) $name = str_replace(',', '|', $name);
$name = $this->filterSelectableFieldName($name);
if(!strlen($name)) continue;
if(strpos($value, ',')) {
// commas between words: split one key=value, into multiple key=value, key=value
$valuesAND = explode(',', $value);
} else {
$valuesAND = array($value);
}
foreach($valuesAND as $key => $val) {
if(strpos($val, '|')) {
$valuesOR = explode('|', $val);
foreach($valuesOR as $k => $v) {
$valuesOR[$k] = $sanitizer->selectorValue($v);
}
$val = implode('|', $valuesOR);
} else {
$val = $sanitizer->selectorValue($val);
}
$valuesAND[$key] = $val;
}
$value = implode(',', $valuesAND);
$input->whitelist($name . rtrim($operator, '='), trim($value, '"\''));
foreach($valuesAND as $val) {
$n++;
$selectors["input-$n"] = "$name$operator$val";
}
$names[] = $name;
} // foreach input
if($start) $selectors['start'] = "start=$start";
$selectors['limit'] = "limit=$limit";
$displaySelector = implode(',', $selectors);
if(!$status && !$hasInclude && $superuser) {
// superuser only
$selectors['superuser'] = "include=all, status<" . Page::statusTrash;
}
$selector = implode(', ', $selectors);
$selector = $this->findReady($selector);
$items = $this->pages->find($selector);
if(!$superuser && $checkEditAccess) {
// filter out non-editable pages, since some may be included via include=unpublished
foreach($items as $item) {
if(!$item->editable()) $items->remove($item);
}
}
$out = $this->render($items, $displaySelector);
if($userLanguage) $user->language = $userLanguage;
return $out;
}
/**
* Execute live search
*
* @return string
*
*/
public function executeLive() {
require_once(dirname(__FILE__) . '/ProcessPageSearchLive.php');
$liveSearch = new ProcessPageSearchLive($this);
$liveSearch->setSearchTypesOrder($this->searchTypesOrder);
$liveSearch->setNoSearchTypes($this->noSearchTypes);
$liveSearch->setDefaultOperators($this->operator, $this->operator2);
if($this->wire()->config->ajax) {
header('Content-type: application/json');
return $liveSearch->execute();
} else {
return $liveSearch->executeViewAll();
}
}
/**
* Get ID of the repeaters root page ID or 0 if not installed
*
* @return int
*
*/
public function getRepeatersPageID() {
$session = $this->wire()->session;
$repeaterID = $session->getFor($this, 'repeaterID');
if(is_int($repeaterID)) return $repeaterID;
if($this->wire()->modules->isInstalled('FieldtypeRepeater')) {
$repeaterPage = $this->wire()->pages->get(
"parent_id=" . $this->wire()->config->adminRootPageID . ", " .
"name=repeaters, " .
"include=all"
);
$repeaterID = $repeaterPage->id;
$session->setFor($this, 'repeaterID', (int) $repeaterID);
} else {
$repeaterID = 0;
}
return $repeaterID;
}
/**
* Return array of fields to display in results
*
*/
protected function getDisplayFields() {
$sanitizer = $this->wire()->sanitizer;
$input = $this->wire()->input;
$display = (string) $input->get('display');
if(!strlen($display)) $display = (string) $input->get('get'); // as required by ProcessPageSearch API
if(!strlen($display)) $display = (string) $this->displayField;
if(!strlen($display)) $display = 'title path';
$display = str_replace(',', ' ', $display);
$display = explode(' ', $display); // convert to array
foreach($display as $key => $name) {
$name = $sanitizer->fieldName($name);
$display[$key] = $name;
if($this->isSelectableFieldName($name)) continue;
if(in_array($name, array('url', 'path', 'httpUrl'))) continue;
unset($display[$key]);
}
return array_values($display);
}
/**
* As an alternative to getting specific fields, return a format string
*
* This format string must be pre-populated to session variable:
* ProcessPageSearch.[format_name] = '{title} - {path}'; // format string
*
* The name the session variable must be provided as a GET var: format_name=[name]
*
* @return array|string
*
*/
protected function getDisplayFormat() {
$name = $this->wire()->input->get('format_name');
if(empty($name)) return '';
$data = $this->wire()->session->getFor($this, "format_" . $name);
if(empty($data)) return '';
return array(
'name' => $name,
'format' => $data['format'],
'textOnly' => $data['textOnly']
);
}
/**
* Set a display format
*
* @param string $name Session var name that will be used, output will be returned in JSON results indexed by $name as well.
* @param string $format Format string to pass to $page->getMarkup(str)
* @param bool $textOnly
*
*/
public function setDisplayFormat($name, $format, $textOnly = false) {
$this->wire()->session->setFor($this, "format_" . $name, array(
'format' => $format,
'textOnly' => $textOnly
));
}
/**
* Set a selector to use when $_GET['for_selector_name'] matches given $name
*
* This is for cases where you don't want the selector to pass through user input,
* and you instead just want to pass the name of it via user input. This enables
* use of some features that may not be available through user selectors passing
* only through user input.
*
* Used in executeFor() mode only.
*
* @param string $name
* @param string $selector
* @return string Returns URL needed to use this selector
* @since 3.0.223
*
*/
public function setForSelector($name, $selector) {
$this->wire()->session->setFor($this, "for_selector_$name", $selector);
return $this->config->urls->admin . 'page/search/for?for_selector_name=' . urlencode($name);
}
/**
* Get selector identified by $name that was previously set with setForSelector()
*
* For executeFor() mode only.
*
* #pw-internal
*
* @param string $name
* @return string
* @since 3.0.223
*
*/
public function getForSelector($name) {
return (string) $this->wire()->session->getFor($this, "for_selector_$name");
}
/**
* Render the search results
*
* @param PageArray $matches
* @param string $displaySelector
* @return string
*
*/
protected function render(PageArray $matches, $displaySelector = '') {
$input = $this->wire()->input;
$ajax = $this->wire()->config->ajax;
$out = '';
if($displaySelector) {
$this->message(
sprintf(
$this->_n('Found %1$d page using selector: %2$s', 'Found %1$d pages using selector: %2$s', $matches->getTotal()),
$matches->getTotal(),
$displaySelector
)
);
}
// determine what fields will be displayed
$display = array();
if($ajax) $display = $this->getDisplayFormat();
if(empty($display)) {
$display = $this->getDisplayFields();
$input->whitelist('display', implode(',', $display));
}
if($ajax) {
// ajax json output
header("Content-type: application/json");
$out = $this->renderMatchesAjax($matches, $display, $displaySelector);
} else {
// html output
$class = '';
if((int) $input->get('show_options') !== 0 && $input->urlSegment1 != 'find') {
$out = "\n<div id='ProcessPageSearchOptions'>" . $this->renderFullSearchForm() . "</div>";
$class = 'show_options';
}
$out .=
"\n<div id='ProcessPageSearchResults' class='$class'>" .
$this->renderMatchesTable($matches, $display) .
"\n</div>";
}
return $out;
}
/**
* Build a selector based upon interactive choices from the search form
*
* Only used by execute(), not used by executeFor()
*
* ~~~~~
* Returns array(
* 0 => $selector, // string, main selector for search
* 1 => $displaySelector, // string, selector for display purposes
* 2 => $initSelector, // string, selector for initialization in Lister (the part user cannot change)
* 3 => $defaultSelector // string default selector used by Lister (the part user can change)
* );
* ~~~~~
* @return array
*
*/
protected function buildSelector() {
$input = $this->wire()->input;
$sanitizer = $this->wire()->sanitizer;
$user = $this->wire()->user;
$pages = $this->wire()->pages;
$config = $this->wire()->config;
$selector = ''; // for regular ProcessPageSearch
// search query text
$q = (string) $input->whitelist('q');
if(strlen($q)) {
// GET vars "property" or "field" can used interchangably
if($input->whitelist('property')) {
$searchFields = array($input->whitelist('property'));
} else if($input->whitelist('field')) {
$searchFields = explode(' ', $input->whitelist('field'));
} else {
$searchFields = $input->get('live') ? $this->searchFields2 : $this->searchFields;
if(is_string($searchFields)) $searchFields = explode(' ', $searchFields);
}
foreach($searchFields as $fieldName) {
$fieldName = $sanitizer->fieldName($fieldName);
$selector .= "$fieldName|";
}
$selector = rtrim($selector, '|') . $this->operator . $sanitizer->selectorValue($q);
}
// determine if results are sorted by something other than relevance
$sort = $input->whitelist('sort');
if($sort && $sort != 'relevance') {
$reverse = $input->whitelist('reverse') ? "-" : '';
$selector .= ", sort=$reverse$sort";
// if a specific template isn't requested, then locate the templates that use this field and confine the search to them
if(!$input->whitelist('template') && !isset($this->nativeSorts[$sort])) {
$templates = array();
foreach($this->templates as $template) {
if($template->fieldgroup->has($sort)) $templates[] = $template->name;
}
if(count($templates)) $selector .= ", template=" . implode("|", $templates);
}
}
// determine if search limited to a specific template
if($input->whitelist('template')) {
$selector .= ", template=" . $input->whitelist('template');
}
$trash = $input->whitelist('trash');
if($trash !== null && $user->isSuperuser()) {
if($trash === 0) {
$selector .= ", status!=trash";
} else if($trash === 1) {
$selector .= ", status=trash, include=all";
}
}
if(!$selector) {
if(!$this->lister) $this->error($this->_("No search specified"));
return array('','','','');
}
$selector = trim($selector, ", ");
$displaySelector = $selector; // highlight the selector that was used for display purposes
$defaultSelector = $selector; // user changable selector in Lister
$initSelector = '' ; // non-user changable selector in Lister
$s = ''; // anything added to this will be populated to both $selector and $initSelector below
// limit results for pagination
$s .= ", limit=$this->resultLimit";
$adminRootPage = $pages->get($config->adminRootPageID);
// exclude admin repeater pages unless the admin template is chosen
if(!$input->whitelist('template')) {
// but only for superuser, as we're excluding all admin pages for non-superusers
if($this->user->isSuperuser()) {
$repeaters = $adminRootPage->child('name=repeaters, include=all');
if($repeaters->id) $s .= ", has_parent!=$repeaters->id";
}
}
// include hidden pages
if($user->isSuperuser()) {
$s .= ", include=all";
} else {
// non superuser doesn't get any admin pages in their results
$s .= ", has_parent!=$adminRootPage";
// if user has any kind of edit access, allow unpublished pages to be included
if($user->hasPermission('page-edit')) $s .= ", include=unpublished";
}
$selector .= $s;
$initSelector .= $s;
return array($selector, $displaySelector, trim($initSelector, ', '), $defaultSelector);
}
/**
* Process input from the search form
*
*/
protected function processInput() {
$user = $this->wire()->user;
$input = $this->wire()->input;
$sanitizer = $this->wire()->sanitizer;
// search query
$q = $input->get('q');
if($q !== null) $this->processInputQuery($q);
// search fields (can optionally contain multiple CSV field names)
$field = $input->get('field');
if($field) {
$field = str_replace(',', ' ', $field);
$fieldArray = explode(' ', $field);
$field = '';
foreach($fieldArray as $f) {
$f = $sanitizer->fieldName($f);
if(!isset($this->fieldOptions[$f]) && !isset($this->nativeSorts[$f])) continue;
$field .= $f . " ";
}
$field = rtrim($field, " ");
if($field) {
$this->searchFields = $field;
$input->whitelist('field', $field);
}
} else if($input->get('live')) {
$input->whitelist('field', $this->searchFields2);
} else {
$input->whitelist('field', $this->searchFields);
}
// operator, search type
if(empty($this->operator)) $this->operator = self::defaultOperator;
$operator = $input->get('operator');
if(!is_null($operator)) {
if(array_key_exists($operator, $this->operators)) {
$this->operator = substr($this->input->get('operator'), 0, 3);
} else if(ctype_digit("$operator")) {
$operators = array_keys($this->operators);
if(isset($operators[$operator])) $this->operator = $operators[$operator];
}
$input->whitelist('operator', $this->operator);
}
// sort
$input->whitelist('sort', 'relevance');
$sort = $input->get('sort');
if($sort) {
$sort = $sanitizer->fieldName($sort);
if($sort && (isset($this->nativeSorts[$sort]) || isset($this->fieldOptions[$sort]))) {
$input->whitelist('sort', $sort);
}
if($input->get('reverse')) {
$input->whitelist('reverse', 1);
}
}
// template
$template = $input->get('template');
if($template) {
$template = $sanitizer->templateName($template);
$template = $this->wire()->templates->get($template);
if($template && $user->hasPermission('page-view', $template)) {
$input->whitelist('template', $template->name);
}
}
// trash (liveSearch)
$trash = $input->get('trash');
if($trash !== null && $user->isSuperuser()) {
$trash = (int) $trash;
if($trash === 0 || $trash === 1) {
$input->whitelist('trash', $trash);
}
}
// custom property (like 'field', except can contain only one name)
$property = $input->get('property');
if($property !== null) {
$property = $sanitizer->fieldName($property);
if($this->isSelectableFieldName($property)) {
$input->whitelist('property', $property);
}
}
}
/**
* Process input for the $q query variable
*
* Since $q can also have type, field/property, operator and search text embedded within it,
* this function separates all of those out and populates GET variables for them, when present.
*
* @param $q
*
*/
protected function processInputQuery($q) {
$input = $this->wire()->input;
$sanitizer = $this->wire()->sanitizer;
$q = trim($sanitizer->text($q));
$redirectUrl = '';
$operators = $this->operators;
$type = '';
$operators['=='] = 'Equals';
$operators[':'] = 'Auto'; // alternative to '='
// handle cases where search type (template), property, and operator are bundled in with the $q
if(!$this->operator) $this->operator = '%=';
// deetermine which operator (if any) is present in $q
foreach($operators as $operator => $description) {
if(strpos($q, $operator) === false) continue;
if(!preg_match('/^([^=%$*+<>~^:]+)' . $operator . '([^=%$*+<>~^:]+)$/', $q, $matches)) continue;
if($operator === '=') $operator = '?'; // operator to be determined on factors search text
if($operator === '==') $operator = '=';
$type = $sanitizer->name($matches[1]);
$q = trim($matches[2]);
break;
}
if($operator === '?') {
// operator was '=': use 'contains words' operator if there is more than one word in $q
$operator = strpos($q, ' ') ? '~=' : $this->operator;
} else if(empty($operator)) {
// operator was not present, only query text was, so use default operator
$operator = $this->operator;
}
$input->get->set('operator', $operator);
if(strpos($type, '.')) {
// type with property/field
list($type, $field) = explode('.', $type, 2);
$field = $sanitizer->fieldName(trim($field));
$input->get->set('field', $field);
} else {
$field = '';
}
if($type == 'pages') {
// okay
} else if($type == 'trash') {
$input->get->set('trash', 1);
} else if($type) {
$template = $this->wire()->templates->get($type);
if($template) {
// defined template
$input->get->set('template', $template->name);
} else {
// some other non-page type
$redirectUrl = $this->wire()->page->url . 'live/' .
'?q=' . urlencode($q) .
'&type=' . urlencode($type) .
'&property=' . urlencode($field) .
'&operator=' . urlencode($operator);
}
}
if($redirectUrl) $this->wire()->session->redirect($redirectUrl);
$input->whitelist('q', $q);
}
/**
* Is the given field name selectable?
*
* @param string $name Field "name" or "name1|name2|name3"
* @param int $level Greater than 0 when recursive
* @return bool
*
*/
protected function isSelectableFieldName($name, $level = 0) {
$selectable = array(
'parent',
'template',
'template_label',
'has_parent',
'hasParent',
'children',
'numChildren',
'num_children',
'count',
'path',
'owner',
);
$notSelectable = array(
// must be lowercase
'pass',
'config',
'it',
'display',
);
$noSubnames = array(
// must be lowercase
'include',
'check_access',
'checkaccess',
);
$is = false;
if(!$level && strpos($name, '|') !== false) {
// a|b|c
// note: use filterSelectableFieldName to instead remove non-selectable fields
$names = explode('|', $name);
$cnt = 0;
foreach($names as $n) {
if(!$this->isSelectableFieldName($n, $level + 1)) $cnt++;
}
return $cnt == 0;
}
if(strpos($name, '.')) {
// field.subfield
list($name, $subname) = explode('.', $name, 2);
if(strpos($subname, '.') !== false && !$this->isSelectableFieldName($subname, $level + 1)) return false;
if(in_array(strtolower($subname), $noSubnames)) return false;
if(in_array(strtolower($subname), $notSelectable)) return false;
if(!$this->isSelectableFieldName($name, $level + 1)) return false;
$field = isset($this->fieldOptions[$name]) ? $this->wire()->fields->get($name) : null;
if($field && $field->type) {
if(!$field->viewable()) return false;
if(strpos($subname, 'owner.') === 0 && wireInstanceOf($field->type, array('FieldtypePage', 'FieldtypeRepeater'))) {
list(, $tername) = explode('.', $subname, 2);
if($this->isSelectableFieldName($tername, $level + 1)) return true;
}
$info = $field->type->getSelectorInfo($field);
if(isset($info['subfields'][$subname])) return true;
if($field->type instanceof FieldtypePage) return $this->isSelectableFieldName($subname, $level + 1);
} else if($name === 'parent' || $name === 'children' || $name === 'owner') {
if(in_array($subname, $selectable)) return true;
if(isset($this->nativeSorts[$subname])) return true;
return $this->isSelectableFieldName($subname, $level + 1);
}
return false;
}
$lowerName = strtolower($name);
if($lowerName == 'path') {
if($this->wire()->languages || !$this->wire()->modules->isInstalled('PagePaths')) {
$name = 'name';
$lowerName = $name;
}
}
if(isset($this->nativeSorts[$name])) {
// native sort properties
$is = true;
} else if(in_array($name, $selectable)) {
// always selectable properties
$is = true;
} else if(!$level && in_array($name, array('include', 'status', 'check_access'))) {
// selectable, but only if not ORd with other fields (level=0), and must be access checked outside this method
$is = true;
} else if(isset($this->fieldOptions[$name])) {
// custom fields
$field = $this->wire()->fields->get($name);
$is = $field && $field->viewable();
}
if($is && in_array($lowerName, $notSelectable)) {
$is = false;
}
return $is;
}
/**
* Given string 'name' or 'name1|name2|name3' remove any 'name(s)' that are not selectable and return
*
* @param string $name
* @return string
* @since 3.0.190
*
*/
protected function filterSelectableFieldName($name) {
if(!strlen($name)) return '';
$onlySingles = array('include', 'check_access', 'checkaccess', 'status');
$names = strpos($name, '|') !== false ? explode('|', $name) : array($name);
$qty = count($names);
foreach($names as $key => $name) {
$lowerName = strtolower($name);
if(empty($name) || ($qty > 1 && in_array($lowerName, $onlySingles))) {
unset($names[$key]);
} else if(!$this->isSelectableFieldName($name)) {
unset($names[$key]);
}
}
return count($names) ? implode('|', $names) : '';
}
protected function renderFullSearchForm() {
$input = $this->wire()->input;
$modules = $this->wire()->modules;
// Search options
$out = "\n\t<p id='wrap_search_query'>";
$out .=
"\n\t<p id='wrap_search_field'>" .
"\n\t<label for='search_field'>" . $this->_('Search in field(s):') . "</label>" .
"\n\t<input type='text' name='field' value='" . htmlentities($this->searchFields, ENT_QUOTES) . "' />" .
"\n\t</p>";
$out .=
"\n\t<p id='wrap_search_operator'>" .
"\n\t<label for='search_operator'>" . $this->_('Type of search:') . "</label>" .
"\n\t<select id='search_operator' name='operator'>";
$n = 0;
foreach($this->operators as $operator => $desc) {
$attrs = $this->operator === $operator ? " selected='selected'" : '';
$out .= "\n\t\t<option$attrs value='$n'>$desc (a" . htmlentities($operator) . "b)</option>";
$n++;
}
$out .=
"\n\t</select>" .
"\n\t</p>";
$out .=
"\n\t<label class='ui-priority-primary' for='search_query'>" . $this->_('Search for:') . "</label>" .
"\n\t<input id='search_query' type='text' name='q' value='" . htmlentities($input->whitelist('q'), ENT_QUOTES, "UTF-8") . "' />" .
"\n\t<input type='hidden' name='show_options' value='1' />" .
"\n\t</p>";
// Advanced
$advCollapsed = true;
$out2 =
"\n\t<p id='wrap_search_template'>" .
"\n\t<label for='search_template'>" . $this->_('Limit to template:') . "</label>" .
"\n\t<select id='search_template' name='template'>" .
"\n\t\t<option></option>";
$templateName = $input->whitelist('template');
if($templateName) $advCollapsed = false;
foreach($this->wire()->templates as $template) {
$attrs = $template->name === $templateName ? " selected='selected'" : '';
$out2 .= "\n\t<option$attrs>$template->name</option>";
}
$out2 .=
"\n\t</select>" .
"\n\t</p>";
$out2.=
"\n\t<p id='wrap_search_sort'>" .
"\n\t<label for='search_sort'>" . $this->_('Sort by:') . "</label>" .
"\n\t<select id='search_sort' name='sort'>";
$sorts = $this->nativeSorts + $this->fieldOptions;
$sort = $input->whitelist('sort');
if($sort && $sort != 'relevance') $advCollapsed = false;
foreach($sorts as $s) {
if(strpos($s, ' ')) continue; // skip over multi fields
$attrs = '';
if($s === $sort) $attrs = " selected='selected'";
$out2 .= "\n\t\t<option$attrs>$s</option>";
}
$out2 .=
"\n\t</select>" .
"\n\t</p>";
if($sort != 'relevance') {
$reverse = $input->whitelist('reverse');
$out2 .=
"\n\t<p id='wrap_search_options'>" .
"\n\t<label><input type='checkbox' name='reverse' value='1' " . ($reverse ? "checked='checked' " : '') . "/> " . $this->_('Reverse sort?') . "</label>" .
"\n\t</p>";
if($reverse) $advCollapsed = false;
}
$display = $input->whitelist('display');
$out2 .=
"\n\t<p id='wrap_search_display'>" .
"\n\t<label for='search_display'>" . $this->_('Display field(s):') . "</label>" .
"\n\t<input type='text' name='display' value='" . htmlentities($display, ENT_QUOTES) . "' />" .
"\n\t</p>";
if($display && $display != 'title,path') $advCollapsed = false;
/** @var InputfieldSubmit $submit */
$submit = $modules->get("InputfieldSubmit");
$submit->attr('name', 'submit');
$submit->attr('value', $this->_x('Search', 'submit')); // Search submit button for advanced search
$out .= "<p>" . $submit->render() . "</p>";
/** @var InputfieldForm $form */
$form = $modules->get("InputfieldForm");
$form->attr('id', 'ProcessPageSearchOptionsForm');
$form->method = 'get';
$form->action = './';
/** @var InputfieldMarkup $field */
$field = $modules->get("InputfieldMarkup");
$field->label = $this->_("Search Options");
$field->value = $out;
$form->add($field);
/** @var InputfieldMarkup $field */
$field = $modules->get("InputfieldMarkup");
if($advCollapsed) $field->collapsed = Inputfield::collapsedYes;
$field->label = $this->_("Advanced");
$field->value = $out2;
$form->add($field);
return $form->render();
}
/**
* Render a table of matches
*
* @param PageArray $matches
* @param array $display Fields to display (from getDisplayFields method)
* @return string
*
*/
protected function renderMatchesTable(PageArray $matches, array $display) {
$input = $this->wire()->input;
$config = $this->wire()->config;
$modules = $this->wire()->modules;
if(!count($matches)) return '';
if(!count($display)) $display = array('path');
/** @var MarkupAdminDataTable $table */
$table = $modules->get("MarkupAdminDataTable");
$table->setSortable(false);
$table->setEncodeEntities(false);
$header = $display;
$header[] = "";
$table->headerRow($header);
foreach($matches as $match) {
$match->setOutputFormatting(true);
$editUrl = "{$config->urls->admin}page/edit/?id={$match->id}";
$viewUrl = $match->url();
$row = array();
foreach($display as $name) {
$value = $match->get($name);
if($value instanceof Page) $value = $value->name;
$value = strip_tags($value);
if($name == 'created' || $name == 'modified' || $name == 'published') $value = date('Y-m-d H:i:s', $value);
$value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
$row[] = "<a href='$viewUrl'>$value</a>";
}
$row[] = $match->editable() ? "<a class='action' href='$editUrl'>" . $this->_('edit') . "</a>" : '&nbsp;';
$table->row($row);
}
if($matches->getTotal() > count($matches)) {
/** @var MarkupPagerNav $pager */
$pager = $modules->get('MarkupPagerNav');
if($input->urlSegment1 == 'for') $pager->setBaseUrl($this->wire()->page->url . "for/");
$pager = $pager->render($matches);
} else {
$pager = '';
}
$out = $pager . $table->render() . $pager;
return $out;
}
/**
* Render the provided matches as a JSON string for AJAX use
*
* @param PageArray $matches
* @param array $display Array of fields to display, or display format associative array
* @param string $selector
* @return string
*
*/
protected function renderMatchesAjax(PageArray $matches, $display, $selector) {
$a = array(
'selector' => $selector,
'total' => $matches->getTotal(),
'limit' => $matches->getLimit(),
'start' => $matches->getStart(),
'matches' => array(),
);
// determine which template label we'll be asking for (for multi-language support)
$templateLabel = 'label';
if($this->wire()->languages) {
$language = $this->wire()->user->language;
if($language && !$language->isDefault()) $templateLabel = "label$language";
}
foreach($matches as $page) {
/** @var Page $page */
$p = array(
'id' => $page->id,
'parent_id' => $page->parent_id,
'template' => $page->template->name,
'path' => $page->path,
'name' => $page->name,
);
if($this->adminSearchMode) {
// don't include non-editable pages in admin search mode
if(!$page->editable()) {
$a['total']--;
continue;
}
// include the type of match and URL to edit, when in adminSearchMode
$p['type'] = $this->_x('Pages', 'match-type');
$p['editUrl'] = $page->editable() ? $page->editUrl() : '';
}
if(isset($display['name']) && isset($display['format'])) {
// use display format, returning a 'value' property containing the formatted value
if($display['textOnly']) {
$value = $page->getText($display['format'], true, false);
} else {
$value = $page->getMarkup($display['format']);
}
$p[$display['name']] = $value;
} else {
// use display fields
foreach($display as $key) {
if($key == 'template_label') {
$p['template_label'] = $page->template->$templateLabel ? $page->template->$templateLabel : $page->template->label;
if(empty($p['template_label'])) $p['template_label'] = $page->template->name;
continue;
}
$value = $page->get($key);
if(empty($value) && $this->adminSearchMode) {
if($key == 'title') $value = $page->name; // prevent empty title
}
if(is_object($value)) $value = $this->setupObjectMatch($value);
if(is_array($value)) $value = $this->setupArrayMatch($value);
$p[$key] = $value;
}
}
$a['matches'][] = $p;
}
return json_encode($a);
}
/**
* Convert object to an array where possible, otherwise convert to a string
*
* For use by renderMatchesAjax
*
* @param Page|WireData|WireArray|Wire|object $o
* @return array|string
*
*/
protected function setupObjectMatch($o) {
if($o instanceof Page) {
return array(
'id' => $o->id,
'parent_id' => $o->parent_id,
'template' => $o->template->name,
'name' => $o->name,
'path' => $o->path,
'title' => $o->title
);
}
if($o instanceof WireData || $o instanceof WireArray) return $o->getArray();
return (string) $o;
}
/**
* Filter an array converting any indexes containing objects to arrays or strings
*
* For use by renderMatchesAjax
*
* @param array $a
* @return array
*
*/
protected function setupArrayMatch(array $a) {
foreach($a as $key => $value) {
if(is_object($value)) $a[$key] = $this->setupObjectMatch($value);
else if(is_array($value)) $a[$key] = $this->setupArrayMatch($value);
}
return $a;
}
/**
* Render search for that submits to this process
*
* @param string $placeholder Value for placeholder attribute in search input
* @return string
*
*/
public function renderSearchForm($placeholder = '') {
$sanitizer = $this->wire()->sanitizer;
$q = substr((string) $this->wire()->input->get('q'), 0, 128);
$q = $sanitizer->entities($q);
$adminURL = $this->wire()->config->urls->admin;
if($placeholder) {
$placeholder = $sanitizer->entities1($placeholder);
$placeholder = " placeholder='$placeholder'";
} else {
$placeholder = '';
}
$action = $adminURL . 'page/search/live/';
$out =
"\n<form id='ProcessPageSearchForm' data-action='$action' action='$action' method='get'>" .
"\n\t<label for='ProcessPageSearchQuery'><i class='fa fa-search'></i></label>" .
"\n\t<input type='text' id='ProcessPageSearchQuery' name='q' value='$q' $placeholder />" .
"\n\t<input type='submit' id='ProcessPageSearchSubmit' name='search' value='Search' />" .
"\n\t<input type='hidden' name='show_options' value='1' />" .
"\n\t<span id='ProcessPageSearchStatus'></span>" .
"\n</form>";
return $out;
}
public function getModuleConfigInputfields(array $data) {
$modules = $this->wire()->modules;
$adminLiveSearchLabel = $this->_('Admin live search');
$inputfields = $this->wire(new InputfieldWrapper());
$textFields = array();
$allSearchTypes = array('pages', 'trash', 'modules');
$textOperators = Selectors::getOperators(array(
'compareType' => Selector::compareTypeFind,
'getIndexType' => 'operator',
'getValueType' => 'label',
));
$textOperators['='] = SelectorEqual::getLabel();
unset($textOperators['#=']);
if(!isset($data['searchTypesOrder'])) $data['searchTypesOrder'] = array();
if(!isset($data['noSearchTypes'])) $data['noSearchTypes'] = array();
$searchTypesOrder = &$data['searchTypesOrder'];
$noSearchTypes = &$data['noSearchTypes'];
// find all text fields
foreach($this->wire()->fields as $field) {
if(!$field->type instanceof FieldtypeText) continue;
$textFields[$field->name] = $field;
}
// ensure that base/built-in search types are present
foreach($allSearchTypes as $key) {
if(!in_array($key, $searchTypesOrder)) $searchTypesOrder[] = $key;
}
// find searchable modules
foreach($modules as $module) {
$info = $modules->getModuleInfoVerbose($module);
if(empty($info['searchable'])) continue;
$name = $info['searchable'];
if(is_bool($name) || ctype_digit($name)) $name = $info['name'];
$allSearchTypes[$name] = $name;
if(!in_array($name, $searchTypesOrder)) $searchTypesOrder[] = $name;
}
/** @var InputfieldFieldset $fieldset */
$fieldset = $modules->get('InputfieldFieldset');
$fieldset->label = $adminLiveSearchLabel;
$fieldset->icon = 'search';
$inputfields->add($fieldset);
/** @var InputfieldAsmSelect $f */
$f = $modules->get('InputfieldAsmSelect');
$f->attr('name', 'searchTypesOrder');
$f->label = $this->_('Search order');
$f->description =
$this->_('These are the types of searches that will be performed during an admin live search.') . ' ' .
$this->_('Drag them to the order you want the search results to be listed in.');
foreach($allSearchTypes as $name) {
$label = $name;
if(in_array($name, $noSearchTypes)) $label .= ' ' . $this->_('(excluded)');
$f->addOption($name, $label);
}
$f->attr('value', $searchTypesOrder);
$f->setAsmSelectOption('deletable', false);
$f->setAsmSelectOption('addable', false);
$fieldset->add($f);
/** @var InputfieldAsmSelect $f */
$f = $modules->get('InputfieldAsmSelect');
$f->attr('name', 'noSearchTypes');
$f->label = $this->_('Exclude search types');
$f->description =
$this->_('Select any search types that you want to exclude from live search. These might be types you dont 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;
}
}