jenkins-bot has submitted this change and it was merged. ( https://gerrit.wikimedia.org/r/352578 )
Change subject: CX2: New link tool - Add any internal or external links ...................................................................... CX2: New link tool - Add any internal or external links UI change: Used index layout for the internal, external selector. Used Apply button as action for card. A selection can be changed to an external or internal link using the new link tool. An existing link target can be changed to a new target TODO: * Control + K shortcut to insert new link * Make the selection handling more rebust. Now the selection is not indicated when the focus is inside the internal link selector. Bug: T162126 Bug: T106445 Change-Id: I9590894d6d1bf478b13944511c79d21ecc887776 --- M i18n/en.json M modules/dm/translationunits/mw.cx.dm.ExternalLinkTranslationUnit.js M modules/dm/translationunits/mw.cx.dm.LinkTranslationUnit.js M modules/dm/translationunits/mw.cx.dm.SectionTranslationUnit.js M modules/tools/mw.cx.tools.NewLinkTool.js M modules/tools/mw.cx.tools.TargetLinkTool.js M modules/tools/styles/mw.cx.tools.LinkTool.less M modules/tools/styles/mw.cx.tools.NewLinkTool.less M modules/ui/mw.cx.ui.ToolsColumn.js M modules/ui/translationunits/mw.cx.ui.LinkTranslationUnit.js M modules/ui/translationunits/mw.cx.ui.SectionTranslationUnit.js 11 files changed, 295 insertions(+), 97 deletions(-) Approvals: jenkins-bot: Verified Nikerabbit: Looks good to me, approved diff --git a/i18n/en.json b/i18n/en.json index 8854428..d5c3070 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -187,9 +187,9 @@ "cx-your-translations-link": "{{GENDER:$1|Your translations}}", "cx-translationlist-empty-title": "Nothing to translate?", "cx-translationlist-empty-desc": "{{GENDER:|Start}} your translation now and continue it anytime.", - "cx-tools-link-internal-link": "Link to page", + "cx-tools-link-internal-link": "Search pages", "cx-tools-link-internal-link-placeholder": "Find page to link", - "cx-tools-link-external-link": "External", + "cx-tools-link-external-link": "External link", "cx-tools-link-external-link-placeholder": "Add external link", "cx-tools-link-to-another-page": "Link to another page", "cx-tools-link-add-as-missing": "Add as missing link", diff --git a/modules/dm/translationunits/mw.cx.dm.ExternalLinkTranslationUnit.js b/modules/dm/translationunits/mw.cx.dm.ExternalLinkTranslationUnit.js index 1ab985e..64dd183 100644 --- a/modules/dm/translationunits/mw.cx.dm.ExternalLinkTranslationUnit.js +++ b/modules/dm/translationunits/mw.cx.dm.ExternalLinkTranslationUnit.js @@ -22,16 +22,39 @@ mw.cx.dm.ExternalLinkTranslationUnit.static.matchRdfaTypes = [ 'mw:ExtLink' ]; mw.cx.dm.ExternalLinkTranslationUnit.prototype.getTargetURL = function () { - return this.sourceDocument.href; + if ( this.sourceDocument ) { + return this.sourceDocument.href; + } + if ( this.targetDocument ) { + return this.targetDocument.href; + } }; /** * Get the id of the section * @return {string} */ -mw.cx.dm.TranslationUnit.prototype.getSectionId = function () { +mw.cx.dm.ExternalLinkTranslationUnit.prototype.getSectionId = function () { // Make sure that there is an id for the unit even if id attribute is not present. - return this.sourceDocument.id || this.sourceDocument.dataset.linkid || OO.ui.generateElementId(); + var id; + // Make sure that there is an id for the unit even if id attribute is not present. + if ( this.sourceDocument ) { + id = this.sourceDocument.id || this.sourceDocument.dataset.linkid; + } + // Source document does not exist. See if there is target document + if ( !id && this.targetDocument ) { + id = this.targetDocument.id || this.targetDocument.dataset.linkid; + } + + return id || OO.ui.generateElementId(); +}; + +/** + * @param {string} url + * @return {boolean} + */ +mw.cx.dm.ExternalLinkTranslationUnit.static.isSafeUrl = function ( url ) { + return OO.ui.isSafeUrl( url ); }; mw.cx.dm.modelRegistry.register( mw.cx.dm.ExternalLinkTranslationUnit ); diff --git a/modules/dm/translationunits/mw.cx.dm.LinkTranslationUnit.js b/modules/dm/translationunits/mw.cx.dm.LinkTranslationUnit.js index cb0f19c..e9ead85 100644 --- a/modules/dm/translationunits/mw.cx.dm.LinkTranslationUnit.js +++ b/modules/dm/translationunits/mw.cx.dm.LinkTranslationUnit.js @@ -185,4 +185,38 @@ this.redlink = true; }; +mw.cx.dm.LinkTranslationUnit.prototype.changeLinkTarget = function ( newTarget ) { + var href, title; + title = mw.cx.dm.LinkTranslationUnit.static.getValidTitle( newTarget ); + if ( !title ) { + mw.log.error( '[CX] Invalid title given' ); + return; + } + // Convert title to a relative URL to avoid insecure values like + // javascript:.. appearing in it. + if ( title.indexOf( './' ) < 0 ) { + href = './' + title; + } + this.targetTitle = title; + this.targetDocument.setAttribute( 'href', href ); + this.targetDocument.setAttribute( 'title', this.targetTitle ); +}; + +/** + * Get a valid normalized title from the given text + * If the text is not suitable for the title, return null; + * Validation is done by mw.Title + * + * @param {string} text Text for the title. + * @return {string|null} + */ +mw.cx.dm.LinkTranslationUnit.static.getValidTitle = function ( text ) { + var title = text.trim(); + + title = mw.Title.newFromText( title ); + title = title && title.toText(); + + return title; +}; + mw.cx.dm.modelRegistry.register( mw.cx.dm.LinkTranslationUnit ); diff --git a/modules/dm/translationunits/mw.cx.dm.SectionTranslationUnit.js b/modules/dm/translationunits/mw.cx.dm.SectionTranslationUnit.js index 0f93831..a11f886 100644 --- a/modules/dm/translationunits/mw.cx.dm.SectionTranslationUnit.js +++ b/modules/dm/translationunits/mw.cx.dm.SectionTranslationUnit.js @@ -147,11 +147,22 @@ * @param {boolean} targetExists Whether the title exist in target language */ mw.cx.dm.SectionTranslationUnit.prototype.addLink = function ( selection, title, targetExists ) { - var range, html, newLink; + var newLink, href; + + title = mw.cx.dm.LinkTranslationUnit.static.getValidTitle( title ); + if ( !title ) { + mw.log.error( '[CX] Invalid title given' ); + return; + } + // Convert title to a relative URL to avoid insecure values like + // javascript:.. appearing in it. + if ( title.indexOf( './' ) < 0 ) { + href = './' + title; + } newLink = document.createElement( 'a' ); newLink.appendChild( document.createTextNode( title ) ); - newLink.setAttribute( 'href', title ); + newLink.setAttribute( 'href', href ); newLink.setAttribute( 'title', title ); newLink.setAttribute( 'rel', 'mw:WikiLink' ); // Set a sufficiently good random id @@ -162,21 +173,53 @@ // translation view. It has no effect on generated wiki text. newLink.className = 'new'; } + mw.cx.dm.SectionTranslationUnit.static.pasteAtSelection( selection, newLink ); + this.buildSubTranslationUnits( this.sourceDocument, this.targetDocument ); +}; + +/** + * Add an external link to the section. + * @param {Selection} selection The selection object + * @param {string} url Target URL + */ +mw.cx.dm.SectionTranslationUnit.prototype.addExternalLink = function ( selection, url ) { + var newLink; + + // Validate the link + if ( !mw.cx.dm.ExternalLinkTranslationUnit.static.isSafeUrl( url ) ) { + mw.log.error( '[CX] Invalid or unsafe url given' ); + return; + } + newLink = document.createElement( 'a' ); + newLink.appendChild( document.createTextNode( url ) ); + newLink.setAttribute( 'href', url ); + newLink.setAttribute( 'rel', 'mw:ExtLink' ); + // Set a sufficiently good random id + newLink.setAttribute( 'id', 'cx' + new Date().valueOf() ); + mw.cx.dm.SectionTranslationUnit.static.pasteAtSelection( selection, newLink ); + this.buildSubTranslationUnits( this.sourceDocument, this.targetDocument ); +}; + +/** + * Paste an element at given selection + * @param {Selection} selection The selection object + * @param {Element} elementToPaste [description] + */ +mw.cx.dm.SectionTranslationUnit.static.pasteAtSelection = function ( selection, elementToPaste ) { + var range, html; // TODO: We can probably move the below block to a utility library // after we see more usecases similar to this and consolidate. if ( window.getSelection ) { if ( selection.getRangeAt && selection.rangeCount ) { range = selection.getRangeAt( 0 ); range.deleteContents(); - range.insertNode( newLink ); + range.insertNode( elementToPaste ); } } else if ( document.selection && document.selection.createRange ) { range = selection.createRange(); - html = newLink.outerHTML; + html = elementToPaste.outerHTML; range.pasteHTML( html ); } - - this.buildSubTranslationUnits( this.sourceDocument, this.targetDocument ); }; /* Register */ diff --git a/modules/tools/mw.cx.tools.NewLinkTool.js b/modules/tools/mw.cx.tools.NewLinkTool.js index a8dd29c..73534a2 100644 --- a/modules/tools/mw.cx.tools.NewLinkTool.js +++ b/modules/tools/mw.cx.tools.NewLinkTool.js @@ -14,12 +14,9 @@ config.language = config.targetLanguage; // Parent constructor mw.cx.tools.NewLinkTool.super.call( this, model, config ); - - this.sourceTitle = null; - this.targetTitle = null; - this.pageInfo = null; - this.makeRedLinkButton = null; - this.removeLinkButton = null; + this.linkTabs = null; + this.internalLink = null; + this.externalLink = null; }; /* Inheritance */ @@ -27,9 +24,35 @@ mw.cx.tools.NewLinkTool.static.name = 'newlink'; +/** + * Text selection handler + * @param {Selection} selectionObj Selection object + */ +mw.cx.tools.NewLinkTool.prototype.onSelect = function ( selectionObj ) { + var selection; + + // TODO: Sanitize content + selection = selectionObj.toString().trim(); + if ( selection && selection.length < 1000 ) { + this.selectionObj = selectionObj; + } else { + return; + } + // Save the selection with a name so that it can be restored after the tool + // modify the selected content. + mw.cx.selection.save( 'translation', this.selectionObj ); + // Check if selection changed. + if ( this.selection !== selection ) { + this.selection = selection; + this.pageInfo = null; + this.refresh(); + } +}; + mw.cx.tools.NewLinkTool.prototype.getContent = function () { - var card, compactTrigger, tabBar, internalLink, internalLinkPanel, externalLink, externalLinkPanel, - internalLinkTab, externalLinkTab, applyButton; + var card, compactTrigger, internalLinkTab, externalLinkTab; + + this.collapse(); compactTrigger = new OO.ui.PanelLayout( { classes: [ 'cx-tools-newlink-compact-trigger' ], @@ -38,61 +61,50 @@ padded: false, text: mw.msg( 'cx-tools-link-to-another-page' ) } ); - internalLinkTab = new OO.ui.ButtonWidget( { - classes: [ 'cx-tools-newlink-internal-trigger' ], - framed: false, - label: mw.msg( 'cx-tools-link-internal-link' ) - } ); - externalLinkTab = new OO.ui.ButtonWidget( { - classes: [ 'cx-tools-newlink-external-trigger' ], - framed: false, - label: mw.msg( 'cx-tools-link-external-link' ) - } ); - applyButton = new OO.ui.ButtonWidget( { - classes: [ 'cx-tools-newlink-apply' ], - flags: [ 'primary', 'progressive' ], - icon: 'check', - label: mw.msg( 'cx-tools-link-apply' ) - } ); - tabBar = new OO.ui.HorizontalLayout( { - classes: [ 'cx-tools-newlink-tabs' ], - items: [ internalLinkTab, externalLinkTab, applyButton ] - } ); - internalLink = new mw.cx.ui.PageSelectorWidget( { - classes: [ 'cx-tools-newlink-internallink-selector' ], + + this.internalLink = new mw.cx.ui.PageSelectorWidget( { + value: this.selection, + classes: [ 'cx-tools-newlink-internallink' ], language: this.language, + placeholder: mw.msg( 'cx-tools-link-internal-link-placeholder' ), siteMapper: this.model.config.siteMapper } ); - externalLink = new OO.ui.TextInputWidget( { - classes: [ 'cx-tools-newlink-internallink' ], - icon: 'linkExternal' + internalLinkTab = new OO.ui.CardLayout( 'internal', { + label: mw.msg( 'cx-tools-link-internal-link' ), + expanded: false, + scrollable: false } ); - internalLinkPanel = new OO.ui.PanelLayout( { - classes: [ 'cx-tools-newlink-internallink-panel', 'active' ], - content: [ internalLink ] + internalLinkTab.$element.append( this.internalLink.$element ); + + this.externalLink = new OO.ui.TextInputWidget( { + classes: [ 'cx-tools-newlink-externallink' ], + icon: 'linkExternal', + placeholder: 'https://...' } ); - externalLinkPanel = new OO.ui.PanelLayout( { - classes: [ 'cx-tools-newlink-externallink-panel' ], - content: [ externalLink ] + externalLinkTab = new OO.ui.CardLayout( 'external', { + label: mw.msg( 'cx-tools-link-external-link' ), + expanded: false, + scrollable: false } ); + externalLinkTab.$element.append( this.externalLink.$element ); + + this.linkTabs = new OO.ui.IndexLayout( { + expanded: false, + scrollable: false, + classes: [ 'cx-tools-newlink-tabs' ] + } ); + this.linkTabs.addCards( [ internalLinkTab, externalLinkTab ] ); card = new OO.ui.StackLayout( { continuous: true, expanded: false, scrollable: false, - items: [ compactTrigger, tabBar, internalLinkPanel, externalLinkPanel ] + items: [ compactTrigger, this.linkTabs ] } ); - compactTrigger.$element.on( 'click', function () { - card.$element.addClass( 'expanded' ); - } ); - internalLinkTab.on( 'click', function () { - internalLinkPanel.$element.addClass( 'active' ); - externalLinkPanel.$element.removeClass( 'active' ); - } ); - externalLinkTab.on( 'click', function () { - externalLinkPanel.$element.addClass( 'active' ); - internalLinkPanel.$element.removeClass( 'active' ); - } ); + compactTrigger.$element.on( 'click', this.expand.bind( this ) ); + + this.internalLink.getLookupMenu().connect( this, { choose: 'onApply' } ); + this.externalLink.connect( this, { enter: 'onApply' } ); return card.$element; }; @@ -106,9 +118,40 @@ return null; }; -mw.cx.tools.NewLinkTool.prototype.removeLink = function () { - this.emit( 'remove' ); - this.destroy(); +mw.cx.tools.NewLinkTool.prototype.expand = function () { + this.getCard().$element.addClass( 'expanded' ); + this.linkTabs.getCard( 'internal' ).focus(); + // Hide the source link and target link cards + this.card.$element.siblings( '.cx-card-sourcelink' ).addClass( 'collapse' ); + this.card.$element.siblings( '.cx-card-targetlink' ).addClass( 'collapse' ); +}; + +mw.cx.tools.NewLinkTool.prototype.collapse = function () { + if ( this.card ) { + this.card.$element.removeClass( 'expanded' ); + this.card.$element.siblings( '.cx-card-sourcelink' ).removeClass( 'collapse' ); + this.card.$element.siblings( '.cx-card-targetlink' ).removeClass( 'collapse' ); + } +}; + +/** + * Check if the current input mode is for external links + * + * @return {boolean} Input mode is for external links + */ +mw.cx.tools.NewLinkTool.prototype.isExternal = function () { + return this.linkTabs.getCurrentCardName() === 'external'; +}; + +mw.cx.tools.NewLinkTool.prototype.onApply = function () { + this.collapse(); + if ( this.isExternal() ) { + this.emit( 'addExternalLink', this.selectionObj, this.externalLink.getValue() ); + } else if ( this.selection ) { + this.emit( 'addlink', this.selectionObj, this.internalLink.getValue(), true ); + } else { + this.emit( 'changeLinkTarget', this.internalLink.getValue() ); + } }; /* Register */ diff --git a/modules/tools/mw.cx.tools.TargetLinkTool.js b/modules/tools/mw.cx.tools.TargetLinkTool.js index 7446699..68bb897 100644 --- a/modules/tools/mw.cx.tools.TargetLinkTool.js +++ b/modules/tools/mw.cx.tools.TargetLinkTool.js @@ -44,6 +44,8 @@ } else { return; } + // Save the selection with a name so that it can be restored after the tool + // modify the selected content. mw.cx.selection.save( 'translation', this.selectionObj ); // Check if selection changed. if ( this.selection !== selection ) { diff --git a/modules/tools/styles/mw.cx.tools.LinkTool.less b/modules/tools/styles/mw.cx.tools.LinkTool.less index d54cd5f..3728767 100644 --- a/modules/tools/styles/mw.cx.tools.LinkTool.less +++ b/modules/tools/styles/mw.cx.tools.LinkTool.less @@ -34,6 +34,9 @@ padding: 5px 0; vertical-align: top; color: @gray-dark; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } } .cx-widget-translationtool-actions { @@ -43,6 +46,10 @@ margin-right: 0; } } + + &.collapse { + display: none; + } } // Stack the source and target link cards diff --git a/modules/tools/styles/mw.cx.tools.NewLinkTool.less b/modules/tools/styles/mw.cx.tools.NewLinkTool.less index 7fd40e4..aeadd5f 100644 --- a/modules/tools/styles/mw.cx.tools.NewLinkTool.less +++ b/modules/tools/styles/mw.cx.tools.NewLinkTool.less @@ -7,6 +7,14 @@ display: none; } + .cx-widget-translationtool-container { + padding: 0; + } + + .cx-widget-translationtool-actions { + display: none; + } + .cx-tools-newlink-compact-trigger { padding-left: 30px; background-repeat: no-repeat; @@ -16,31 +24,32 @@ cursor: pointer; } - .cx-tools-newlink-tabs, - .cx-tools-newlink-internallink-panel, - .cx-tools-newlink-externallink-panel { + .cx-tools-newlink-tabs { + display: none; + .oo-ui-menuLayout-content .oo-ui-indexLayout-stackLayout { + overflow: visible; + } + } +} + +.cx-card-newlink.expanded { + height: 100px; + padding: 2px 0; + + // Hide the compact trigger + .cx-tools-newlink-compact-trigger { display: none; } + // Show the tabs + .cx-tools-newlink-tabs { + display: block; + } - .expanded { - .cx-tools-newlink-compact-trigger { - display: none; - } - .cx-tools-newlink-tabs { - display: block; - } - .cx-tools-newlink-internal-trigger, - .cx-tools-newlink-external-trigger, - .cx-tools-newlink-apply { - margin-right: 0; - width: 33%; - } + .cx-tools-link-external-link-add-button { + float: right; + } - .cx-tools-newlink-internallink-panel.active { - display: block; - } - .cx-tools-newlink-externallink-panel.active { - display: block; - } + .oo-ui-indexLayout-stackLayout > .oo-ui-panelLayout { + padding: 1.5em 0; } } diff --git a/modules/ui/mw.cx.ui.ToolsColumn.js b/modules/ui/mw.cx.ui.ToolsColumn.js index b91c7aa..79609a3 100644 --- a/modules/ui/mw.cx.ui.ToolsColumn.js +++ b/modules/ui/mw.cx.ui.ToolsColumn.js @@ -21,6 +21,7 @@ continuous: true, classes: [ 'cx-column-tools-container' ], expanded: false, + scrollable: false, padded: true } ); // Configuration initialization diff --git a/modules/ui/translationunits/mw.cx.ui.LinkTranslationUnit.js b/modules/ui/translationunits/mw.cx.ui.LinkTranslationUnit.js index 282a9ca..ab3916b 100644 --- a/modules/ui/translationunits/mw.cx.ui.LinkTranslationUnit.js +++ b/modules/ui/translationunits/mw.cx.ui.LinkTranslationUnit.js @@ -31,7 +31,12 @@ makeRedLink: 'makeRedLink' } }, - newlink: [ 'click', 'focus' ] + newlink: { + triggers: [ 'click', 'focus' ], + events: { + changeLinkTarget: 'changeLinkTarget' + } + } }; mw.cx.ui.LinkTranslationUnit.prototype.adapt = function () { @@ -92,8 +97,6 @@ }; mw.cx.ui.LinkTranslationUnit.prototype.removeLink = function () { - // Save the selection - mw.cx.selection.save( 'translation' ); this.model.removeLink(); if ( this.model.getTargetDocument() === null ) { @@ -105,14 +108,13 @@ // Destroy all tools this.tools.forEach( function ( tool ) { tool.destroy(); } ); this.parentTranslationUnit.focus(); - // Restore the cursor + // Restore the cursor. This was saved with this label when selection was made. + // See mw.cx.tools.TargetLinkTool.prototype.onSelect method mw.cx.selection.restore( 'translation' ); this.emit( 'change' ); }; mw.cx.ui.LinkTranslationUnit.prototype.makeRedLink = function () { - // Save the selection - mw.cx.selection.save( 'translation' ); this.model.makeRedLink(); this.$translationSection.removeClass( 'cx-target-link-unadapted' ).addClass( 'new' ); this.parentTranslationUnit.focus(); @@ -121,5 +123,22 @@ this.emit( 'change' ); }; +mw.cx.ui.LinkTranslationUnit.prototype.changeLinkTarget = function ( newTarget ) { + if ( !newTarget || !newTarget.trim() ) { + mw.log.error( '[CX] Attempting to change the link target to blank ' + this ); + return; + } + this.model.changeLinkTarget( newTarget ); + if ( this.model.getTargetTitle() === newTarget ) { + mw.log( '[CX] Target link target changed successfully. ' + this ); + } else { + mw.log.error( '[CX] Target link target change failed. ' + this ); + } + this.parentTranslationUnit.focus(); + // Restore the cursor + mw.cx.selection.restore( 'translation' ); + this.emit( 'change' ); +}; + /* Register */ mw.cx.ui.translationUnitFactory.register( mw.cx.ui.LinkTranslationUnit ); diff --git a/modules/ui/translationunits/mw.cx.ui.SectionTranslationUnit.js b/modules/ui/translationunits/mw.cx.ui.SectionTranslationUnit.js index 841c038..0499ba9 100644 --- a/modules/ui/translationunits/mw.cx.ui.SectionTranslationUnit.js +++ b/modules/ui/translationunits/mw.cx.ui.SectionTranslationUnit.js @@ -38,7 +38,8 @@ newlink: { triggers: [ 'select' ], events: { - addlink: 'addLink' + addlink: 'addLink', + addExternalLink: 'addExternalLink' } } }; @@ -165,10 +166,26 @@ return; } // Restore the selection - mw.cx.selection.save( 'translation' ); - this.model.addLink( selection, title, targetExists ); - // Set the cursor at the end of inserted link. mw.cx.selection.restore( 'translation' ); + this.model.addLink( selection, title, targetExists ); + mw.cx.selection.setCursorAfter( 'translation' ); + this.emit( 'change' ); + this.buildSubTranslationUnits( this.model ); +}; + +/** + * Add a new link to the section. + * @param {Selection} selection The selection object + * @param {string} url External link URL. + */ +mw.cx.ui.SectionTranslationUnit.prototype.addExternalLink = function ( selection, url ) { + if ( !url || !url.trim() || !OO.ui.isSafeUrl( url ) ) { + mw.log.error( '[CX] Attempting to create external link with blank or invalid URL: ' + this ); + return; + } + // Restore the selection + mw.cx.selection.restore( 'translation' ); + this.model.addExternalLink( selection, url ); mw.cx.selection.setCursorAfter( 'translation' ); this.emit( 'change' ); this.buildSubTranslationUnits( this.model ); -- To view, visit https://gerrit.wikimedia.org/r/352578 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: merged Gerrit-Change-Id: I9590894d6d1bf478b13944511c79d21ecc887776 Gerrit-PatchSet: 13 Gerrit-Project: mediawiki/extensions/ContentTranslation Gerrit-Branch: master Gerrit-Owner: Santhosh <santhosh.thottin...@gmail.com> Gerrit-Reviewer: Nikerabbit <niklas.laxst...@gmail.com> Gerrit-Reviewer: Santhosh <santhosh.thottin...@gmail.com> Gerrit-Reviewer: jenkins-bot <> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits