includes/search/searchwidgets/SearchFormWidget.php
<?php
namespace MediaWiki\Search\SearchWidgets;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\HookContainer\HookRunner;
use MediaWiki\Html\Html;
use MediaWiki\Language\ILanguageConverter;
use MediaWiki\MainConfigNames;
use MediaWiki\Specials\SpecialSearch;
use MediaWiki\Title\NamespaceInfo;
use MediaWiki\Widget\SearchInputWidget;
use MediaWiki\Xml\Xml;
use OOUI\ActionFieldLayout;
use OOUI\ButtonInputWidget;
use OOUI\CheckboxInputWidget;
use OOUI\FieldLayout;
use SearchEngineConfig;
class SearchFormWidget {
/** @internal For use by SpecialSearch only */
public const CONSTRUCTOR_OPTIONS = [
MainConfigNames::CapitalLinks,
];
private ServiceOptions $options;
/** @var SpecialSearch */
protected $specialSearch;
/** @var SearchEngineConfig */
protected $searchConfig;
/** @var array */
protected $profiles;
/** @var HookContainer */
private $hookContainer;
/** @var HookRunner */
private $hookRunner;
/** @var ILanguageConverter */
private $languageConverter;
/** @var NamespaceInfo */
private $namespaceInfo;
/**
* @param ServiceOptions $options
* @param SpecialSearch $specialSearch
* @param SearchEngineConfig $searchConfig
* @param HookContainer $hookContainer
* @param ILanguageConverter $languageConverter
* @param NamespaceInfo $namespaceInfo
* @param array $profiles
*/
public function __construct(
ServiceOptions $options,
SpecialSearch $specialSearch,
SearchEngineConfig $searchConfig,
HookContainer $hookContainer,
ILanguageConverter $languageConverter,
NamespaceInfo $namespaceInfo,
array $profiles
) {
$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
$this->options = $options;
$this->specialSearch = $specialSearch;
$this->searchConfig = $searchConfig;
$this->hookContainer = $hookContainer;
$this->hookRunner = new HookRunner( $hookContainer );
$this->languageConverter = $languageConverter;
$this->namespaceInfo = $namespaceInfo;
$this->profiles = $profiles;
}
/**
* @param string $profile The current search profile
* @param string $term The current search term
* @param int $numResults The number of results shown
* @param int $totalResults The total estimated results found
* @param int $offset Current offset in search results
* @param bool $isPowerSearch Is the 'advanced' section open?
* @param array $options Widget options
* @return string HTML
*/
public function render(
$profile,
$term,
$numResults,
$totalResults,
$offset,
$isPowerSearch,
array $options = []
) {
$user = $this->specialSearch->getUser();
$form = Xml::openElement(
'form',
[
'id' => $isPowerSearch ? 'powersearch' : 'search',
// T151903: default to POST in case JS is disabled
'method' => ( $isPowerSearch && $user->isRegistered() ) ? 'post' : 'get',
'action' => wfScript(),
]
) .
Html::rawElement(
'div',
[ 'id' => 'mw-search-top-table' ],
$this->shortDialogHtml( $profile, $term, $numResults, $totalResults, $offset, $options )
) .
Html::rawElement( 'div', [ 'class' => 'mw-search-visualclear' ] ) .
Html::rawElement(
'div',
[ 'class' => 'mw-search-profile-tabs' ],
$this->profileTabsHtml( $profile, $term ) .
Html::rawElement( 'div', [ 'style' => 'clear:both' ] )
) .
$this->optionsHtml( $term, $isPowerSearch, $profile ) .
Xml::closeElement( 'form' );
return Html::rawElement( 'div', [ 'class' => 'mw-search-form-wrapper' ], $form );
}
/**
* @param string $profile The current search profile
* @param string $term The current search term
* @param int $numResults The number of results shown
* @param int $totalResults The total estimated results found
* @param int $offset Current offset in search results
* @param array $options Widget options
* @return string HTML
*/
protected function shortDialogHtml(
$profile,
$term,
$numResults,
$totalResults,
$offset,
array $options = []
) {
$autoCapHint = $this->options->get( MainConfigNames::CapitalLinks );
$searchWidget = new SearchInputWidget( $options + [
'id' => 'searchText',
'name' => 'search',
'autofocus' => trim( $term ) === '',
'title' => $this->specialSearch->msg( 'searchsuggest-search' )->text(),
'value' => $term,
'dataLocation' => 'content',
'infusable' => true,
'autocapitalize' => $autoCapHint ? 'sentences' : 'none',
] );
$html = new ActionFieldLayout( $searchWidget, new ButtonInputWidget( [
'type' => 'submit',
'label' => $this->specialSearch->msg( 'searchbutton' )->text(),
'flags' => [ 'progressive', 'primary' ],
] ), [
'align' => 'top',
] );
if ( $this->specialSearch->getPrefix() !== '' ) {
$html .= Html::hidden( 'prefix', $this->specialSearch->getPrefix() );
}
if ( $totalResults > 0 && $offset < $totalResults ) {
$html .= Xml::tags(
'div',
[
'class' => 'results-info',
'data-mw-num-results-offset' => $offset,
'data-mw-num-results-total' => $totalResults
],
$this->specialSearch->msg( 'search-showingresults' )
->numParams( $offset + 1, $offset + $numResults, $totalResults )
->numParams( $numResults )
->parse()
);
}
$html .=
Html::hidden( 'title', $this->specialSearch->getPageTitle()->getPrefixedText() ) .
Html::hidden( 'profile', $profile ) .
Html::hidden( 'fulltext', '1' );
return $html;
}
/**
* Generates HTML for the list of available search profiles.
*
* @param string $profile The currently selected profile
* @param string $term The user provided search terms
* @return string HTML
*/
protected function profileTabsHtml( $profile, $term ) {
$bareterm = $this->startsWithImage( $term )
? substr( $term, strpos( $term, ':' ) + 1 )
: $term;
$lang = $this->specialSearch->getLanguage();
$items = [];
foreach ( $this->profiles as $id => $profileConfig ) {
$profileConfig['parameters']['profile'] = $id;
$tooltipParam = isset( $profileConfig['namespace-messages'] )
? $lang->commaList( $profileConfig['namespace-messages'] )
: null;
$items[] = Xml::tags(
'li',
[ 'class' => $profile === $id ? 'current' : 'normal' ],
$this->makeSearchLink(
$bareterm,
$this->specialSearch->msg( $profileConfig['message'] )->text(),
$this->specialSearch->msg( $profileConfig['tooltip'], $tooltipParam )->text(),
$profileConfig['parameters']
)
);
}
return Html::rawElement(
'div',
[ 'class' => 'search-types' ],
Html::rawElement( 'ul', [], implode( '', $items ) )
);
}
/**
* Check if query starts with image: prefix
*
* @param string $term The string to check
* @return bool
*/
protected function startsWithImage( $term ) {
$parts = explode( ':', $term );
return count( $parts ) > 1
&& $this->specialSearch->getContentLanguage()->getNsIndex( $parts[0] ) === NS_FILE;
}
/**
* Make a search link with some target namespaces
*
* @param string $term The term to search for
* @param string $label Link's text
* @param string $tooltip Link's tooltip
* @param array $params Query string parameters
* @return string HTML fragment
*/
protected function makeSearchLink( $term, $label, $tooltip, array $params = [] ) {
$params += [
'search' => $term,
'fulltext' => 1,
];
return Xml::element(
'a',
[
'href' => $this->specialSearch->getPageTitle()->getLocalURL( $params ),
'title' => $tooltip,
],
$label
);
}
/**
* Generates HTML for advanced options available with the currently
* selected search profile.
*
* @param string $term User provided search term
* @param bool $isPowerSearch Is the advanced search profile enabled?
* @param string $profile The current search profile
* @return string HTML
*/
protected function optionsHtml( $term, $isPowerSearch, $profile ) {
if ( $isPowerSearch ) {
$html = $this->powerSearchBox( $term, [] );
} else {
$html = '';
$this->getHookRunner()->onSpecialSearchProfileForm(
$this->specialSearch, $html, $profile, $term, []
);
}
return $html;
}
/**
* @param string $term The current search term
* @param array $opts Additional key/value pairs that will be submitted
* with the generated form.
* @return string HTML
*/
protected function powerSearchBox( $term, array $opts ) {
$namespaceTables =
[ 'namespaceTables' => $this->createCheckboxesForEverySearchableNamespace() ];
$this->getHookRunner()->onSpecialSearchPowerBox( $namespaceTables, $term, $opts );
$outputHtml = '';
$outputHtml .= $this->createSearchBoxHeadHtml();
$outputHtml .= $this->searchFilterSeparatorHtml();
$outputHtml .= implode( $this->searchFilterSeparatorHtml(), $namespaceTables );
$outputHtml .= $this->createHiddenOptsHtml( $opts );
// Stuff to feed SpecialSearch::saveNamespaces()
if ( $this->specialSearch->getUser()->isRegistered() ) {
$outputHtml .= $this->searchFilterSeparatorHtml();
$outputHtml .= $this->createPowerSearchRememberCheckBoxHtml();
}
return Html::rawElement( 'fieldset', [ 'id' => 'mw-searchoptions' ], $outputHtml );
}
/**
* @return HookContainer
* @since 1.35
*/
protected function getHookContainer() {
return $this->hookContainer;
}
/**
* @return HookRunner
* @since 1.35
* @internal This is for use by core only. Hook interfaces may be removed
* without notice.
*/
protected function getHookRunner() {
return $this->hookRunner;
}
private function searchFilterSeparatorHtml(): string {
return Html::rawElement( 'div', [ 'class' => 'divider' ], '' );
}
private function createPowerSearchRememberCheckBoxHtml(): string {
return new FieldLayout(
new CheckboxInputWidget( [
'name' => 'nsRemember',
'selected' => false,
'inputId' => 'mw-search-powersearch-remember',
// The token goes here rather than in a hidden field so it
// is only sent when necessary (not every form submission)
'value' => $this->specialSearch->getContext()->getCsrfTokenSet()
->getToken( 'searchnamespace' )
] ),
[
'label' => $this->specialSearch->msg( 'powersearch-remember' )->text(),
'align' => 'inline'
]
);
}
private function createNamespaceToggleBoxHtml(): string {
$toggleBoxContents = "";
$toggleBoxContents .= Html::rawElement( 'label', [],
$this->specialSearch->msg( 'powersearch-togglelabel' )->escaped() );
$toggleBoxContents .= Html::rawElement( 'input', [
'type' => 'button',
'id' => 'mw-search-toggleall',
'value' => $this->specialSearch->msg( 'powersearch-toggleall' )->text(),
] );
$toggleBoxContents .= Html::rawElement( 'input', [
'type' => 'button',
'id' => 'mw-search-togglenone',
'value' => $this->specialSearch->msg( 'powersearch-togglenone' )->text(),
] );
// Handled by JavaScript if available
return Html::rawElement( 'div', [ 'id' => 'mw-search-togglebox' ], $toggleBoxContents );
}
private function createSearchBoxHeadHtml(): string {
return Html::rawElement( 'legend', [],
$this->specialSearch->msg( 'powersearch-legend' )->escaped() ) .
Html::rawElement( 'h4', [], $this->specialSearch->msg( 'powersearch-ns' )->parse() ) .
$this->createNamespaceToggleBoxHtml();
}
private function createNamespaceCheckbox( int $namespace, array $activeNamespaces ): string {
$namespaceDisplayName = $this->getNamespaceDisplayName( $namespace );
return new FieldLayout(
new CheckboxInputWidget( [
'name' => "ns{$namespace}",
'selected' => in_array( $namespace, $activeNamespaces ),
'inputId' => "mw-search-ns{$namespace}",
'value' => '1'
] ),
[
'label' => $namespaceDisplayName,
'align' => 'inline'
]
);
}
private function getNamespaceDisplayName( int $namespace ): string {
$name = $this->languageConverter->convertNamespace( $namespace );
if ( $name === '' ) {
$name = $this->specialSearch->msg( 'blanknamespace' )->text();
}
return $name;
}
private function createCheckboxesForEverySearchableNamespace(): string {
$rows = [];
$activeNamespaces = $this->specialSearch->getNamespaces();
foreach ( $this->searchConfig->searchableNamespaces() as $namespace => $name ) {
$subject = $this->namespaceInfo->getSubject( $namespace );
if ( !isset( $rows[$subject] ) ) {
$rows[$subject] = "";
}
$rows[$subject] .= $this->createNamespaceCheckbox( $namespace, $activeNamespaces );
}
$namespaceTables = [];
foreach ( array_chunk( $rows, 4 ) as $chunk ) {
$namespaceTables[] = implode( '', $chunk );
}
return '<div class="checkbox-container">' .
implode( '</div><div class="checkbox-container">', $namespaceTables ) . '</div>';
}
private function createHiddenOptsHtml( array $opts ): string {
$hidden = '';
foreach ( $opts as $key => $value ) {
$hidden .= Html::hidden( $key, $value );
}
return $hidden;
}
}