NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name ICM - Movie info labels
// @description Click 'Filter/Load data' above a list to add labels with checks/runtime that can sort lists on click.
// @namespace https://openuserjs.org/users/monk-time
// @author monk-time
// @copyright 2017, monk-time (https://github.com/monk-time)
// @license MIT; https://opensource.org/licenses/MIT
// @homepageURL https://openuserjs.org/scripts/monk-time/ICM_-_Movie_info_labels
// @updateURL https://openuserjs.org/meta/monk-time/ICM_-_Movie_info_labels.meta.js
// @include https://www.icheckmovies.com/lists/*
// @include https://www.icheckmovies.com/search/movies/*
// @include https://www.icheckmovies.com/movies/unchecked/*
// @include https://www.icheckmovies.com/movies/checked/*
// @include https://www.icheckmovies.com/movies/favorited/*
// @include https://www.icheckmovies.com/movies/disliked/*
// @include https://www.icheckmovies.com/movies/watchlist/*
// @include https://www.icheckmovies.com/movies/?tags=*
// @exclude https://www.icheckmovies.com/lists/
// @exclude https://www.icheckmovies.com/lists/?*
// @exclude https://www.icheckmovies.com/lists/favorited/*
// @exclude https://www.icheckmovies.com/lists/disliked/*
// @exclude https://www.icheckmovies.com/lists/watchlist/*
// @icon https://www.icheckmovies.com/favicon.ico
// @version 1.0.2
// ==/UserScript==
/* Changelog:
* 2016.03.14 [0.8.1]: Added some extra logging.
* 2017.09.31 [0.9.0]: Fixed formatting.
* 2017.11.23 [1.0.0]: Removed jQuery dependency, fixed for GM4.
* 2017.11.23 [1.0.1]: Chrome didn't render sorting arrows correctly.
* 2018.07.26 [1.0.2]: Fixed the watchlist page
*/
// TODO: keep sorting arrows visible
'use strict';
// Set to true to enable verbose logging
const debug = false;
const log = (msg, ...rest) => {
if (!debug) return;
console.log(`MIL: ${msg}`, ...rest);
};
const error = (msg, ...rest) => {
console.error(`MIL: ${msg}`, ...rest);
};
// ----- Helper functions -----
const $ = (sel, context) => (context || document).querySelector(sel);
const $$ = (sel, context) => [...(context || document).querySelectorAll(sel)];
const getVisible = elements => [...elements].filter(el => el && el.style.display !== 'none');
const reflect = promise => promise
.then(val => ({ val, status: 'resolved' }))
.catch(err => ({ err, status: 'rejected' }));
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
// Convert a value according to a list of thresholds and corresp. values
const convertByRange = (rangeMap, defValue) => n => {
for (const [limit, value] of rangeMap) {
if (n < limit) {
return value;
}
}
return defValue;
};
// ----- Main -----
const stylesheet = `
.mil-trigger {
cursor: pointer;
}
.mil-trigger:hover {
background-color: #ccc !important;
}
.mil-label {
color: #FFF;
font-size: 12px;
font-family: Calibri;
padding-left: 3px;
padding-right: 3px;
border-radius: 3px;
border-width: 0px;
display: inline;
margin-left: 4px;
text-decoration: none;
cursor: pointer;
}
.mil-label-checks {
background-color: #3223c3;
}
.mil-label-mins {
background-color: #c33223;
}
.mil-arrows {
padding: 0px 4px;
cursor: pointer;
display: none;
}
.mil-data:hover + .mil-arrows,
.mil-arrows:hover {
display: inline;
}
`;
const container = $('#itemListMovies');
const main = () => {
log('Initializing "ICM - Movie info labels"...');
if (!container) {
throw new Error('"ICM - Movie info labels" script works only on movie lists.');
}
const trigger = $('#tagFilter > h4');
if (!trigger) {
log(`Unexpected tag filter layout:\n${$('#tagFilter').outerHTML}`);
throw new Error('Can\'t find the "Filter" button.');
}
document.head.insertAdjacentHTML('beforeend', `<style>${stylesheet}</style>`);
trigger.classList.add('mil-trigger');
trigger.addEventListener('mouseenter', () => {
trigger.textContent = 'Load data';
});
trigger.addEventListener('mouseleave', () => {
trigger.textContent = 'Filter';
});
trigger.addEventListener('click', processList);
// Allow row hiding on double click
container.addEventListener('dblclick', e => {
if (!e.target.classList.contains('listItemMovie')) return;
e.target.style.display = 'none';
});
};
const getVisibleMovies = () => getVisible(container.children);
const lookupTable = {};
const labelTypes = ['checks', 'mins'];
const processList = () => {
// Load the hidden part of the list
getVisible([$('#topListAllMovies')]).forEach(el => el.click());
const moviesToProcess = getVisibleMovies()
.filter(el => !el.classList.contains('mil-with-data'));
if (!moviesToProcess.length) return;
// The rest of handlers will be added after ajax requests
['rank', 'year', 'toplists'].forEach(attachHandlers);
// Resolve all instead of stopping on the first rejected request
Promise.all(moviesToProcess.map(processMovie).map(reflect))
.then(results => {
// Some movie links can fail (e.g. "()" from the Film Comment a-g list)
const fails = results.filter(x => x.status === 'rejected').length;
const total = results.length;
log(`DLed data and appended labels for ${total - fails}/${total} movies`);
log('Lookup table:', lookupTable);
labelTypes.forEach(type => {
// Subsequent runs can add new labels that don't have handlers yet
types[type].allHandlersAttached = false;
attachHandlers(type);
});
});
};
const processMovie = async (el, i) => {
await sleep(10 * i);
const target = $('.info', el);
let url = $('h2 > a', el).href;
// ICM bug: search pages redirect links to http: that gets blocked by Mixed Content Policy
if (url.startsWith('/search/result')) {
const [, moviePath] = url.split('&url=');
url = `//www.icheckmovies.com${decodeURIComponent(moviePath)}`;
}
try {
await appendLabels(url, target, el.id);
el.classList.add('mil-with-data');
} catch (e) {
error(`Error while loading ${url}dialog/:`, e);
throw e;
}
};
// ----- Labels -----
const types = {
rank: {
sel: '.rank',
asc: false,
showButton: false,
},
year: {
sel: '.info > a[title*="year"]',
asc: true,
showButton: true,
},
toplists: {
sel: '.info > a[href*="/rankings/"]',
asc: false,
showButton: true,
},
checks: {
regex: /movieChecks">([^<]+?)</,
getOpacity: convertByRange([[10, 0.2], [100, 0.4], [400, 0.7], [1000, 0.9]], 1),
sel: '.mil-label-checks',
asc: false,
showButton: false,
},
mins: {
regex: /Runtime<\/dt>\s*<dd>([^<]+?)</,
getOpacity: convertByRange([[20, 0.4], [40, 0.7], [120, 0.9]], 1),
sel: '.mil-label-mins',
asc: true,
showButton: false,
},
};
for (const typeObj of Object.values(types)) {
typeObj.allHandlersAttached = false;
}
const appendLabels = async (url, target, id) => {
if (lookupTable[id] === undefined) {
lookupTable[id] = {};
}
const { html } = await (await fetch(`${url}dialog/`)).json();
for (const type of labelTypes) {
const val = extractInt(html, types[type].regex);
lookupTable[id][type] = val || 0;
// Create label only if value is not NaN
const label = createLabel(type, val);
if (label) target.appendChild(label);
}
};
const createLabel = (type, val) => {
if (Number.isNaN(val)) return undefined;
const label = document.createElement('span');
label.classList.add('mil-label', `mil-label-${type}`);
label.style.opacity = types[type].getOpacity(val);
label.title = type;
label.textContent = val;
return label;
};
// Returns NaN if can't match or matched fragment has no numbers
const extractInt = (text, re) => {
const match = text.match(re);
if (!match) {
error(`Got malformed dialog page:\n${text}`);
return NaN;
}
return parseInt(match[1].replace(/\D/g, ''), 10);
};
// ----- Sortings -----
const debugCounters = {};
const attachHandlers = type => {
const { allHandlersAttached, sel, showButton } = types[type];
if (allHandlersAttached) return;
log(`Attaching handlers for ${type}`);
$$(sel, container).forEach(el => {
// If it's not the first run, skip items that already have data loaded
if (el.dataset.hasHandler) return;
// Track clicks on a button or an element itself
const clickTarget = showButton ? addSortButton(el) : el;
clickTarget.addEventListener('click', e => {
e.preventDefault();
Object.assign(debugCounters, { created: 0, parsed: 0 });
getVisibleMovies()
.sort(by(type))
.forEach(m => container.appendChild(m));
const { created, parsed } = debugCounters;
if (created + parsed > 0) {
log((created ? `created ${created}, ` : '') + (parsed ? `parsed ${parsed}` : ''));
}
// toggle sorting direction
types[type].asc = !types[type].asc;
log('Lookup table:', lookupTable);
});
el.dataset.hasHandler = true;
});
types[type].allHandlersAttached = true;
};
let sortButton = document.createElement('template');
sortButton.innerHTML = '<a class="mil-arrows" href="#">\u2191\u2193</a>';
sortButton = sortButton.content.firstChild;
const addSortButton = el => {
el.classList.add('mil-data');
const clone = sortButton.cloneNode(true);
// insert after element (omg)
return el.parentNode.insertBefore(clone, el.nextSibling);
};
// Universal sorting: the order is determined by a type-specific parameter
const by = type => (elA, elB) => {
const [valA, valB] = [elA, elB].map(lookup(type));
return types[type].asc ? valA - valB : valB - valA;
};
// Missing values are parsed with type-specific selectors and stored in a lookup table.
// Always returns a number.
const lookup = type => el => {
if (lookupTable[el.id] === undefined) {
lookupTable[el.id] = {};
debugCounters.created++;
}
const data = lookupTable[el.id];
if (data[type] !== undefined) return data[type];
// Label types that haven't been loaded yet (e.g. after loading data
// on a partial (New/Checked/etc.) view and switching to another view)
// or non-label types can be missing from the lookup table
if (labelTypes.includes(type)) return 0;
// Missing non-label types must be parsed from the page
data[type] = parseFromPage(type, el);
debugCounters.parsed++;
return data[type];
};
const parseFromPage = (type, el) => {
const src = $(types[type].sel, el);
if (src) return +src.textContent.match(/\d+/);
error(`Can't parse ${type} for ${el.id} on this page`);
return 0;
};
main();