Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 197
0.00% covered (danger)
0.00%
0 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
SearchFormWidget
0.00% covered (danger)
0.00%
0 / 197
0.00% covered (danger)
0.00%
0 / 18
1406
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 render
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
20
 shortDialogHtml
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
30
 profileTabsHtml
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
30
 startsWithImage
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 makeSearchLink
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 optionsHtml
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 powerSearchBox
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 getHookContainer
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHookRunner
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 searchFilterSeparatorHtml
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 createPowerSearchRememberCheckBoxHtml
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 createNamespaceToggleBoxHtml
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 createSearchBoxHeadHtml
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 createNamespaceCheckbox
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 getNamespaceDisplayName
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 createCheckboxesForEverySearchableNamespace
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 createHiddenOptsHtml
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace MediaWiki\Search\SearchWidgets;
4
5use ILanguageConverter;
6use MediaWiki\Config\ServiceOptions;
7use MediaWiki\HookContainer\HookContainer;
8use MediaWiki\HookContainer\HookRunner;
9use MediaWiki\Html\Html;
10use MediaWiki\MainConfigNames;
11use MediaWiki\Specials\SpecialSearch;
12use MediaWiki\Title\NamespaceInfo;
13use MediaWiki\Widget\SearchInputWidget;
14use OOUI\ActionFieldLayout;
15use OOUI\ButtonInputWidget;
16use OOUI\CheckboxInputWidget;
17use OOUI\FieldLayout;
18use SearchEngineConfig;
19use Xml;
20
21class SearchFormWidget {
22    /** @internal For use by SpecialSearch only */
23    public const CONSTRUCTOR_OPTIONS = [
24        MainConfigNames::CapitalLinks,
25    ];
26    private ServiceOptions $options;
27    /** @var SpecialSearch */
28    protected $specialSearch;
29    /** @var SearchEngineConfig */
30    protected $searchConfig;
31    /** @var array */
32    protected $profiles;
33    /** @var HookContainer */
34    private $hookContainer;
35    /** @var HookRunner */
36    private $hookRunner;
37    /** @var ILanguageConverter */
38    private $languageConverter;
39    /** @var NamespaceInfo */
40    private $namespaceInfo;
41
42    /**
43     * @param ServiceOptions $options
44     * @param SpecialSearch $specialSearch
45     * @param SearchEngineConfig $searchConfig
46     * @param HookContainer $hookContainer
47     * @param ILanguageConverter $languageConverter
48     * @param NamespaceInfo $namespaceInfo
49     * @param array $profiles
50     */
51    public function __construct(
52        ServiceOptions $options,
53        SpecialSearch $specialSearch,
54        SearchEngineConfig $searchConfig,
55        HookContainer $hookContainer,
56        ILanguageConverter $languageConverter,
57        NamespaceInfo $namespaceInfo,
58        array $profiles
59    ) {
60        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
61        $this->options = $options;
62        $this->specialSearch = $specialSearch;
63        $this->searchConfig = $searchConfig;
64        $this->hookContainer = $hookContainer;
65        $this->hookRunner = new HookRunner( $hookContainer );
66        $this->languageConverter = $languageConverter;
67        $this->namespaceInfo = $namespaceInfo;
68        $this->profiles = $profiles;
69    }
70
71    /**
72     * @param string $profile The current search profile
73     * @param string $term The current search term
74     * @param int $numResults The number of results shown
75     * @param int $totalResults The total estimated results found
76     * @param int $offset Current offset in search results
77     * @param bool $isPowerSearch Is the 'advanced' section open?
78     * @param array $options Widget options
79     * @return string HTML
80     */
81    public function render(
82        $profile,
83        $term,
84        $numResults,
85        $totalResults,
86        $offset,
87        $isPowerSearch,
88        array $options = []
89    ) {
90        $user = $this->specialSearch->getUser();
91
92        $form = Xml::openElement(
93                'form',
94                [
95                    'id' => $isPowerSearch ? 'powersearch' : 'search',
96                    // T151903: default to POST in case JS is disabled
97                    'method' => ( $isPowerSearch && $user->isRegistered() ) ? 'post' : 'get',
98                    'action' => wfScript(),
99                ]
100            ) .
101                Html::rawElement(
102                    'div',
103                    [ 'id' => 'mw-search-top-table' ],
104                    $this->shortDialogHtml( $profile, $term, $numResults, $totalResults, $offset, $options )
105                ) .
106                Html::rawElement( 'div', [ 'class' => 'mw-search-visualclear' ] ) .
107                Html::rawElement(
108                    'div',
109                    [ 'class' => 'mw-search-profile-tabs' ],
110                    $this->profileTabsHtml( $profile, $term ) .
111                        Html::rawElement( 'div', [ 'style' => 'clear:both' ] )
112                ) .
113                $this->optionsHtml( $term, $isPowerSearch, $profile ) .
114            Xml::closeElement( 'form' );
115
116        return Html::rawElement( 'div', [ 'class' => 'mw-search-form-wrapper' ], $form );
117    }
118
119    /**
120     * @param string $profile The current search profile
121     * @param string $term The current search term
122     * @param int $numResults The number of results shown
123     * @param int $totalResults The total estimated results found
124     * @param int $offset Current offset in search results
125     * @param array $options Widget options
126     * @return string HTML
127     */
128    protected function shortDialogHtml(
129        $profile,
130        $term,
131        $numResults,
132        $totalResults,
133        $offset,
134        array $options = []
135    ) {
136        $autoCapHint = $this->options->get( MainConfigNames::CapitalLinks );
137
138        $searchWidget = new SearchInputWidget( $options + [
139            'id' => 'searchText',
140            'name' => 'search',
141            'autofocus' => trim( $term ) === '',
142            'title' => $this->specialSearch->msg( 'searchsuggest-search' )->text(),
143            'value' => $term,
144            'dataLocation' => 'content',
145            'infusable' => true,
146            'autocapitalize' => $autoCapHint ? 'sentences' : 'none',
147        ] );
148
149        $html = new ActionFieldLayout( $searchWidget, new ButtonInputWidget( [
150            'type' => 'submit',
151            'label' => $this->specialSearch->msg( 'searchbutton' )->text(),
152            'flags' => [ 'progressive', 'primary' ],
153        ] ), [
154            'align' => 'top',
155        ] );
156
157        if ( $this->specialSearch->getPrefix() !== '' ) {
158            $html .= Html::hidden( 'prefix', $this->specialSearch->getPrefix() );
159        }
160
161        if ( $totalResults > 0 && $offset < $totalResults ) {
162            $html .= Xml::tags(
163                'div',
164                [
165                    'class' => 'results-info',
166                    'data-mw-num-results-offset' => $offset,
167                    'data-mw-num-results-total' => $totalResults
168                ],
169                $this->specialSearch->msg( 'search-showingresults' )
170                    ->numParams( $offset + 1, $offset + $numResults, $totalResults )
171                    ->numParams( $numResults )
172                    ->parse()
173            );
174        }
175
176        $html .=
177            Html::hidden( 'title', $this->specialSearch->getPageTitle()->getPrefixedText() ) .
178            Html::hidden( 'profile', $profile ) .
179            Html::hidden( 'fulltext', '1' );
180
181        return $html;
182    }
183
184    /**
185     * Generates HTML for the list of available search profiles.
186     *
187     * @param string $profile The currently selected profile
188     * @param string $term The user provided search terms
189     * @return string HTML
190     */
191    protected function profileTabsHtml( $profile, $term ) {
192        $bareterm = $this->startsWithImage( $term )
193            ? substr( $term, strpos( $term, ':' ) + 1 )
194            : $term;
195        $lang = $this->specialSearch->getLanguage();
196        $items = [];
197        foreach ( $this->profiles as $id => $profileConfig ) {
198            $profileConfig['parameters']['profile'] = $id;
199            $tooltipParam = isset( $profileConfig['namespace-messages'] )
200                ? $lang->commaList( $profileConfig['namespace-messages'] )
201                : null;
202            $items[] = Xml::tags(
203                'li',
204                [ 'class' => $profile === $id ? 'current' : 'normal' ],
205                $this->makeSearchLink(
206                    $bareterm,
207                    $this->specialSearch->msg( $profileConfig['message'] )->text(),
208                    $this->specialSearch->msg( $profileConfig['tooltip'], $tooltipParam )->text(),
209                    $profileConfig['parameters']
210                )
211            );
212        }
213
214        return Html::rawElement(
215            'div',
216            [ 'class' => 'search-types' ],
217            Html::rawElement( 'ul', [], implode( '', $items ) )
218        );
219    }
220
221    /**
222     * Check if query starts with image: prefix
223     *
224     * @param string $term The string to check
225     * @return bool
226     */
227    protected function startsWithImage( $term ) {
228        $parts = explode( ':', $term );
229        return count( $parts ) > 1
230            && $this->specialSearch->getContentLanguage()->getNsIndex( $parts[0] ) === NS_FILE;
231    }
232
233    /**
234     * Make a search link with some target namespaces
235     *
236     * @param string $term The term to search for
237     * @param string $label Link's text
238     * @param string $tooltip Link's tooltip
239     * @param array $params Query string parameters
240     * @return string HTML fragment
241     */
242    protected function makeSearchLink( $term, $label, $tooltip, array $params = [] ) {
243        $params += [
244            'search' => $term,
245            'fulltext' => 1,
246        ];
247
248        return Xml::element(
249            'a',
250            [
251                'href' => $this->specialSearch->getPageTitle()->getLocalURL( $params ),
252                'title' => $tooltip,
253            ],
254            $label
255        );
256    }
257
258    /**
259     * Generates HTML for advanced options available with the currently
260     * selected search profile.
261     *
262     * @param string $term User provided search term
263     * @param bool $isPowerSearch Is the advanced search profile enabled?
264     * @param string $profile The current search profile
265     * @return string HTML
266     */
267    protected function optionsHtml( $term, $isPowerSearch, $profile ) {
268        if ( $isPowerSearch ) {
269            $html = $this->powerSearchBox( $term, [] );
270        } else {
271            $html = '';
272            $this->getHookRunner()->onSpecialSearchProfileForm(
273                $this->specialSearch, $html, $profile, $term, []
274            );
275        }
276
277        return $html;
278    }
279
280    /**
281     * @param string $term The current search term
282     * @param array $opts Additional key/value pairs that will be submitted
283     *  with the generated form.
284     * @return string HTML
285     */
286    protected function powerSearchBox( $term, array $opts ) {
287        $namespaceTables =
288            [ 'namespaceTables' => $this->createCheckboxesForEverySearchableNamespace() ];
289        $this->getHookRunner()->onSpecialSearchPowerBox( $namespaceTables, $term, $opts );
290
291        $outputHtml = '';
292        $outputHtml .= $this->createSearchBoxHeadHtml();
293        $outputHtml .= $this->searchFilterSeparatorHtml();
294        $outputHtml .= implode( $this->searchFilterSeparatorHtml(), $namespaceTables );
295        $outputHtml .= $this->createHiddenOptsHtml( $opts );
296
297        // Stuff to feed SpecialSearch::saveNamespaces()
298        if ( $this->specialSearch->getUser()->isRegistered() ) {
299            $outputHtml .= $this->searchFilterSeparatorHtml();
300            $outputHtml .= $this->createPowerSearchRememberCheckBoxHtml();
301        }
302
303        return Html::rawElement( 'fieldset', [ 'id' => 'mw-searchoptions' ], $outputHtml );
304    }
305
306    /**
307     * @return HookContainer
308     * @since 1.35
309     */
310    protected function getHookContainer() {
311        return $this->hookContainer;
312    }
313
314    /**
315     * @return HookRunner
316     * @since 1.35
317     * @internal This is for use by core only. Hook interfaces may be removed
318     *   without notice.
319     */
320    protected function getHookRunner() {
321        return $this->hookRunner;
322    }
323
324    private function searchFilterSeparatorHtml(): string {
325        return Html::rawElement( 'div', [ 'class' => 'divider' ], '' );
326    }
327
328    private function createPowerSearchRememberCheckBoxHtml(): string {
329        return new FieldLayout(
330            new CheckboxInputWidget( [
331                'name' => 'nsRemember',
332                'selected' => false,
333                'inputId' => 'mw-search-powersearch-remember',
334                // The token goes here rather than in a hidden field so it
335                // is only sent when necessary (not every form submission)
336                'value' => $this->specialSearch->getContext()->getCsrfTokenSet()
337                    ->getToken( 'searchnamespace' )
338            ] ),
339            [
340            'label' => $this->specialSearch->msg( 'powersearch-remember' )->text(),
341            'align' => 'inline'
342            ]
343        );
344    }
345
346    private function createNamespaceToggleBoxHtml(): string {
347        $toggleBoxContents = "";
348        $toggleBoxContents .= Html::rawElement( 'label', [],
349                $this->specialSearch->msg( 'powersearch-togglelabel' )->escaped() );
350        $toggleBoxContents .= Html::rawElement( 'input', [
351                    'type' => 'button',
352                    'id' => 'mw-search-toggleall',
353                    'value' => $this->specialSearch->msg( 'powersearch-toggleall' )->text(),
354                ] );
355        $toggleBoxContents .= Html::rawElement( 'input', [
356                    'type' => 'button',
357                    'id' => 'mw-search-togglenone',
358                    'value' => $this->specialSearch->msg( 'powersearch-togglenone' )->text(),
359                ] );
360
361        // Handled by JavaScript if available
362        return Html::rawElement( 'div', [ 'id' => 'mw-search-togglebox' ], $toggleBoxContents );
363    }
364
365    private function createSearchBoxHeadHtml(): string {
366        return Html::rawElement( 'legend', [],
367                $this->specialSearch->msg( 'powersearch-legend' )->escaped() ) .
368            Html::rawElement( 'h4', [], $this->specialSearch->msg( 'powersearch-ns' )->parse() ) .
369            $this->createNamespaceToggleBoxHtml();
370    }
371
372    private function createNamespaceCheckbox( int $namespace, array $activeNamespaces ): string {
373        $namespaceDisplayName = $this->getNamespaceDisplayName( $namespace );
374
375        return new FieldLayout(
376            new CheckboxInputWidget( [
377                'name' => "ns{$namespace}",
378                'selected' => in_array( $namespace, $activeNamespaces ),
379                'inputId' => "mw-search-ns{$namespace}",
380                'value' => '1'
381            ] ),
382            [
383            'label' => $namespaceDisplayName,
384            'align' => 'inline'
385            ]
386        );
387    }
388
389    private function getNamespaceDisplayName( int $namespace ): string {
390        $name = $this->languageConverter->convertNamespace( $namespace );
391        if ( $name === '' ) {
392            $name = $this->specialSearch->msg( 'blanknamespace' )->text();
393        }
394
395        return $name;
396    }
397
398    private function createCheckboxesForEverySearchableNamespace(): string {
399        $rows = [];
400        $activeNamespaces = $this->specialSearch->getNamespaces();
401        foreach ( $this->searchConfig->searchableNamespaces() as $namespace => $name ) {
402            $subject = $this->namespaceInfo->getSubject( $namespace );
403            if ( !isset( $rows[$subject] ) ) {
404                $rows[$subject] = "";
405            }
406
407            $rows[$subject] .= $this->createNamespaceCheckbox( $namespace, $activeNamespaces );
408        }
409
410        $namespaceTables = [];
411        foreach ( array_chunk( $rows, 4 ) as $chunk ) {
412            $namespaceTables[] = implode( '', $chunk );
413        }
414
415        return '<div class="checkbox-container">' .
416            implode( '</div><div class="checkbox-container">', $namespaceTables ) . '</div>';
417    }
418
419    private function createHiddenOptsHtml( array $opts ): string {
420        $hidden = '';
421        foreach ( $opts as $key => $value ) {
422            $hidden .= Html::hidden( $key, $value );
423        }
424
425        return $hidden;
426    }
427}