Search: Provide new preference to control redirects on search matches

To avoid preference bloat, this preference is hidden unless the new
sysadmin config $wgSearchMatchRedirectPreference is set.

Bug: T235263
Change-Id: Ic16f53a4e6ddb6da071d63cd5da28d937d4692c8
This commit is contained in:
Cormac Parle 2019-10-11 15:06:13 +01:00
parent d30dc86558
commit c4eae0dad4
11 changed files with 148 additions and 4 deletions

View file

@ -32,6 +32,12 @@ For notes on 1.34.x and older releases, see HISTORY.
* $wgDiffEngine can be used to specify the difference engine to use, rather
than choosing the first of $wgExternalDiffEngine, wikidiff2, or php that
is usable.
* $wgSearchMatchRedirectPreference  This configuration setting controls whether
users can set a new preference, search-match-redirect, which decides if search
should redirect them to exact matches is available. By default, this is set to
false, which maintains the previous behaviour without preference bloat. Change
your site's default by setting $wgDefaultUserOptions['search-match-redirect'].
* …
==== Changed configuration ====
* $wgResourceLoaderMaxage (T235314) - This configuration array controls the

View file

@ -4862,6 +4862,7 @@ $wgDefaultUserOptions = [
'rcenhancedfilters-disable' => 0,
'rclimit' => 50,
'rows' => 25, // @deprecated since 1.29 No longer used in core
'search-match-redirect' => true,
'showhiddencats' => 0,
'shownumberswatching' => 1,
'showrollbackconfirmation' => 0,
@ -9079,6 +9080,17 @@ $wgFeaturePolicyReportOnly = [];
*/
$wgSpecialSearchFormOptions = [];
/**
* Set true to allow logged-in users to set a preference whether or not matches in
* search results should force redirection to that page. If false, the preference is
* not exposed and cannot be altered from site default. To change your site's default
* preference, set via $wgDefaultUserOptions['search-match-redirect'].
*
* @since 1.35
* @var bool
*/
$wgSearchMatchRedirectPreference = false;
/**
* Toggles native image lazy loading, via the "loading" attribute.
*

View file

@ -108,6 +108,7 @@ class DefaultPreferencesFactory implements PreferencesFactory {
'RCMaxAge',
'RCShowWatchingUsers',
'RCWatchCategoryMembership',
'SearchMatchRedirectPreference',
'SecureLogin',
'ThumbLimits',
];
@ -1279,6 +1280,19 @@ class DefaultPreferencesFactory implements PreferencesFactory {
'type' => 'api',
];
}
if ( $this->options->get( 'SearchMatchRedirectPreference' ) ) {
$defaultPreferences['search-match-redirect'] = [
'type' => 'toggle',
'section' => 'searchoptions',
'label-message' => 'search-match-redirect-label',
'help-message' => 'search-match-redirect-help',
];
} else {
$defaultPreferences['search-match-redirect'] = [
'type' => 'api',
];
}
}
/**

View file

@ -277,6 +277,9 @@ class SpecialSearch extends SpecialPage {
* @return string|null The url to redirect to, or null if no redirect.
*/
public function goResult( $term ) {
if ( !$this->redirectOnExactMatch() ) {
return null;
}
# If the string cannot be used to create a title
if ( is_null( Title::newFromText( $term ) ) ) {
return null;
@ -295,6 +298,18 @@ class SpecialSearch extends SpecialPage {
return $url ?? $title->getFullUrlForRedirect();
}
private function redirectOnExactMatch() {
global $wgSearchMatchRedirectPreference;
if ( !$wgSearchMatchRedirectPreference ) {
// If the preference for whether to redirect is disabled, use the default setting
$defaultOptions = $this->getUser()->getDefaultOptions();
return $defaultOptions['search-match-redirect'];
} else {
// Otherwise use the user's preference
return $this->getUser()->getOption( 'search-match-redirect' );
}
}
/**
* @param string $term
*/

View file

@ -1705,7 +1705,9 @@ class User implements IDBAccessObject, UserIdentity {
* @return array Array of String options
*/
public static function getDefaultOptions() {
global $wgNamespacesToBeSearchedDefault, $wgDefaultUserOptions, $wgDefaultSkin;
global $wgNamespacesToBeSearchedDefault,
$wgDefaultUserOptions,
$wgDefaultSkin;
$contLang = MediaWikiServices::getInstance()->getContentLanguage();
if ( self::$defOpt !== null && self::$defOptLang === $contLang->getCode() ) {

View file

@ -3922,6 +3922,8 @@
"feedback-useragent": "User agent:",
"searchsuggest-search": "Search {{SITENAME}}",
"searchsuggest-containing": "containing...",
"search-match-redirect-label": "Redirect to exact matches when searching",
"search-match-redirect-help": "Select to get redirected to a page when that page title matches what you have searched for",
"api-error-badtoken": "Internal error: Bad token.",
"api-error-emptypage": "Creating new, empty pages is not allowed.",
"api-error-publishfailed": "Internal error: Server failed to publish temporary file.",

View file

@ -4134,6 +4134,8 @@
"feedback-useragent": "A label denoting the user agent in the feedback that is posted to the feedback page.\n{{Identical|User agent}}",
"searchsuggest-search": "Greyed out default text in the simple search box in the Vector skin. (It disappears and lets the user enter the requested search terms when the search box receives focus.)\n{{Identical|Search}}",
"searchsuggest-containing": "Label used in the special item of the search suggestions list which gives the user an option to perform a full text search for the term.",
"search-match-redirect-label": "Label for user preference to force redirect to a page during search if the page's title matches a search term",
"search-match-redirect-help": "Help text for user preference to force redirect to a page during search if the page's title matches a search term",
"api-error-badtoken": "API error message that can be used for client side localisation of API errors.",
"api-error-emptypage": "API error message that can be used for client side localisation of API errors.",
"api-error-publishfailed": "API error message that can be used for client side localisation of API errors.",

View file

@ -182,7 +182,7 @@
index: context.config.suggestions.indexOf( query )
} );
if ( $el.children().length === 0 ) {
if ( mw.user.options.get( 'search-match-redirect' ) && $el.children().length === 0 ) {
$el
.append(
$( '<div>' )

View file

@ -74,6 +74,7 @@ class DefaultPreferencesFactoryTest extends \MediaWikiTestCase {
/**
* @covers MediaWiki\Preferences\DefaultPreferencesFactory::getForm()
* @covers MediaWiki\Preferences\DefaultPreferencesFactory::searchPreferences()
*/
public function testGetForm() {
$this->setTemporaryHook( 'GetPreferences', null );

View file

@ -238,13 +238,26 @@ class SpecialSearchTest extends MediaWikiTestCase {
protected function mockSearchEngine( $results ) {
$mock = $this->getMockBuilder( SearchEngine::class )
->setMethods( [ 'searchText', 'searchTitle' ] )
->setMethods( [ 'searchText', 'searchTitle', 'getNearMatcher' ] )
->getMock();
$mock->expects( $this->any() )
->method( 'searchText' )
->will( $this->returnValue( $results ) );
$nearMatcherMock = $this->getMockBuilder( SearchNearMatcher::class )
->disableOriginalConstructor()
->setMethods( [ 'getNearMatch' ] )
->getMock();
$nearMatcherMock->expects( $this->any() )
->method( 'getNearMatch' )
->willReturn( $results->getFirstResult() );
$mock->expects( $this->any() )
->method( 'getNearMatcher' )
->willReturn( $nearMatcherMock );
return $mock;
}
@ -267,6 +280,62 @@ class SpecialSearchTest extends MediaWikiTestCase {
$this->assertEquals( 'Special:Search', $query['title'] );
$this->assertEquals( 'foo bar', $query['search'] );
}
/**
* If the 'search-match-redirect' user pref is false, then SpecialSearch::goResult() should
* return null
*
* @covers SpecialSearch::goResult
*/
public function testGoResult_userPrefRedirectOn() {
$context = new RequestContext;
$context->setUser(
$this->newUserWithSearchNS( [ 'search-match-redirect' => false ] )
);
$context->setRequest(
new FauxRequest( [ 'search' => 'TEST_SEARCH_PARAM', 'fulltext' => 1 ] )
);
$search = new SpecialSearch();
$search->setContext( $context );
$search->load();
$this->assertNull( $search->goResult( 'TEST_SEARCH_PARAM' ) );
}
/**
* If the 'search-match-redirect' user pref is true, then SpecialSearch::goResult() should
* NOT return null if there is a near match found for the search term
*
* @covers SpecialSearch::goResult
*/
public function testGoResult_userPrefRedirectOff() {
// mock the search engine so it returns a near match for an arbitrary search term
$searchResults = new SpecialSearchTestMockResultSet(
'TEST_SEARCH_SUGGESTION',
'',
[ SearchResult::newFromTitle( Title::newMainPage() ) ]
);
$mockSearchEngine = $this->mockSearchEngine( $searchResults );
$search = $this->getMockBuilder( SpecialSearch::class )
->setMethods( [ 'getSearchEngine' ] )
->getMock();
$search->expects( $this->any() )
->method( 'getSearchEngine' )
->will( $this->returnValue( $mockSearchEngine ) );
// set up a mock user with 'search-match-redirect' set to true
$context = new RequestContext;
$context->setUser(
$this->newUserWithSearchNS( [ 'search-match-redirect' => true ] )
);
$context->setRequest(
new FauxRequest( [ 'search' => 'TEST_SEARCH_PARAM', 'fulltext' => 1 ] )
);
$search->setContext( $context );
$search->load();
$this->assertNotNull( $search->goResult( 'TEST_SEARCH_PARAM' ) );
}
}
class SpecialSearchTestMockResultSet extends SearchResultSet {
@ -316,4 +385,11 @@ class SpecialSearchTestMockResultSet extends SearchResultSet {
public function getQueryAfterRewriteSnippet() {
return htmlspecialchars( $this->rewrittenQuery );
}
public function getFirstResult() {
if ( count( $this->results ) === 0 ) {
return null;
}
return $this->results[0]->getTitle();
}
}

View file

@ -1656,7 +1656,12 @@ class UserTest extends MediaWikiTestCase {
return [
'Basic creation' => [ 'missing', [], [], 'user' ],
'No creation' => [ 'missing', [ 'create' => false ], [], 'null' ],
'Validation fail' => [ 'missing', [ 'validate' => 'usable' ], [ 'reserved' => true ], 'null' ],
'Validation fail' => [
'missing',
[ 'validate' => 'usable' ],
[ 'reserved' => true ],
'null'
],
'No stealing' => [ 'user', [], [], 'null' ],
'Stealing allowed' => [ 'user', [ 'steal' => true ], [], 'user' ],
'Stealing an already-system user' => [ 'system', [ 'steal' => true ], [], 'user' ],
@ -1666,4 +1671,13 @@ class UserTest extends MediaWikiTestCase {
'Anonymous actor but not reserved' => [ 'actor', [], [], 'exception' ],
];
}
/**
* @covers User::getDefaultOptions
*/
public function testGetDefaultOptions() {
User::resetGetDefaultOptionsForTestsOnly();
$defaultOptions = User::getDefaultOptions();
$this->assertArrayHasKey( 'search-match-redirect', $defaultOptions );
}
}