Mwalker has uploaded a new change for review.

  https://gerrit.wikimedia.org/r/118055

Change subject: Introducting jQuery.Payment
......................................................................

Introducting jQuery.Payment

A library by Stripe from https://github.com/stripe/jquery.payment/
for more easily creating UIs that do intelligent things with
credit cards.

Change-Id: I6d11aa1e9ff5183f81c93bf3d824f91552f0cbb8
---
M DonationInterface.php
A modules/jquery.payment/LICENSE
A modules/jquery.payment/README.md
A modules/jquery.payment/jquery.payment.js
4 files changed, 731 insertions(+), 0 deletions(-)


  git pull 
ssh://gerrit.wikimedia.org:29418/mediawiki/extensions/DonationInterface 
refs/changes/55/118055/1

diff --git a/DonationInterface.php b/DonationInterface.php
index 4045eeb..ca46ef7 100644
--- a/DonationInterface.php
+++ b/DonationInterface.php
@@ -861,6 +861,11 @@
        )
 ) + $wgResourceTemplate;
 
+$wgResourceModules['jquery.payment'] = array(
+       'scripts' => 'jquery.payment/jquery.payment.js',
+       'dependencies' => array( 'jquery' )
+) + $wgResourceTemplate;;
+
 // load any rapidhtml related resources
 require_once( $donationinterface_dir . 
'gateway_forms/rapidhtml/RapidHtmlResources.php' );
 
