Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

MediaWiki:Gadget-UploadMultipleFiles.js

MediaWiki interface page

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/* uploadMultipleFiles v2 by westgrass */
/* v2.1: support multiple categories, by Mourten */
/* Improved from https://dev.fandom.com/wiki/UploadMultipleFiles */
/* Adapted for Deepspace Lore */

$(function () {

    class apiQueue {
        constructor({ api, concurrency=3, rateLimitDelay = 60, maxRetries = 5, handlers = {} }) {
            this.api = api;

            this.concurrency = concurrency;
            this.maxRetries = maxRetries;
            this.rateLimitDelay = rateLimitDelay;

            this.rateLimitUntil = 0;
            this.rateLimitPromise = null;

            this.handlers = handlers;

            this.queue = [];
            this.runningWorkers = 0;
        }

        add(job) {
            if (!job) return;

            this.handlers.onAdd && this.handlers.onAdd(job);
            this.queue.push({job, retries: 0});
            this._ensureWorkers();
        }

        _ensureWorkers() {
            while (this.runningWorkers < this.concurrency && this.queue.length > 0) {
                this._worker();
            }
        }

        async _worker() {
            this.runningWorkers++;

            try {
                while (true) {
                    const item = this.queue.shift();
                    if (!item) break;

                    await this._work(item);
                }
            } finally {
                this.runningWorkers--;
                this._ensureWorkers();

                if (this.runningWorkers === 0 && this.queue.length === 0) {
                    this.handlers.onDone && this.handlers.onDone();
                }
            }
        }

        async _work(item) {
            const job = item.job;
            this.handlers.onStart && this.handlers.onStart(job);
            while (true) {
                await this._waitForGlobalRateLimit();
                try {
                    const result = this.handlers.onWork && await this.handlers.onWork(job, this.api);
                    this.handlers.onSuccess && this.handlers.onSuccess(job, result);
                    return;
                } catch (e) {
                    if (e.type === 'retriable') {
                        item.retries++;
                        if (item.retries > this.maxRetries) {
                            this.handlers.onFail && this.handlers.onFail(job, e);
                            return;
                        }

                        this._triggerGlobalRateLimit(this.rateLimitDelay);
                        continue;
                    }

                    this.handlers.onFail && this.handlers.onFail(job, e);
                    return;
                }
            }
        }

        async _waitForGlobalRateLimit() {
            if (!this.rateLimitPromise) return;

            await this.rateLimitPromise;
        }

        _triggerGlobalRateLimit(seconds) {
            const now = Date.now();
            const until = now + seconds * 1000;

            if (this.rateLimitUntil >= until) {
                return;
            }

            this.rateLimitUntil = until;

            this.rateLimitPromise = (async () => {
                let remaining;
                while ((remaining = this.rateLimitUntil - Date.now()) >= 0) {
                    const sec = Math.ceil(remaining / 1000);
                    this.handlers.onProgress && this.handlers.onProgress(sec);
                    await new Promise(r => setTimeout(r, 1000));
                }
            })().finally(() => {
                this.rateLimitPromise = null;
                this.rateLimitUntil = 0;
            });
        }
    }

    ///////////////////////////////////////////////////////////////////////////////

    if (mw.config.get("wgCanonicalSpecialPageName") !== "Upload") {
        return;
    }

    if (window.__wgg_UploadMultipleFiles_IsLoaded) {
        return;
    }
    window.__wgg_UploadMultipleFiles_IsLoaded = true;

    mw.messages.set({
        'gadget-uploadMultipleFiles-multiupload': 'Multiple upload',
        'gadget-uploadMultipleFiles-yes': 'Yes',
        'gadget-uploadMultipleFiles-no': 'No',
        'gadget-uploadMultipleFiles-sourcefiles': 'Source files',
        'gadget-uploadMultipleFiles-categoryname': 'Category',
        'gadget-uploadMultipleFiles-categorynamehint': 'Enter one category name per line',
        'gadget-uploadMultipleFiles-categoryname-placeholder': 'One category per line',
        'gadget-uploadMultipleFiles-watchFiles': 'Watch these files',
        'gadget-uploadMultipleFiles-uploadfiles': 'Upload files',
        'gadget-uploadMultipleFiles-nofiles': 'No files selected.',
        'gadget-uploadMultipleFiles-nolicense': 'No license selected.',
        'gadget-uploadMultipleFiles-license': 'Licensing',
        'gadget-uploadMultipleFiles-uploading': 'Uploading\u2026',
        'gadget-uploadMultipleFiles-uploaded': 'Uploaded',
        'gadget-uploadMultipleFiles-uploaded-placeholder': 'No files uploaded yet.',
        'gadget-uploadMultipleFiles-failed': 'Failed',
        'gadget-uploadMultipleFiles-failed-placeholder': 'No failed uploads.',
        'gadget-uploadMultipleFiles-warning': 'Warnings',
        'gadget-uploadMultipleFiles-warning-placeholder': 'No warnings.',
        'gadget-uploadMultipleFiles-status-uploading': 'Uploading\u2026',
        'gadget-uploadMultipleFiles-done': 'Done.',
        'gadget-uploadMultipleFiles-done-with-error': 'Done, with some errors or warnings.',
        'gadget-uploadMultipleFiles-ratelimited': 'Rate limited. Resuming in $1 seconds\u2026',
        'gadget-uploadMultipleFiles-error': 'Upload failed.',
        'gadget-uploadMultipleFiles-error-fileexists-no-change': '$1 is identical to the current version and was not uploaded.',
        'gadget-uploadMultipleFiles-warning-exists': '$1 already exists.',
        'gadget-uploadMultipleFiles-warning-deleted': '$1 was previously deleted.',
        'gadget-uploadMultipleFiles-warning-duplicate': 'Duplicate of $1.',
        'gadget-uploadMultipleFiles-warning-duplicate-deleted': 'Duplicate of previously deleted file $1.',
        'gadget-uploadMultipleFiles-warning-exists-normalized': '$1 already exists (normalized name).',
        'gadget-uploadMultipleFiles-warning-page-exists': 'Page $1 already exists.',
        'gadget-uploadMultipleFiles-retry': 'Retry',
        'gadget-uploadMultipleFiles-ignore': 'Upload anyway',
    });

    $.when(
        mw.loader.using(['site', 'mediawiki.util', 'mediawiki.api', 'mediawiki.jqueryMsg']), $.ready
    ).then(function () {

        enhanceUploadUI();

        $("#multiFileSubmit").on('click', function () {
            const files = $("#multiupload")[0].files;

            // Cancel upload if no files are selected
            if (files.length === 0) {
                alert(mw.msg("gadget-uploadMultipleFiles-nofiles"));
                return false;
            }

            // get form options
            const description = getUploadDescription(); // Description is summary + license + category
            const watch = $("#wpWatchthis").is(":checked") ? "watch" : "nochange";
            const ignoreWarnings = $("#wpIgnoreWarning").is(":checked");

            switchToUploadingUI();

            if (ignoreWarnings) {
                $('#multiUploadWarning').hide();
            }

            const $qu = $('#multiUploadQueue ul');
            const $done = $('#multiUploadUploaded ul');
            const $donePh = $('#multiUploadUploaded p');
            const $fail = $('#multiUploadFailed ul');
            const $failPh = $('#multiUploadFailed p');
            const $warn = $('#multiUploadWarning ul');
            const $warnPh = $('#multiUploadWarning p');
            const $message = $('#multiUploadMessage');
            const $result = $("#firstHeading .result");

            //prepare upload queue
            const queue = new apiQueue({api: new mw.Api(), handlers: {
                    onAdd(job) {
                        $result.empty();
                        let $li = $('<li data-idx="'+job.idx+'">'+job.file.name+' <span class="status"></span></li>');
                        job.node = $li;
                        $qu.append($li);
                    },
                    onDone() {
                        if ($fail.find('li').length || $warn.find('li').length) {
                            $result.msg('gadget-uploadMultipleFiles-done-with-error');
                        } else {
                            $result.msg('gadget-uploadMultipleFiles-done');
                        }
                    },
                    onStart(job) {
                        job.node.find('.status').msg('gadget-uploadMultipleFiles-status-uploading');
                    },
                    onSuccess(job, result) {
                        job.node.remove();
                        $donePh.remove();
                        $done.append('<li><a href="' + result.imageinfo.descriptionurl + '" target="_blank">' + result.filename + '</a></li>');
                    },
                    onFail(job, e) {
                        job.node.remove();
                        if (e.type === 'warning'){
                            const warnings = e.result.upload.warnings;
                            if(warnings["exists"]){
                                warningOutput(job.idx, mwMsg('gadget-uploadMultipleFiles-warning-exists', job.file.name));
                            }
                            else if(warnings["duplicate"]){
                                warningOutput(job.idx, mwMsg('gadget-uploadMultipleFiles-warning-duplicate', warnings.duplicate));
                            }
                            else if(warnings["was-deleted"]){
                                warningOutput(job.idx, mwMsg('gadget-uploadMultipleFiles-warning-deleted', warnings["was-deleted"]));
                            }
                            else if(warnings["duplicate-archive"]){
                                warningOutput(job.idx, mwMsg('gadget-uploadMultipleFiles-warning-duplicate-deleted', warnings["duplicate-archive"]));
                            }
                            else if(warnings["exists-normalized"]){
                                warningOutput(job.idx, mwMsg('gadget-uploadMultipleFiles-warning-exists-normalized', warnings["exists-normalized"]));
                            }
                            else if(warnings["page-exists"]){
                                warningOutput(job.idx, mwMsg('gadget-uploadMultipleFiles-warning-page-exists', warnings["page-exists"]));
                            }else{
                                // generic fallback
                                warningOutput(job.idx, JSON.stringify(warnings));
                            }
                        }
                        else{ //fail
                            // specific catch for some types
                            if(e.code === "fileexists-no-change"){
                                failedOutput(job.idx, mwMsg('gadget-uploadMultipleFiles-error-fileexists-no-change', job.file.name));
                            }else{
                                // generic error fallback
                                if (e.result && e.result.error) {
                                    failedOutput(job.idx, e.result.error.info + ' (' + e.result.error.code +')', true);
                                }
                                else{
                                    failedOutput(job.idx, mw.message('gadget-uploadMultipleFiles-error').parse(), true);
                                }
                            }
                        }
                    },
                    onProgress(sec) {
                        $message.show();
                        $('#rateLimitCounter').text(sec);
                        if(sec <= 0){
                            $message.hide();
                        }
                    },
                    async onWork(job, api) {
                        const params = {
                            format: 'json',
                            filename: job.file.name,
                            text: job.description,
                            watchlist: job.watch
                        };
                        if (job.ignoreWarnings) {
                            params.ignorewarnings = 1;
                        }
                        return new Promise((resolve, reject) => {
                            api.upload(job.file, params)
                                .done((result) => resolve(result.upload))
                                .fail((code, result) => {
                                    if (result.upload && result.upload.warnings) { // warning
                                        if (result.upload.result === "Success"){ //upload with "ignoreWarnings=1"
                                            resolve(result.upload); //success
                                        }
                                        else if (result.upload.warnings.exists && result.upload.warnings.nochange) {
                                            //an exact duplicate of the current version, treat as failure
                                            reject({ code:'fileexists-no-change', result, type:'fail' });
                                        }
                                        else{
                                            reject({ code, result, type:'warning' });
                                        }
                                    }
                                    else if (code === "ratelimited") { // specific catch for ratelimiting
                                        reject({ code, result, type:'retriable' });
                                    }
                                    else{
                                        reject({ code, result, type:'fail' });
                                    }
                                });
                        });
                    }
                }});

            const jobMap = new Map();

            $fail.on('click', 'a.retry', function(event){
                const $li = $(event.target).parents('li');
                const idx = $li.data('idx');
                const job = jobMap.get(idx);
                $li.remove();
                if($fail.find('li').length === 0){
                    $failPh.show();
                }
                queue.add(job);
                return false;
            });
            $warn.on('click', 'a.retry', function(event){
                const $li = $(event.target).parents('li');
                const idx = $li.data('idx');
                const job = jobMap.get(idx);
                job.ignoreWarnings = true;
                $li.remove();
                if($warn.find('li').length === 0){
                    $warnPh.show();
                }
                queue.add(job);
                return false;
            });

            Array.from(files).forEach((file, idx) => {
                const job = {
                    idx,
                    file,
                    description,
                    watch,
                    ignoreWarnings
                };
                jobMap.set(idx, job);
                queue.add(job);
            });

            ////////////////////////

            function failedOutput (fileIdx, msg, retry = false) {
                $failPh.hide();
                var $node = $('<li data-idx="'+fileIdx+'"></li>').append(files[fileIdx].name + ': ', msg);
                if(retry){
                    $node.append(' ', $('<a class="retry" href="#">'+mw.msg('gadget-uploadMultipleFiles-retry')+'</a>'));
                }
                $fail.append($node);
            }

            function warningOutput (fileIdx, msg) {
                $warnPh.hide();
                var $node = $('<li data-idx="'+fileIdx+'"></li>').append(files[fileIdx].name + ': ', msg);
                $node.append(' ', $('<a class="retry ignore" href="#">'+mw.msg('gadget-uploadMultipleFiles-ignore')+'</a>'));
                $warn.append($node);
            }

        });
    });

    ///////////////////////////////////////////////////////////////////////////////
    // functions
    ///////////////////////////////////////////////////////////////////////////////

    function getUploadDescription () {
        var sections = [];
        var categories = [];

        var summary = $("#wpUploadDescription").val();
        var licenseDisplayName = $("#wpLicense option:selected").val();
        var categoryName = $("#multiFileCategory").val();

        if(summary !== ""){
            sections.push(summary);
        }

        if(licenseDisplayName !== ""){
            var licenseTemplateText = $("#wpLicense option:selected").prop("title");
            sections.push("== " + mw.message("gadget-uploadMultipleFiles-license").parse() + " ==\n" + licenseTemplateText);
        }

        if (categoryName !== "") {
            categoryName
                .split(/\n+/)
                .map(c => c.trim())
                .filter(Boolean)
                .forEach(cat => {
                    categories.push("[[" + "Category:" + cat + "]]");
                });
        }
        // categories go at the end, joined WITHOUT blank lines
        if (categories.length) {
            sections.push(categories.join("\n"));
        }

        return sections.join("\n\n");
    }

    function mwMsg (key, filename) {
        const getFileLink = function(filename){
            return '<a href="'+mw.util.getUrl('File:'+filename)+'" target="_blank">' + filename + '</a>';
        };
        if(Array.isArray(filename)){
            return mw.message(key, $(filename.map((x)=>getFileLink(x)).join(', '))).parse();
        }
        else{
            return mw.message(key, $(getFileLink(filename))).parse();
        }
    }

    function enhanceUploadUI(){
        $("#wpUploadFile").parents('.mw-htmlform-field-UploadSourceField').addClass("regularFileSelect") // source file input
            .before( // multiple? yes/no
                $("<tr></tr>").append(
                    $('<td class="mw-label"></td>').msg("gadget-uploadMultipleFiles-multiupload"),
                    $('<td class="mw-input"></td>').append(
                        $("<label></label>").msg("gadget-uploadMultipleFiles-yes").prepend('<input type="radio" name="multipleFiles" value="yes" /> '),
                        ' &nbsp; ',
                        $("<label></label>").msg("gadget-uploadMultipleFiles-no").prepend('<input type="radio" name="multipleFiles" value="no" checked/> '),
                    )
                )
            )
            .after( //source files
                $('<tr class="mw-htmlform-field-UploadSourceField multipleFileSelect"></tr>').hide().append(
                    $('<td class="mw-label"></td>').msg("gadget-uploadMultipleFiles-sourcefiles"),
                    $('<td class="mw-input"></td>').append('<input type="file" id="multiupload" size="60" multiple />')
                )
            );
        $("#wpDestFile").parents(".mw-htmlform-field-HTMLTextField").addClass("regularFileSelect") //dest filename
            .after(// add "category name" textbox to multiupload section
                $('<tr class="mw-htmlform-field-HTMLTextField multipleFileSelect"></tr>').hide().append(
                    $('<td class="mw-label"></td>').append(
                        $('<label for="multiFileCategory"></label>').append(
                            $('<abbr></abbr>').msg("gadget-uploadMultipleFiles-categoryname").attr('title', mw.msg("gadget-uploadMultipleFiles-categorynamehint"))
                        )
                    ),
                    $('<td class="mw-input"></td>').append('<textarea id="multiFileCategory" name="multiFileCategory" rows="5" placeholder="'+mw.msg("gadget-uploadMultipleFiles-categoryname-placeholder")+'"></textarea>')
                )
            );
        $("label[for=wpWatchthis]").addClass("regularFileSelect") //watch this file
            .after(
                $('<label for="wpWatchthis" class="multipleFileSelect"></label>').msg("gadget-uploadMultipleFiles-watchFiles").hide() //watch these files
            );
        $("input[name=wpUpload]").addClass("regularFileSelect") //upload file button
            .after(
                $('<input type="button" id="multiFileSubmit" class="multipleFileSelect" />').val(mw.msg("gadget-uploadMultipleFiles-uploadfiles")).hide() //upload files
            );

        $("input[name=multipleFiles]").on('change', function(){
            if(this.value === 'yes'){
                // also hide thumbnail and warning in regular mode.
                $(".regularFileSelect, #mw-upload-thumbnail, .mw-destfile-warning").hide();
                $(".multipleFileSelect").show();
            }else{
                $(".regularFileSelect, #mw-upload-thumbnail, .mw-destfile-warning").show();
                $(".multipleFileSelect").hide();
            }
        });
    }

    function switchToUploadingUI(){
        // init uploading UI
        $("#firstHeading").msg("gadget-uploadMultipleFiles-uploading").append('<span class="result"></span>');
        $("#mw-content-text").empty().append(
            $('<div id="multiUploadMessage"></div>').msg('gadget-uploadMultipleFiles-ratelimited', $('<span id="rateLimitCounter">60</span>')).hide(),
            $('<div id="multiUploadQueue"></div>').append($('<ul></ul>')),
            $('<div id="multiUploadResult"></div>').append(
                $('<div id="multiUploadFailed"></div>').append(
                    $('<h3></h3>').msg("gadget-uploadMultipleFiles-failed"),
                    $('<p></p>').msg("gadget-uploadMultipleFiles-failed-placeholder"),
                    $('<ul></ul>')
                ),
                $('<div id="multiUploadWarning"></div>').append(
                    $('<h3></h3>').msg("gadget-uploadMultipleFiles-warning"),
                    $('<p></p>').msg("gadget-uploadMultipleFiles-warning-placeholder"),
                    $('<ul></ul>')
                ),
                $('<div id="multiUploadUploaded"></div>').append(
                    $('<h3></h3>').msg("gadget-uploadMultipleFiles-uploaded"),
                    $('<p></p>').msg("gadget-uploadMultipleFiles-uploaded-placeholder"),
                    $('<ul></ul>')
                )
            )
        );
    }

});