diff --git a/src/modules/twinkleprod.js b/src/modules/twinkleprod.js index d72fe3510..390443125 100644 --- a/src/modules/twinkleprod.js +++ b/src/modules/twinkleprod.js @@ -347,7 +347,7 @@ Twinkle.prod.callbacks = { return def; }, - notifyAuthor: function twinkleprodNotifyAuthor() { + notifyAuthor: async function twinkleprodNotifyAuthor() { const def = $.Deferred(); if (!params.blp && !params.usertalk) { @@ -359,6 +359,11 @@ Twinkle.prod.callbacks = { Morebits.Status.info('Notifying creator', 'You (' + params.initialContrib + ') created this page; skipping user notification'); return def.resolve(); } + + if (await Twinkle.hasUserOptedOutOfNotice(params.initialContrib, ['prod'])) { + return def.resolve(); + } + // [[Template:Proposed deletion notify]] supports File namespace let notifyTemplate; if (params.blp) { diff --git a/src/modules/twinklespeedy.js b/src/modules/twinklespeedy.js index 5d19cfafd..2bdf9e63a 100644 --- a/src/modules/twinklespeedy.js +++ b/src/modules/twinklespeedy.js @@ -1329,7 +1329,7 @@ Twinkle.speedy.callbacks = { api.post(); }, - noteToCreator: function(pageobj) { + noteToCreator: async function(pageobj) { const params = pageobj.getCallbackParameters(); let initialContrib = pageobj.getCreator(); @@ -1354,7 +1354,8 @@ Twinkle.speedy.callbacks = { initialContrib = null; } - if (initialContrib) { + // TODO: allow opting out of specific CSD criteria + if (initialContrib && !await Twinkle.hasUserOptedOutOfNotice(initialContrib, ['csd'])) { const usertalkpage = new Morebits.wiki.Page('User talk:' + initialContrib, 'Notifying initial contributor (' + initialContrib + ')'); let notifytext, i, editsummary; diff --git a/src/modules/twinklexfd.js b/src/modules/twinklexfd.js index 3337721cb..02e833594 100644 --- a/src/modules/twinklexfd.js +++ b/src/modules/twinklexfd.js @@ -896,7 +896,7 @@ Twinkle.xfd.callbacks = { * @param {string} [actionName] Alternative description of the action * being undertaken. Required if not notifying a user talk page. */ - notifyUser: function(params, notifyTarget, noLog, actionName) { + notifyUser: async function(params, notifyTarget, noLog, actionName) { // Ensure items with User talk or no namespace prefix both end // up at user talkspace as expected, but retain the // prefix-less username for addToLog @@ -914,6 +914,10 @@ Twinkle.xfd.callbacks = { Twinkle.xfd.callbacks.addToLog(params, null); return; } + if (await Twinkle.hasUserOptedOutOfNotice(usernameOrTarget, ['xfd', params.venue])) { + Twinkle.xfd.callbacks.addToLog(params, null); + return; + } // Default is notifying the initial contributor, but MfD also // notifies userspace page owner actionName = actionName || 'Notifying initial contributor (' + usernameOrTarget + ')'; diff --git a/src/twinkle.js b/src/twinkle.js index 477f9448e..96968136f 100644 --- a/src/twinkle.js +++ b/src/twinkle.js @@ -499,6 +499,54 @@ Twinkle.generateBatchPageLinks = function (checkbox) { */ Twinkle.removeMoveToCommonsTagsFromWikicode = ( wikicode ) => wikicode.replace(/\{\{(mtc|(copy |move )?to ?commons|move to wikimedia commons|copy to wikimedia commons)(?!( in))[^}]*\}\}/gi, ''); +/** + * Check if the user has opted out of talk pages notices of a given type. + * + * @param {string} username + * @param {string[]} types Multiple values are allowed so that multiple levels can be + * passed in (eg. ['xfd', 'afd'] to detect if user has opted out of just AFD notices, + * or all XFD notices.) + * @return {Promise} Resolves to true if user has opted out of notification. + */ +Twinkle.hasUserOptedOutOfNotice = async function (username, types) { + const typesSet = new Set(types); + const status = new Morebits.Status('Checking for notification opt-out'); + const api = new Morebits.wiki.Api('checking...', { + action: 'query', + format: 'json', + prop: 'extlinks', + titles: 'User talk:' + username, + elquery: 'optout.twinkle', + // There should be only one matching external link. If there are multiple of them, + // consider up to the first 10. + ellimit: 10 + }, null, status); + return api.post().then((apiobj) => { + const page = apiobj.getResponse().query.pages[0]; + if (page.missing) { + return false; + } + const extlinks = page.extlinks || []; + return extlinks.some((link) => { + try { + const url = new URL(link.url); + const typesFromLink = url.searchParams.get('types'); + return typesFromLink && typesFromLink.split(',').some((e) => typesSet.has(e.trim().toLowerCase())); + } catch (e) { + // Invalid URL + return false; + } + }); + }).then((isOptOut) => { + if (isOptOut) { + status.warn(`${username} has opted out of notification`); + } else { + status.info('Not opted out'); + } + return isOptOut; + }); +}; + }()); //