diff --git a/modules/jquery.payment/LICENSE b/modules/jquery.payment/LICENSE
new file mode 100644
index 0000000..b685134
--- /dev/null
+++ b/modules/jquery.payment/LICENSE
@@ -0,0 +1,20 @@
+Copyright (c) 2012 Stripe (a...@stripe.com)
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/modules/jquery.payment/README.md b/modules/jquery.payment/README.md
new file mode 100644
index 0000000..9e7d03c
--- /dev/null
+++ b/modules/jquery.payment/README.md
@@ -0,0 +1,209 @@
+# jQuery.payment
+
+A general purpose library for building credit card forms, validating inputs 
and formatting numbers.
+
+For example, you can make a input act like a credit card field (with number 
formatting, and length restriction):
+
+``` javascript
+$('input.cc-num').payment('formatCardNumber');
+```
+
+Then, when say the payment form is submitted, you can validate the card number 
on the client-side like so:
+
+``` javascript
+var valid = $.payment.validateCardNumber($('input.cc-num').val());
+
+if ( !valid ) {
+  alert('Your card is not valid!');
+  return false;
+}
+```
+
+You can find a full [demo 
here](http://stripe.github.com/jquery.payment/example).
+
+Supported card types are:
+
+* Visa
+* MasterCard
+* American Express
+* Discover
+* JCB
+* Diners Club
+* Maestro
+* Laster
+* UnionPay
+
+## API
+
+### $.fn.payment('formatCardNumber')
+
+Formats card numbers:
+
+* Including a space between every 4 digits
+* Restricts input to numbers
+* Limits to 16 numbers
+* American Express formatting support
+* Adds a class of the card type (i.e. 'visa') to the input
+
+Example:
+
+``` javascript
+$('input.cc-num').payment('formatCardNumber');
+```
+
+### $.fn.payment('formatCardExpiry')
+
+Formats card expiry:
+
+* Includes a `/` between the month and year
+* Restricts input to numbers
+* Restricts length
+
+Example:
+
+``` javascript
+$('input.cc-exp').payment('formatCardExpiry');
+```
+
+### $.fn.payment('formatCardCVC')
+
+Formats card CVC:
+
+* Restricts length to 4 numbers
+* Restricts input to numbers
+
+Example:
+
+``` javascript
+$('input.cc-cvc').payment('formatCardCVC');
+```
+
+### $.fn.payment('restrictNumeric')
+
+General numeric input restriction.
+
+Example:
+
+``` javascript
+$('data-numeric').payment('restrictNumeric');
+```
+
+### $.payment.validateCardNumber(number)
+
+Validates a card number:
+
+* Validates numbers
+* Validates Luhn algorithm
+* Validates length
+
+Example:
+
+``` javascript
+$.payment.validateCardNumber('4242 4242 4242 4242'); //=> true
+```
+
+### $.payment.validateCardExpiry(month, year)
+
+Validates a card expiry:
+
+* Validates numbers
+* Validates in the future
+* Supports year shorthand
+
+Example:
+
+``` javascript
+$.payment.validateCardExpiry('05', '20'); //=> true
+$.payment.validateCardExpiry('05', '2015'); //=> true
+$.payment.validateCardExpiry('05', '05'); //=> false
+```
+
+### $.payment.validateCardCVC(cvc, type)
+
+Validates a card CVC:
+
+* Validates number
+* Validates length to 4
+
+Example:
+
+``` javascript
+$.payment.validateCardCVC('123'); //=> true
+$.payment.validateCardCVC('123', 'amex'); //=> true
+$.payment.validateCardCVC('1234', 'amex'); //=> true
+$.payment.validateCardCVC('12344'); //=> false
+```
+
+### $.payment.cardType(number)
+
+Returns a card type. Either:
+
+* `visa`
+* `mastercard`
+* `discover`
+* `amex`
+* `dinersclub`
+* `maestro`
+* `laser`
+* `unionpay`
+
+The function will return `null` if the card type can't be determined.
+
+Example:
+
+``` javascript
+$.payment.cardType('4242 4242 4242 4242'); //=> 'visa'
+```
+
+### $.payment.cardExpiryVal(string) and $.fn.payment('cardExpiryVal')
+
+Parses a credit card expiry in the form of MM/YYYY, returning an object 
containing the `month` and `year`. Shorthand years, such as `13` are also 
supported (and converted into the longhand, e.g. `2013`).
+
+``` javascript
+$.payment.cardExpiryVal('03 / 2025'); //=> {month: 3: year: 2025}
+$.payment.cardExpiryVal('05 / 04'); //=> {month: 5, year: 2004}
+$('input.cc-exp').payment('cardExpiryVal') //=> {month: 4, year: 2020}
+```
+
+This function doesn't do any validation of the month or year, use 
`$.payment.validateCardExpiry(month, year)` for that.
+
+## Example
+
+Look in `./example/index.html`
+
+## Building
+
+Run `cake build`
+
+## Run tests
+
+Run `mocha --compilers coffee:coffee-script`
+
+## Autocomplete recommendations
+
+We recommend you turn autocomplete on for credit card forms, except for the 
CVC field. You can do this by setting the `autocomplete` attribute:
+
+``` html
+<form autocomplete="on">
+  <input class="cc-number">
+  <input class="cc-cvc" autocomplete="off">
+</form>
+```
+
+You should also mark up your fields using the [Autocomplete Types 
spec](http://wiki.whatwg.org/wiki/Autocomplete_Types). These are respected by a 
number of browsers, including Chrome.
+
+``` html
+<input type="text" class="cc-number" pattern="\d*" 
autocompletetype="cc-number" placeholder="Card number" required>
+```
+
+Set `autocompletetype` to `cc-number` for credit card numbers, `cc-exp` for 
credit card expiry and `cc-csc` for the CVC (security code).
+
+## Mobile recommendations
+
+We recommend you set the `pattern` attribute which will cause the numeric 
keyboard to be displayed on mobiles:
+
+``` html
+<input class="cc-number" pattern="\d*">
+```
+
+You may have to turn off HTML5 validation (using the `novalidate` form 
attribute) when using this `pattern`, as it won't match space formatting.
diff --git a/modules/jquery.payment/jquery.payment.js 
b/modules/jquery.payment/jquery.payment.js
new file mode 100644
index 0000000..c81c6e0
--- /dev/null
+++ b/modules/jquery.payment/jquery.payment.js
@@ -0,0 +1,497 @@
+// Generated by CoffeeScript 1.4.0
+(function() {
+  var $, cardFromNumber, cardFromType, cards, defaultFormat, 
formatBackCardNumber, formatBackExpiry, formatCardNumber, formatExpiry, 
formatForwardExpiry, formatForwardSlash, hasTextSelected, luhnCheck, 
reFormatCardNumber, restrictCVC, restrictCardNumber, restrictExpiry, 
restrictNumeric, setCardType,
+    __slice = [].slice,
+    __indexOf = [].indexOf || function(item) { for (var i = 0, l = 
this.length; i < l; i++) { if (i in this && this[i] === item) return i; } 
return -1; },
+    _this = this;
+
+  $ = jQuery;
+
+  $.payment = {};
+
+  $.payment.fn = {};
+
+  $.fn.payment = function() {
+    var args, method;
+    method = arguments[0], args = 2 <= arguments.length ? 
__slice.call(arguments, 1) : [];
+    return $.payment.fn[method].apply(this, args);
+  };
+
+  defaultFormat = /(\d{1,4})/g;
+
+  cards = [
+    {
+      type: 'maestro',
+      pattern: /^(5018|5020|5038|6304|6759|676[1-3])/,
+      format: defaultFormat,
+      length: [12, 13, 14, 15, 16, 17, 18, 19],
+      cvcLength: [3],
+      luhn: true
+    }, {
+      type: 'dinersclub',
+      pattern: /^(36|38|30[0-5])/,
+      format: defaultFormat,
+      length: [14],
+      cvcLength: [3],
+      luhn: true
+    }, {
+      type: 'laser',
+      pattern: /^(6706|6771|6709)/,
+      format: defaultFormat,
+      length: [16, 17, 18, 19],
+      cvcLength: [3],
+      luhn: true
+    }, {
+      type: 'jcb',
+      pattern: /^35/,
+      format: defaultFormat,
+      length: [16],
+      cvcLength: [3],
+      luhn: true
+    }, {
+      type: 'unionpay',
+      pattern: /^62/,
+      format: defaultFormat,
+      length: [16, 17, 18, 19],
+      cvcLength: [3],
+      luhn: false
+    }, {
+      type: 'discover',
+      pattern: /^(6011|65|64[4-9]|622)/,
+      format: defaultFormat,
+      length: [16],
+      cvcLength: [3],
+      luhn: true
+    }, {
+      type: 'mastercard',
+      pattern: /^5[1-5]/,
+      format: defaultFormat,
+      length: [16],
+      cvcLength: [3],
+      luhn: true
+    }, {
+      type: 'amex',
+      pattern: /^3[47]/,
+      format: /(\d{1,4})(\d{1,6})?(\d{1,5})?/,
+      length: [15],
+      cvcLength: [3, 4],
+      luhn: true
+    }, {
+      type: 'visa',
+      pattern: /^4/,
+      format: defaultFormat,
+      length: [13, 14, 15, 16],
+      cvcLength: [3],
+      luhn: true
+    }
+  ];
+
+  cardFromNumber = function(num) {
+    var card, _i, _len;
+    num = (num + '').replace(/\D/g, '');
+    for (_i = 0, _len = cards.length; _i < _len; _i++) {
+      card = cards[_i];
+      if (card.pattern.test(num)) {
+        return card;
+      }
+    }
+  };
+
+  cardFromType = function(type) {
+    var card, _i, _len;
+    for (_i = 0, _len = cards.length; _i < _len; _i++) {
+      card = cards[_i];
+      if (card.type === type) {
+        return card;
+      }
+    }
+  };
+
+  luhnCheck = function(num) {
+    var digit, digits, odd, sum, _i, _len;
+    odd = true;
+    sum = 0;
+    digits = (num + '').split('').reverse();
+    for (_i = 0, _len = digits.length; _i < _len; _i++) {
+      digit = digits[_i];
+      digit = parseInt(digit, 10);
+      if ((odd = !odd)) {
+        digit *= 2;
+      }
+      if (digit > 9) {
+        digit -= 9;
+      }
+      sum += digit;
+    }
+    return sum % 10 === 0;
+  };
+
+  hasTextSelected = function($target) {
+    var _ref;
+    if (($target.prop('selectionStart') != null) && 
$target.prop('selectionStart') !== $target.prop('selectionEnd')) {
+      return true;
+    }
+    if (typeof document !== "undefined" && document !== null ? (_ref = 
document.selection) != null ? typeof _ref.createRange === "function" ? 
_ref.createRange().text : void 0 : void 0 : void 0) {
+      return true;
+    }
+    return false;
+  };
+
+  reFormatCardNumber = function(e) {
+    var _this = this;
+    return setTimeout(function() {
+      var $target, value;
+      $target = $(e.currentTarget);
+      value = $target.val();
+      value = $.payment.formatCardNumber(value);
+      return $target.val(value);
+    });
+  };
+
+  formatCardNumber = function(e) {
+    var $target, card, digit, length, re, upperLength, value;
+    digit = String.fromCharCode(e.which);
+    if (!/^\d+$/.test(digit)) {
+      return;
+    }
+    $target = $(e.currentTarget);
+    value = $target.val();
+    card = cardFromNumber(value + digit);
+    length = (value.replace(/\D/g, '') + digit).length;
+    upperLength = 16;
+    if (card) {
+      upperLength = card.length[card.length.length - 1];
+    }
+    if (length >= upperLength) {
+      return;
+    }
+    if (($target.prop('selectionStart') != null) && 
$target.prop('selectionStart') !== value.length) {
+      return;
+    }
+    if (card && card.type === 'amex') {
+      re = /^(\d{4}|\d{4}\s\d{6})$/;
+    } else {
+      re = /(?:^|\s)(\d{4})$/;
+    }
+    if (re.test(value)) {
+      e.preventDefault();
+      return $target.val(value + ' ' + digit);
+    } else if (re.test(value + digit)) {
+      e.preventDefault();
+      return $target.val(value + digit + ' ');
+    }
+  };
+
+  formatBackCardNumber = function(e) {
+    var $target, value;
+    $target = $(e.currentTarget);
+    value = $target.val();
+    if (e.meta) {
+      return;
+    }
+    if (e.which !== 8) {
+      return;
+    }
+    if (($target.prop('selectionStart') != null) && 
$target.prop('selectionStart') !== value.length) {
+      return;
+    }
+    if (/\d\s$/.test(value)) {
+      e.preventDefault();
+      return $target.val(value.replace(/\d\s$/, ''));
+    } else if (/\s\d?$/.test(value)) {
+      e.preventDefault();
+      return $target.val(value.replace(/\s\d?$/, ''));
+    }
+  };
+
+  formatExpiry = function(e) {
+    var $target, digit, val;
+    digit = String.fromCharCode(e.which);
+    if (!/^\d+$/.test(digit)) {
+      return;
+    }
+    $target = $(e.currentTarget);
+    val = $target.val() + digit;
+    if (/^\d$/.test(val) && (val !== '0' && val !== '1')) {
+      e.preventDefault();
+      return $target.val("0" + val + " / ");
+    } else if (/^\d\d$/.test(val)) {
+      e.preventDefault();
+      return $target.val("" + val + " / ");
+    }
+  };
+
+  formatForwardExpiry = function(e) {
+    var $target, digit, val;
+    digit = String.fromCharCode(e.which);
+    if (!/^\d+$/.test(digit)) {
+      return;
+    }
+    $target = $(e.currentTarget);
+    val = $target.val();
+    if (/^\d\d$/.test(val)) {
+      return $target.val("" + val + " / ");
+    }
+  };
+
+  formatForwardSlash = function(e) {
+    var $target, slash, val;
+    slash = String.fromCharCode(e.which);
+    if (slash !== '/') {
+      return;
+    }
+    $target = $(e.currentTarget);
+    val = $target.val();
+    if (/^\d$/.test(val) && val !== '0') {
+      return $target.val("0" + val + " / ");
+    }
+  };
+
+  formatBackExpiry = function(e) {
+    var $target, value;
+    if (e.meta) {
+      return;
+    }
+    $target = $(e.currentTarget);
+    value = $target.val();
+    if (e.which !== 8) {
+      return;
+    }
+    if (($target.prop('selectionStart') != null) && 
$target.prop('selectionStart') !== value.length) {
+      return;
+    }
+    if (/\d(\s|\/)+$/.test(value)) {
+      e.preventDefault();
+      return $target.val(value.replace(/\d(\s|\/)*$/, ''));
+    } else if (/\s\/\s?\d?$/.test(value)) {
+      e.preventDefault();
+      return $target.val(value.replace(/\s\/\s?\d?$/, ''));
+    }
+  };
+
+  restrictNumeric = function(e) {
+    var input;
+    if (e.metaKey || e.ctrlKey) {
+      return true;
+    }
+    if (e.which === 32) {
+      return false;
+    }
+    if (e.which === 0) {
+      return true;
+    }
+    if (e.which < 33) {
+      return true;
+    }
+    input = String.fromCharCode(e.which);
+    return !!/[\d\s]/.test(input);
+  };
+
+  restrictCardNumber = function(e) {
+    var $target, card, digit, value;
+    $target = $(e.currentTarget);
+    digit = String.fromCharCode(e.which);
+    if (!/^\d+$/.test(digit)) {
+      return;
+    }
+    if (hasTextSelected($target)) {
+      return;
+    }
+    value = ($target.val() + digit).replace(/\D/g, '');
+    card = cardFromNumber(value);
+    if (card) {
+      return value.length <= card.length[card.length.length - 1];
+    } else {
+      return value.length <= 16;
+    }
+  };
+
+  restrictExpiry = function(e) {
+    var $target, digit, value;
+    $target = $(e.currentTarget);
+    digit = String.fromCharCode(e.which);
+    if (!/^\d+$/.test(digit)) {
+      return;
+    }
+    if (hasTextSelected($target)) {
+      return;
+    }
+    value = $target.val() + digit;
+    value = value.replace(/\D/g, '');
+    if (value.length > 6) {
+      return false;
+    }
+  };
+
+  restrictCVC = function(e) {
+    var $target, digit, val;
+    $target = $(e.currentTarget);
+    digit = String.fromCharCode(e.which);
+    if (!/^\d+$/.test(digit)) {
+      return;
+    }
+    val = $target.val() + digit;
+    return val.length <= 4;
+  };
+
+  setCardType = function(e) {
+    var $target, allTypes, card, cardType, val;
+    $target = $(e.currentTarget);
+    val = $target.val();
+    cardType = $.payment.cardType(val) || 'unknown';
+    if (!$target.hasClass(cardType)) {
+      allTypes = (function() {
+        var _i, _len, _results;
+        _results = [];
+        for (_i = 0, _len = cards.length; _i < _len; _i++) {
+          card = cards[_i];
+          _results.push(card.type);
+        }
+        return _results;
+      })();
+      $target.removeClass('unknown');
+      $target.removeClass(allTypes.join(' '));
+      $target.addClass(cardType);
+      $target.toggleClass('identified', cardType !== 'unknown');
+      return $target.trigger('payment.cardType', cardType);
+    }
+  };
+
+  $.payment.fn.formatCardCVC = function() {
+    this.payment('restrictNumeric');
+    this.on('keypress', restrictCVC);
+    return this;
+  };
+
+  $.payment.fn.formatCardExpiry = function() {
+    this.payment('restrictNumeric');
+    this.on('keypress', restrictExpiry);
+    this.on('keypress', formatExpiry);
+    this.on('keypress', formatForwardSlash);
+    this.on('keypress', formatForwardExpiry);
+    this.on('keydown', formatBackExpiry);
+    return this;
+  };
+
+  $.payment.fn.formatCardNumber = function() {
+    this.payment('restrictNumeric');
+    this.on('keypress', restrictCardNumber);
+    this.on('keypress', formatCardNumber);
+    this.on('keydown', formatBackCardNumber);
+    this.on('keyup', setCardType);
+    this.on('paste', reFormatCardNumber);
+    return this;
+  };
+
+  $.payment.fn.restrictNumeric = function() {
+    this.on('keypress', restrictNumeric);
+    return this;
+  };
+
+  $.payment.fn.cardExpiryVal = function() {
+    return $.payment.cardExpiryVal($(this).val());
+  };
+
+  $.payment.cardExpiryVal = function(value) {
+    var month, prefix, year, _ref;
+    value = value.replace(/\s/g, '');
+    _ref = value.split('/', 2), month = _ref[0], year = _ref[1];
+    if ((year != null ? year.length : void 0) === 2 && /^\d+$/.test(year)) {
+      prefix = (new Date).getFullYear();
+      prefix = prefix.toString().slice(0, 2);
+      year = prefix + year;
+    }
+    month = parseInt(month, 10);
+    year = parseInt(year, 10);
+    return {
+      month: month,
+      year: year
+    };
+  };
+
+  $.payment.validateCardNumber = function(num) {
+    var card, _ref;
+    num = (num + '').replace(/\s+|-/g, '');
+    if (!/^\d+$/.test(num)) {
+      return false;
+    }
+    card = cardFromNumber(num);
+    if (!card) {
+      return false;
+    }
+    return (_ref = num.length, __indexOf.call(card.length, _ref) >= 0) && 
(card.luhn === false || luhnCheck(num));
+  };
+
+  $.payment.validateCardExpiry = function(month, year) {
+    var currentTime, expiry, prefix, _ref;
+    if (typeof month === 'object' && 'month' in month) {
+      _ref = month, month = _ref.month, year = _ref.year;
+    }
+    if (!(month && year)) {
+      return false;
+    }
+    month = $.trim(month);
+    year = $.trim(year);
+    if (!/^\d+$/.test(month)) {
+      return false;
+    }
+    if (!/^\d+$/.test(year)) {
+      return false;
+    }
+    if (!(parseInt(month, 10) <= 12)) {
+      return false;
+    }
+    if (year.length === 2) {
+      prefix = (new Date).getFullYear();
+      prefix = prefix.toString().slice(0, 2);
+      year = prefix + year;
+    }
+    expiry = new Date(year, month);
+    currentTime = new Date;
+    expiry.setMonth(expiry.getMonth() - 1);
+    expiry.setMonth(expiry.getMonth() + 1, 1);
+    return expiry > currentTime;
+  };
+
+  $.payment.validateCardCVC = function(cvc, type) {
+    var _ref, _ref1;
+    cvc = $.trim(cvc);
+    if (!/^\d+$/.test(cvc)) {
+      return false;
+    }
+    if (type) {
+      return _ref = cvc.length, __indexOf.call((_ref1 = cardFromType(type)) != 
null ? _ref1.cvcLength : void 0, _ref) >= 0;
+    } else {
+      return cvc.length >= 3 && cvc.length <= 4;
+    }
+  };
+
+  $.payment.cardType = function(num) {
+    var _ref;
+    if (!num) {
+      return null;
+    }
+    return ((_ref = cardFromNumber(num)) != null ? _ref.type : void 0) || null;
+  };
+
+  $.payment.formatCardNumber = function(num) {
+    var card, groups, upperLength, _ref;
+    card = cardFromNumber(num);
+    if (!card) {
+      return num;
+    }
+    upperLength = card.length[card.length.length - 1];
+    num = num.replace(/\D/g, '');
+    num = num.slice(0, +upperLength + 1 || 9e9);
+    if (card.format.global) {
+      return (_ref = num.match(card.format)) != null ? _ref.join(' ') : void 0;
+    } else {
+      groups = card.format.exec(num);
+      if (groups != null) {
+        groups.shift();
+      }
+      return groups != null ? groups.join(' ') : void 0;
+    }
+  };
+
+}).call(this);

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

Gerrit-MessageType: newchange
Gerrit-Change-Id: I6d11aa1e9ff5183f81c93bf3d824f91552f0cbb8
Gerrit-PatchSet: 1
Gerrit-Project: mediawiki/extensions/DonationInterface
Gerrit-Branch: master
Gerrit-Owner: Mwalker <mwal...@wikimedia.org>

_______________________________________________
MediaWiki-commits mailing list
MediaWiki-commits@lists.wikimedia.org
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to