iCheckMovies Enhanced

Adds new features to enhance the iCheckMovies user experience

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name           iCheckMovies Enhanced
// @namespace      iCheckMovies
// @description    Adds new features to enhance the iCheckMovies user experience
// @license        MIT; https://opensource.org/licenses/MIT
// @author         themagician, monk-time
// @include        http://icheckmovies.com*
// @include        http://www.icheckmovies.com*
// @include        https://icheckmovies.com*
// @include        https://www.icheckmovies.com*
// @grant          unsafeWindow
// @grant          GM_getValue
// @icon           https://www.icheckmovies.com/favicon.ico
// @version        2.0.3
// ==/UserScript==

'use strict';

const VERSION = '2.0.3';

// ----- Utils -----

const $ = sel => document.querySelector(sel); // eslint-disable-line no-redeclare
const $$ = sel => document.querySelectorAll(sel);

const save = (key, val) => localStorage.setItem(key, JSON.stringify(val));
const load = key => JSON.parse(localStorage.getItem(key));
const addCSS = css => document.head.insertAdjacentHTML('beforeend', `<style>${css}</style>`);

const extractFrom = async (url, extractor) => {
    const r = await fetch(url, { credentials: 'same-origin' });
    const html = await r.text();
    const el = new DOMParser().parseFromString(html, 'text/html');
    return extractor(el);
};

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

// ----- Data migration for 1.8.0 -> 2.0.0; remove afterwards -----

const migrateData = () => {
    // 1.8.0 didn't work in GM
    if (typeof GM_getValue === 'undefined') return; // eslint-disable-line camelcase
    const hasMigrated = localStorage.getItem('icme_migrated_1_8_0');
    if (hasMigrated) return;

    const strHiddenLists = GM_getValue('hidden_lists');
    if (strHiddenLists) {
        const hiddenLists = JSON.parse(strHiddenLists);
        console.log('Migrating hidden_lists from GM storage', hiddenLists);
        save('icme_hidden_lists', hiddenLists);
    }

    const strOwnedMovies = GM_getValue('owned_movies');
    if (strOwnedMovies) {
        const ownedMoviesArr = JSON.parse(strOwnedMovies);
        console.log('Migrating owned_movies from GM storage', ownedMoviesArr);
        const ownedMovies = Object.fromEntries(ownedMoviesArr.map(id => [id, true]));
        save('icme_owned_movies', ownedMovies);
    }

    localStorage.setItem('icme_migrated_1_8_0', true);
};

migrateData();

// ----- Interacting with ICM -----

// Mutually exclusive regexes for matching page type
const reICM = Object.freeze({
    movie: // movie pages only, not /movies/ or /movies/checked/ etc. or /rankings/
        // https://www.icheckmovies.com/movies/inception/
        // https://www.icheckmovies.com/movies/inception/comments/
        /icheckmovies\.com\/movies\/(?!$|\?|(?:(un)?checked|favorited|disliked|watchlist|owned|recommended)\/)[^/]+\/(?!rankings\/)/,
    movieList: // personal user list
        // https://www.icheckmovies.com/lists/imdbs+2010s+top+50/
        // https://www.icheckmovies.com/lists/imdbs+2010s+top+50/?sort=title
        // https://www.icheckmovies.com/lists/alfred+hitchcock+filmography/fritz/
        // https://www.icheckmovies.com/lists/alfred+hitchcock+filmography/fritz/?sort=title
        // https://www.icheckmovies.com/lists/watchlist+2015/juliske/
        /icheckmovies\.com\/lists\/(?!$|\?|(?:favorited|disliked|watchlist)\/)/,
    movieListGeneral: // /movies/ only
        // https://www.icheckmovies.com/movies/
        // https://www.icheckmovies.com/movies/?sort=title
        /icheckmovies\.com\/movies\/(?:$|\?)/,
    movieListSpecial: // /movies/checked/ etc.
        // https://www.icheckmovies.com/movies/favorited/
        // https://www.icheckmovies.com/movies/favorited/?sort=title
        // https://www.icheckmovies.com/movies/checked/
        // https://www.icheckmovies.com/movies/checked/?sort=title
        // https://www.icheckmovies.com/movies/unchecked/
        // https://www.icheckmovies.com/movies/owned/
        /icheckmovies\.com\/movies\/(?:((un)?checked|favorited|disliked|watchlist|owned|recommended)\/)/,
    movieSearch:
        // https://www.icheckmovies.com/search/movies/?query=inception
        /icheckmovies\.com\/search\/movies\//,
    movieRankings:
        // https://www.icheckmovies.com/movies/inception/rankings/
        // https://www.icheckmovies.com/movies/inception/rankings/?excludetags=user:icheckmovies
        /icheckmovies\.com\/movies\/[^/]+\/rankings\//,
    listsGeneral: // /lists/ only
        // https://www.icheckmovies.com/lists/
        // https://www.icheckmovies.com/lists/?sort=dateadded
        /icheckmovies\.com\/lists\/(?:$|\?)/,
    listsSpecial: // /lists/favorited/ etc.
        // https://www.icheckmovies.com/lists/favorited/
        // https://www.icheckmovies.com/lists/favorited/?sort=name
        /icheckmovies\.com\/lists\/(?:favorited|disliked|watchlist)\//,
    listsSearch:
        // https://www.icheckmovies.com/search/lists/?query=nolan
        /icheckmovies\.com\/search\/lists\//,
    progress:
        // https://www.icheckmovies.com/profiles/progress/
        /icheckmovies.com\/profiles\/progress\//,
});

const addToMovieListBar = htmlStr => {
    if (!$('#icmeControls')) {
        const html = '<div id="icmeControls" style="height: 35px; position: relative"></div>';
        // movieList and movieListGeneral+Special use different headers
        const elMain = $(':is(#topList, #listTitle) ~ .container:last-of-type');
        elMain.insertAdjacentHTML('beforebegin', html);
    }

    $('#icmeControls').insertAdjacentHTML('beforeend', htmlStr);
};

const addNearOrderByLinks = htmlStr => {
    addCSS(`.icmeOrderByLink {
        float: left;
        margin-right: 1em;
    }`);
    $('#listOrderingWrapper').insertAdjacentHTML('afterbegin', htmlStr);
};

// Remove the premium feature pop-up using two ways to unbind events from the button
// (only one is not enough because TM/VM launch the script at different times)
const removePremiumPopup = el => {
    const elClone = el.cloneNode(true);
    el.replaceWith(elClone);
    elClone.classList.remove('paidFeature');
    elClone.href = '#';
    return elClone;
};

// ----- Base classes and config windows -----

class BaseModule {
    constructor(globalCfg) {
        this.metadata = null; // check any module for required fields
        this.config = null; // will be created after the module has been registered
        this.globalCfg = globalCfg; // allows modules to use Save/Set/Get
    }

    // Create a necessary metadata.options item for if a module should be loaded by default.
    static getStatus(isEnabled) {
        return {
            id: 'enabled',
            desc: 'Enabled',
            type: 'checkbox',
            default: isEnabled,
        };
    }

    /**
     * Check if the current page matches at least one of given page types.
     *
     * @param {(string|string[])} keys - A key of reICM, or an array of keys
     * @returns {boolean} true if the current page matches any of specified regexes
     */
    static matchesPageType(keys) {
        if (!Array.isArray(keys)) keys = [keys];
        const matchUrl = regex => regex.test(window.location.href);
        return BaseModule.getRegexes(keys).some(matchUrl);
    }

    static getRegexes(arrOfKeys) {
        return arrOfKeys.map(key => {
            if (reICM[key] === undefined) {
                throw new TypeError(`Invalid icm-regex name: ${key}`);
            }

            return reICM[key];
        });
    }

    isOnSupportedPage() {
        return BaseModule.matchesPageType(this.metadata.enableOn);
    }

    // Synchronize the loaded config with the module's options (delete outdated, add new)
    // and make it accessible to the module.
    syncGlobalCfg() {
        const { id } = this.metadata;
        const config = {};
        for (const opt of this.metadata.options) {
            config[opt.id] = this.globalCfg.get(`${id}.${opt.id}`) ?? opt.default;
        }

        // Link module's config values to the whole config.
        // As they both reference the same object, you can modify module's config from inside it.
        // Changes through the ConfigWindow will be immediately available to modules.
        this.config = config;
        this.globalCfg.data[id] = config;
    }
}

class GlobalCfg {
    constructor() {
        // test:
        // ['1', '1.7', '1.7.1', '1.7.1.1', '1.7.1.1.1'].map(verToNumber) ===
        // [1000, 1700, 1710, 1711, 1711]
        const verToNumber = str => Number(`${str.replace(/\./g, '')}0000`.slice(0, 4));

        this.data = {
            script_info: {
                version: VERSION, // dot-separated string
                revision: verToNumber(VERSION), // 4-digit number
            },
        };

        const oldcfg = load('icm_enhanced');
        if (!oldcfg || !oldcfg.script_info) return;

        const oldInfo = oldcfg.script_info;
        const newInfo = this.data.script_info;
        // Rewrite script_info in the loaded config
        this.data = { ...oldcfg, script_info: newInfo };

        const isUpdated = oldInfo.revision !== newInfo.revision;
        if (isUpdated) {
            console.log(`Updating to ${newInfo.revision}`);
            this.save();
        }
    }

    save() {
        save('icm_enhanced', this.data);
    }

