User:Ilmari Karonen/ajax quick delete.js

From Wikimedia Commons, the free media repository
Jump to navigation Jump to search
Note: After saving, you have to bypass your browser's cache to see the changes. Internet Explorer: press Ctrl-F5, Mozilla: hold down Shift while clicking Reload (or press Ctrl-Shift-R), Opera/Konqueror: press F5, Safari: hold down Shift + Alt while clicking Reload, Chrome: hold down Shift while clicking Reload.
// Ajax-based replacement for [[MediaWiki:Quick-delete-code.js]]
// *** EXPERIMENTAL ***
// TODO: more reliable localization loading
// TODO: use prependtext/appendtext API parameters for more reliable editing
// TODO: display detailed progress; better error handling/reporting
// TODO: allow user to choose which uploaders to notify
// TODO: (somehow) detect bots, don't notify them
// TODO: try to find an actual user to notify for bot-uploaded files
// TODO: follow talk page redirects (including {{softredirect}})
// TODO: ...
// <source lang="JavaScript">

if (typeof wfSupportsAjax !== 'undefined' && typeof (AjaxQuickDelete) == 'undefined' && wgNamespaceNumber >= 0) {

var AjaxQuickDelete = {
    /**
     * Set up the AjaxQuickDelete object and add the toolbox link.  Called via
     * addOnloadHook() during page loading.
     */
    install : function () {
        // abort if AJAX is not supported
        if (!wfSupportsAjax || !wfSupportsAjax()) return;

        // abort if page seems to be already nominated for deletion
        if (document.getElementById("nuke")) return;

        // remove old toolbox link if called twice
        var tool = document.getElementById('t-ajaxquickdelete');
        if (tool) tool.parentNode.removeChild(tool);

        // set up toolbox link
        mw.util.addPortletLink('p-tb', 'javascript:AjaxQuickDelete.nominateForDeletion();',
                       this.i18n.toolboxLink + " (Ajax)", 't-ajaxquickdelete', null);
    },

    /**
     * This is the main entry point which actually starts the nomination process.
     * Takes as an optional parameter a string used as the deletion reason; if none
     * is given, prompts the user for one first.
     */
    nominateForDeletion : function (reason) {
        this.startDate = new Date ();

        // prompt for reason if necessary
        // TODO: replace this with a nice textbox / drop menu entry form like in TWINKLE
        if (!reason) reason = prompt( this.i18n.reasonForDeletion );
        if (!reason) return;
        this.reason = reason;

        // set up some page names we'll need later
        this.requestPage = this.requestPagePrefix + wgPageName;
        this.dailyLogPage = this.requestPagePrefix + this.formatDate( "YYYY/MM/DD" );

        // first schedule some API queries to fetch the info we need...
        this.tasks = [];  // reset task list in case an earlier error left it non-empty
        this.addTask( 'findUploaders' );  // TODO: allow user to edit list
        this.addTask( 'loadPages' );

        // ...then schedule the actual edits
        this.addTask( 'prependDeletionTemplate' );
        this.addTask( 'createRequestSubpage' );
        this.addTask( 'listRequestSubpage' );
        this.addTask( 'notifyUploaders' );

        // finally reload the page to show the deletion tag
        this.addTask( 'reloadPage' );

        // now go do all the stuff we just scheduled!
        document.body.style.cursor = 'wait';
        this.nextTask();
    },

    /**
     * Edit the current page to add the {{delete}} tag.  Assumes that the page hasn't
     * been tagged yet; if it is, a duplicate tag will be added.
     */
    prependDeletionTemplate : function () {
        var page = this.pages[ wgPageName ];
        page.text = "{{delete|reason=" + this.reason + this.formatDate( "|year=YYYY|month=MON|day=DAY}}\n" ) + page.text;
        this.savePage( page, "Nominating for deletion", 'nextTask' );
    },

    /**
     * Create the DR subpage (or append a new request to an existing subpage).  The request
     * page will always be watchlisted.
     * TODO: if the page exists, check that any earlier nomination has been closed already
     */
    createRequestSubpage : function () {
        this.templateAdded = true;  // we've got this far; if something fails, user can follow instructions on template to finish
        var page = this.pages[ this.requestPage ];
        page.text = page.text.replace( /\n*$/, "\n\n=== [[:" + wgPageName + "]] ===\n" + this.reason + " --~~"+"~~\n" );
        page.watchlist = 'watch';
        this.savePage( page, "Starting deletion request", 'nextTask' );
    },

    /**
     * Transclude the nomination page onto today's DR log page, creating it if necessary.
     * The log page will never be watchlisted (unless the user is already watching it).
     */
    listRequestSubpage : function () {
        var page = this.pages[ this.dailyLogPage ];
        if (!page.text) page.text = "{{"+"subst:" + this.requestPagePrefix + "newday}}";  // add header to new log pages
        page.text = page.text.replace( /\n*$/, "\n{{" + this.requestPage + "}}\n" );
        page.watchlist = 'nochange';
        this.savePage( page, "Listing [[" + this.requestPage + "]]", 'nextTask' );
    },

    /**
     * Notify any uploaders/creators of this page using {{idw}}.
     * TODO: follow talk page redirects (including {{softredirect}})
     * TODO: obey {{nobots}} and/or other opt-out mechanisms
     */
    notifyUploaders : function () {
        this.uploadersToNotify = 0;
        for (var user in this.uploaders) {
            var page = this.pages[ this.userTalkPrefix + user ];
            page.text = page.text.replace( /\n*$/, "\n{{"+"subst:idw|" + wgPageName + "}}\n" );
            this.savePage( page, "[[:" + wgPageName + "]] has been nominated for deletion", 'uploaderNotified' );
            this.uploadersToNotify++;
        }
        if (this.uploadersToNotify == 0) this.nextTask();
    },
    uploaderNotified : function () {
        this.uploadersToNotify--;
        if (this.uploadersToNotify == 0) this.nextTask();
    },

    /**
     * Compile a list of uploaders to notify.  Users who have only reverted the file to an
     * earlier version will not be notified.
     * TODO: notify creator of non-file pages
     * TODO: handle continuations if there are more than 50 revisions
     * TODO: don't notify bots, try to figure out a real human user to notify instead
     * TODO: allow nominator to choose which users to actually notify
     */
    findUploaders : function () {
        var query = {
            action : 'query',
            prop : 'imageinfo',
            iiprop : 'user|sha1',
            iilimit : 50,
            titles : wgPageName };
        this.doAPICall( query, 'findUploadersCB' );
    },
    findUploadersCB : function (result) {
        this.uploaders = {};
        var pages = result.query.pages;
        for (var id in pages) {  // there should be only one, but we don't know its ID
            var info = pages[id].imageinfo;
            if (!info) continue;  // not a file?
            var seenHashes = {};
            for (var i = info.length-1; i >= 0; i--) {  // iterate in reverse order
                if (info[i].sha1 && seenHashes[ info[i].sha1 ]) continue;  // skip reverts
                this.uploaders[ info[i].user ] = true;
            }
            // TODO: improve handling of bot uploads
        }
        this.nextTask();
    },

    /**
     * Fetch page content for editing.  Currently we do it all in one batch; if we'd
     * like to fetch more pages later, this code would need some redesign.
     * TODO: follow redirects?
     */
    loadPages : function () {
        var pages = [ wgPageName, this.requestPage, this.dailyLogPage ];
        for (var user in this.uploaders) pages.push( this.userTalkPrefix + user );
        var query = {
            action : 'query',
            prop : 'info|revisions',
            intoken : 'edit',
            rvprop : 'content|timestamp',
            titles : pages.join('|') };
        this.doAPICall( query, 'loadPagesCB' );
    },
    loadPagesCB : function (result) {
        // build a denormalization map so that we can keep using the same (possibly non-canonical) page names as we originally queried:
        var denorm = {};
        var norm = result.query.normalized || [];
        for (var i = 0; i < norm.length; i++) {
            denorm[ norm[i].to ] = norm[i].from;
        }
        // save results:
        this.pages = {};
        var pages = result.query.pages;
        for (var id in pages) {
            var page = pages[id];
            var name = denorm[ page.title ] || page.title;
            page.text = ((page.revisions || [])[0] || {})['*'] || "";  // copy of revision text for editing
            this.pages[ name ] = page;
        }
        this.nextTask();
    },

    /**
     * Submit an edited page.
     */
    savePage : function (page, summary, callback) {
        var edit = {
            action : 'edit',
            summary : summary,
            watchlist : (page.watchlist || 'preferences'),
            text : page.text,
            title : page.title,
            token : page.edittoken,
            starttimestamp : page.starttimestamp };

        if (page.revisions && page.revisions.length) {
            edit.basetimestamp = page.revisions[0].timestamp;
            edit.nocreate = 1;
        } else {
            edit.createonly = 1;
        }

        this.doAPICall( edit, callback );
    },

    /**
     * Does a MediaWiki API request and passes the result to the supplied callback (method name).
     * Uses POST requests for everything for simplicity.
     * TODO: better error handling
     */
    doAPICall : function ( params, callback ) {
        var query = [ "format=json" ];
        for (var name in params) {
            query.push( encodeURIComponent(name) + "=" + encodeURIComponent(params[name]) );
        }
        query = query.sort().join("&");  // conveniently, "text" sorts before "token"

        var o = this;
        var x = sajax_init_object();
        x.open( 'POST', this.apiURL, true );
        x.setRequestHeader( 'Content-Type', 'application/x-www-form-urlencoded' );
        x.onreadystatechange = function () {
            if (x.readyState != 4) return;
            if (x.status >= 300) return o.fail( "API request returned code " + x.status + " " + x.statusText );
            try {
                var result = eval( "(" + x.responseText + ")" );
            } catch (e) {
                return o.fail( "Error evaluating API result: " + e + "\n\nResponse content:\n" + x.responseText );
            }
            if (!result) return o.fail( "Receive empty API response:\n" + x.responseText );
            if (result.error) return o.fail( "API request failed (" + result.error.code + "): " + result.error.info );
            try { o[callback](result); } catch (e) { return o.fail(e); }
        };
	x.send(query);
    },

    /**
     * Simple task queue.  addTask() adds a new task to the queue, nextTask() executes
     * the next scheduled task.  Tasks are specified as method names to call.
     */
    tasks : [],  // list of pending tasks
    currentTask : '',  // current task, for error reporting
    addTask : function ( task ) {
        this.tasks.push( task );
    },
    nextTask : function () {
        var task = this.currentTask = this.tasks.shift();
        try { this[task](); } catch (e) { this.fail(e); }
    },

    /**
     * Once we're all done, reload the page.
     */
    reloadPage : function () {
        if (wgAction == 'view') location.reload();
        else location.href = mw.config.get('wgServer') + mw.config.get('wgArticlePath').replace("$1", encodeURIComponent(mw.config.get('wgPageName')));
    },

    /**
     * Crude error handler. Just throws an alert at the user and (if we managed to
     * add the {{delete}} tag) reloads the page.
     */
    fail : function ( err ) {
        var msg = this.i18n.taskFailure[this.currentTask] || this.i18n.genericFailure;
        var fix = (this.templateAdded ? this.i18n.completeRequestByHand : this.i18n.addTemplateByHand );
        alert( msg + " " + fix + "\n\n" + this.i18n.errorDetails + "\n" + err );
        if (this.templateAdded) this.reloadPage();
        else document.body.style.cursor = 'default';
    },

    /**
     * Very simple date formatter.  Replaces the substrings "YYYY", "MM" and "DD" in a
     * given string with the UTC year, month and day numbers respectively.  Also
     * replaces "MON" with the English full month name and "DAY" with the unpadded day. 
     */
    formatDate : function ( fmt, date ) {
        var pad0 = function ( s ) { s = "" + s; return (s.length > 1 ? s : "0" + s); };  // zero-pad to two digits
        if (!date) date = this.startDate;
        fmt = fmt.replace( /YYYY/g, date.getUTCFullYear() );
        fmt = fmt.replace( /MM/g, pad0( date.getUTCMonth()+1 ) );
        fmt = fmt.replace( /DD/g, pad0( date.getUTCDate() ) );
        fmt = fmt.replace( /MON/g, this.months[ date.getUTCMonth() ] );
        fmt = fmt.replace( /DAY/g, date.getUTCDate() );
        return fmt;
    },
    months : "January February March April May June July August September October November December".split(" "),  // srsly? I have to do this myself?? wtf?

    // Constants
    requestPagePrefix : "Commons:Deletion requests/",  // DR subpage prefix
    userTalkPrefix : wgFormattedNamespaces[3] + ":",   // user talk page prefix
    apiURL : mw.config.get('wgServer') + mw.config.get('wgScriptPath') + "/api.php",     // MediaWiki API script URL

    // Translatable strings (many unused for now)
    i18n : {
        toolboxLink : "Nominate for deletion",

        // GUI reason prompt form (mostly unused)
        reasonForDeletion : "Reason for deletion:",
        notifyFollowingUsers : "Notify the following users:",
        submitButtonLabel : "Nominate",
        cancelButtonLabel : "Cancel",

        // GUI progress messages (unused)
        preparingToEdit : "Preparing to edit %COUNT% pages... ",
        creatingNomination : "Creating nomination page... ",
        listingNomination : "Adding nomination page to daily list... ",
        addingTemplate : "Adding deletion template to file description page... ",
        notifyingUploader : "Notifying %USER%... ",

        // GUI results (unused)
        operationSucceeded : "done",
        operationFailed : "ERROR",

        // Errors
        genericFailure : "An error occurred while nominating this "+(6==wgNamespaceNumber?"file":"page")+" for deletion.",
        taskFailure : {
            listUploaders : "An error occurred while determining the " + (6==wgNamespaceNumber ? "uploader(s) of this file" : "creator of this page") + ".",
            loadPages : "An error occurred while preparing to nominate this "+(6==wgNamespaceNumber?"file":"page")+" for deletion.",
            prependDeletionTemplate : "An error occurred while adding the {{delete}} template to this "+(6==wgNamespaceNumber?"file":"page")+".",
            createRequestSubpage : "An error occurred while creating the request subpage.",
            listRequestSubpage : "An error occurred while adding the deletion request to today's log.",
            notifyUploaders : "An error occurred while notifying the " + (6==wgNamespaceNumber ? "uploader(s) of this file" : "creator of this page") + ".",
            dummy : ""  // IE doesn't like trailing commas
        },
        addTemplateByHand : "To nominate this "+(6==wgNamespaceNumber?"file":"page")+" for deletion, please edit the page to add the {{delete}} template and follow the instructions shown on it.",
        completeRequestByHand : "Please follow the instructions on the deletion notice to complete the request.",
        errorDetails : "A detailed description of the error is shown below:",

        dummy : ""  // IE doesn't like trailing commas
    }
};

if (!/^en\b/.test(wgUserLanguage)) importScript( 'User:Ilmari_Karonen/ajax_quick_delete.js/' + wgUserLanguage.replace(/-.*/, "") );

$ (function () { AjaxQuickDelete.install(); });

} // end if (guard)
 
// </source>