Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.44% covered (success)
97.44%
76 / 78
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
97.44% covered (success)
97.44%
76 / 78
75.00% covered (warning)
75.00%
6 / 8
21
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onSpecialPageBeforeExecute
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
5
 getJsConfigVars
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
4
 redirectToNamespacedRequest
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getDefaultNamespaces
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isNamespacedSearch
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 onSpecialSearchResultsPrepend
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 onGetPreferences
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace AdvancedSearch;
4
5use MediaWiki\Config\Config;
6use MediaWiki\Hook\SpecialSearchResultsPrependHook;
7use MediaWiki\Html\Html;
8use MediaWiki\Language\Language;
9use MediaWiki\Languages\LanguageNameUtils;
10use MediaWiki\MediaWikiServices;
11use MediaWiki\Output\OutputPage;
12use MediaWiki\Preferences\Hook\GetPreferencesHook;
13use MediaWiki\Registration\ExtensionRegistry;
14use MediaWiki\Request\WebRequest;
15use MediaWiki\SiteStats\SiteStats;
16use MediaWiki\SpecialPage\Hook\SpecialPageBeforeExecuteHook;
17use MediaWiki\SpecialPage\SpecialPage;
18use MediaWiki\Specials\SpecialSearch;
19use MediaWiki\User\Options\UserOptionsLookup;
20use MediaWiki\User\User;
21use MediaWiki\User\UserIdentity;
22use MessageLocalizer;
23use SearchEngineConfig;
24use Wikimedia\Mime\MimeAnalyzer;
25
26/**
27 * @license GPL-2.0-or-later
28 */
29class Hooks implements
30    SpecialPageBeforeExecuteHook,
31    GetPreferencesHook,
32    SpecialSearchResultsPrependHook
33{
34
35    public function __construct(
36        private readonly UserOptionsLookup $userOptionsLookup,
37        private readonly LanguageNameUtils $languageNameUtils,
38        private readonly SearchEngineConfig $searchEngineConfig,
39        private readonly MimeAnalyzer $mimeAnalyzer,
40    ) {
41    }
42
43    /**
44     * @see https://www.mediawiki.org/wiki/Manual:Hooks/SpecialPageBeforeExecute
45     *
46     * @param SpecialPage $special
47     * @param string|null $subpage
48     * @return false|void false to abort the execution of the special page, "void" otherwise
49     */
50    public function onSpecialPageBeforeExecute( $special, $subpage ) {
51        if ( $special->getName() !== 'Search' ) {
52            return;
53        }
54
55        $user = $special->getUser();
56        $outputPage = $special->getOutput();
57
58        /**
59         * If the user is logged in and has explicitly requested to disable the extension, don't load.
60         * Ensure namespaces are always part of search URLs
61         */
62        if ( $user->isNamed() &&
63            $this->userOptionsLookup->getBoolOption( $user, 'advancedsearch-disable' )
64        ) {
65            return;
66        }
67
68        /**
69         * Ensure the current URL is specifying the namespaces which are to be used
70         */
71        $redirect = $this->redirectToNamespacedRequest( $special );
72        if ( $redirect !== null ) {
73            $outputPage->redirect( $redirect );
74            // Abort execution of the SpecialPage by returning false since we are redirecting
75            return false;
76        }
77
78        $outputPage->addModules( [
79            'ext.advancedSearch.init',
80            'ext.advancedSearch.searchtoken',
81        ] );
82
83        $outputPage->addModuleStyles( 'ext.advancedSearch.initialstyles' );
84
85        $outputPage->addJsConfigVars( $this->getJsConfigVars(
86            $special->getContext(),
87            $special->getLanguage(),
88            $special->getConfig(),
89            $this->getDefaultNamespaces( $user ),
90            ExtensionRegistry::getInstance()
91        ) );
92    }
93
94    /**
95     * @param MessageLocalizer $context
96     * @param Language $userLang
97     * @param Config $config
98     * @param int[] $defaultNamespaces
99     * @param ExtensionRegistry $extensionRegistry
100     * @return array<string,mixed>
101     */
102    private function getJsConfigVars(
103        MessageLocalizer $context,
104        Language $userLang,
105        Config $config,
106        array $defaultNamespaces,
107        ExtensionRegistry $extensionRegistry
108    ): array {
109        $namespaceBuilder = new SearchableNamespaceListBuilder(
110            MediaWikiServices::getInstance()->getLanguageConverterFactory()
111                ->getLanguageConverter( $userLang ),
112            static function ( int $ns ) use ( $defaultNamespaces ): bool {
113                // Skip the expensive query for all standard namespaces that are hard-coded in core
114                return $ns <= NS_CATEGORY_TALK ||
115                    // Must include empty namespaces in $wgNamespacesToBeSearchedDefault
116                    in_array( $ns, $defaultNamespaces ) ||
117                    SiteStats::pagesInNs( $ns );
118            }
119        );
120
121        $vars = [
122            'advancedSearch.mimeTypes' =>
123                ( new MimeTypeConfigurator( $this->mimeAnalyzer ) )->getMimeTypes(
124                    $config->get( 'FileExtensions' )
125                ),
126            'advancedSearch.tooltips' => ( new TooltipGenerator( $context ) )->generateTooltips(),
127            'advancedSearch.namespacePresets' => $config->get( 'AdvancedSearchNamespacePresets' ),
128            'advancedSearch.deepcategoryEnabled' => $config->get( 'AdvancedSearchDeepcatEnabled' ),
129            'advancedSearch.searchableNamespaces' =>
130                $namespaceBuilder->getCuratedNamespaces(
131                    $this->searchEngineConfig->searchableNamespaces()
132                ),
133        ];
134
135        if ( $extensionRegistry->isLoaded( 'Translate' ) ) {
136            $vars += [ 'advancedSearch.languages' =>
137                $this->languageNameUtils->getLanguageNames()
138            ];
139        }
140
141        return $vars;
142    }
143
144    /**
145     * If the request does not contain any namespaces, redirect to URL with user default namespaces
146     *
147     * @param SpecialPage $special
148     * @return string|null the URL to redirect to or null if not needed
149     */
150    private function redirectToNamespacedRequest( SpecialPage $special ): ?string {
151        if ( !self::isNamespacedSearch( $special->getRequest() ) ) {
152            $namespacedSearchUrl = $special->getRequest()->getFullRequestURL();
153            $queryParts = [];
154            foreach ( $this->getDefaultNamespaces( $special->getUser() ) as $ns ) {
155                $queryParts['ns' . $ns] = '1';
156            }
157            return wfAppendQuery( $namespacedSearchUrl, $queryParts );
158        }
159        return null;
160    }
161
162    /**
163     * Retrieves the default namespaces for the current user
164     *
165     * @param UserIdentity $user The user to lookup default namespaces for
166     * @return int[] List of namespaces to be searched by default
167     */
168    private function getDefaultNamespaces( UserIdentity $user ): array {
169        return $this->searchEngineConfig->userNamespaces( $user ) ?: $this->searchEngineConfig->defaultNamespaces();
170    }
171
172    /**
173     * Checks if there is a search request, and it already specifies namespaces.
174     *
175     * @param WebRequest $request
176     * @return bool
177     */
178    private static function isNamespacedSearch( WebRequest $request ): bool {
179        if ( ( $request->getRawVal( 'search' ) ?? '' ) === '' ) {
180            return true;
181        }
182
183        foreach ( $request->getValueNames() as $requestKey ) {
184            if ( preg_match( '/^ns\d+$/', $requestKey ) ) {
185                return true;
186            }
187        }
188        return false;
189    }
190
191    /**
192     * @see https://www.mediawiki.org/wiki/Manual:Hooks/SpecialSearchResultsPrepend
193     *
194     * @param SpecialSearch $specialSearch
195     * @param OutputPage $output
196     * @param string $term
197     */
198    public function onSpecialSearchResultsPrepend( $specialSearch, $output, $term ) {
199        $output->addHTML(
200            Html::rawElement(
201                'div',
202                [ 'class' => 'mw-search-spinner' ],
203                Html::element( 'div', [ 'class' => 'mw-search-spinner-bounce' ] )
204            )
205        );
206    }
207
208    /**
209     * @param User $user
210     * @param array[] &$preferences
211     */
212    public function onGetPreferences( $user, &$preferences ) {
213        $preferences['advancedsearch-disable'] = [
214            'type' => 'toggle',
215            'label-message' => 'advancedsearch-preference-disable',
216            'section' => 'searchoptions/advancedsearch',
217            'help-message' => 'advancedsearch-preference-help',
218        ];
219    }
220}