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