jenkins-bot has submitted this change and it was merged. ( https://gerrit.wikimedia.org/r/376645 )
Change subject: Enable indexing statements on items ...................................................................... Enable indexing statements on items Statements are indexed in the field 'statements' and are represented as 'property:value' e.g. P31:Q4167410. The list of properties indexed is configured by searchIndexProperties config var. Bug: T175199 Change-Id: I880808d70ed5350d39c81e1f41560dccfd982d00 --- M docs/options.wiki M lib/includes/DataTypeDefinitions.php M repo/Wikibase.hooks.php M repo/Wikibase.php M repo/WikibaseRepo.datatypes.php M repo/config/Wikibase.default.php M repo/includes/Search/Elastic/Fields/ItemFieldDefinitions.php M repo/includes/Search/Elastic/Fields/PropertyFieldDefinitions.php A repo/includes/Search/Elastic/Fields/StatementProviderFieldDefinitions.php A repo/includes/Search/Elastic/Fields/StatementsField.php M repo/includes/WikibaseRepo.php M repo/tests/phpunit/includes/Search/Elastic/Fields/ItemFieldDefinitionsTest.php A repo/tests/phpunit/includes/Search/Elastic/Fields/StatementsFieldTest.php 13 files changed, 393 insertions(+), 11 deletions(-) Approvals: jenkins-bot: Verified Thiemo Mättig (WMDE): Looks good to me, approved diff --git a/docs/options.wiki b/docs/options.wiki index e6d55c5..e17c651 100644 --- a/docs/options.wiki +++ b/docs/options.wiki @@ -66,6 +66,7 @@ :;prefixSearchProfiles: Loaded from <code>config/EntityPrefixSearchProfiles.php</code>, does not need to be defined manually. (Cirrus) :;defaultPrefixRescoreProfile: name of the rescoring profile to use for prefix search. The profile should be defined in <code>config/ElasticSearchRescoreProfiles.php</code>. (Cirrus) :;rescoreProfiles: Loaded from <code>config/ElasticSearchRescoreProfiles.php</code>, does not have to be defined manually. (Cirrus). +;searchIndexProperties: Array of names of properties that would be included in the 'properties' field of the search index. For now, only relevant when Cirrus search is enabled. == Client Settings == === Basic Settings === diff --git a/lib/includes/DataTypeDefinitions.php b/lib/includes/DataTypeDefinitions.php index 4f4ba53..cc6cc94 100644 --- a/lib/includes/DataTypeDefinitions.php +++ b/lib/includes/DataTypeDefinitions.php @@ -314,4 +314,12 @@ ); } + /** + * Get data formatters for search indexing for each type. + * @return callable[] List of callbacks, with keys having "VT:" prefixes. + */ + public function getIndexDataFormatters() { + return $this->getMapForDefinitionField( 'search-index-data-formatter' ); + } + } diff --git a/repo/Wikibase.hooks.php b/repo/Wikibase.hooks.php index c9a0c45..7500e9d 100644 --- a/repo/Wikibase.hooks.php +++ b/repo/Wikibase.hooks.php @@ -35,6 +35,7 @@ use Wikibase\Repo\Content\EntityHandler; use Wikibase\Repo\Hooks\InfoActionHookHandler; use Wikibase\Repo\Hooks\OutputPageEntityIdReader; +use Wikibase\Repo\Search\Elastic\Fields\StatementsField; use Wikibase\Repo\WikibaseRepo; use Wikibase\Store\Sql\SqlSubscriptionLookup; use WikiPage; @@ -983,4 +984,26 @@ $pageInfo = $infoActionHookHandler->handle( $context, $pageInfo ); } + /** + * Add Wikibase-specific analyzer configs. + * @param array $config + */ + public static function onCirrusSearchAnalysisConfig( &$config ) { + // Analyzer for splitting statements and extracting properties: + // P31:Q1234 => P31 + $config['analyzer']['extract_wb_property'] = [ + 'type' => 'custom', + 'tokenizer' => 'split_wb_statements', + 'filter' => [ 'first_token' ], + ]; + $config['tokenizer']['split_wb_statements'] = [ + 'type' => 'pattern', + 'pattern' => StatementsField::STATEMENT_SEPARATOR, + ]; + $config['filter']['first_token'] = [ + 'type' => 'limit', + 'max_token_count' => 1 + ]; + } + } diff --git a/repo/Wikibase.php b/repo/Wikibase.php index 75c87b2..82fa168 100644 --- a/repo/Wikibase.php +++ b/repo/Wikibase.php @@ -1003,6 +1003,7 @@ $wgHooks['BeforeDisplayNoArticleText'][] = 'Wikibase\ViewEntityAction::onBeforeDisplayNoArticleText'; $wgHooks['InfoAction'][] = '\Wikibase\RepoHooks::onInfoAction'; $wgHooks['BeforePageDisplayMobile'][] = '\Wikibase\RepoHooks::onBeforePageDisplayMobile'; + $wgHooks['CirrusSearchAnalysisConfig'][] = '\Wikibase\RepoHooks::onCirrusSearchAnalysisConfig'; // update hooks $wgHooks['LoadExtensionSchemaUpdates'][] = '\Wikibase\Repo\Store\Sql\ChangesSubscriptionSchemaUpdater::onSchemaUpdate'; diff --git a/repo/WikibaseRepo.datatypes.php b/repo/WikibaseRepo.datatypes.php index f71b329..b4683f3 100644 --- a/repo/WikibaseRepo.datatypes.php +++ b/repo/WikibaseRepo.datatypes.php @@ -25,11 +25,13 @@ */ use DataValues\Geo\Parsers\GlobeCoordinateParser; +use DataValues\StringValue; use ValueFormatters\FormatterOptions; use ValueParsers\ParserOptions; use ValueParsers\QuantityParser; use ValueParsers\StringParser; use ValueParsers\ValueParser; +use Wikibase\DataModel\Entity\EntityIdValue; use Wikibase\Lib\Store\FieldPropertyInfoProvider; use Wikibase\Lib\Store\PropertyInfoStore; use Wikibase\Rdf\DedupeBag; @@ -249,6 +251,11 @@ ) { return new LiteralValueRdfBuilder( null, null ); }, + 'search-index-data-formatter' => function( + StringValue $value + ) { + return $value->getValue(); + }, ], 'VT:time' => [ 'expert-module' => 'jquery.valueview.experts.TimeInput', @@ -340,6 +347,11 @@ ) { return new EntityIdRdfBuilder( $vocab, $tracker ); }, + 'search-index-data-formatter' => function( + EntityIdValue $value + ) { + return $value->getEntityId()->getSerialization(); + }, ], 'PT:wikibase-item' => [ 'expert-module' => 'wikibase.experts.Item', diff --git a/repo/config/Wikibase.default.php b/repo/config/Wikibase.default.php index 40b6e9b..4de2da9 100644 --- a/repo/config/Wikibase.default.php +++ b/repo/config/Wikibase.default.php @@ -256,4 +256,7 @@ // Rescore profiles, loaded by Wikibase.php 'rescoreProfiles' => [], ], + + // List of properties to be indexed + 'searchIndexProperties' => [] ]; diff --git a/repo/includes/Search/Elastic/Fields/ItemFieldDefinitions.php b/repo/includes/Search/Elastic/Fields/ItemFieldDefinitions.php index 8e9edb5..4d6a6b3 100644 --- a/repo/includes/Search/Elastic/Fields/ItemFieldDefinitions.php +++ b/repo/includes/Search/Elastic/Fields/ItemFieldDefinitions.php @@ -16,12 +16,19 @@ */ private $descriptionsProviderFieldDefinitions; + /** + * @var StatementProviderFieldDefinitions + */ + private $statementProviderFieldDefinitions; + public function __construct( LabelsProviderFieldDefinitions $labelsProviderFieldDefinitions, - DescriptionsProviderFieldDefinitions $descriptionsProviderFieldDefinitions + DescriptionsProviderFieldDefinitions $descriptionsProviderFieldDefinitions, + StatementProviderFieldDefinitions $statementProviderFieldDefinitions ) { $this->labelsProviderFieldDefinitions = $labelsProviderFieldDefinitions; $this->descriptionsProviderFieldDefinitions = $descriptionsProviderFieldDefinitions; + $this->statementProviderFieldDefinitions = $statementProviderFieldDefinitions; } /** @@ -33,15 +40,15 @@ * - labels * - descriptions * - link count - * - statement count + * - statements */ $fields = array_merge( $this->labelsProviderFieldDefinitions->getFields(), - $this->descriptionsProviderFieldDefinitions->getFields() + $this->descriptionsProviderFieldDefinitions->getFields(), + $this->statementProviderFieldDefinitions->getFields() ); $fields['sitelink_count'] = new SiteLinkCountField(); - $fields['statement_count'] = new StatementCountField(); return $fields; } diff --git a/repo/includes/Search/Elastic/Fields/PropertyFieldDefinitions.php b/repo/includes/Search/Elastic/Fields/PropertyFieldDefinitions.php index 1d2e760..0db6a14 100644 --- a/repo/includes/Search/Elastic/Fields/PropertyFieldDefinitions.php +++ b/repo/includes/Search/Elastic/Fields/PropertyFieldDefinitions.php @@ -16,12 +16,19 @@ */ private $descriptionsProviderFieldDefinitions; + /** + * @var StatementProviderFieldDefinitions + */ + private $statementProviderFieldDefinitions; + public function __construct( LabelsProviderFieldDefinitions $labelsProviderFieldDefinitions, - DescriptionsProviderFieldDefinitions $descriptionsProviderFieldDefinitions + DescriptionsProviderFieldDefinitions $descriptionsProviderFieldDefinitions, + StatementProviderFieldDefinitions $statementProviderFieldDefinitions ) { $this->labelsProviderFieldDefinitions = $labelsProviderFieldDefinitions; $this->descriptionsProviderFieldDefinitions = $descriptionsProviderFieldDefinitions; + $this->statementProviderFieldDefinitions = $statementProviderFieldDefinitions; } /** @@ -32,14 +39,14 @@ * Properties have: * - labels * - descriptions + * - statements * - statement count */ $fields = array_merge( $this->labelsProviderFieldDefinitions->getFields(), - $this->descriptionsProviderFieldDefinitions->getFields() + $this->descriptionsProviderFieldDefinitions->getFields(), + $this->statementProviderFieldDefinitions->getFields() ); - - $fields['statement_count'] = new StatementCountField(); return $fields; } diff --git a/repo/includes/Search/Elastic/Fields/StatementProviderFieldDefinitions.php b/repo/includes/Search/Elastic/Fields/StatementProviderFieldDefinitions.php new file mode 100644 index 0000000..285c2d0 --- /dev/null +++ b/repo/includes/Search/Elastic/Fields/StatementProviderFieldDefinitions.php @@ -0,0 +1,43 @@ +<?php + +namespace Wikibase\Repo\Search\Elastic\Fields; + +use Wikibase\Lib\DataTypeDefinitions; + +/** + * Fields for an object that has statements. + */ +class StatementProviderFieldDefinitions implements FieldDefinitions { + + /** + * List of properties to index. + * @var string[] + */ + private $properties; + + /** + * @var callable[] + */ + private $definitions; + + /** + * StatementProviderFieldDefinitions constructor. + * @param string[] $properties List of properties to index + * @param callable[] $definitions + */ + public function __construct( array $properties, array $definitions ) { + $this->definitions = $definitions; + $this->properties = $properties; + } + + /** + * Get the list of definitions + * @return WikibaseIndexField[] key is field name, value is WikibaseIndexField + */ + public function getFields() { + $fields['statement_keywords'] = new StatementsField( $this->properties, $this->definitions ); + $fields['statement_count'] = new StatementCountField(); + return $fields; + } + +} diff --git a/repo/includes/Search/Elastic/Fields/StatementsField.php b/repo/includes/Search/Elastic/Fields/StatementsField.php new file mode 100644 index 0000000..985fbcd --- /dev/null +++ b/repo/includes/Search/Elastic/Fields/StatementsField.php @@ -0,0 +1,134 @@ +<?php +namespace Wikibase\Repo\Search\Elastic\Fields; + +use CirrusSearch; +use SearchEngine; +use SearchIndexFieldDefinition; +use Wikibase\DataModel\Entity\EntityDocument; +use Wikibase\DataModel\Entity\PropertyId; +use Wikibase\DataModel\Snak\PropertyValueSnak; +use Wikibase\DataModel\Statement\StatementListProvider; + +/** + * Field indexing statements for particular item. + */ +class StatementsField extends SearchIndexFieldDefinition implements WikibaseIndexField { + + /** + * String which separates property from value in statement representation. + * Should be the string that is: + * - Not part of property ID serialization + * - Regex-safe + */ + const STATEMENT_SEPARATOR = '='; + + /** + * List of properties to index + * @var string[] + */ + private $properties; + + /** + * @var callable[] + */ + private $definitions; + + /** + * StatementsField constructor. + * @param string[] $properties + * @param callable[] $definitions + */ + public function __construct( array $properties, array $definitions ) { + $this->properties = $properties; + $this->definitions = $definitions; + parent::__construct( "", \SearchIndexField::INDEX_TYPE_KEYWORD ); + } + + /** + * Produce specific field mapping + * + * @param SearchEngine $engine + * @param string $name + * + * @return \SearchIndexField|null Null if mapping is not supported + */ + public function getMappingField( SearchEngine $engine, $name ) { + if ( !( $engine instanceof CirrusSearch ) ) { + // For now only Cirrus/Elastic is supported + return null; + } + return $this; + } + + /** + * @param EntityDocument $entity + * + * @return mixed Get the value of the field to be indexed when a page/document + * is indexed. This might be an array with nested data, if the field + * is defined with nested type or an int or string for simple field types. + */ + public function getFieldData( EntityDocument $entity ) { + if ( !( $entity instanceof StatementListProvider ) ) { + return []; + } + $data = []; + + foreach ( $this->properties as $property ) { + try { + $id = new PropertyId( $property ); + } catch ( \Exception $e ) { + // If we couldn't resolve ID for this property, skip it + continue; + } + foreach ( $entity->getStatements()->getByPropertyId( $id )->getMainSnaks() as $snak ) { + if ( !( $snak instanceof PropertyValueSnak ) ) { + // Won't index novalue/somevalue for now + continue; + } + + $dataValue = $snak->getDataValue(); + if ( !isset( $this->definitions["VT:" . $dataValue->getType()] ) ) { + // We do not know how to format these values + continue; + } + $callback = $this->definitions["VT:" . $dataValue->getType()]; + $value = $callback( $dataValue ); + if ( !$value ) { + continue; + } + $data[] = $snak->getPropertyId()->getSerialization() . self::STATEMENT_SEPARATOR + . $value; + } + } + + return $data; + } + + /** + * @param SearchEngine $engine + * @return array + */ + public function getMapping( SearchEngine $engine ) { + // Since we need a specially tuned field, we can not use + // standard search engine types. + if ( !( $engine instanceof CirrusSearch ) ) { + // For now only Cirrus/Elastic is supported + return []; + } + + $config = [ + 'type' => 'keyword', + "ignore_above" => 255 + ]; + // Subfield indexing only property names, so we could do matches + // like "property exists" without specifying the value. + $config['fields']['property'] = [ + 'type' => 'text', + 'analyzer' => "extract_wb_property", + 'search_analyzer' => 'keyword', + ]; + + return $config; + } + +} diff --git a/repo/includes/WikibaseRepo.php b/repo/includes/WikibaseRepo.php index 6e69dba..10a47dc 100644 --- a/repo/includes/WikibaseRepo.php +++ b/repo/includes/WikibaseRepo.php @@ -99,6 +99,7 @@ use Wikibase\Repo\Search\Elastic\Fields\ItemFieldDefinitions; use Wikibase\Repo\Search\Elastic\Fields\LabelsProviderFieldDefinitions; use Wikibase\Repo\Search\Elastic\Fields\PropertyFieldDefinitions; +use Wikibase\Repo\Search\Elastic\Fields\StatementProviderFieldDefinitions; use Wikibase\Repo\Store\EntityTitleStoreLookup; use Wikibase\Lib\Store\LanguageFallbackLabelDescriptionLookupFactory; use Wikibase\Lib\Store\PrefetchingTermLookup; @@ -1465,11 +1466,22 @@ } /** + * @return StatementProviderFieldDefinitions + */ + public function getStatementProviderDefinitions() { + return new StatementProviderFieldDefinitions( + $this->settings->getSetting( 'searchIndexProperties' ), + $this->getDataTypeDefinitions()->getIndexDataFormatters() + ); + } + + /** * @return ItemFieldDefinitions */ private function getItemFieldDefinitions() { return new ItemFieldDefinitions( - $this->getLabelProviderDefinitions(), $this->getDescriptionProviderDefinitions() + $this->getLabelProviderDefinitions(), $this->getDescriptionProviderDefinitions(), + $this->getStatementProviderDefinitions() ); } @@ -1478,7 +1490,8 @@ */ private function getPropertyFieldDefinitions() { return new PropertyFieldDefinitions( - $this->getLabelProviderDefinitions(), $this->getDescriptionProviderDefinitions() + $this->getLabelProviderDefinitions(), $this->getDescriptionProviderDefinitions(), + $this->getStatementProviderDefinitions() ); } diff --git a/repo/tests/phpunit/includes/Search/Elastic/Fields/ItemFieldDefinitionsTest.php b/repo/tests/phpunit/includes/Search/Elastic/Fields/ItemFieldDefinitionsTest.php index 3c58931..d890f25 100644 --- a/repo/tests/phpunit/includes/Search/Elastic/Fields/ItemFieldDefinitionsTest.php +++ b/repo/tests/phpunit/includes/Search/Elastic/Fields/ItemFieldDefinitionsTest.php @@ -9,6 +9,8 @@ use Wikibase\Repo\Search\Elastic\Fields\LabelsProviderFieldDefinitions; use Wikibase\Repo\Search\Elastic\Fields\SiteLinkCountField; use Wikibase\Repo\Search\Elastic\Fields\StatementCountField; +use Wikibase\Repo\Search\Elastic\Fields\StatementProviderFieldDefinitions; +use Wikibase\Repo\Search\Elastic\Fields\StatementsField; /** * @covers Wikibase\Repo\Search\Elastic\Fields\ItemFieldDefinitions @@ -24,7 +26,8 @@ $fieldDefinitions = new ItemFieldDefinitions( $this->newLabelsProviderFieldDefinitions( $languageCodes ), - $this->newDescriptionsProviderFieldDefinitions( $languageCodes ) + $this->newDescriptionsProviderFieldDefinitions( $languageCodes ), + new StatementProviderFieldDefinitions( [], [] ) ); $fields = $fieldDefinitions->getFields(); @@ -37,6 +40,9 @@ $this->assertArrayHasKey( 'statement_count', $fields ); $this->assertInstanceOf( StatementCountField::class, $fields['statement_count'] ); + + $this->assertArrayHasKey( 'statement_keywords', $fields ); + $this->assertInstanceOf( StatementsField::class, $fields['statement_keywords'] ); } private function newLabelsProviderFieldDefinitions( array $languageCodes ) { diff --git a/repo/tests/phpunit/includes/Search/Elastic/Fields/StatementsFieldTest.php b/repo/tests/phpunit/includes/Search/Elastic/Fields/StatementsFieldTest.php new file mode 100644 index 0000000..2f8a8ef --- /dev/null +++ b/repo/tests/phpunit/includes/Search/Elastic/Fields/StatementsFieldTest.php @@ -0,0 +1,124 @@ +<?php + +namespace Wikibase\Repo\Tests\Search\Elastic\Fields; + +use CirrusSearch; +use DataValues\BooleanValue; +use DataValues\DecimalValue; +use DataValues\StringValue; +use DataValues\UnboundedQuantityValue; +use PHPUnit_Framework_TestCase; +use Wikibase\DataModel\Entity\EntityDocument; +use Wikibase\DataModel\Entity\PropertyId; +use Wikibase\DataModel\Snak\PropertyNoValueSnak; +use Wikibase\DataModel\Snak\PropertySomeValueSnak; +use Wikibase\DataModel\Snak\PropertyValueSnak; +use Wikibase\DataModel\Statement\StatementListProvider; +use Wikibase\Repo\Search\Elastic\Fields\StatementsField; +use Wikibase\Repo\Tests\ChangeOp\StatementListProviderDummy; +use Wikibase\Repo\Tests\Rdf\RdfBuilderTestData; +use Wikibase\Repo\WikibaseRepo; + +/** + * @covers Wikibase\Repo\Search\Elastic\Fields\StatementsField + * + * @group WikibaseElastic + * @group Wikibase + * + */ +class StatementsFieldTest extends PHPUnit_Framework_TestCase { + + /** + * List of properties we handle. + * @var string[] + */ + private $properties = [ 'P1', 'P2', 'P4', 'P7', 'P8' ]; + + public function statementsProvider() { + $testData = new RdfBuilderTestData( + __DIR__ . '/../../../../data/rdf/entities', '' + ); + + return [ + 'empty' => [ + $testData->getEntity( 'Q1' ), + [] + ], + 'Q4' => [ + $testData->getEntity( 'Q4' ), + [ 'P2=Q42', 'P2=Q666', 'P7=simplestring' ] + ], + 'Q7' => [ + $testData->getEntity( 'Q7' ), + [ 'P7=string', 'P7=string2' ] + ], + 'Q8' => [ + $testData->getEntity( 'Q8' ), + [] + ], + ]; + } + + /** + * @dataProvider statementsProvider + * @param EntityDocument $entity + * @param $expected + */ + public function testStatements( EntityDocument $entity, $expected ) { + if ( !class_exists( CirrusSearch::class ) ) { + $this->markTestSkipped( 'CirrusSearch needed.' ); + } + $repo = WikibaseRepo::getDefaultInstance(); + + $field = new StatementsField( $this->properties, $repo->getDataTypeDefinitions()->getIndexDataFormatters() ); + $this->assertEquals( $expected, $field->getFieldData( $entity ) ); + } + + public function testFormatters() { + $formatters = [ + 'VT:string' => function ( StringValue $s ) { + return "STRING:" . $s->getValue(); + }, + + 'VT:quantity' => function ( UnboundedQuantityValue $v ) { + return "VALUE:" . $v->getAmount(); + }, + + ]; + $field = new StatementsField( [ 'P123' ], $formatters ); + + $snaks = [ + new PropertyValueSnak( 123, new StringValue( 'testString' ) ), + new PropertyValueSnak( 123, + new UnboundedQuantityValue( new DecimalValue( 456 ), "1" ) ), + new PropertySomeValueSnak( 123 ), + new PropertyValueSnak( 123, new StringValue( 'testString2' ) ), + new PropertyNoValueSnak( 123 ), + new PropertyValueSnak( 123, new BooleanValue( false ) ), + ]; + + $mockList = $this->getMockBuilder( StatementListProvider::class )->setMethods( [ + 'getByPropertyId', + 'getMainSnaks', + 'getStatements', + ] )->getMock(); + $mockList->expects( $this->once() )->method( 'getByPropertyId' )->willReturnSelf(); + $mockList->expects( $this->once() )->method( 'getMainSnaks' )->willReturn( $snaks ); + + $entity = + $this->getMockBuilder( StatementListProviderDummy::class ) + ->disableOriginalConstructor() + ->getMock(); + $entity->expects( $this->once() )->method( 'getStatements' )->willReturn( $mockList ); + + $expected = [ + 'P123=STRING:testString', + 'P123=VALUE:+456', + 'P123=STRING:testString2' + ]; + + $data = $field->getFieldData( $entity ); + $this->assertEquals( $expected, $data ); + } + +} -- To view, visit https://gerrit.wikimedia.org/r/376645 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: merged Gerrit-Change-Id: I880808d70ed5350d39c81e1f41560dccfd982d00 Gerrit-PatchSet: 21 Gerrit-Project: mediawiki/extensions/Wikibase Gerrit-Branch: master Gerrit-Owner: Smalyshev <smalys...@wikimedia.org> Gerrit-Reviewer: Addshore <addshorew...@gmail.com> Gerrit-Reviewer: Aleksey Bekh-Ivanov (WMDE) <aleksey.bekh-iva...@wikimedia.de> Gerrit-Reviewer: Aude <aude.w...@gmail.com> Gerrit-Reviewer: DCausse <dcau...@wikimedia.org> Gerrit-Reviewer: Daniel Kinzler <daniel.kinz...@wikimedia.de> Gerrit-Reviewer: EBernhardson <ebernhard...@wikimedia.org> Gerrit-Reviewer: Hoo man <h...@online.de> Gerrit-Reviewer: Smalyshev <smalys...@wikimedia.org> Gerrit-Reviewer: Thiemo Mättig (WMDE) <thiemo.maet...@wikimedia.de> Gerrit-Reviewer: jenkins-bot <> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits