您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds new features to enhance the iCheckMovies user experience
// ==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">×</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/