    // Get a config value by a dot-separated path
    get(path) {
        return path.split('.').reduce((prev, curr) => prev && prev[curr], this.data);
    }

    // Set a config value by a dot-separated path
    set(path, value) {
        const parts = path.split('.');
        const last = parts.pop();
        let obj = this.data;
        for (const part of parts) {
            if (!(obj[part] instanceof Object)) obj[part] = {};
            obj = obj[part];
        }

        obj[last] = value;
    }

    // Set false to true and vice versa
    toggle(path) {
        const val = this.get(path);
        let toggled;

        if (val === true || val === false) {
            toggled = !val;
        } else if (val === 'asc' || val === 'desc') {
            toggled = val === 'asc' ? 'desc' : 'asc';
        } else {
            return false; // couldn't toggle the value
        }

        this.set(path, toggled);
        return true; // value has been toggled
    }
}

class ConfigWindow {
    constructor(globalCfg) {
        this.globalCfg = globalCfg;
        this.modules = [];
    }

    addModule(metadata) {
        if (!this.modules.some(m => m.id === metadata.id)) {
            this.modules.push(metadata);
        }
    }

    buildOptionHTML(path, { frontDesc, desc, type, default: def, inline, newline }) {
        let value = this.globalCfg.get(path); // always up to date
        // optValue can be a string (until a module parses it) or an array (after)
        if (Array.isArray(value)) {
            value = value.join('\n');
        }

        const attrPath = `data-cfg-path="${path}"`;
        const checkbox = () => `
            ${newline ? '<br>' : ''}
            <p${inline ? ' class="icmeCfgInlineOpt"' : ''}>
                ${frontDesc ?? ''}
                <label>
                    <input type="checkbox" ${attrPath} ${value ? 'checked="checked"' : ''}
                        title="default: ${def ? 'yes' : 'no'}">
                    ${desc}
                </label>
            </p>`;
        const textinput = () => `
            <p>
                ${desc}:
                <input type="text" ${attrPath} value="${value}" title="default: ${def}">
            </p>`;
        const textarea = () => `
            <p>
                <span class="icmeCfgTextareaDesc">${desc}:</span>
                <textarea rows="4" cols="70" ${attrPath}>${value}</textarea>
            </p>`;
        const textinputcolor = () => `
            <p>
                ${desc}:
                <input type="text" class="icmeColorPickerText" ${attrPath}
                    value="${value}" title="default: ${def}">
                <input type="color" class="icmeColorPicker" ${attrPath}
                    value="${value}" title="default: ${def}">
            </p>`;

        const htmlByType = { checkbox, textinput, textarea, textinputcolor };
        return htmlByType[type]();
    }

    loadOptions(index) {
        const { id, desc, options } = this.modules[index];
        const buildHTML = opt => this.buildOptionHTML(`${id}.${opt.id}`, opt);
        const html = `<p>${desc}</p> ${options.map(buildHTML).join('')}`;

        $('#icmeCfgModule').innerHTML = html;
        ConfigWindow.initColorPickers();
    }

    static initColorPickers() {
        $$('.icmeColorPicker').forEach(el => {
            el.addEventListener('change', () => {
                el.previousElementSibling.value = el.value;
            });
        });

        $$('.icmeColorPickerText').forEach(el => {
            el.addEventListener('change', () => {
                el.nextElementSibling.value = el.value;
            });
        });
    }

