jenkins-bot has submitted this change and it was merged.
Change subject: Parallel corpora: Implement storage
......................................................................
Parallel corpora: Implement storage
* Save the source sections translated
* Save the inital translation of a section - Inital MT
* Save the improvised version of a section
* API to accept the contents to save.
* Add boolean config ContentTranslationCorpora to enable this.
Not planned for keeping it once we have tables in production.
* Section data is send as compressed.
Bug: T120062
Change-Id: I96af0fe181747c75d2c8251a8af29f1430ca1857
---
M ContentTranslation.hooks.php
A api/ApiContentTranslationSave.php
M extension.json
M i18n/api/en.json
M i18n/api/qqq.json
A includes/TranslationStorageManager.php
A includes/TranslationUnit.php
M modules/tools/ext.cx.tools.mt.js
M modules/translation/ext.cx.translation.draft.js
A modules/translation/ext.cx.translation.storage.init.js
A modules/translation/ext.cx.translation.storage.js
M modules/translationview/ext.cx.translationview.js
12 files changed, 441 insertions(+), 31 deletions(-)
Approvals:
Nikerabbit: Looks good to me, approved
jenkins-bot: Verified
diff --git a/ContentTranslation.hooks.php b/ContentTranslation.hooks.php
index 1bf5ba6..a96bb9d 100644
--- a/ContentTranslation.hooks.php
+++ b/ContentTranslation.hooks.php
@@ -171,7 +171,8 @@
$wgContentTranslationCampaigns,
$wgContentTranslationBrowserBlacklist,
$wgContentTranslationDefaultSourceLanguage,
- $wgContentTranslationTargetNamespace;
+ $wgContentTranslationTargetNamespace,
+ $wgContentTranslationCorpora;
$vars['wgContentTranslationTranslateInTarget'] =
$wgContentTranslationTranslateInTarget;
$vars['wgContentTranslationDomainCodeMapping'] =
$wgContentTranslationDomainCodeMapping;
@@ -183,6 +184,7 @@
$vars['wgContentTranslationBrowserBlacklist'] =
$wgContentTranslationBrowserBlacklist;
$vars['wgContentTranslationDefaultSourceLanguage'] =
$wgContentTranslationDefaultSourceLanguage;
$vars['wgContentTranslationTargetNamespace'] =
$wgContentTranslationTargetNamespace;
+ $vars['wgContentTranslationCorpora'] =
$wgContentTranslationCorpora;
}
/**
diff --git a/api/ApiContentTranslationSave.php
b/api/ApiContentTranslationSave.php
new file mode 100644
index 0000000..bcb09bc
--- /dev/null
+++ b/api/ApiContentTranslationSave.php
@@ -0,0 +1,84 @@
+<?php
+/**
+ *
+ * @file
+ * @copyright See AUTHORS.txt
+ * @license GPL-2.0+
+ */
+
+use ContentTranslation\Translation;
+use ContentTranslation\Database;
+use ContentTranslation\Translator;
+use ContentTranslation\TranslationUnit;
+use ContentTranslation\TranslationStorageManager;
+
+class ApiContentTranslationSave extends ApiBase {
+
+ public function execute() {
+ $params = $this->extractRequestParams();
+ $user = $this->getUser();
+ $content = null;
+ if ( $this->getUser()->isBlocked() ) {
+ $this->dieUsageMsg( 'blockedtext' );
+ }
+
+ if ( trim( $params['content'] ) === '' ) {
+ $this->dieUsage( 'content cannot be empty',
'invalidcontent' );
+ }
+
+ if ( substr( $params['content'], 0, 11 ) === 'rawdeflate,' ) {
+ $content = gzinflate( base64_decode( substr( $params[
'content' ], 11 ) ) );
+ // gzinflate returns false on error.
+ if ( $content === false ) {
+ $this->dieUsage( 'Invalid section content' );
+ }
+ }
+ $translationUnits = json_decode( $content, true );
+ if ( !is_array( $translationUnits ) ) {
+ $this->dieUsage( 'content must be valid json array',
'invalidjson' );
+ }
+
+ $translationId = $params['translationid'];
+ $translator = new Translator( $user );
+ $translation = $translator->getTranslation( $translationId );
+ $translation = $translation->translation;
+ if ( $translationId === null ||
+ $translator->getGlobalUserId() !== intval(
$translation['lastUpdatedTranslator'] ) ) {
+ // Translation does not exist or belong to another
translator
+ $this->dieUsage( 'Invalid translation ID: ' .
$params['translationid'] );
+ }
+ foreach ( $translationUnits as $tuData ) {
+ $tuData['translationId'] = $translationId;
+ if ( !isset( $tuData['sectionId'] ) || !isset(
$tuData['origin'] ) ) {
+ $this->dieUsage( 'Invalid section data' );
+ }
+ $translationUnit = new TranslationUnit( $tuData );
+ TranslationStorageManager::save( $translationUnit );
+ }
+
+ $result = array(
+ 'result' => 'success'
+ );
+ $this->getResult()->addValue( null, $this->getModuleName(),
$result );
+ }
+
+ public function getAllowedParams() {
+ return array(
+ 'translationid' => array(
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_REQUIRED => true,
+ ),
+ 'content' => array(
+ ApiBase::PARAM_REQUIRED => true,
+ ),
+ );
+ }
+
+ public function needsToken() {
+ return 'csrf';
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+}
diff --git a/extension.json b/extension.json
index 823e6bd..9b56517 100644
--- a/extension.json
+++ b/extension.json
@@ -37,11 +37,12 @@
"EchoGetDefaultNotifiedUsers":
"ContentTranslationHooks::onEchoGetDefaultNotifiedUsers"
},
"APIModules": {
- "cxpublish": "ApiContentTranslationPublish",
- "cxdelete": "ApiContentTranslationDelete",
"cxconfiguration": "ApiContentTranslationConfiguration",
- "cxtoken": "ApiContentTranslationToken",
- "cxsuggestionlist": "ApiContentTranslationSuggestionList"
+ "cxdelete": "ApiContentTranslationDelete",
+ "cxpublish": "ApiContentTranslationPublish",
+ "cxsave": "ApiContentTranslationSave",
+ "cxsuggestionlist": "ApiContentTranslationSuggestionList",
+ "cxtoken": "ApiContentTranslationToken"
},
"APIListModules": {
"contenttranslation": "ApiQueryContentTranslation",
@@ -62,6 +63,7 @@
"ApiContentTranslationConfiguration":
"api/ApiContentTranslationConfiguration.php",
"ApiContentTranslationDelete":
"api/ApiContentTranslationDelete.php",
"ApiContentTranslationPublish":
"api/ApiContentTranslationPublish.php",
+ "ApiContentTranslationSave":
"api/ApiContentTranslationSave.php",
"ApiContentTranslationSuggestionList":
"api/ApiContentTranslationSuggestionList.php",
"ApiContentTranslationToken":
"api/ApiContentTranslationToken.php",
"ApiQueryContentTranslation":
"api/ApiQueryContentTranslation.php",
@@ -72,16 +74,18 @@
"ContentTranslationHooks": "ContentTranslation.hooks.php",
"ContentTranslation\\Database": "includes/Database.php",
"ContentTranslation\\Draft": "includes/Draft.php",
+ "ContentTranslation\\EchoNotificationPresentationModel":
"includes/EchoNotificationPresentationModel.php",
"ContentTranslation\\GlobalUser": "includes/GlobalUser.php",
"ContentTranslation\\Notification": "includes/Notification.php",
"ContentTranslation\\SiteMapper": "includes/SiteMapper.php",
"ContentTranslation\\Stats": "includes/Stats.php",
- "ContentTranslation\\Translation": "includes/Translation.php",
"ContentTranslation\\Suggestion": "includes/Suggestion.php",
"ContentTranslation\\SuggestionList":
"includes/SuggestionList.php",
"ContentTranslation\\SuggestionListManager":
"includes/SuggestionListManager.php",
+ "ContentTranslation\\Translation": "includes/Translation.php",
+ "ContentTranslation\\TranslationUnit":
"includes/TranslationUnit.php",
+ "ContentTranslation\\TranslationStorageManager":
"includes/TranslationStorageManager.php",
"ContentTranslation\\Translator": "includes/Translator.php",
- "ContentTranslation\\EchoNotificationPresentationModel":
"includes/EchoNotificationPresentationModel.php",
"SpecialContentTranslation":
"specials/SpecialContentTranslation.php",
"SpecialContentTranslationStats":
"specials/SpecialContentTranslationStats.php"
},
@@ -140,7 +144,8 @@
"key": "",
"age": "3600"
},
- "ContentTranslationEnableSuggestions": false
+ "ContentTranslationEnableSuggestions": false,
+ "ContentTranslationCorpora": false
},
"ResourceModules": {
"Base64.js": {
@@ -705,6 +710,19 @@
"cx-draft-restore-failed"
]
},
+ "ext.cx.translation.storage.init": {
+ "scripts": [
+ "translation/ext.cx.translation.storage.init.js"
+ ]
+ },
+ "ext.cx.translation.storage": {
+ "scripts": [
+ "translation/ext.cx.translation.storage.js"
+ ],
+ "dependencies": [
+ "easy-deflate.deflate"
+ ]
+ },
"ext.cx.publish": {
"scripts": [
"publish/ext.cx.publish.js"
diff --git a/i18n/api/en.json b/i18n/api/en.json
index a6a8ca5..0ceab83 100644
--- a/i18n/api/en.json
+++ b/i18n/api/en.json
@@ -70,6 +70,8 @@
"apihelp-cxsuggestionlist-param-listaction": "Action to be performed on
the list.",
"apihelp-cxsuggestionlist-param-titles": "Page titles.",
"apihelp-cxsuggestionlist-param-from": "The source language code.",
- "apihelp-cxsuggestionlist-param-to": "The target language code."
-
+ "apihelp-cxsuggestionlist-param-to": "The target language code.",
+ "apihelp-cxsave-example-1": "Save the source and translation sections.
The content must be JSON encoded string.",
+ "apihelp-cxsave-param-translationId": "The translation ID",
+ "apihelp-cxsave-param-content": "JSON encoded section data. Each
section is an object and has the following keys: content, sectionId,
sequenceId, sequenceId, origin"
}
diff --git a/i18n/api/qqq.json b/i18n/api/qqq.json
index 2589de9..652cd76 100644
--- a/i18n/api/qqq.json
+++ b/i18n/api/qqq.json
@@ -65,5 +65,8 @@
"apihelp-cxsuggestionlist-param-listaction":
"{{doc-apihelp-param|cxsuggestionlist|listaction}}",
"apihelp-cxsuggestionlist-param-titles":
"{{doc-apihelp-param|cxsuggestionlist|titles}}",
"apihelp-cxsuggestionlist-param-from":
"{{doc-apihelp-param|cxsuggestionlist|from}}",
- "apihelp-cxsuggestionlist-param-to":
"{{doc-apihelp-param|cxsuggestionlist|to}}"
+ "apihelp-cxsuggestionlist-param-to":
"{{doc-apihelp-param|cxsuggestionlist|to}}",
+ "apihelp-cxsave-example-1": "{{doc-apihelp-description|cxsave}}",
+ "apihelp-cxsave-param-translationId":
"{{doc-apihelp-param|cxsave|translationId}}",
+ "apihelp-cxsave-param-content": "{{doc-apihelp-param|cxsave|content}}"
}
diff --git a/includes/TranslationStorageManager.php
b/includes/TranslationStorageManager.php
new file mode 100644
index 0000000..3ce9793
--- /dev/null
+++ b/includes/TranslationStorageManager.php
@@ -0,0 +1,95 @@
+<?php
+/**
+ *
+ * @file
+ * @copyright See AUTHORS.txt
+ * @license GPL-2.0+
+ */
+
+namespace ContentTranslation;
+
+use ContentTranslation\TranslationUnit;
+
+class TranslationStorageManager {
+
+ /**
+ * Update a translation unit.
+ *
+ * @param TranslationUnit $translationUnit
+ */
+ public static function update( TranslationUnit $translationUnit ) {
+ $dbw = Database::getConnection( DB_MASTER );
+ $values = array(
+ 'cxc_sequence_id' => $translationUnit->getSequenceId(),
+ 'cxc_timestamp' => $dbw->timestamp(),
+ 'cxc_content' => $translationUnit->getContent()
+ );
+ $conditions = array(
+ 'cxc_translation_id' =>
$translationUnit->getTranslationId(),
+ 'cxc_section_id' => $translationUnit->getSectionId(),
+ 'cxc_origin' => $translationUnit->getOrigin()
+ );
+
+ $dbw->update( 'cx_corpora', $values, $conditions, __METHOD__ );
+
+ return $dbw->insertId();
+ }
+
+ /**
+ * Insert a translation unit.
+ *
+ * @param TranslationUnit $translationUnit
+ */
+ public static function create( TranslationUnit $translationUnit ) {
+ $dbw = Database::getConnection( DB_MASTER );
+ $values = array(
+ 'cxc_translation_id' =>
$translationUnit->getTranslationId(),
+ 'cxc_section_id' => $translationUnit->getSectionId(),
+ 'cxc_origin' => $translationUnit->getOrigin(),
+ 'cxc_sequence_id' => $translationUnit->getSequenceId(),
+ 'cxc_timestamp' => $dbw->timestamp(),
+ 'cxc_content' => $translationUnit->getContent()
+ );
+ $dbw->insert( 'cx_corpora', $values, __METHOD__ );
+ }
+
+ /**
+ * Save the translation unit.
+ * If the record exist, update it, otherwise create.
+ * @param TranslationUnit $translationUnit
+ */
+ public static function save( TranslationUnit $translationUnit ) {
+ if ( TranslationStorageManager::find(
+ $translationUnit->getTranslationId(),
+ $translationUnit->getSectionId(),
+ $translationUnit->getOrigin()
+ ) !== null ) {
+ TranslationStorageManager::update( $translationUnit );
+ } else {
+ TranslationStorageManager::create( $translationUnit );
+ }
+ }
+
+ /**
+ * Find the translation unit.
+ * @param int $translationId Translation Id
+ * @param string $sectionId Section id
+ * @param string $origin Origin of translation unit
+ * @return TranslationUnit|null
+ */
+ public static function find( $translationId, $sectionId, $origin ) {
+ $dbr = Database::getConnection( DB_SLAVE );
+ $conditions = array(
+ 'cxc_translation_id' => $translationId,
+ 'cxc_section_id' => $sectionId,
+ 'cxc_origin' => $origin
+ );
+ $row = $dbr->selectRow( 'cx_corpora', '*', $conditions,
__METHOD__ );
+
+ if ( $row ) {
+ return TranslationUnit::newFromRow( $row );
+ }
+
+ return null;
+ }
+}
diff --git a/includes/TranslationUnit.php b/includes/TranslationUnit.php
new file mode 100644
index 0000000..b587b5b
--- /dev/null
+++ b/includes/TranslationUnit.php
@@ -0,0 +1,73 @@
+<?php
+/**
+ * Value object for translation section.
+ *
+ * @file
+ * @copyright See AUTHORS.txt
+ * @license GPL-2.0+
+ */
+
+namespace ContentTranslation;
+
+class TranslationUnit {
+ protected $translationId;
+ protected $sectionId;
+ protected $origin;
+ protected $sequenceId;
+ protected $content;
+ protected $timestamp;
+
+ public function __construct( array $params ) {
+ $this->translationId = (int)$params['translationId'];
+ $this->sectionId = (string)$params['sectionId'];
+ $this->origin = (string)$params['origin'];
+ $this->sequenceId = (int)$params['sequenceId'];
+ $this->content = (string)$params['content'];
+ if ( isset( $params['timestamp'] ) ) {
+ $this->timestamp = (int)$params['timestamp'];
+ } else {
+ $this->timestamp = wfTimestamp();
+ }
+ }
+
+ /**
+ * @param stdClass $row
+ * @return TranslationUnit
+ */
+ public static function newFromRow( $row ) {
+ $params = array(
+ 'translationId' => $row->cxc_translation_id,
+ 'sectionId' => $row->cxc_section_id,
+ 'origin' => $row->cxc_origin,
+ 'sequenceId' => $row->cxc_sequence_id,
+ 'content' => $row->cxc_content,
+ 'timestamp' => $row->cxc_timestamp,
+ );
+
+ return new self( $params );
+ }
+
+ public function getTranslationId() {
+ return $this->translationId;
+ }
+
+ public function getSectionId() {
+ return $this->sectionId;
+ }
+
+ public function getSequenceId() {
+ return $this->sequenceId;
+ }
+
+ public function getOrigin() {
+ return $this->origin;
+ }
+
+ public function getTimestamp() {
+ return $this->timestamp;
+ }
+
+ public function getContent() {
+ return $this->content;
+ }
+}
diff --git a/modules/tools/ext.cx.tools.mt.js b/modules/tools/ext.cx.tools.mt.js
index e71eecb..86737dd 100644
--- a/modules/tools/ext.cx.tools.mt.js
+++ b/modules/tools/ext.cx.tools.mt.js
@@ -247,7 +247,8 @@
.attr( {
id: 'cx' + sourceId,
'data-source': sourceId,
- 'data-cx-state': 'mt'
+ 'data-cx-state': 'mt',
+ 'data-cx-mt-provider':
MTControlCard.provider
} )
);
// $section was replaced. Get the
updated instance.
diff --git a/modules/translation/ext.cx.translation.draft.js
b/modules/translation/ext.cx.translation.draft.js
index 9f6800b..98f0766 100644
--- a/modules/translation/ext.cx.translation.draft.js
+++ b/modules/translation/ext.cx.translation.draft.js
@@ -430,19 +430,5 @@
);
} );
};
-
mw.cx.ContentTranslationDraft = ContentTranslationDraft;
- $( function () {
- var draft,
- query = new mw.Uri().query;
-
- if ( mw.config.get( 'wgContentTranslationDatabase' ) === null )
{
- mw.log( 'The ext.cx.translation.draft module can only
work if CX Database configured.' );
- return;
- }
- draft = new ContentTranslationDraft();
- if ( query.to && query.from && query.page ) {
- draft.init();
- }
- } );
}( jQuery, mediaWiki ) );
diff --git a/modules/translation/ext.cx.translation.storage.init.js
b/modules/translation/ext.cx.translation.storage.init.js
new file mode 100644
index 0000000..9e6ab8f
--- /dev/null
+++ b/modules/translation/ext.cx.translation.storage.init.js
@@ -0,0 +1,35 @@
+/*!
+ * Intialize CX storage modules
+ *
+ * @copyright See AUTHORS.txt
+ * @license GPL-2.0+
+ */
+( function ( $, mw ) {
+ 'use strict';
+
+ $( function () {
+ var storageModules = [
+ 'ext.cx.translation.draft'
+ ];
+
+ if ( mw.config.get( 'wgContentTranslationDatabase' ) === null )
{
+ mw.log( 'CX Database not configured' );
+ return;
+ }
+ if ( mw.config.get( 'wgContentTranslationCorpora' ) ) {
+ storageModules.push( 'ext.cx.translation.storage' );
+ }
+ // CX Database configured.
+ mw.loader.using( storageModules ).then( function () {
+ var storage, draft;
+
+ draft = new mw.cx.ContentTranslationDraft();
+ draft.init();
+ if ( mw.cx.ContentTranslationStorage ) {
+ storage = new mw.cx.ContentTranslationStorage();
+ storage.init();
+ }
+ } );
+ } );
+
+}( jQuery, mediaWiki ) );
diff --git a/modules/translation/ext.cx.translation.storage.js
b/modules/translation/ext.cx.translation.storage.js
new file mode 100644
index 0000000..2ab36d8
--- /dev/null
+++ b/modules/translation/ext.cx.translation.storage.js
@@ -0,0 +1,114 @@
+/*!
+ * Client side interface for storing translations
+ *
+ * @copyright See AUTHORS.txt
+ * @license GPL-2.0+
+ */
+( function ( $, mw ) {
+ 'use strict';
+
+ /**
+ * @class
+ */
+ function ContentTranslationStorage() {
+ this.sections = null;
+ }
+
+ ContentTranslationStorage.prototype.init = function () {
+ this.sections = {};
+ this.listen();
+ };
+
+ /**
+ * Get the content to save. Clean up the content by removing
+ * all unwanted classes and placeholders.
+ *
+ * @return {string} HTML to save
+ */
+ ContentTranslationStorage.prototype.getContent = function ( $section ) {
+ var $content;
+
+ $content = $section.clone();
+ // Remove all highlighting before saving
+ $content
+ .find( '.cx-highlight, .cx-highlight--blue,
.cx-highlight--lightblue' )
+ .removeClass( 'cx-highlight cx-highlight--blue
cx-highlight--lightblue' );
+
+ return $content.html();
+ };
+
+ ContentTranslationStorage.prototype.listen = function () {
+ var self = this;
+ mw.hook( 'mw.cx.translation.change' ).add( function (
$targetSection ) {
+ self.markForSave( $targetSection );
+ } );
+
+ mw.hook( 'mw.cx.translation.save' ).add( function () {
+ var sectionId, sections = [];
+
+ for ( sectionId in self.sections ) {
+ if ( !self.sections[ sectionId ].saved ) {
+ sections.push( self.sections[ sectionId
] );
+ }
+ }
+ self.saveSections( sections );
+ } );
+ };
+
+ ContentTranslationStorage.prototype.saveSections = function ( sections
) {
+ var api = new mw.Api();
+
+ if ( !mw.cx.translationId ) {
+ // A translation id is must to save translations. This
must be set by
+ // the ext.cx.translation.draft module. And that module
to be eventually
+ // merged to this module.
+ return;
+ }
+
+ return api.postWithToken( 'csrf', {
+ action: 'cxsave',
+ translationid: mw.cx.translationId,
+ content: EasyDeflate.deflate( JSON.stringify( sections
) )
+ } ).done( function () {
+ var i;
+ // Mark the sections saved
+ for ( i = 0; i < sections.length; i++ ) {
+ sections[ i ].saved = true;
+ }
+ } );
+ };
+
+ ContentTranslationStorage.prototype.markForSave = function (
$targetSection ) {
+ var $sourceSection, sourceSectionId, targetSectionId,
sequenceId, state, origin;
+
+ targetSectionId = $targetSection.attr( 'id' );
+ state = $targetSection.data( 'cx-state' );
+ sourceSectionId = $targetSection.data( 'source' );
+ $sourceSection = mw.cx.getSourceSection( sourceSectionId );
+
+ if ( state === 'mt' ) {
+ origin = $targetSection.data( 'cx-mt-provider' ) ||
'user';
+ } else {
+ origin = 'user';
+ }
+ sequenceId = $sourceSection.data( 'seqid' );
+ this.sections[ targetSectionId ] = {
+ content: this.getContent( $targetSection ),
+ sectionId: sourceSectionId, // source section id is the
canonical section id.
+ saved: false,
+ sequenceId: sequenceId,
+ origin: origin
+ };
+
+ // Source sections are saved only once.
+ this.sections[ sourceSectionId ] = this.sections[
sourceSectionId ] || {
+ content: this.getContent( $sourceSection ),
+ sectionId: sourceSectionId,
+ saved: false,
+ sequenceId: sequenceId,
+ origin: 'source'
+ };
+ };
+
+ mw.cx.ContentTranslationStorage = ContentTranslationStorage;
+}( jQuery, mediaWiki ) );
diff --git a/modules/translationview/ext.cx.translationview.js
b/modules/translationview/ext.cx.translationview.js
index da0954a..a033a2e 100644
--- a/modules/translationview/ext.cx.translationview.js
+++ b/modules/translationview/ext.cx.translationview.js
@@ -48,12 +48,9 @@
'ext.cx.tools',
'ext.cx.translation',
'ext.cx.translation.progress',
- 'ext.cx.publish'
+ 'ext.cx.publish',
+ 'ext.cx.translation.storage.init'
];
- if ( mw.config.get( 'wgContentTranslationDatabase' ) !== null )
{
- // CX Database configured. Load
ext.cx.translation.draft module.
- modules.push( 'ext.cx.translation.draft' );
- }
if ( mw.cx.sourceTitle ) {
mw.loader.using( modules ).then( function () {
--
To view, visit https://gerrit.wikimedia.org/r/257283
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings
Gerrit-MessageType: merged
Gerrit-Change-Id: I96af0fe181747c75d2c8251a8af29f1430ca1857
Gerrit-PatchSet: 18
Gerrit-Project: mediawiki/extensions/ContentTranslation
Gerrit-Branch: master
Gerrit-Owner: Santhosh <[email protected]>
Gerrit-Reviewer: KartikMistry <[email protected]>
Gerrit-Reviewer: Nikerabbit <[email protected]>
Gerrit-Reviewer: Santhosh <[email protected]>
Gerrit-Reviewer: jenkins-bot <>
_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits