jenkins-bot has submitted this change and it was merged.

Change subject: Refactor the BannerImage module
......................................................................


Refactor the BannerImage module

* Move the HeaderImage class to the Image module
* Separate fetching and rendering of images so that the images can come
  from anywhere
* Don't load banner images from Wikidata/Commons, instead load them from
  wherever the PageImages API tells us

Change-Id: I3a900fd3e4386032b377682de4afbe79bbee148a
---
M includes/Resources.php
M javascripts/modules/bannerImage/BannerImage.js
A javascripts/modules/bannerImage/Image.js
A javascripts/modules/bannerImage/PageImagesBannerImageRepository.js
M javascripts/modules/bannerImage/init.js
M less/modules/bannerImage.less
A tests/qunit/modules/bannerImage/test_PageImagesBannerImageRepository.js
7 files changed, 334 insertions(+), 246 deletions(-)

Approvals:
  Bmansurov: Looks good to me, approved
  jenkins-bot: Verified



diff --git a/includes/Resources.php b/includes/Resources.php
index 8d4ae08..6e3b563 100644
--- a/includes/Resources.php
+++ b/includes/Resources.php
@@ -1352,11 +1352,11 @@
 
        'mobile.bannerImage' => $wgMFResourceFileModuleBoilerplate + array(
                'dependencies' => array(
-                       'mobile.wikidata.api',
                        'mobile.ajax',
-                       'mobile.hexmd5',
                ),
                'scripts' => array(
+                       'javascripts/modules/bannerImage/Image.js',
+                       
'javascripts/modules/bannerImage/PageImagesBannerImageRepository.js',
                        'javascripts/modules/bannerImage/BannerImage.js',
                ),
                'styles' => array(
diff --git a/javascripts/modules/bannerImage/BannerImage.js 
b/javascripts/modules/bannerImage/BannerImage.js
index 0c85326..0acf277 100644
--- a/javascripts/modules/bannerImage/BannerImage.js
+++ b/javascripts/modules/bannerImage/BannerImage.js
@@ -1,183 +1,70 @@
 ( function ( M, $ ) {
        var BannerImage,
-               md5fn = M.require( 'hex_md5' ),
-               WikiDataApi = M.require( 'modules/wikigrok/WikiDataApi' ),
                View = M.require( 'View' ),
-               config = mw.config.get( 'wgWikiBasePropertyConfig' ),
-               icons = M.require( 'icons' ),
                browser = M.require( 'browser' ),
                ratio = ( browser.isWideScreen() ) ? 21 / 9 : 16 / 9;
 
        /**
-        * Gets a width that is close to the current screen width
-        * Limits the amount of thumbnail images being generated on the server
-        * It rounds to upper limit to try to avoid upscaling
-        * @ignore
-        * @param {Number} screenWidth to optimise for
+        * Tries to load one image from a list of images.
         *
-        * @returns {Number} pixel width of the image
+        * The first image from the list is loaded. If it loads, then it is 
returned, otherwise the next
+        * image in the list is loaded. If the list of images is exhausted then 
the loading has
+        * completely failed and nothing is returned.
+        *
+        * @param {Array} images A list of images
+        * @returns {jQuery.Promise}
         */
-       function getImageWidth( screenWidth ) {
-               var i = 0,
-                       // Thumbnail widths
-                       widths = [ 320, 360, 380, 400, 420, 460, 480, 640, 680, 
720, 736, 768 ],
-                       width;
+       function loadOneImage( images ) {
 
-               for ( i; i < widths.length; i++ ) {
-                       width = widths[ i ];
-                       if ( screenWidth <= width ) {
-                               break;
+               /**
+                * @ignore
+                *
+                * @param {Array} images
+                * @param {Number} index
+                * @param {jQuery.Deferred} deferred
+                */
+               function loadOneImageAcc( images, index, deferred ) {
+                       var image;
+
+                       if ( index >= images.length ) {
+                               deferred.reject();
+
+                               return;
                        }
+
+                       image = images[ index ];
+                       image.load()
+                               .done( function () {
+                                       deferred.resolve( image );
+                               } )
+                               .fail( function () {
+                                       loadOneImageAcc( images, ++index, 
deferred );
+                               } );
                }
-               return width;
+
+               var deferred = $.Deferred();
+
+               loadOneImageAcc( images, 0, deferred );
+
+               return deferred.promise();
        }
 
        /**
-        * @class HeaderImage
-        * Class in charge of a Header image. It fills up the source url, can 
try
-        * to load it, adjust its width/height depending on ratio, and modify 
the
-        * source url for use on the BannerImage class.
-        */
-       function HeaderImage( title, desiredWidth ) {
-               desiredWidth = getImageWidth( desiredWidth || $( window 
).width() );
-               this.title = title;
-               this.width = desiredWidth;
-               this.height = this.width / ratio;
-               this.src = this.getImageUrl( title, desiredWidth );
-       }
-
-       /**
-        * Given a title work out the url to the thumbnail for that image
-        * FIXME: This should not make its way into stable.
-        * NOTE: Modified/Borrowed from from Infobox.js
-        * @method
-        * @param {String} title of file page without File: prefix
-        * @param {Number|String} desiredWidth to load thumbnail for.
-        * @return {String} url corresponding to thumbnail (size 160px)
-        */
-       HeaderImage.prototype.getImageUrl = function ( title, desiredWidth ) {
-               var md5, filename, source,
-                       path = 
'https://upload.wikimedia.org/wikipedia/commons/';
-               // uppercase first letter in file name
-               filename = title.charAt( 0 ).toUpperCase() + title.substr( 1 );
-               // replace spaces with underscores
-               filename = filename.replace( / /g, '_' );
-               md5 = md5fn( filename );
-               source = md5.charAt( 0 ) + '/' + md5.substr( 0, 2 ) + '/' + 
filename;
-               if ( filename.substr( filename.length - 3 ) !== 'svg' ) {
-                       return path + 'thumb/' + source + '/' + desiredWidth + 
'px-' + filename;
-               } else {
-                       return path + source;
-               }
-       };
-
-       /**
-        * Change width of the image (and update src url)
-        *
-        * @method
-        * @param {Number} newWidth New width in px
-        */
-       HeaderImage.prototype.updateWidthAndSrc = function ( newWidth ) {
-               newWidth = getImageWidth( newWidth );
-               this.src = this.src.replace( this.width + 'px', newWidth + 'px' 
);
-               this.width = newWidth;
-       };
-
-       /**
-        * Try to load image and resolve or fail when it loads / or not.
-        * On tablets don't resolve images that are less than 768px wide.
-        * @returns {jQuery.Deferred}
-        */
-       HeaderImage.prototype.load = function () {
-               var loaded = $.Deferred(),
-                       self = this;
-               // Try to load it
-               // There is an issue with reliably knowing if the
-               // original image is wider than the thumbnail.
-               // If so, that image will fail to load.
-               $( '<img>' )
-                       .attr( 'src', this.src )
-                       .load( function () {
-                               if ( browser.isWideScreen() && $( this 
).width() < 768 ) {
-                                       loaded.reject();
-                               } else {
-                                       self.setDimensions( $( this ).width(), 
$( this ).height() );
-                                       loaded.resolve( self );
-                               }
-                               $( this ).remove();
-                       } )
-                       .error( function () {
-                               $( this ).remove();
-                               loaded.reject();
-                       } )
-                       .appendTo( $( 'body' ) )
-                       .hide();
-               return loaded;
-       };
-
-       /**
-        * Set dimensions and ratio of the real (loaded) image
-        * @method
-        * @param {Number} width Width in pixels of the loaded image
-        * @param {Number} height Height in pixels of the loaded image
-        */
-       HeaderImage.prototype.setDimensions = function ( width, height ) {
-               this.dimensions = {
-                       width: width,
-                       height: height
-               };
-               this.imageRatio = width / height;
-       };
-
-       /**
-        * Returns true if dimensions are wider than ratio
-        *
-        * @returns {Boolean} If is wider than the desired ratio or not
-        */
-       HeaderImage.prototype.widerThanRatio = function () {
-               if ( this.dimensions.width / this.dimensions.height > ratio ) {
-                       return true;
-               }
-               return false;
-       };
-
-       /**
-        * Looking into the real image dimensions adjust width and height of the
-        * image to fit the ratio and avoid upscaling if image is wider than 
desired
-        * ratio
-        */
-       HeaderImage.prototype.adjustSize = function () {
-               // If the image is wider than the ratio we want, there will be
-               // upscaling. Change width to a bigger image.
-               if ( this.widerThanRatio() ) {
-                       // Width of this image we need is the height we want by 
the real ratio of
-                       // the image (to fill the height for the device width)
-                       this.updateWidthAndSrc( this.height * this.imageRatio );
-               }
-       };
-
-       /**
-        * A WikiData banner image at the head of the page
+        * A banner image at the head of the page
         * @class BannerImage
         * @extends View
         */
        BannerImage = View.extend( {
-               className: 'wikidata-banner-image',
-               /**
-                * @cfg {Object} defaults Default options hash.
-                * @cfg {String} defaults.spinner HTML of the spinner icon.
-                */
-               defaults: {
-                       spinner: icons.spinner().toHtmlString()
-               },
+               className: 'banner-image',
+
                /**
                 * @inheritdoc
+                *
+                * @param {Object} options
+                * @param {Object} options.repository The repository from which 
to load images
                 */
                initialize: function ( options ) {
-                       this.api = new WikiDataApi( {
-                               itemId: options.itemId
-                       } );
-                       this.images = [];
+                       this.repository = options.repository;
 
                        View.prototype.initialize.apply( this, options );
                },
@@ -185,105 +72,65 @@
                 * @inheritdoc
                 */
                postRender: function () {
-                       var self = this;
-
-                       this.api.getClaims().done( function ( claims ) {
-                               var claim,
-                                       imgId = config.bannerImage,
-                                       commonsCategory = 
config.commonsCategory;
-
-                               if ( claims.entities ) {
-                                       if ( claims.entities.hasOwnProperty( 
imgId ) &&
-                                               claims.entities[imgId].length ) 
{
-                                               // Parse data from the API into 
Image objects.
-                                               $.each( claims.entities[imgId], 
function ( index, image ) {
-                                                       if ( 
image.mainsnak.datatype === 'commonsMedia' ) {
-                                                               self.addImage( 
image.mainsnak.datavalue.value );
-                                                       }
-                                               } );
-
-                                               // Start trying to load images 
sequentially from most important to
-                                               // least.
-                                               self.loadImage( 0 );
-                                       }
-                                       if ( claims.entities.hasOwnProperty( 
commonsCategory ) ) {
-                                               claim = 
claims.entities[commonsCategory][0];
-                                               $( '<a>' ).attr( 'href', 
'#/commons-category/' +  claim.mainsnak.datavalue.value )
-                                                       .text( 'View on 
Commons' ).appendTo( self.$el );
-                                       }
-                               }
-                       } );
+                       this.loadImage();
                },
+
                /**
-                * Add an image to the `images` list.
-                * @param {String} title Title of the image
-                */
-               addImage: function ( title ) {
-                       this.images.push( new HeaderImage( title ) );
-               },
-               /**
-                * Async series image loading loop.
+                * Tries to load an image.
                 *
-                * Start trying to get images in series from `this.images`. 
When we get
-                * a valid one, don't load more and go to `this.imageLoaded` to 
set it as
-                * the banner
-                * @param {Number} index Image index to try and load
+                * If an image is successfully loaded, then the image is 
displayed, otherwise the view
+                * element is removed from the DOM.
+                *
+                * @method
                 */
-               loadImage: function ( index ) {
+               loadImage: function () {
+                       var self = this,
+                               targetWidth = $( window ).width();
 
-                       // If we have gone through all images, and nothing got 
done, then
-                       // self-destruct!
-                       if ( index >= this.images.length ) {
-                               this.remove();
-                               return;
-                       }
-
-                       // Try to load the current image
-                       var image = this.images[ index ],
-                               loading = image.load(),
-                               self = this;
-
-                       // If the image loaded, we are done, leave to 
this.imageLoaded
-                       loading.done( function () {
-                               self.imageLoaded( image );
-                       } );
-
-                       // If the image didn't load, then try with the next one
-                       loading.fail( function () {
-                               self.loadImage( ++index );
-                       } );
+                       self.repository.getImages( targetWidth )
+                               .then( function ( images ) {
+                                       loadOneImage( images )
+                                               .done( function ( image ) {
+                                                       self.onImageLoaded( 
image );
+                                               } )
+                                               .fail( function () {
+                                                       self.remove();
+                                               } );
+                               } );
                },
+
                /**
                 * When we have a valid image, set it as background, bind 
resize events.
                 * @method
-                * @param {HeaderImage} image Image object that loaded
+                * @param {Image} image Image object that loaded
                 */
-               imageLoaded: function ( image ) {
+               onImageLoaded: function ( image ) {
                        var self = this;
 
-                       // Adjust image src size in case it is too wide.
-                       image.adjustSize();
-
-                       // Set image in header
-                       this.$el
+                       self.$el
                                .css( {
                                        'background-image': 'url("' + image.src 
+ '")'
                                } )
                                .show();
-                       this.resizeImage();
 
-                       // Set resize events once
-                       if ( !this.resizeEventsSet ) {
-                               this.resizeEventsSet = true;
+                       self.resizeFrame();
+
+                       if ( !self.hasLoadedOnce ) {
+                               self.hasLoadedOnce = true;
                                M.on( 'resize', function () {
-                                       self.resizeImage();
+                                       // Don't wait until the image that best 
fits the width of the window has loaded
+                                       // to resize the container.
+                                       self.resizeFrame();
+
+                                       self.loadImage();
                                } );
                        }
                },
+
                /**
-                * Resize image to maintain aspect ratio
+                * Resize the frame to maintain aspect a 16:9 aspect ratio.
                 */
-               resizeImage: function () {
+               resizeFrame: function () {
                        this.$el
                                .css( {
                                        // Maintain 21:9 (on tablet or bigger) 
or 16:9 ratio
diff --git a/javascripts/modules/bannerImage/Image.js 
b/javascripts/modules/bannerImage/Image.js
new file mode 100644
index 0000000..51ae1d2
--- /dev/null
+++ b/javascripts/modules/bannerImage/Image.js
@@ -0,0 +1,48 @@
+( function ( M, $ ) {
+
+       var browser = M.require( 'browser' );
+
+       /**
+        * An image that can be loaded.
+        *
+        * @class Image
+        */
+       function Image( src, width, height ) {
+               this.src = src;
+               this.width = width;
+               this.height = height;
+       }
+
+       /**
+        * Try to load image and resolve or fail when it loads / or not.
+        * @returns {jQuery.Deferred}
+        */
+       Image.prototype.load = function () {
+               var loaded = $.Deferred(),
+                       self = this;
+               // Try to load it
+               // There is an issue with reliably knowing if the
+               // original image is wider than the thumbnail.
+               // If so, that image will fail to load.
+               $( '<img>' )
+                       .attr( 'src', this.src )
+                       .load( function () {
+                               if ( browser.isWideScreen() && $( this 
).width() < 768 ) {
+                                       loaded.reject();
+                               } else {
+                                       loaded.resolve( self );
+                               }
+                               $( this ).remove();
+                       } )
+                       .error( function () {
+                               $( this ).remove();
+                               loaded.reject();
+                       } )
+                       .appendTo( $( 'body' ) )
+                       .hide();
+               return loaded;
+       };
+
+       M.define( 'modules/bannerImage/Image', Image );
+
+} ( mw.mobileFrontend, jQuery ) );
diff --git a/javascripts/modules/bannerImage/PageImagesBannerImageRepository.js 
b/javascripts/modules/bannerImage/PageImagesBannerImageRepository.js
new file mode 100644
index 0000000..5dbd640
--- /dev/null
+++ b/javascripts/modules/bannerImage/PageImagesBannerImageRepository.js
@@ -0,0 +1,86 @@
+ ( function ( M, $ ) {
+
+       var Class = M.require( 'Class' ),
+               Image = M.require( 'modules/bannerImage/Image' ),
+               PageImagesBannerImageRepository;
+
+       /**
+        * Uses the PageImages API to get images that can be used as banner 
images
+        * for a page.
+        *
+        * @class
+        */
+       PageImagesBannerImageRepository = Class.extend( {
+
+               /**
+                * @constructor
+                * @param {mw.Api} api A MediaWiki API client
+                * @param {String} title The title of the page
+                */
+               initialize: function ( api, title ) {
+                       this.api = api;
+                       this.title = title;
+                       this.cache = {};
+               },
+
+               /**
+                * Gets images that can be used as banner images.
+                *
+                * @param {Number} targetWidth The target width of the image
+                * @return {$.Promise} A promise that resolves with an array of 
Image objects if the
+                *  MediaWiki API request was successful or rejects otherwise.
+                */
+               getImages: function ( targetWidth ) {
+                       var deferred = $.Deferred(),
+                               promise = deferred.promise(),
+                               self = this;
+
+                       if ( self.cache.hasOwnProperty( targetWidth ) ) {
+                               deferred.resolve( self.cache[targetWidth] );
+
+                               return promise;
+                       }
+
+                       self.api.get( {
+                               prop: 'pageimages',
+                               titles: self.title,
+                               pithumbsize: targetWidth
+                       } )
+                       .then( function ( data ) {
+                               var images = [];
+
+                               // Page doesn't exist.
+                               if ( data.query.pages.hasOwnProperty( '-1' ) ) {
+                                       deferred.resolve( images );
+
+                                       return;
+                               }
+
+                               $.each( data.query.pages, function ( key, value 
) {
+                                       var thumbnail = value.thumbnail;
+
+                                       // Page exists but doesn't have 
thumbnail.
+                                       if ( !thumbnail ) {
+                                               return;
+                                       }
+
+                                       images.push( new Image(
+                                               thumbnail.source,
+                                               thumbnail.width,
+                                               thumbnail.height
+                                       ) );
+                               } );
+
+                               self.cache[targetWidth] = images;
+
+                               deferred.resolve( images );
+                       } )
+                       .fail( deferred.reject );
+
+                       return promise;
+               }
+       } );
+
+       M.define( 'modules/bannerImage/PageImagesBannerImageRepository', 
PageImagesBannerImageRepository );
+
+} ( mw.mobileFrontend, jQuery ) );
diff --git a/javascripts/modules/bannerImage/init.js 
b/javascripts/modules/bannerImage/init.js
index 6d9bfa3..d1d6aef 100644
--- a/javascripts/modules/bannerImage/init.js
+++ b/javascripts/modules/bannerImage/init.js
@@ -1,20 +1,20 @@
 ( function ( M ) {
        M.require( 'context' ).assertMode( [ 'alpha' ] );
-       var bannerImage,
-               page = M.getCurrentPage(),
-               wikidataID = mw.config.get( 'wgWikibaseItemId' ),
-               BannerImage = M.require( 'modules/bannerImage/BannerImage' );
 
-       // Load banner images on mobile devices
-       // On pages in the main space which are not main pages
+       var PageImagesBannerImageRepository = M.require( 
'modules/bannerImage/PageImagesBannerImageRepository' ),
+               BannerImage = M.require( 'modules/bannerImage/BannerImage' ),
+               page = M.getCurrentPage(),
+               repository,
+               bannerImage;
+
+       // Load banner images on mobile devices for pages that are in mainspace 
but aren't Main_Page.
        if (
-               // Set item id or specified in url with query param 
(wikidataid=Q937)
-               wikidataID &&
                !page.isMainPage() &&
                page.getNamespaceId() === 0
        ) {
+               repository = new PageImagesBannerImageRepository( new mw.Api(), 
page.title );
                bannerImage = new BannerImage( {
-                       itemId: wikidataID
+                       repository: repository
                } );
                bannerImage.insertBefore( '.pre-content' );
        }
diff --git a/less/modules/bannerImage.less b/less/modules/bannerImage.less
index ac1a2f3..b8b296f 100644
--- a/less/modules/bannerImage.less
+++ b/less/modules/bannerImage.less
@@ -2,7 +2,7 @@
 @import "minerva.mixins";
 @import "mediawiki.mixins";
 
-.wikidata-banner-image {
+.banner-image {
        width: 100%;
        height: 360px;
        max-height: 360px;
@@ -22,7 +22,7 @@
        }
 }
 @media all and (min-width: @wgMFDeviceWidthTablet) {
-       .wikidata-banner-image {
+       .banner-image {
                max-width: @wgMFDeviceWidthTablet;
                margin: 0 auto;
        }
diff --git 
a/tests/qunit/modules/bannerImage/test_PageImagesBannerImageRepository.js 
b/tests/qunit/modules/bannerImage/test_PageImagesBannerImageRepository.js
new file mode 100644
index 0000000..53d7de4
--- /dev/null
+++ b/tests/qunit/modules/bannerImage/test_PageImagesBannerImageRepository.js
@@ -0,0 +1,107 @@
+( function ( M, mw, $ ) {
+
+       var PageImagesBannerImageRepository = M.require( 
'modules/bannerImage/PageImagesBannerImageRepository' ),
+               Image = M.require( 'modules/bannerImage/Image' );
+
+       QUnit.module( 'MobileFrontend 
modules/bannerImage/PageImagesBannerImageRepository', {
+               setup: function () {
+                       var api = new mw.Api();
+                       this.getDeferred = $.Deferred();
+                       this.getSpy = this.stub( api, 'get' ).returns( 
this.getDeferred.promise() );
+                       this.title = 'Samurai';
+                       this.repository = new PageImagesBannerImageRepository( 
api, this.title );
+               }
+       } );
+
+       QUnit.test( 'it should try to get the images at the target width', 1, 
function ( assert ) {
+               this.repository.getImages( 1234 );
+
+               assert.deepEqual( this.getSpy.lastCall.args[0], {
+                       prop: 'pageimages',
+                       titles: this.title,
+                       pithumbsize: 1234
+               } );
+       } );
+
+       QUnit.test( 'it should resolve with an empty list if the page doesn\'t 
exist', 1, function ( assert ) {
+               this.getDeferred.resolve( {
+                       query: {
+                               pages: {
+                                       '-1': {}
+                               }
+                       }
+               } );
+
+               this.repository.getImages( 1234 ).then( function ( images ) {
+                       assert.deepEqual( images, [] );
+               } );
+       } );
+
+       QUnit.test( 'it should reject if there\'s an error getting the images', 
1, function ( assert ) {
+               var error = 'ERR0R!!!';
+
+               this.getDeferred.reject( error );
+
+               this.repository.getImages( 1234 ).fail( function ( actualError 
) {
+                       assert.deepEqual( error, actualError );
+               } );
+       } );
+
+       QUnit.test( 'it should return a list of images if there are any', 1, 
function ( assert ) {
+               this.getDeferred.resolve( {
+                       query: {
+                               pages: {
+                                       1234: {
+                                               thumbnail: {
+                                                       source: 
'http://foo/bar/baz',
+                                                       width: 1,
+                                                       height: 1
+                                               }
+                                       }
+                               }
+                       }
+               } );
+
+               this.repository.getImages( 1234 ).then( function ( images ) {
+                       assert.deepEqual( images, [
+                               new Image( 'http://foo/bar/baz', 1, 1 )
+                       ] );
+               } );
+       } );
+
+       QUnit.test( 'it shouldn\'t return an image if there isn\'t a 
thumbnail', 1, function ( assert ) {
+               this.getDeferred.resolve( {
+                       query: {
+                               pages: {
+                                       1234: {}
+                               }
+                       }
+               } );
+
+               this.repository.getImages( 1234 ).then( function ( images ) {
+                       assert.deepEqual( images, [] );
+               } );
+       } );
+
+       QUnit.test( 'it shouldn\'t try to get images at the target width more 
than once', 1, function ( assert ) {
+               this.getDeferred.resolve( {
+                       query: {
+                               pages: {
+                                       1234: {
+                                               thumbnail: {
+                                                       source: 
'http://foo/bar/baz',
+                                                       width: 1,
+                                                       height: 1
+                                               }
+                                       }
+                               }
+                       }
+               } );
+
+               this.repository.getImages( 1234 );
+               this.repository.getImages( 1234 );
+
+               assert.strictEqual( this.getSpy.callCount, 1 );
+       } );
+
+} ( mw.mobileFrontend, mw, jQuery ) );

-- 
To view, visit https://gerrit.wikimedia.org/r/189687
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: I3a900fd3e4386032b377682de4afbe79bbee148a
Gerrit-PatchSet: 12
Gerrit-Project: mediawiki/extensions/MobileFrontend
Gerrit-Branch: master
Gerrit-Owner: Phuedx <[email protected]>
Gerrit-Reviewer: Bmansurov <[email protected]>
Gerrit-Reviewer: Phuedx <[email protected]>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to