    load() {
        addCSS(`
            #icmeCfgMain { font-family: verdana, arial, sans-serif; }
            #icmeCfgMain hr {
                border: 0;
                height: 1px;
                width: 100%;
                background-color: #aaa;
                margin: 7px 0px;
            }
            #icmeCfgMain h3 { color: #bbb; }
            #icmeCfgModule { margin: 10px 0; }
            #icmeCfgModule > p { margin-bottom: 0.5em; }
            #icmeCfgModule > p.icmeCfgInlineOpt { display: inline-block; margin-right: 5px }
            #icmeCfgModule input { margin: 0px 3px; }
            #icmeCfgModule input[type=text] { font-family: monospace }
            #icmeCfgModule .icmeCfgTextareaDesc { vertical-align: top; margin-right: 5px }
        `);

        // Create and append a new item in the drop down menu under your username
        const cfgLink = `
            <li>
                <a id="icmeCfgTrigger" href="#"
                   title="Configure iCheckMovies Enhanced script options">ICM Enhanced</a>
            </li>`;

        $('ul#profileOptions').insertAdjacentHTML('beforeend', cfgLink);

        this.modules.sort((a, b) => (a.title > b.title ? 1 : -1));
        const options = this.modules.map(m => `<option>${m.title}</option>`);
        const ver = this.globalCfg.data.script_info.version;

        const cfgMainHtml = `
            <div id="icmeCfgMain">
                <h3>iCheckMovies Enhanced ${ver} configuration</h3>
                <select id="icmeCfgModuleList" name="modulelist">${options}</select>
                <hr>
                <div id="icmeCfgModule"></div>
            </div>
        `;

        document.body.insertAdjacentHTML('beforeend', cfgMainHtml);
        const elCfgMain = $('#icmeCfgMain');
        const elModuleList = elCfgMain.querySelector('#icmeCfgModuleList');

        elCfgMain.addEventListener('change', e => {
            if (!['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;
            const path = e.target.dataset.cfgPath;
            if (!path) return;

            if (!this.globalCfg.toggle(path)) {
                this.globalCfg.set(path, e.target.value);
            }

            this.globalCfg.save();
        });

        elModuleList.addEventListener('change', () => {
            this.loadOptions(elModuleList.selectedIndex);
        });

        elModuleList.dispatchEvent(new Event('change'));

        ConfigWindow.loadModal(elCfgMain, $('#icmeCfgTrigger'));
    }

    static loadModal(elContent, elTrigger) {
        addCSS(`
            #icmeCfgModalOverlay {
                display: none;
                position: fixed;
                z-index: 3000;
                left: 0;
                top: 0;
                width: 100%;
                height: 100%;
                overflow: auto;
                background-color: rgba(0, 0, 0, 0.4);
            }

            .icmeCfgModal {
                background-color: #f5f5ef;
                margin: 80px auto;
                padding: 15px 30px;
                border: 1px solid #888;
                width: 800px;
                height: 450px;
            }

            .icmeCfgModalClose {
                color: #aaa;
                float: right;
                font-size: 28px;
                font-weight: bold;
            }

            .icmeCfgModalClose:hover,
            .icmeCfgModalClose:focus {
                color: black;
                cursor: pointer;
            }
        `);

        document.body.insertAdjacentHTML('beforeend', `
            <div id="icmeCfgModalOverlay">
                <div class="icmeCfgModal">
                    <span class="icmeCfgModalClose">&times;</span>
                </div>
            </div>
        `);

        const elModalOverlay = $('#icmeCfgModalOverlay');
        const elModal = elModalOverlay.querySelector('.icmeCfgModal');
        const elClose = $('.icmeCfgModalClose');

        elModal.append(elContent);

        elTrigger.addEventListener('click', e => {
            e.preventDefault();
            elModalOverlay.style.display = 'block';
        });

        elClose.addEventListener('click', () => {
            elModalOverlay.style.display = 'none';
        });

        window.addEventListener('click', e => {
            if (e.target !== elModalOverlay) return;
            elModalOverlay.style.display = 'none';
        });
    }
}

// ----- Modules -----

class RandomFilmLink extends BaseModule {
    constructor(globalCfg) {
        super(globalCfg);

        this.metadata = {
            title: 'Random film link',
            desc: 'Show a "Help me pick a film" link on movie lists with unchecked movies.' +
                '<br>Suggestions don\'t repeat until all have been shown once. ' +
                'Click on the list tab\'s label to return to the full list.',
            id: 'random_film',
            enableOn: ['movieList', 'movieListSpecial'], // movieListGeneral doesn't make sense here
            options: [BaseModule.getStatus(true)],
        };

        this.randomIndices = [];
    }

    attach() {
        // Disable on completed lists and list of checked/favs.
        // If a user unchecks a movie, it will show up only after reloading
        if (!$$('#itemListMovies > li.unchecked').length) return;

        const html =
            `<span style="float: right; margin-left: 15px">
                <a href="#" id="icmeRandomFilm">Help me pick a film!</a>
            </span>`;

        addToMovieListBar(html);

        $('#icmeRandomFilm').addEventListener('click', e => {
            e.preventDefault();
            this.pickRandomFilm();
        });

        // Allow resetting visible movies on /movies/watchlist/ etc. by clicking on tab's label
        const elActiveTab = $('.tabMenu > .active');
        if (!elActiveTab.querySelector('a')) {
            elActiveTab.addEventListener('click', () => {
                $$('#itemListMovies > li').forEach(el => {
                    el.style.display = 'list-item';
                });
            });
        }
    }

    pickRandomFilm() {
        const elUnchecked = $$('#itemListMovies > li.unchecked');
        if (!elUnchecked.length) return;

        if (!this.randomIndices.length) {
            this.randomIndices = [...Array(elUnchecked.length).keys()];
            RandomFilmLink.shuffle(this.randomIndices);
        }

        const selectedIndex = this.randomIndices.pop();

        $$('#itemListMovies > li').forEach(el => {
            el.style.display = 'none';
        });
        elUnchecked[selectedIndex].style.display = 'list-item';
    }

    // https://stackoverflow.com/a/12646864/6270692
    static shuffle(array) {
        for (let i = array.length - 1; i > 0; i--) {
            const j = Math.floor(Math.random() * (i + 1));
            [array[i], array[j]] = [array[j], array[i]];
        }

        return array;
    }
}

class UpcomingAwardsList extends BaseModule {
    constructor(globalCfg) {
        super(globalCfg);

        this.metadata = {
            title: 'Upcoming awards (individual lists)',
            desc: 'Show numbers of checks needed for getting awards on individual lists',
            id: 'ua_list',
            enableOn: ['movieList'],
            options: [BaseModule.getStatus(true), {
                id: 'show_negative',
                desc: 'Show negative values for received awards',
                type: 'checkbox',
                default: true,
            }],
        };
    }

    attach() {
        if (!$('#itemListMovies')) return;

        const parseNum = sel => Number($(sel).textContent.match(/\d+/));
        const totalItems = parseNum('#listFilterMovies');
        const checks = parseNum('#topListMoviesCheckedCount');

        const getSpan = ([award, cutoff]) => {
            const neededForAward = Math.ceil(totalItems * cutoff) - checks;
            if (!this.config.show_negative && neededForAward <= 0) {
                return '';
            }

            return `<span style="margin-left: 30px">${award}: <b>${neededForAward}</b></span>`;
        };

        const awardTypes = [['Bronze', 0.5], ['Silver', 0.75], ['Gold', 0.9], ['Platinum', 1]];
        const html = `<span><b>Upcoming awards:</b>${awardTypes.map(getSpan).join('')}`;
        addToMovieListBar(html);
    }
}

class UpcomingAwardsOverview extends BaseModule {
    constructor(globalCfg) {
        super(globalCfg);

        this.metadata = {
            title: 'Upcoming awards overview',
            desc: 'Show a summary of upcoming awards on the progress page and lists of your ' +
                ' watchlisted/fav. lists',
            id: 'ua',
            enableOn: ['listsSpecial', 'progress'],
            options: [BaseModule.getStatus(true), {
                id: 'hide_imdb',
                desc: 'Add all IMDb top-50s to hidden lists (you can unhide them afterwards)',
                type: 'checkbox',
                default: false,
            }],
        };

        this.lists = [];
        this.hiddenLists = [];
    }

    attach() {
        if (!$('.listItemToplist')) return;

        const hiddenLists = this.loadHiddenLists();
        const listObjs = UpcomingAwardsOverview.parseLists();
        UpcomingAwardsOverview.sortListObjects(listObjs);
        UpcomingAwardsOverview.loadCss();
        UpcomingAwardsOverview.loadHtml(listObjs, hiddenLists);
        UpcomingAwardsOverview.addListeners(hiddenLists);
    }

    loadHiddenLists() {
        const hiddenLists = load('icme_hidden_lists') ?? [];
        if (!this.config.hide_imdb) return hiddenLists;

        const imdbUrls = [
            '1910s', '1920s', '1930s', '1940s', '1950s', '1960s', '1970s', '1980s', '1990s',
            '2000s', '2010s', 'action', 'adventure', 'animation', 'biography', 'comedy',
            'crime', 'documentary', 'drama', 'family', 'fantasy', 'film-noir', 'history',
            'horror', 'independent', 'mini-series', 'music', 'musical', 'mystery', 'romance',
            'sci-fi', 'shorts', 'sport', 'thriller', 'war', 'western',
        ].map(s => `/lists/imdbs+${s}+top+50/`);
        const hiddenAndImdb = [...new Set([...hiddenLists, ...imdbUrls])]; // remove duplicates
        save('icme_hidden_lists', hiddenAndImdb);

        // This is a one-off action, disable the option so that it's not repeated every time
        this.config.hide_imdb = false;
        this.globalCfg.save();
        return hiddenAndImdb;
    }

    static parseLists() {
        // Use different selectors depending on the page
        const sel = {
            progress: { rank: 'span.rank', title: 'h3 > a' },
            lists: { rank: 'span.info > strong:first-of-type', title: 'h2 > a.title' },
        };
        const curSel = UpcomingAwardsOverview.matchesPageType('progress') ? sel.progress : sel.lists;
        const awardTypes = [['Bronze', 0.5], ['Silver', 0.75], ['Gold', 0.9], ['Platinum', 1]];

        const elLists = $$('#progressall > li, #itemListToplists > li');
        return [...elLists].flatMap(el => {
            const counts = el.querySelector(curSel.rank).textContent.match(/\d+/g);
            if (!counts) return [];

            const [checks, totalItems] = counts.map(Number);
            const elTitle = el.querySelector(curSel.title);
            const listTitle = elTitle.title.replace(/^View the | top list$/g, '');
            const listUrl = elTitle.pathname;

            const apply = cutoff => Math.ceil(totalItems * cutoff) - checks;
            return awardTypes
                .map(([awardType, cutoff]) => ({ awardType, neededForAward: apply(cutoff) }))
                .filter(({ neededForAward }) => neededForAward > 0)
                .map((obj, i) => ({ ...obj, listTitle, listUrl, isNext: i === 0 }));
        });
    }

    static sortListObjects(listObjs) {
        // By least required checks ASC, then by award type DESC, then by list title ASC
        const awardOrder = { Bronze: 0, Silver: 1, Gold: 2, Platinum: 3 };
        listObjs.sort((a, b) =>
            a.neededForAward - b.neededForAward ||
            awardOrder[b.awardType] - awardOrder[a.awardType] ||
            a.listTitle.localeCompare(b.listTitle));
    }

    static loadCss() {
        const unhideIcon = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAA' +
            'AQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW' +
            '1hZ2VSZWFkeXHJZTwAAAGrSURBVDjLvZPZLkNhFIV75zjvYm7VGFNCqoZUJ+roKUUpjR' +
            'uqp61Wq0NKDMelGGqOxBSUIBKXWtWGZxAvobr8lWjChRgSF//dv9be+9trCwAI/vIE/2' +
            '6gXmviW5bqnb8yUK028qZjPfoPWEj4Ku5HBspgAz941IXZeze8N1bottSo8BTZviVWrE' +
            'h546EO03EXpuJOdG63otJbjBKHkEp/Ml6yNYYzpuezWL4s5VMtT8acCMQcb5XL3eJE8V' +
            'gBlR7BeMGW9Z4yT9y1CeyucuhdTGDxfftaBO7G4L+zg91UocxVmCiy51NpiP3n2treUP' +
            'ujL8xhOjYOzZYsQWANyRYlU4Y9Br6oHd5bDh0bCpSOixJiWx71YY09J5pM/WEbzFcDmH' +
            'vwwBu2wnikg+lEj4mwBe5bC5h1OUqcwpdC60dxegRmR06TyjCF9G9z+qM2uCJmuMJmaN' +
            'ZaUrCSIi6X+jJIBBYtW5Cge7cd7sgoHDfDaAvKQGAlRZYc6ltJlMxX03UzlaRlBdQrzS' +
            'CwksLRbOpHUSb7pcsnxCCwngvM2Rm/ugUCi84fycr4l2t8Bb6iqTxSCgNIAAAAAElFTk' +
            'SuQmCC';
        const hideIcon = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQC' +
            'AYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAIGNIUk0AAHolAACAgwAA+f8AA' +
            'IDpAAB1MAAA6mAAADqYAAAXb5JfxUYAAAE+SURBVHja1JO/SsNwEMc/P7Fos2TqE1joL' +
            'B3EpRQaobsUAj6AL9C9ce8L+ACOTlkKUiglIA5iHqAdOndpk9Ik/QXO4SeSSAShkwdf7' +
            'nf3uzvurxIRjqETjqSjA5yWpPsbAA8YAeqHrQAPgMfjS3UGudZervUIxyHXWnAciu9c6' +
            '1GutVf0UcUmJnfXUu/3jXB+TjafA3DW6UCaGpvJhPrTq6oMsL29BBC71zOKRsPw9dr8T' +
            '6cAyn7+qC4hSxJs11WbIIAoguXSIIrYBAG266osSX6fQrbfA3DY7cCyYLs1sCyjK9hUl' +
            'rC4agBIcziE2aw8g26XxXgMoJpv6+oMDmlqnH0f4phVGLIKQ4hj8H2awyGHNC2vroh8I' +
            '2zVvLBVExm05YsjgzZFXdiqeUWfUgnvF+pPi9ReSnUP/ucxfQ4ASu+wNb1N4vcAAAAAS' +
            'UVORK5CYII=';

        addCSS(`
            #icmeUAO {
                z-index: 0;
                position: relative;
                margin-top: 0;
                margin-bottom: 20px;
            }
            #icmeUAOTableContainer {
                position: relative;
                top: 0;
                width: 830px;
                height: 240px;
                overflow: scroll;
            }
            #icmeUAOTableToggleContainer {
                position: relative;
                left: 0;
                top: 0;
                width: 200px;
            }
            #icmeUAOLinks {
                position: absolute;
                right: 0;
                top: 0;
                font-weight: bold;
            }
            .icmeUAOAward td:nth-child(1) { width: 65px; }
            .icmeUAOAward td:nth-child(2) { width: 65px; }
            .icmeUAOAward td:nth-child(3) div { height: 28px; overflow: hidden; }
            .icmeUAOAward td:nth-child(4) { width: 70px; }
            .icmeToggleList {
                width: 16px;
                height: 16px;
                cursor: pointer;
            }
            .icmeUAOAward.icmeHidden .icmeToggleList { background-image: url(${unhideIcon}); }
            .icmeUAOAward:not(.icmeHidden) .icmeToggleList { background-image: url(${hideIcon}); }

            #icmeUAOTable                  .icmeUAOAward               { display: none; }
            #icmeUAOTable:not(.icmeHidden) .icmeUAOAward.icmeHidden    { display: none !important; }
            #icmeUAOTable.icmeAll          .icmeUAOAward,
            #icmeUAOTable.icmeNext         .icmeUAOAward.icmeNext,
            #icmeUAOTable.icmeBronze       .icmeUAOAward.icmeBronze,
            #icmeUAOTable.icmeSilver       .icmeUAOAward.icmeSilver,
            #icmeUAOTable.icmeGold         .icmeUAOAward.icmeGold,
            #icmeUAOTable.icmePlatinum     .icmeUAOAward.icmePlatinum,
            #icmeUAOTable.icmeHidden       .icmeUAOAward.icmeHidden    { display: table-row; }
        `);
    }

    static loadHtml(listObjs, hiddenLists) {
        const html = `
            <div id="icmeUAO">
                <p id="icmeUAOTableToggleContainer">
                    <a id="icmeUAOTableToggle" href="#">
                        <span style="display: none">Show upcoming awards</span>
                        <span>Hide upcoming awards</span>
                    </a>
                </p>
                <p id="icmeUAOLinks">
                    Display:
                    <a id="icmeAll"      class="icmeUAOFilter" href="#">All</a>,
                    <a id="icmeNext"     class="icmeUAOFilter" href="#">Next</a>,
                    <a id="icmeBronze"   class="icmeUAOFilter" href="#">Bronze</a>,
                    <a id="icmeSilver"   class="icmeUAOFilter" href="#">Silver</a>,
                    <a id="icmeGold"     class="icmeUAOFilter" href="#">Gold</a>,
                    <a id="icmePlatinum" class="icmeUAOFilter" href="#">Platinum</a>,
                    <a id="icmeHidden"   class="icmeUAOFilter" href="#">Hidden</a> |
                    <a id="icmeToggleSize" href="#">
                        <span style="display: none">Minimize</span>
                        <span>Maximize</span>
                    </a>
                </p>
                <div id="icmeUAOTableContainer" class="container">
                    <table id="icmeUAOTable" class="icmeAll">
                        <thead>
                            <tr>
                                <th>Awards</th>
                                <th>Checks</th>
                                <th>List title</th>
                                <th>(Un)Hide</th>
                            </tr>
                        </thead>
                        <tbody>
                        </tbody>
                    </table>
                </div>
            </div>`;

        const sel = UpcomingAwardsOverview.matchesPageType('progress') ? '#listOrdering' : '#itemContainer';
        $(sel).insertAdjacentHTML('beforebegin', html);

        const htmlAwards = listObjs.map(({ listTitle, listUrl, awardType, neededForAward, isNext }) => `
            <tr class="icmeUAOAward icme${awardType} ${isNext ? 'icmeNext' : ''}
                    ${hiddenLists.includes(listUrl) ? 'icmeHidden' : ''}"
                    data-list-url="${listUrl}">
                <td>${awardType}</td>
                <td>${neededForAward}</td>
                <td>
                    <div>
                        <a class="icmeListTitle" href="${listUrl}">${listTitle}</a>
                    </div>
                </td>
                <td>
                    <div class="icmeToggleList" title="Toggle the list's visibility"></div>
                </td>
            </tr>
        `).join('');

        $('#icmeUAOTable tbody').insertAdjacentHTML('beforeend', htmlAwards);
    }

    static addListeners(hiddenLists) {
        const elAwards = [...$$('#icmeUAOTable .icmeUAOAward')];

        const elTable = $('#icmeUAOTable');
        elTable.addEventListener('click', e => {
            if (!e.target.classList.contains('icmeToggleList')) return;
            e.preventDefault();

            const { listUrl } = e.target.closest('.icmeUAOAward').dataset;
            const index = hiddenLists.indexOf(listUrl);
            const isVisible = index === -1;

            if (isVisible) {
                hiddenLists.push(listUrl);
            } else {
                hiddenLists.splice(index, 1);
            }

            elAwards
                .filter(el => el.dataset.listUrl === listUrl)
                .forEach(el => { el.classList.toggle('icmeHidden'); });

            save('icme_hidden_lists', hiddenLists);
        });

        const elToggle = $('#icmeUAOTableToggle');
        elToggle.addEventListener('click', e => {
            e.preventDefault();

            const els = $$('#icmeUAOLinks, #icmeUAOTableContainer');
            [...els, ...elToggle.children].forEach(el => {
                el.style.display = el.style.display === 'none' ? '' : 'none';
            });
        });

        const elToggleSize = $('#icmeToggleSize');
        const elContainer = $('#icmeUAOTableContainer');
        elToggleSize.addEventListener('click', e => {
            e.preventDefault();

            elContainer.style.height = elContainer.style.height === 'auto' ? '240px' : 'auto';
            [...elToggleSize.children].forEach(el => {
                el.style.display = el.style.display === 'none' ? '' : 'none';
            });
        });

        $$('.icmeUAOFilter').forEach(elFilter => elFilter.addEventListener('click', e => {
            e.preventDefault();
            elTable.className = elFilter.id; // switch to data attr if you add more classes to table
        }));
    }
}

class CustomMovieColors extends BaseModule {
    constructor(globalCfg) {
        super(globalCfg);

        this.metadata = {
            title: 'Custom movie colors',
            desc: 'Set movie colors on lists for your favs/watchlist/dislikes',
            id: 'movie_colors',
            enableOn: ['movieList', 'movieListGeneral', 'movieListSpecial', 'movieSearch',
                'listsGeneral', 'listsSpecial'],
            options: [BaseModule.getStatus(true), {
                id: 'favorite',
                desc: 'Favorites',
                type: 'textinputcolor',
                default: '#ffdda9',
            }, {
                id: 'watchlist',
                desc: 'Watchlist',
                type: 'textinputcolor',
                default: '#ffffd6',
            }, {
                id: 'disliked',
                desc: 'Disliked',
                type: 'textinputcolor',
                default: '#ffad99',
            }],
        };
    }

    attach() {
        const colors = [
            ['favorite', this.config.favorite],
            ['watch', this.config.watchlist],
            ['hated', this.config.disliked]];

        const buildCSS = ([className, color]) => {
            const sel = `#itemListMovies li.${className}`;
            return `${sel}, ${sel} ul.optionIconMenu { background-color: ${color} !important; }`;
        };

        addCSS(colors.map(buildCSS).join(''));
    }
}

class ListCrossRef extends BaseModule {
    constructor(globalCfg) {
        super(globalCfg);

        this.metadata = {
            title: 'Cross-reference lists',
            desc: 'Cross-reference lists to find which movies they share',
            id: 'list_cross_ref',
            enableOn: ['listsGeneral', 'listsSpecial', 'progress'],
            options: [BaseModule.getStatus(true), {
                id: 'match_all',
                desc: 'Find movies that appear on all selected lists',
                type: 'checkbox',
                default: false,
            }, {
                id: 'match_min',
                desc: 'Otherwise, find movies that appear on at least N lists (N > 1)',
                type: 'textinput',
                default: 2,
            }, {
                id: 'unchecked_only',
                desc: 'Find only unchecked movies',
                type: 'checkbox',
                default: true,
            }],
        };
    }

    attach() {
        if (!$('.listItemToplist')) return;

        const htmlActions = `
            <div id="icmeCRActions">
                Cross-reference lists:
                <button id="icmeCRStartSel">Start selection</button>
                <button id="icmeCRCancelSel">Cancel selection</button>
                <button id="icmeCRSelectAll">Select all</button>
                <button id="icmeCRRun">Run</button>
            </div>`;
        const sel = ListCrossRef.matchesPageType('progress') ? '#listOrdering' : '#itemContainer';
        $(sel).insertAdjacentHTML('beforebegin', htmlActions);

        addCSS(`
            #icmeCRActions { margin-bottom: 18px; }
            #icmeCRActions:not(.icmeCRSelecting) :not(#icmeCRStartSel) { display: none; }
            #icmeCRActions.icmeCRSelecting       #icmeCRStartSel       { display: none; }
            .icmeCRSelected, .icmeCRSelected .progress {
                background-color: #bbbbbb !important;
            }
            .icmeCRHover, .icmeCRHover .progress {
                background-color: #cccccc !important;
            }
            .icmeCRPending, .icmeCRPending .progress {
                background-color: #ffffb2 !important;
            }
        `);

        this.selectionStarted = false;
        this.attachSelectionHandlers();
        const elActions = $('#icmeCRActions');
        const [elStart, elCancel, elSelectAll, elRun] = $$('#icmeCRActions button');
        elStart.addEventListener('click', () => {
            elActions.classList.add('icmeCRSelecting');
            this.selectionStarted = true;
        });

        elCancel.addEventListener('click', () => {
            elActions.classList.remove('icmeCRSelecting');
            this.selectionStarted = false;
            $$('.icmeCRSelected, .icmeCRHover').forEach(el => {
                el.classList.remove('icmeCRSelected', 'icmeCRHover');
            });
        });

        elSelectAll.addEventListener('click', () => {
            // Select lists only from the active tab (for /progress/)
            $$(':is(ol[id^=progress]:not([style*=none]), #itemListToplists) .listItemToplist').forEach(el => {
                el.classList.add('icmeCRSelected');
            });
        });

        const setButtonState = bool => [elCancel, elSelectAll, elRun].forEach(el => {
            el.disabled = bool;
        });

        elRun.addEventListener('click', () => {
            setButtonState(true);
            this.selectionStarted = false;
            this.run().then(() => {
                setButtonState(false);
                elActions.classList.remove('icmeCRSelecting');
            });
        });
    }

    attachSelectionHandlers() {
        const eventTypes = ['click', 'mouseover', 'mouseout'];
        const elContainers = $$('ol[id^=progress], #itemListToplists');
        for (const type of eventTypes) {
            elContainers.forEach(elContainer => elContainer.addEventListener(type, e => {
                const elList = e.target.closest('.listItemToplist');
                if (!this.selectionStarted || !elList) return;

                if (e.type === 'mouseover') {
                    elList.classList.add('icmeCRHover');
                } else if (e.type === 'mouseout') {
                    elList.classList.remove('icmeCRHover');
                } else if (e.type === 'click') {
                    elList.classList.toggle('icmeCRSelected');
                }
            }));
        }
    }

    async run() {
        const elLists = [...$$('.icmeCRSelected')];
        const results = await this.fetchMovies(elLists);

        const counter = {};
        results.forEach(elMovies => ListCrossRef.updateCounter(elMovies, counter));

        this.output(elLists, counter);
    }

    async fetchMovies(elLists) {
        const sel = `#itemListMovies > li${this.config.unchecked_only ? '.unchecked' : ''}`;
        const results = [];
        for (const elList of elLists) {
            const url = elList.querySelector('a.title').href;
            elList.classList.add('icmeCRPending');

            /* eslint-disable no-await-in-loop -- Load pages one by one to reduce the load */
            const elMovies = await extractFrom(url, el => el.querySelectorAll(sel));
            results.push(elMovies);
            await sleep(500);
            /* eslint-enable no-await-in-loop */

            elList.classList.remove('icmeCRPending', 'icmeCRSelected');
        }

        return results;
    }

    static updateCounter(elMovies, counter) {
        elMovies.forEach(elMovie => {
            const { id } = elMovie;
            if (counter[id]) {
                counter[id].count += 1;
                return;
            }

            // Compatibility with the NewTabs module
            const owned = load('icme_owned_movies') ?? {};
            if (owned[id]) {
                elMovie.classList.remove('notowned');
                elMovie.classList.add('owned');
            }

            const elTitle = elMovie.querySelector('h2 a');
            const title = elTitle.textContent.trim();
            const url = elTitle.href;
            const year = elMovie.querySelector('.info > a:first-of-type').textContent;

            counter[id] = { count: 1, title, url, year, el: elMovie };
        });
    }

    output(elLists, counter) {
        let cutoff = this.config.match_all ? elLists.length : this.config.match_min;
        cutoff = Math.max(2, cutoff); // doesn't make sense to have a cutoff lower than 2
        const isOnEnoughLists = id => counter[id].count >= Math.max(2, cutoff);
        const movies = Object.keys(counter).filter(isOnEnoughLists).map(k => counter[k]);

        // Sort by checks DESC, then by year ASC, then by title ASC
        movies.sort((a, b) =>
            b.count - a.count || a.year - b.year || a.title.localeCompare(b.title));

        // Collapse visible lists from previous runs
        $$('.topListMoviesFilter.active a').forEach(el => el.click());

        const listTitles = elLists.map(el => `
            <li><b>${el.querySelector('.title').textContent.trim()}</b></li>
        `);
        const sel = ListCrossRef.matchesPageType('progress') ? '#progressall' : '#itemContainer';
        $(sel).insertAdjacentHTML('afterend', `
            <div class="icmeCRResults">
                ${movies.length} ${this.config.unchecked_only ? 'unchecked' : ''} movies
                appear on ${this.config.match_all ? 'all' : `at least ${cutoff}`} of these lists:
                <ul>${listTitles.join('')}</ul>
            </div>
        `);

        if (!movies.length) return;

        const elResults = $('.icmeCRResults');
        elResults.insertAdjacentHTML('beforeend', `
            <ul class="tabMenu tabMenuPush">
                <li class="topListMoviesFilter active">
                    <a href="#" title="View all movies">All (${movies.length})</a>
                </li>
                <li class="icmeCRExport">
                    <a href="#" title="Export all movies in CSV format">Export CSV</a>
                </li>
            </ul>
            <ol id="itemListMovies" class="itemList listViewNormal"></ol>
        `);

        // Target only the topmost list (in case there are several)
        const elMovieList = elResults.querySelector('#itemListMovies');
        for (const movie of movies) {
            movie.el.querySelector('.rank').innerHTML = movie.count;
            movie.el.style.display = ''; // movies from fetched lists might be hidden
            elMovieList.append(movie.el);
        }

        elResults.scrollIntoView(); // scroll only after all elements have been added

        // Make movie lists collapsible
        elResults.querySelector('.topListMoviesFilter a').addEventListener('click', e => {
            e.preventDefault();
            const elMovieFilter = e.target.parentElement;
            elMovieFilter.classList.toggle('active');
            elMovieList.style.display = elMovieFilter.classList.contains('active') ? '' : 'none';
        });

        // Allow exporting results as a .csv file
        const elExport = elResults.querySelector('.icmeCRExport a');
        const filename = 'Cross-referencing results';
        const { delimiter, bom } = this.globalCfg.data.export_lists;
        // eslint-disable-next-line no-use-before-define
        ExportLists.export(elExport, elMovieList.children, filename, delimiter, bom);
    }
}

class HideTags extends BaseModule {
    constructor(globalCfg) {
        super(globalCfg);

        this.metadata = {
            title: 'Hide tags',
            desc: 'Hide tags on movie lists and lists of lists in normal view',
            id: 'hide_tags',
            // ICM bug: movieListGeneral and movieSearch never have tags
            enableOn: ['listsGeneral', 'listsSpecial', 'listsSearch',
                'movieList', 'movieListGeneral', 'movieListSpecial', 'movieSearch', 'movieRankings'],
            options: [BaseModule.getStatus(false), {
                id: 'list_tags',
                frontDesc: 'Hide on: ',
                desc: 'lists',
                type: 'checkbox',
                inline: true,
                default: true,
            }, {
                id: 'movie_tags',
                desc: 'movies',
                type: 'checkbox',
                inline: true,
                default: true,
            }, {
                id: 'show_on_hover',
                desc: 'Show tags when moving the cursor over a movie or a list',
                type: 'checkbox',
                default: false,
            }],
        };
    }

    attach() {
        if (this.config.list_tags) {
            // /lists/ and /movies/<title>/rankings/ have different structure
            addCSS(`
                #itemListToplists.listViewNormal > li > .info:last-child,
                #itemListToplists > li > .tagList {
                    display: none !important;
                }
            `);
        }

        if (this.config.movie_tags) {
            addCSS(`
                #itemListMovies.listViewNormal > li > .tagList {
                    display: none !important;
                }
            `);
        }

        if (this.config.show_on_hover) {
            addCSS(`
                #itemListToplists.listViewNormal > li:hover > .info:last-child,
                #itemListToplists > li:hover > .tagList,
                #itemListMovies.listViewNormal > li:hover > .tagList {
                    display: block !important;
                }
            `);
        }
    }
}

class NewTabs extends BaseModule {
    constructor(globalCfg) {
        super(globalCfg);

        this.metadata = {
            title: 'New tabs',
            desc: 'Add additional tabs on movie lists',
            id: 'new_tabs',
            enableOn: ['movieList', 'movieListGeneral', 'movieListSpecial', 'movieSearch',
                'movie', 'movieRankings'],
            options: [BaseModule.getStatus(false), {
                id: 'owned_tab',
                frontDesc: 'Create tabs for: ',
                desc: 'owned movies',
                type: 'checkbox',
                inline: true,
                default: false,
            }, {
                id: 'wlist_tab',
                desc: 'watchlisted movies',
                type: 'checkbox',
                inline: true,
                default: false,
            }, {
                id: 'free_account',
                desc: 'Store owned movies (emulates the paid feature; ' +
                    'enable only if you have a free account)',
                type: 'checkbox',
                default: false,
            }],
        };
    }

    attach() {
        if (this.config.free_account) NewTabs.trackOwned();

        if (!$('#itemListMovies')) return;
        if (NewTabs.matchesPageType('movieList') && (this.config.wlist_tab || this.config.owned_tab)) {
            NewTabs.prepareTabBar();
            if (this.config.wlist_tab) NewTabs.addNewTab('watch', 'watchlist', 'optionAddWatchlist');
            if (this.config.owned_tab) NewTabs.addNewTab('owned', 'owned', 'optionMarkOwned');
        }
    }

    static prepareTabBar() {
        // Gain some extra space in the tab bar
        const elAllTab = $('#listFilterMovies a');
        elAllTab.textContent = elAllTab.textContent.replace(' movies', '');

        // Move the 'order by' and view switch elements to the list title
        $('#topList').insertAdjacentHTML('beforeend', `
            <div id="icmeOrderByAndView"></div>
        `);
        addCSS(`
            #icmeOrderByAndView {
                z-index: 200;
                position: absolute;
                top: 30px;
                right: 0;
                width: 300px;
                height: 20px;
            }
        `);
        const elOrderBy = $('#listOrdering');
        const elView = $('#listViewswitch');
        $('#icmeOrderByAndView').append(elOrderBy, elView);
    }

    static addNewTab(itemClass, title, btnClass) {
        const elMovieList = $('#itemListMovies');
        title = title.toLowerCase();
        const titleCap = title[0].toUpperCase() + title.slice(1);
        const count = elMovieList.querySelectorAll(`:scope > li.${itemClass}`).length;
        const tabHtml = `
            <li id="listFilter${titleCap}" class="topListMoviesFilter">
                <a title="View all your ${title} movies" href="#">
                    ${titleCap}
                    <span id="topListMovies${titleCap}Count">(${count})</span>
                </a>
            </li>`;

        $('#listFilterNew').insertAdjacentHTML('beforebegin', tabHtml);

        const elTabLink = $(`#listFilter${titleCap} a`);
        elTabLink.addEventListener('click', e => {
            e.preventDefault();
            elMovieList.querySelectorAll(':scope > li.listItem')
                .forEach(el => { el.style.display = 'none'; });
            elMovieList.querySelectorAll(`:scope > li.${itemClass}`)
                .forEach(el => { el.style.display = ''; });
            $('#topListAllMovies').style.display = 'none'; // hide 'Show all'

            const elTab = elTabLink.parentElement;
            elTab.parentElement.querySelector('.active').classList.remove('active');
            elTab.classList.add('active');
        });

        // To work around the owned button click bubbling to ICM, the button stops propagation,
        // so the tab count update must happen before that, at the capturing phase
        elMovieList.addEventListener('click', e => {
            if (!e.target.classList.contains(btnClass)) return;
            const curCount = elMovieList.querySelectorAll(`:scope > li.${itemClass}`).length;
            // This capture happens before the movie class is updated (by ICM or the script)
            const delta = e.target.closest('li.listItem').classList.contains(itemClass) ? -1 : 1;
            $(`#topListMovies${titleCap}Count`).textContent = `(${curCount + delta})`;
        }, true);
    }

    static trackOwned() {
        const owned = load('icme_owned_movies') ?? {};

        const elMarkOwnedArr = $$('.optionMarkOwned');
        elMarkOwnedArr.forEach(elMarkOwned => {
            const elCheckbox = elMarkOwned.closest('.optionIconMenu').previousElementSibling;
            const elMovie = elCheckbox.parentElement;
            const id = elCheckbox.id.replace('check', 'movie');

            if (owned[id]) {
                elMovie.classList.remove('notowned');
                elMovie.classList.add('owned');
            }

            elMarkOwned = removePremiumPopup(elMarkOwned);

            elMarkOwned.addEventListener('click', e => {
                e.preventDefault();
                // ICM intercepts clicks by the class name, throwing an error in the console
                e.stopPropagation();

                // Storage could've changed in the meanwhile in other tabs
                const ownedFresh = load('icme_owned_movies') ?? {};
                if (ownedFresh[id]) {
                    delete ownedFresh[id];
                } else {
                    ownedFresh[id] = true;
                }

                elMovie.classList.toggle('notowned');
                elMovie.classList.toggle('owned');

                save('icme_owned_movies', ownedFresh);
            });
        });
    }
}

class LargePosters extends BaseModule {
    constructor(globalCfg) {
        super(globalCfg);

        this.metadata = {
            title: 'Large posters',
            desc: 'Show large posters on individual lists (replaces normal view)',
            id: 'large_posters',
            enableOn: ['movieList', 'movieListGeneral', 'movieListSpecial'],
            options: [BaseModule.getStatus(true), {
                id: 'default_view',
                desc: 'Use as the default list view',
                type: 'checkbox',
                default: false,
            }, {
                id: 'noinfo',
                desc: 'Hide info (title, year, lists)',
                type: 'checkbox',
                default: false,
            }],
        };
    }

    attach() {
        if (!$('#itemListMovies')) return;

        if (this.config.default_view) {
            this.load();
            return;
        }

        const link = `
            <span style="float: right; margin-left: 15px">
                <a id="icmeLPLink" href="#">Large posters</a>
            </span>`;

        addToMovieListBar(link);

        const elLink = $('#icmeLPLink');
        elLink.addEventListener('click', e => {
            e.preventDefault();
            this.load();
            elLink.remove();
        });
    }

    load() {
        const root = '#itemListMovies.listViewNormal';
        let css = `
            ${root} > .listItem {
                float: left;
                width: 255px;
            }
            ${root} .listItem .listImage {
                float: none;
                width: 230px;
                height: 305px;
                left: -18px;
                top: -18px;
                margin: 0;
            }
            ${root} .listImage a {
                width: 100%;
                height: 100%;
                background: url("/images/dvdCover.png") no-repeat scroll center center transparent;
            }
            ${root} .listImage .coverImage {
                width: 190px;
                height: 258px;
                top: 21px;
                left: 19px;
                right: auto;
            }
            ${root} .listItem .rank {
                top: 15px;
                position: absolute;
                height: auto;
                width: 65px;
                right: 0;
                margin: 0;
                font-size: 30px;
            }
            ${root} .listItem .rank .positiondifference span { font-size: 12px; }
            ${root} .listItem h2 {
                z-index: 11;
                font-size: 14px;
                width: 100%;
                margin:-30px 0 0 0;
            }
            ${root} .listItem .info {
                font-size: 12px;
                width: 100%;
                height: auto;
                line-height: 16px;
                margin-top: 4px;
            }
            ${root} .checkbox { top: 85px; right: 12px; }
            ${root} .optionIconMenu { top: 120px; right: 20px; }
            ${root} .optionIconMenu li { display: block; }
            ${root} .optionIconMenuCheckbox { right: 20px; }
            ${root}.icmeLPNoInfo :is(h2, .tagList, .info) { display: none; }
            ${root}.icmeLPNoInfo .listItem { height: 270px; }
            #itemListMovies.listViewCompact > .listItem { height: auto; }
        `;
        css = css.replace(/;/g, ' !important;');
        addCSS(css);

        // Normal view is used as the basis for the large posters view
        LargePosters.enableNormalView();

        $$('#itemListMovies div.coverImage').forEach(elCover => {
            elCover.style.display = 'none';
            const imgUrl = elCover.style.backgroundImage.split('"')[1]
                .replace('/small/', '/medium/')
                .replace('defaultCoverSmall', 'defaultCoverMedium');
            const imgHtml = `<img class="coverImage" src="${imgUrl}" loading="lazy">`;
            elCover.insertAdjacentHTML('afterend', imgHtml);
        });

        if (this.config.noinfo) {
            $('#itemListMovies').classList.add('icmeLPNoInfo');
        } else {
            // Imitate click on the 'Show all' button
            const elShowAllBtn = $('#topListAllMovies');
            if (elShowAllBtn) {
                $$('#itemListMovies > .listItem')
                    .forEach(el => { el.style.display = ''; });
                elShowAllBtn.style.display = 'none';
            }

            // Tags and long titles (if they are shown) can increase item's height
            LargePosters.adjustHeights();
        }
    }

    static enableNormalView() {
        const [elNormalView, elCompactView] = $$('#listViewswitch a');
        if (elNormalView.classList.contains('active')) return;
        // Modified from ICM source code (triggering the click event requires @run-at document-idle)
        elCompactView.classList.remove('active');
        elNormalView.classList.add('active');
        const elList = $('.itemList');
        elList.classList.replace('listViewCompact', 'listViewNormal');
    }

    static adjustHeights() {
        const getHeight = el => parseFloat(getComputedStyle(el).height);
        $$('.listItemMovie:nth-child(3n-2)').forEach(el1 => {
            const el2 = el1.nextElementSibling ?? el1;
            const el3 = el2.nextElementSibling ?? el1;

            const maxHeight = Math.max(...[el1, el2, el3].map(getHeight));
            [el1, el2, el3].forEach(el => {
                el.style.height = `${maxHeight}px`;
            });
        });
    }
}

class ProgressPage extends BaseModule {
    constructor(globalCfg) {
        super(globalCfg);

        this.metadata = {
            title: 'Progress page',
            desc: 'Change the order of lists on the progress page.<br>All settings can be toggled ' +
                'without reloading the page; click on the tab label to apply them',
            id: 'progress_page',
            enableOn: ['progress'],
            options: [BaseModule.getStatus(false), {
                id: 'sort_by_completion',
                frontDesc: '',
                desc: 'Sort lists by completion rate',
                type: 'checkbox',
                inline: true,
                default: true,
            }, {
                id: 'desc_order',
                desc: 'in descending order',
                type: 'checkbox',
                inline: true,
                default: true,
            }, {
                id: 'left_to_right',
                desc: 'Fill columns from left to right',
                type: 'checkbox',
                default: false,
            }, {
                id: 'single_col',
                desc: 'Show as a single column',
                type: 'checkbox',
                default: false,
            }, {
                id: 'hide_imdb',
                desc: 'Hide IMDb lists from "All" tab',
                type: 'checkbox',
                default: false,
            }],
        };
    }

    attach() {
        addCSS('.itemList.icmePPSingleCol .listItem.listItemProgress { float: none !important; }');

        this.originalOrder = {};
        this.rearrange('all');

        const elFilters = $$('#progressFilter [id^=progressFilter-]');
        elFilters.forEach(el => el.addEventListener('click', () => {
            const [, section] = el.id.split('-');
            this.rearrange(section);
        }));
    }

    rearrange(section) {
        const elContainer = $(`#progress${section}`);
        elContainer.classList.toggle('icmePPSingleCol', this.config.single_col);
        let elLists = [...elContainer.children];
        elLists.forEach(el => el.remove());

        elLists = ProgressPage.straighten(elLists);

        // Remember the original order at the page load (elLists must not be mutated)
        if (!this.originalOrder[section]) {
            this.originalOrder[section] = elLists;
        }

        // Undo further manipulations in case settings have changed
        elLists = this.originalOrder[section];

        if (this.config.sort_by_completion) {
            const order = this.config.desc_order === true ? -1 : 1;
            const getWidth = el => parseFloat(el.querySelector('.progress').style.width);
            const widths = new Map(elLists.map(el => [el, getWidth(el)]));
            elLists = [...elLists].sort((a, b) => order * (widths.get(a) - widths.get(b)));
        }

        if (this.config.hide_imdb && section === 'all') {
            elLists = elLists.filter(el => !el.classList.contains('imdb'));
        }

        if (!this.config.single_col && !this.config.left_to_right) {
            // Restore the default two-column view
            elLists = ProgressPage.interweave(elLists);
        }

        elContainer.append(...elLists);
    }

    // [1, 'a', 2, 'b', 3, 'c']    -> [1, 2, 3, 'a', 'b', 'c']
    // [1, 'a', 2, 'b', 3, 'c', 4] -> [1, 2, 3, 4, 'a', 'b', 'c']
    static straighten(list) {
        const even = list.filter((_, i) => i % 2 === 0);
        const odd = list.filter((_, i) => i % 2 !== 0);
        return [...even, ...odd];
    }

    // [1, 2, 3, 'a', 'b', 'c']    -> [1, 'a', 2, 'b', 3, 'c']
    // [1, 2, 3, 4, 'a', 'b', 'c'] -> [1, 'a', 2, 'b', 3, 'c', 4]
    static interweave(list) {
        const res = [];
        const halfLen = Math.ceil(list.length / 2);
        for (let i = 0; i < halfLen; i++) {
            res.push(list[i]);
            if (i + halfLen < list.length) {
                res.push(list[i + halfLen]);
            }
        }

        return res;
    }
}

class GroupMovieLists extends BaseModule {
    constructor(globalCfg) {
        super(globalCfg);

        this.metadata = {
            title: 'Group movie lists',
            desc: 'Organize a movie\'s "In lists" tab (<a href="/movies/pulp+fiction/rankings/">' +
                'example</a>) by grouping lists together and moving them to the top.<br>To create ' +
                'a group with your watchlisted/fav. lists click "Copy urls for a list group" ' +
                'on their page and paste into the fields below. You can also edit the groups manually',
            id: 'group_movie_lists',
            enableOn: ['movie', 'movieList', 'movieListGeneral', 'movieListSpecial',
                'movieRankings', 'movieSearch', 'listsGeneral', 'listsSpecial'],
            options: [BaseModule.getStatus(true), {
                id: 'redirect',
                desc: 'Redirect "in # lists" links to the tab with all lists',
                type: 'checkbox',
                inline: true,
                default: true,
            }, {
                id: 'by_name',
                desc: 'sorted by name',
                type: 'checkbox',
                inline: true,
                default: false,
            }, {
                id: 'sort_official',
                frontDesc: 'Move to the top: ',
                desc: 'official',
                type: 'checkbox',
                inline: true,
                newline: true,
                default: true,
            }, {
                id: 'sort_own',
                desc: 'created by you',
                type: 'checkbox',
                inline: true,
                default: true,
            }, {
                id: 'sort_groups',
                desc: 'from groups 1-2',
                type: 'checkbox',
                inline: true,
                default: true,
            }, {
                id: 'sort_filmos',
                desc: 'filmographies',
                type: 'checkbox',
                inline: true,
                default: true,
            }, {
                id: 'sort_nonpersonal',
                desc: 'non-personal',
                type: 'checkbox',
                inline: true,
                default: true,
            }, {
                id: 'group1',
                desc: 'Group 1',
                type: 'textarea',
                default: [],
            }, {
                id: 'group2',
                desc: 'Group 2',
                type: 'textarea',
                default: [],
            }],
        };

        // multiline regex that leaves only list name, excl. a common beginning and parameters
        this.reURL = /^[ \t]*(?:https?:\/\/)?(?:www\.)?(?:icheckmovies.com)?\/?(?:lists)?\/?([^?\s]+\/)(?:\?.+)?[ \t]*$/gm;
    }

    attach() {
        if (GroupMovieLists.matchesPageType('movieRankings')) this.reorderLists();
        if (GroupMovieLists.matchesPageType('listsSpecial')) GroupMovieLists.addExportLink();
        if (!this.config.redirect) return;
        this.fixLinks();
        this.fixLinksInNewNodes();
    }

    reorderLists() {
        addCSS(`
            .icmeGMLGroupEnd:not(:last-child) {
                margin-bottom: 25px;
                border-bottom: 2px solid #555;
            }
        `);

        const elContainer = $('#itemListToplists');
        let lists = [...elContainer.children];
        const isNotInArr = toExclude => el => !toExclude.includes(el);
        const getShortUrl = el => el.querySelector('a.title').pathname.slice(7);
        const group1Urls = this.getGroup('group1');
        const group2Urls = this.getGroup('group2');
        const username = $('.showProfileOptions').href.match(/profiles\/(.+)\//)?.[1];

        const groupLogic = [
            {
                option: this.config.sort_official,
                isInGroup: el =>
                    el.querySelector('.tagList a[href$="user%3Aicheckmovies"]') &&
                    // ICM bug: deleted lists reset to icheckmovies user
                    !el.querySelector('.title').href.endsWith('//'),
            }, {
                option: this.config.sort_own,
                isInGroup: el => el.querySelector(`.tagList a[href$="user%3A${username}"]`),
            }, {
                option: this.config.sort_groups,
                isInGroup: el => group1Urls.includes(getShortUrl(el)),
            }, {
                option: this.config.sort_groups,
                isInGroup: el => group2Urls.includes(getShortUrl(el)),
            }, {
                option: this.config.sort_filmos,
                isInGroup: el => el.textContent.toLowerCase().includes('filmography'),
            }, {
                option: this.config.sort_nonpersonal,
                isInGroup: el => !el.querySelector('.tagList a[href$="category%3Apersonal"]'),
            },
        ];

        for (const { option, isInGroup } of groupLogic) {
            if (!option) continue;
            const group = lists.filter(isInGroup);
            GroupMovieLists.move(group, elContainer);
            lists = lists.filter(isNotInArr(group));
        }
    }

    static move(elLists, elContainer) {
        if (!elLists.length) return;
        const elGroupEnds = elContainer.querySelectorAll('.icmeGMLGroupEnd');
        if (elGroupEnds.length) {
            elGroupEnds[elGroupEnds.length - 1].after(...elLists);
        } else {
            elContainer.prepend(...elLists);
        }

        elLists[elLists.length - 1].classList.add('icmeGMLGroupEnd');
    }

    getGroup(group) {
        let groupUrls = this.config[group];
        if (typeof groupUrls !== 'string') return groupUrls;
        console.log(`GroupMovieLists: parsing ${group}`);
        groupUrls = groupUrls.trim().replace(this.reURL, '$1').split('\n');
        this.config[group] = groupUrls;
        this.globalCfg.save();
        return groupUrls;
    }

    static addExportLink() {
        addNearOrderByLinks(`
            <a id="icmeGMLLink" class="icmeOrderByLink" href="#">Copy urls for a list group</a>
        `);
        $('#icmeGMLLink').addEventListener('click', e => {
            e.preventDefault();
            const listLinks = [...$$('#itemListToplists > li')]
                .filter(el => !el.querySelector('.tagList a[href$="user%3Aicheckmovies"'))
                .map(el => el.querySelector('.title').href.split('/lists/')[1]);
            const msg = 'Done! Now you can paste the urls into the "Group 1/Group 2" fields in the "Group movie lists" settings.';
            navigator.clipboard.writeText(listLinks.join('\n')).then(() => alert(msg));
        });
    }

    fixLinks(elContainer = document) {
        const sel = '.listItemMovie .info a[href*="/rankings/"], #listFilterLists a';
        const elLinks = elContainer.querySelectorAll(sel);
        elLinks.forEach(el => {
            el.href = el.href.replace('?tags=user:icheckmovies', '');
            el.href += this.config.by_name ? '?sort=name' : '';
        });
    }

    // Cross-referencing adds new blocks that must also be fixed
    fixLinksInNewNodes() {
        const onListOfLists = GroupMovieLists.matchesPageType(['listsGeneral', 'listsSpecial']);
        const isCREnabled = this.globalCfg.data.list_cross_ref.enabled;
        const elCRActions = $('#icmeCRActions');
        if (!onListOfLists || !isCREnabled || !elCRActions) return;
        const mut = new MutationObserver(mutList => mutList.forEach(({ addedNodes }) => {
            for (const el of addedNodes) {
                if (el.classList?.contains('icmeCRResults')) {
                    this.fixLinks(el);
                }
            }
        }));
        mut.observe(elCRActions.parentElement, { childList: true });
    }
}

class ExportLists extends BaseModule {
    constructor(globalCfg) {
        super(globalCfg);

        this.metadata = {
            title: 'Export lists',
            desc: 'Download any list as .csv (doesn\'t support search results).<br>' +
                'Emulates the paid feature, enable only if you have a free account. Keep in mind ' +
                'that some sites (like Letterboxd) accept only comma-separated .csv',
            id: 'export_lists',
            enableOn: ['movieList', 'movieListGeneral', 'movieListSpecial'],
            options: [BaseModule.getStatus(false), {
                id: 'delimiter',
                desc: 'Use as delimiter (accepts \';\' or \',\'; otherwise uses \\t)',
                type: 'textinput',
                default: ',',
            }, {
                id: 'bom',
                desc: 'Include BOM (required for Excel)',
                type: 'checkbox',
                default: true,
            }],
        };
    }

    attach() {
        if (!$('#itemListMovies')) return;
        let elExport = $('.optionExport');
        if (!elExport) { // /movies/unchecked/, /movies/checked/
            $('#listTitle').insertAdjacentHTML('afterbegin', `
                <ul class="optionIconMenu">
                    <li>
                        <a class="optionIcon optionExport" href="#" title="Export this list to CSV">
                            Export this list to CSV
                        </a>
                    </li>
                </ul>
            `);
            elExport = $('.optionExport');
        }

        elExport = removePremiumPopup(elExport);
        const elMovies = $$('#itemListMovies > li');
        const filename = $(':is(#topList, #listTitle) > h1').textContent;
        ExportLists.export(elExport, elMovies, filename, this.config.delimiter, this.config.bom);
    }

    static export(elExport, elMovies, filename, sep, useBom) {
        if (sep !== ',' && sep !== ';') sep = '\t';
        const wrap = field => (field.includes('"') || field.includes(sep) ?
            `"${field.replace(/"/g, '""')}"` : field);
        const colNames = ['rank', 'title', 'aka', 'year', 'official_toplists',
            'checked', 'favorite', 'dislike', 'imdburl'];
        elExport.addEventListener('click', () => {
            const rows = [...elMovies].map(el => {
                const rank = el.querySelector('.rank')?.textContent.match(/\d+/)[0] ?? '-';
                const title = wrap(el.querySelector('h2 > a').textContent);
                const aka = wrap(el.querySelector('.info > em')?.textContent ?? '');
                const year = el.querySelector('.info > a:first-of-type')?.textContent ?? '';
                const toplists = el.querySelector('.info > a:nth-of-type(2)')?.textContent.match(/\d+/)[0] ?? 0;
                const checked = el.classList.contains('checked') ? 'yes' : 'no';
                const isFav = el.classList.contains('favorite') ? 'yes' : 'no';
                const isDislike = el.classList.contains('hated') ? 'yes' : 'no';
                const imdbUrl = el.querySelector('.optionIMDB').href;
                const cols = [rank, title, aka, year, toplists, checked, isFav, isDislike, imdbUrl];
                return `${cols.join(sep)}`;
            });
            const data = `${colNames.join(sep)}\n${rows.join('\n')}`;
            // For Excel compat: BOM, ; or , as separator and no sep=
            const bom = useBom ? '\uFEFF' : '';

            elExport.href = `data:text/csv;charset=utf-8,${bom}${encodeURIComponent(data)}`;
            elExport.download = `${filename}.csv`;
        }, { once: true });
    }
}

class ProgressTopX extends BaseModule {
    constructor(globalCfg) {
        super(globalCfg);

        this.metadata = {
            title: 'Progress: checks for Top-1000',
            desc: 'Find out how many checks you need to get into Top-25/50/100/1000/...' +
                '<br>Adds a link to the progress page that will attach this number to each list.',
            id: 'progress_top_x',
            enableOn: ['progress'],
            options: [BaseModule.getStatus(true), {
                id: 'target_page',
                desc: 'Ranking page you want to be on (page x 25 = rank)',
                type: 'textinput',
                default: '40',
            }],
        };
    }

    attach() {
        addNearOrderByLinks(`
            <a id="icmePTXLink" class="icmeOrderByLink" href="#">
                Checks to get into Top-${Number(this.config.target_page) * 25}
            </a>
        `);
        const elLink = $('#icmePTXLink');
        // Can't pass the value directly in case of user changing it and not reloading
        elLink.addEventListener('click', event => this.addStats(event));
    }

    addStats(event) {
        event.preventDefault();
        const targetPage = Number(this.config.target_page); // * 25 = target rank
        const elActiveTab = [...$$('.itemListCompact[id^="progress"]')]
            .filter(el => el.style.display !== 'none')[0];
        const elListsWithoutStats = [...elActiveTab.children]
            .filter(el => !el.querySelector('.rank a:first-child'));
        const lists = elListsWithoutStats.map(elList => ({
            elTarget: elList.querySelector('.rank'),
            listUrl: elList.querySelector('.title').href,
            checks: Number(elList.querySelector('.rank').textContent.match(/\d+|-/g)[0]),
            rank: Number(elList.querySelector('.rank').textContent.match(/\d+|-/g)[2]),
        }));

        const getMinChecksFromTopusersPage = el => {
            const elLastProfile = el.querySelector('.listItemProfile:last-child');
            return Number(elLastProfile.querySelector('.info strong').textContent);
        };

        lists.forEach(async ({ elTarget, listUrl, checks, rank }) => {
            if (rank < targetPage * 25) return; // don't skip NaNs for lists with 0 checks
            // Pages higher than the last available page return the last page
            const url = `${listUrl}topusers/?page=${targetPage}`;
            const minChecks = await extractFrom(url, getMinChecksFromTopusersPage);
            const dif = minChecks - checks;

            const elText = elTarget.childNodes[0];
            elText.remove();
            elTarget.insertAdjacentHTML('afterbegin', `
                <a href="${url}" title="Checks needed to get into Top-${targetPage * 25}">
                    ${elText.textContent} - ${dif}
                </a>
            `);
        });
    }
}

class QuickListReorder extends BaseModule {
    constructor(globalCfg) {
        super(globalCfg);

        this.metadata = {
            title: 'Quick list reordering',
            desc: 'Double-click a list\'s rank to edit it. ' +
                'Hit Enter key or click outside to move the list to that position.',
            id: 'quick_list_reorder',
            enableOn: ['listsSpecial', 'movieListSpecial'],
            options: [BaseModule.getStatus(true)],
        };
    }

    attach() { // eslint-disable-line class-methods-use-this
        const elContainer = $('#itemListToplists.sortable, #itemListMovies.sortable');
        if (!elContainer) return; // /movies/checked/ are not sortable
        let oldRank;

        elContainer.addEventListener('dblclick', e => {
            if (!e.target.matches('.rank')) return;
            e.target.contentEditable = 'true';
            e.target.focus();
            oldRank = Number(e.target.textContent.trim());
        });

        elContainer.addEventListener('keydown', e => {
            if (!e.target.matches('.rank') || e.which !== 13) return;
            e.target.blur(); // sends the 'focusout' event
        });

        elContainer.addEventListener('focusout', e => {
            if (!e.target.matches('.rank')) return;
            const newRank = Number(e.target.textContent.trim());
            QuickListReorder.moveList(oldRank, newRank, e.target, elContainer);
        });
    }

    static moveList(oldRank, newRank, elRank, elContainer) {
        const inProperRange = newRank > 0 && newRank <= elContainer.children.length;
        if (!newRank || !inProperRange || newRank === oldRank) {
            elRank.textContent = oldRank;
            return;
        }

        const elList = elRank.closest('.listItem');
        const elListToShift = elContainer.children[newRank - 1];
        const moveDir = newRank < oldRank ? 'before' : 'after';
        elListToShift[moveDir](elList);
        // Modified from ICM source code
        const { id } = $('#itemListToplists, #itemListMovies');
        unsafeWindow.$.iCheckMovies.reOrderTypeSerializedItems[id] =
            unsafeWindow.$('#itemListToplists, #itemListMovies').sortable('serialize');
        unsafeWindow.$.iCheckMovies.reOrder(id);
    }
}

// ----- Main -----

// Main application; initializes, registers and loads modules.
class App {
    constructor(globalCfg) {
        this.modules = [];
        this.globalCfg = globalCfg;
        this.configWindow = new ConfigWindow(globalCfg);
    }

    register(Module) {
        const module = new Module(this.globalCfg);
        this.modules.push(module);
        module.syncGlobalCfg();
        this.configWindow.addModule(module.metadata);
    }

    load() {
        for (const m of this.modules) {
            if (m.isOnSupportedPage()) {
                if (m.config.enabled) {
                    console.log(`Attaching ${m.constructor.name}`);
                    m.attach();
                } else {
                    console.log(`Skipping ${m.constructor.name}`);
                }
            }
        }

        this.configWindow.load();
    }
}

const globalCfg = new GlobalCfg();

const useModules = [
    RandomFilmLink,
    HideTags,
    UpcomingAwardsList,
    CustomMovieColors,
    UpcomingAwardsOverview,
    ListCrossRef,
    NewTabs,
    LargePosters,
    ProgressPage,
    GroupMovieLists,
    ExportLists,
    ProgressTopX,
    QuickListReorder,
];

const app = new App(globalCfg);
useModules.forEach(m => app.register(m));
app.load();
console.log('ICM Enhanced is ready.');

// Links for testing, make sure every attached module works (check the console):
// https://www.icheckmovies.com/lists/favorited/
// https://www.icheckmovies.com/profiles/progress/
// https://www.icheckmovies.com/lists/venice+film+festival+-+golden+lion/
// https://www.icheckmovies.com/movies/watchlist/
// https://www.icheckmovies.com/movies/metropolis/rankings/