User:Magog the Ogre/ExpandedWatchlist.js

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.
/***********************
 * This is the source code version. To make an update, you will need to download Closure compiler on your machine. Sorry; the public web API is old and doesn't properly polyfill for IE11. Hopefully this will change soon.

Instructions:
 *   1) Edit this file in ES6
 *   2) Copy the contents of this file
 *   3) Compress the file in Closure compiler. Use the options
--assume_function_wrapper
--language_in ECMASCRIPT8
--compilation_level SIMPLE
 *   4) Copy the compiled code
 *   5) Go to https://commons.wikimedia.org/w/index.php?title=MediaWiki:Gadget-ExpandedWatchlist.js&action=edit
 *   6) Insert the compiled code at the appropriate marker
 *
 * If you'd like to add your own option, you can add the template below to your skin.js
mw.hook("gadget.expandedWatchlist.define").add(function () {
    function MyFilter() {
    };
    MyFilter.prototype = $.extend({},
        window.ExpandedWatchlist.ShowHideFilter.prototype,
        {
            get name() {
                return "my-name";
            }
            toolFilter: function (all) {
                return all.filter(function (_, element) {
                    //add logic here
                });
            }
        }
    );

    window.ExpandWatchlist.addToolMessages({
        "my-name": {
            shortDesc: {
                en: "My short description"
            },
            longDesc: {
                en: "My long description"
            }
        }
    });
    window.ExpandedWatchlist.addFilters(MyFilter);
});
/* jshint ignore:start */
(async (window) => {
    const $ = window.$;
    const mw = window.mw;

    function objectFromEntries(entries) {
        return Object.assign({}, ...$.map(entries, ([key, val]) => ({[key]: val})));
    }

    /**
     * Mediawiki takes WAY too long for the document ready to load; this will listen for
     * the actual body being loaded before that.
     * @return Promise
     */
    async function onLoad() {
        return new Promise(resolve => {
            $(() => {
                resolve();
            });
            var interval = window.setInterval(() => {
                if ($("#footer")[0] && $("#mw-navigation")[0]) {
                    resolve();
                    window.clearInterval(interval);
                }
            }, 100);
        });
    }

    class GlobalSingletonFactory {
        constructor() {
            this._registry = new Map();
            this._factories = [];
        }
        get(_class) {
            var singleton = this._registry.get(_class);
            if (!singleton) {
                this._factories.some((_factory) =>  {
                    singleton = _factory.get(_class);
                    return singleton;
                });
                this._registry.set(_class, singleton);
            }


            return singleton;
        }

        register(_factory) {
            this._factories.unshift(_factory);
        }
    }

    class DefaultSingletonFactory {
        get(_class) {
            return new _class();
        }
    }

    class StorageFactory {
        /**
         * @return {window.Storage}
         */
        get(_class) {
            if (_class === window.Storage) {
                const STORAGE_CHECK = "gadget-ew-check";
                return [window.localStorage, window.sessionStorage].find((_storage) =>  {
                    try {
                        _storage.setItem(STORAGE_CHECK, 0);
                        _storage.removeItem(STORAGE_CHECK);
                        return true;
                    } catch (e) {
                        // empty
                    }
                });
            }
        }
    }

    class Preferences {
        constructor() {
            const _storageKey = "gadget-ew";

            var storage = factory.get(window.Storage);
            var map = Object.assign({}, JSON.parse(storage.getItem(_storageKey)) || {});

            this.get = (key) => {
                return map[key];
            };

            this.set = (key, val) => {
                map[key] = val;
                storage.setItem(_storageKey, JSON.stringify(map));
            };

        }
    }

    class ExpandedWatchlistNotifier {

        constructor() {
            this._preferences = factory.get(Preferences);
            this._message = factory.get(Messages).getGlobal("disableOptions");
        }

        notify(badOptions) {
            if (badOptions[0] && !this.getNotified()) {
                this.doNotify(badOptions);
            }
        }

        doNotify(badOptions) {
            $("<div></div>")
            .html(this.getMessage(badOptions))
            .dialog({
                appendTo: "body",
                modal: true,
                buttons: {
                    [factory.get(Messages).getGlobal("gotIt")]: function () {
                        $(this).dialog( "close" );
                    }
                },
                close: () => {
                    this.setNotified();
                }
            });
        }

        getMessage(badOptions) {
            var optionList = badOptions.map((_, input) =>
                    $.trim($("label[for=" + $(input).attr("id") + "]").text())
                ).get().join(", ");

            return this._message.replace(/\$1\b/, optionList);
        }

        getNotified() {
            return this._preferences.get("notified");
        }

        setNotified() {
            this._preferences.set("notified", true);
        }
    }

    class Messages {
        constructor() {
            this._mylang = mw.config.get("wgUserLanguage");
            this._defaultLang = "en";
            this.global = {
                show: {
                    en: "Show"
                },
                only: {
                    en: "Show only"
                },
                hide: {
                    en: "Hide"
                },
                disabled: {
                    en: "disabled"
                },
                disableOptions: {
                    en:  "Expanded watchlist will only filter entries returned in your standard watchlist. " +
                        "In order to make the most out of expanded watchlist, you may wish to <strong>edit " +
                        "your preferences</strong> to only hide options you never want to see. <br/><br/>" +
                        "The following options are currently disabled: $1."
                },
                gotIt: {
                    en: "Got it"
                },
                expandWatchlist: {
                    en: "Expanded watchlist"
                },
                standardWatchlist: {
                    en: "Standard watchlist"
                },
                rememberSettings: {
                    en: "Remember settings"
                },
                reset: {
                    en: "Clear state"
                },
                resetSettings: {
                    en: "Reset settings"
                },
                save: {
                    en: "Save state"
                },
                "preferences-saved": {
                    en: "Preferences saved"
                },
                "preferences-cleared": {
                    en: "Preferences cleared"
                }
            };
            this.tools = {
                "ew-bot": {
                    shortDesc: {
                        en: "Bots"
                    },
                    longDesc: {
                        en: "Bot edits"
                    }
                },
                "ew-log": {
                    shortDesc: {
                        en: "Logs"
                    },
                    longDesc: {
                        en: "All log entries"
                    }
                },
                "ew-uploads": {
                    shortDesc: {
                        en: "Upload log"
                    },
                    longDesc: {
                        en: "Upload log"
                    }
                },
                "ew-deletion": {
                    shortDesc: {
                        en: "Deletion log"
                    },
                    longDesc: {
                        en: "Deletion log"
                    }
                },
                "ew-new": {
                    shortDesc: {
                        en: "New pages"
                    },
                    longDesc: {
                        en: "New pages"
                    }
                },
                "ew-patrolled": {
                    shortDesc: {
                        en: "Patrolled pages"
                    },
                    longDesc: {
                        en: "Patrolled pages"
                    }
                },
                "ew-data": {
                    shortDesc: {
                        en: "Wikidata"
                    },
                    longDesc: {
                        en: "Wikidata"
                    }
                },
                "ew-minor": {
                    shortDesc: {
                        en: "Minor edits"
                    },
                    longDesc: {
                        en: "Minor edits"
                    }
                },
                "ew-anon": {
                    shortDesc: {
                        en: "Anonymous users"
                    },
                    longDesc: {
                        en: "Anonymous users"
                    }
                },
                "ew-cat": {
                    shortDesc: {
                        en: "Page categorization"
                    },
                    longDesc: {
                        en: "Page categorization"
                    }
                },
                "ew-mine": {
                    shortDesc: {
                        en: "My edits"
                    },
                    longDesc: {
                        en: "My edits"
                    }
                },
                "ew-experienced": {
                    shortDesc: {
                        en: "Experienced users"
                    },
                    longDesc: {
                        en: "Experienced accounts"
                    }
                },
                "ew-learner": {
                    shortDesc: {
                        en: "Learners"
                    },
                    longDesc: {
                        en: "Learner accounts"
                    }
                },
                "ew-newcomer": {
                    shortDesc: {
                        en: "Newcomers"
                    },
                    longDesc: {
                        en: "Newcomer accounts"
                    }
                },
                "ew-deleted": {
                    shortDesc: {
                        en: "Deleted pages"
                    },
                    longDesc: {
                        en: "Target pages is deleted."
                    }
                }
            };
        }


        _getMessage(parent) {
            return parent[this._myLang] || parent[this._defaultLang];
        }

        getGlobal(key) {
            try {
                return this._getMessage(this.global[key]);
            } catch (e) {
                mw.log.error(`Global message not found: ${key}`);
                return "";
            }
        }

        getTool(toolname, key) {
            try {
                return this._getMessage(this.tools[toolname][key]);
            } catch (e) {
                mw.log.error(`Message not found: ${toolname}.${key}`);
                return "";
            }
        }

        addToolMessages(messages) {
            $.extend(true, this.tools, messages);
        }

        setMylang(lang) {
            this._mylang = lang;
        }
    }

    class ExpandedWatchlist {

        notifyIfBadOptions(badOptions) {
            factory.get(ExpandedWatchlistNotifier).notify(badOptions);
        }

        constructor() {
            this.AbstractExpandedWatchlistFilter = AbstractExpandedWatchlistFilter;
            this.ExistingFilter = ExistingFilter;
            this.ShowHideFilter = ShowHideFilter;

            this._messages = factory.get(Messages);
            this._filters = [
                // //////////////////
                // logs
                // //////////////////
                class extends ShowHideFilter {
                    getName() {
                        return "ew-log";
                    }

                    getToolFilter() {
                        return ".mw-changeslist-src-mw-log";
                    }
                },

                // //////////////////
                // uploads
                // //////////////////
                class extends ShowHideFilter {
                    getName() {
                        return "ew-uploads";
                    }

                    getToolFilter() {
                        return ".mw-changeslist-log-upload";
                    }
                },

                // //////////////////
                // deletions
                // //////////////////
                class extends ShowHideFilter {
                    getName() {
                        return "ew-deletion";
                    }

                    getToolFilter() {
                        return ".mw-changeslist-log-delete";
                    }
                },

                // //////////////////
                // minor edits
                // //////////////////
                class extends ShowHideFilter {
                    getName() {
                        return "ew-minor";
                    }

                    getToolFilter() {
                        return ".mw-changeslist-minor";
                    }

                    getOriginalElementIds() {
                        return ["hideminor"];
                    }
                },

                // //////////////////
                // new pages
                // //////////////////
                class extends ShowHideFilter {
                    getName() {
                        return "ew-new";
                    }

                    getToolFilter()  {
                        return ".mw-changeslist-src-mw-new";
                    }
                },

                // //////////////////
                // patrolled pages
                // //////////////////
                class extends ShowHideFilter {
                    getName() {
                        return "ew-patrolled";
                    }

                    getToolFilter() {
                        return ".mw-changeslist-patrolled";
                    }

                    getOriginalElementIds() {
                        return ["hidepatrolled"];
                    }
                },

                // //////////////////
                // Wikidata
                // //////////////////
                class extends ShowHideFilter {
                    getName() {
                        return "ew-data";
                    }

                    getToolFilter() {
                        return ".mw-changeslist-src-wb";
                    }

                    getOriginalElementIds() {
                        return ["hideWikibase"];
                    }
                },

                // //////////////////
                // Page categorization
                // //////////////////
                class extends ShowHideFilter {
                    getName() {
                        return "ew-cat";
                    }

                    getToolFilter() {
                        return ".mw-changeslist-src-mw-categorize";
                    }

                    getOriginalElementIds() {
                        return ["hidecategorization"];
                    }
                },

                // //////////////////
                // Deleted pages
                // //////////////////
                class extends ShowHideFilter {
                    getName() {
                        return "ew-deleted";
                    }

                    doFilter(all) {
                        return all.children(".new:not(.mw-userlink)").closest(all);
                    }
                },

                // //////////////////
                // My edits
                // //////////////////
                class extends ShowHideFilter {
                    getName() {
                        return "ew-mine";
                    }

                    getToolFilter() {
                        return ".mw-changeslist-self";
                    }

                    getOriginalElementIds() {
                        return ["hidemyself"];
                    }
                },

                // //////////////////
                // Experienced
                // //////////////////
                class extends ShowHideFilter {
                    getName() {
                        return "ew-experienced";
                    }

                    getToolFilter() {
                        return ".mw-changeslist-user-experienced";
                    }

                    getOriginalElementIds() {
                        return ["hideliu"];
                    }
                },

                // //////////////////
                // Learners
                // //////////////////
                class extends ShowHideFilter {
                    getName() {
                        return "ew-learner";
                    }

                    getToolFilter() {
                        return ".mw-changeslist-user-learner";
                    }

                    getOriginalElementIds() {
                        return ["hideliu"];
                    }
                },

                // //////////////////
                // Learners
                // //////////////////
                class extends ShowHideFilter {
                    getName() {
                        return "ew-newcomer";
                    }

                    getToolFilter() {
                        return ".mw-changeslist-user-newcomer";
                    }

                    getOriginalElementIds() {
                        return ["hideliu"];
                    }
                },

                // //////////////////
                // Anonymous users
                // //////////////////
                class extends ShowHideFilter {
                    getName() {
                        return "ew-anon";
                    }

                    getToolFilter() {
                        return ".mw-changeslist-anon";
                    }

                    getOriginalElementIds() {
                        return ["hideanons", "hideliu"];
                    }

                    getDisabledState() {
                        if ($("#hideanons:checked")[0]) {
                            return "hide";
                        }
                        if ($("#hideliu:checked")[0]) {
                            return "only";
                        }
                    }
                },

                // //////////////////
                // bot edits
                // //////////////////
                class extends ShowHideFilter {
                    getName() {
                        return "ew-bot";
                    }

                    getToolFilter() {
                        return ".mw-changeslist-bot";
                    }

                    getOriginalElementIds() {
                        return ["hidebots"];
                    }
                },

                // //////////////////
                // namespace
                // //////////////////
                class extends ExistingFilter {

                    getName() {
                        return "ew-namespace";
                    }

                    getChangeElements() {
                        return $("#namespace,#nsinvert,#nsassociated");
                    }

                    filter(all) {
                        var val = $("#namespace").val();
                        if (!val) {
                            return all;
                        }
                        var inverted = $("#nsinvert").is(":checked");
                        var regexes = [new RegExp(`(^|\\s)(mw\\-changeslist\\-ns|watchlist\\-)${val}\\-`)];
                        if ($("#nsassociated").is(":checked")) {
                            val = (val % 2 ? -1 : 1) + +val;
                            regexes.push(new RegExp(`(^|\\s)(mw\\-changeslist\\-ns|watchlist\\-)${val}\\-`));
                        }
                        var filtered = all.filter((_, element) =>
                            regexes.some((regex) => regex.test($(element).attr("class")))
                        );
                        if (inverted) {
                            filtered = all.not(filtered);
                        }
                        return filtered;
                    }
                }
            ].map((Filter) => {
                var filter = new Filter();
                filter.setNotify(this.filterAll.bind(this));
                return filter;
            });
        }

        getFilters() {
            return this._filters;
        }

        getAllRows() {
            return $(".mw-changeslist > ul > li");
        }

        filterNone() {
            this.getAllRows().show();
        }

        filterAll() {
            var all = this.getAllRows();
            var showing = all;
            this.getFilters().forEach((filter) => {
                showing = filter.filter(showing);
            });
            showing.show();
            all.not(showing).hide();
        }

        load() {
            var submitButton = $("#mw-watchlist-options :submit");
            var saveButton = $("<input type=\"button\" />").val(
                    factory.get(Messages).getGlobal("save"))
                    .click(() => {
                        this.saveState();
                    })[0];
            var resetButton = $("<input type=\"button\" />").val(
                    factory.get(Messages).getGlobal("reset"))
                    .click(() => {
                        this.reset();
                    })[0];

            this._originalElements = $($.map(this.getFilters(), filter => filter.getOriginalElements()));
            this._toggleElements  = $($.map(this.getFilters(),
                    filter => filter.loadElements())).prepend("<br/>").
                    add("<br/>").add([saveButton, resetButton]);
            this._toggleButton = $("<input type=\"button\" />")
                .click(this.toggle.bind(this)).insertAfter(submitButton).after(this._toggleElements);
            this._untoggleElements = this._originalElements.closest(".mw-input-with-label").
                add(submitButton);

            this.loadState();
            this.toggle();
        }


        toggle() {
            this._active = !this._active;

            if (this._active) {
                this.notifyIfBadOptions(this._originalElements.filter(":checked"));
                this.filterAll();
            } else {
                this.getAllRows().show();
            }

            this._untoggleElements.toggle(!this._active);
            this._toggleElements.toggle(this._active);
            this._toggleButton.val(factory.get(Messages).getGlobal(this._active ?
                    "standardWatchlist" : "expandWatchlist"));

        }

        addFilters(filters) {
            this._filters = this._filters.concat(filters);
        }

        reset() {
            var preferences = factory.get(Preferences);
            var messages = factory.get(Messages);
            preferences.set("expanded", false);
            preferences.set("state", "");
            mw.notify(messages.getGlobal("preferences-cleared"));
        }

        saveState() {
            var preferences = factory.get(Preferences);
            var messages = factory.get(Messages);
            preferences.set("expanded", true);
            preferences.set("state",
                    JSON.stringify(objectFromEntries(this._filters.map(filter =>
                        [filter.getName(), filter.serialize()]))));
            mw.notify(messages.getGlobal("preferences-saved"));
        }

        loadState() {
            var preferences = factory.get(Preferences);

            this._active = !preferences.get("expanded");
            var state = factory.get(Preferences).get("state");
            if (state) {
                let stateObject = JSON.parse(state);
                this._filters.forEach(filter => {
                    filter.unserialize(stateObject[filter.getName()]);
                });
            }
        }
    }

    class AbstractExpandedWatchlistFilter {

        // abstract function: filter
        // abstract function: loadElements
        // abstract function: serialize
        // abstract function: unserialize
        // abstract function: getName

        getMyMessage(key) {
            return factory.get(Messages).getTool(this.getName(), key);
        }

        /**
         * The original element on the watchlist that will be hidden when this
         * one is shown.
         *
         * @return {jQuery}
         */
        getOriginalElementIds() {
            return [];
        }

        getOriginalElements() {
            return this.getOriginalElementIds().map(id => document.getElementById(id)).
                filter(element => element);
        }

        setNotify(_notify) {
            this._notify = _notify;
        }

        getNotify() {
            return this._notify;
        }

    }

    class ExistingFilter extends AbstractExpandedWatchlistFilter {
        loadElements() {
            this.getChangeElements().change(this.getNotify());
            return [];
        }

        serialize() {
            return JSON.stringify(objectFromEntries(this.getChangeElements().get().map(element =>
                [$(element).attr("id"), $(element).val()])));
        }

        unserialize(fromString) {
            var unserialized = JSON.parse(fromString);
            Object.keys(unserialized).forEach(id => {
                $(document.getElementById(id)).val(unserialized[id]);
            });
        }

        // abstract function: getChangeElements

    }

    class ShowHideFilter extends AbstractExpandedWatchlistFilter {
        filter(all) {
            var state = $(document.getElementsByName(this.getName())).filter(":checked").val();
            if (state !== "show") {
                var filtered = this.doFilter(all);

                if (state === "hide") {
                    filtered = all.not(filtered);
                }
                all = filtered;
            }
            return all;
        }

        doFilter(all) {
            return all.filter(this.getToolFilter());
        }

        loadElements() {
            var disabledState = this.getDisabledState();
            var longDesc = this.getMyMessage("longDesc");
            var elements = $.map(["show", "hide", "only"], (fn) => {
                var id = `${this.getName()}-${fn}`;
                var checked;
                if (disabledState) {
                    checked = disabledState === fn;
                } else {
                    checked = fn === "show";
                }
                var radio = $("<input type='radio'/>").
                attr({
                    id: id,
                    value: fn,
                    name: this.getName(),
                    title: longDesc,
                    disabled: !!disabledState
                }).prop("checked", checked).change(this.getNotify());

                var label = $("<label/>").attr({
                    for: id,
                    title: longDesc
                }).html(factory.get(Messages).getGlobal(fn));

                return [radio[0], label[0]];
            });

            var shortDesc = this.getMyMessage("shortDesc");
            if (disabledState) {
                shortDesc += $("<span style=\"font-size: 90%\"></span>").
                    html("(" + factory.get(Messages).getGlobal("disabled") + ")")[0].outerHTML;
            }
            elements.unshift($("<span></span>").html(shortDesc).attr("title", longDesc)[0]);

            if (disabledState) {
                $(elements).css("color", "#848484");
            }

            this._radios = $(elements).filter("input");

            return $("<div style=\"line-height: 0.5em;\"></div>").append(elements)[0];
        }

        getDisabledState() {
            return $(this.getOriginalElements()).is(":checked") && "hide";
        }

        serialize() {
            return this._radios.filter(":checked").val() || "";
        }

        unserialize(fromString) {
            this._radios.prop("checked", (index) => this._radios.eq(index).val() === fromString);
        }
        // abstract function: getToolFilter()
    }



    if (mw.config.get("wgPageName") === "Special:Watchlist") {
        var factory = new GlobalSingletonFactory();
        factory.register(new DefaultSingletonFactory());
        factory.register(new StorageFactory());

        let expandedWatchlist = window.expandedWatchlist = factory.get(ExpandedWatchlist);

        mw.hook("gadget.expandedWatchlist.define").fire();
        await onLoad();
        expandedWatchlist.load();
    }
})(window);
/* jshint ignore:end */