// ==UserScript==
// @name 1-click feed maintenance
// @namespace https://greasyfork.org/en/scripts/436097-1-click-feed-maintenance
// @version 1.8.0
// @description Remove a video or channel from your homepage feed forever, even if you're not logged in to youtube.
// @author lwkjef
// @match https://www.youtube.com/
// @match https://www.youtube.com/?*
// @icon https://www.google.com/s2/favicons?domain=youtube.com
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_listValues
// @require https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js
// @license MIT
// ==/UserScript==
/* eslint-env jquery */
// script constants
const scriptName = '1-Click Feed Maintenance Userscript';
(function(){
'use strict'
$(document).ready(async function(){
await awaitNodeAdded(ytPopupContainerSelector, this);
$(ytPopupContainerSelector).each(async function() {
appendChildPermanently(this, await createOCMenu());
});
await awaitNodeAdded(ytMastheadSelector, this);
$(ytMastheadSelector).each(async function() {
appendChildPermanently(this, createButton(ocMenuOpenButtonId, ocMenuButtonText, ocMenuOpen));
});
await awaitNodeAdded(ytContentsGridSelector, this);
$(ytContentsGridSelector).each(async function() {
await automaticallyInitNewRichItems(this);
});
});
}())
// =================================
// 1-Click Feed Maintenance Features
// =================================
// internal GM keys
const gmWatchedVideo = 'watched';
const gmDontLikeVideo = 'dontlike';
const gmDontRecommendChannel = 'dontrecommend';
const gmAutomark = 'automark';
const gmHidepopups = 'hidepopups';
const gmHidelists = 'hidelists';
const gmHidelivestreams = 'hidelivestreams';
const gmHidefundraisers = 'hidefundraisers';
/*
* Initialize oc buttons on every item in yt contents grid, and observe grid to init oc buttons
* when new items are added.
*/
async function automaticallyInitNewRichItems(ytContentsGridNode) {
const observer = new MutationObserver(async function contentsMutated(mutationsList, observer) {
for (const mutation of mutationsList) {
for (const addedNode of mutation.addedNodes) {
await debug(`${ytContentsGridNode.id} mutated, added ${addedNode.id}`);
// TODO: inefficient to re-process entire grid for every added node, but sometimes yt removes buttons,
// or loads items too slowly, so we miss some if we only init added nodes
await initNewRichItems(ytContentsGridNode);
}
}
});
observer.observe(ytContentsGridNode, {attributes: false, childList: true, subtree: false});
await initNewRichItems(ytContentsGridNode);
}
/*
* Search the children of the given node for rich items that lack 1-Click Feed Maintenance
* controls, and add controls to them. Also double-check whether those videos have previously
* been marked as watched or blocked by this script (and Youtube is ignoring that). If so,
* then re-mark them and hide them.
*/
async function initNewRichItems(node) {
await debug(`initializing new ytRichItems on ${node.id}`);
// iterate over each rich item contained in the given node that don't already have buttons
$(node).find(ytRichItemWithoutOCButtonsSelector).each(async function() {
await debug(`found ytRichItem without oc buttons: ${this.id}`);
// add oc buttons
appendChildPermanently(this, createButton(ocAlreadyWatchedButtonId, ocAlreadyWatchedButtonText, alreadyWatchedOnClick));
appendChildPermanently(this, createButton(ocIDontLikeTheVideoButtonId, ocIDontLikeTheVideoButtonText, iDontLikeTheVideoOnClick));
appendChildPermanently(this, createButton(ocDontRecommendId, ocDontRecommendText, dontRecommendOnClick));
if (await GM_getValue(gmDebug) === 'true') {
appendChildPermanently(this, createButton(ocDebugId, ocDebugText, debugOnClick));
}
if (await GM_getValue(gmHidelists) === 'true') {
await debug(`hide list mode enabled`);
const listId = getListId(this);
if (listId) {
await debug(`hiding list id ${listId}`);
hide(this);
return;
}
}
if (await GM_getValue(gmHidelivestreams) === 'true') {
await debug(`hide livestream mode enabled`);
const videoId = getLivestreamId(this);
if (videoId) {
await debug(`hiding livestream id ${videoId}`);
hide(this);
return;
}
}
if (await GM_getValue(gmHidefundraisers) === 'true') {
await debug(`hide fundraiser mode enabled`);
const videoId = getFundraiserId(this);
if (videoId) {
await debug(`hiding fundraiser id ${videoId}`);
hide(this);
return;
}
}
// apply any persisted state
await applyPersistedVideoIdState(this);
await applyPersistedChannelIdState(this);
});
}
/*
* Look up whether this userscript has previously watched or blocked the given video node
* (and Youtube is ignoring that). If so, then re-mark it and hide it again.
*/
async function applyPersistedVideoIdState(videoNode) {
const videoId = getVideoId(videoNode);
if (!videoId) {
return;
}
const videoIdValue = await GM_getValue(videoId);
if (videoIdValue === gmWatchedVideo) {
if (await GM_getValue(gmAutomark) === 'true') {
await awaitNodeAdded(ytIronDropdownButtonSelector, videoNode);
await markAlreadyWatched(videoNode);
}
hide(videoNode);
} else if (videoIdValue === gmDontLikeVideo) {
if (await GM_getValue(gmAutomark) === 'true') {
await awaitNodeAdded(ytIronDropdownButtonSelector, videoNode);
await markDontLike(videoNode);
}
hide(videoNode);
}
}
/*
* Look up whether this userscript has previously blocked the channel of the given video node
* (and Youtube is ignoring that). If so, then re-mark it and hide it again.
*/
async function applyPersistedChannelIdState(videoNode) {
const channelId = getChannelId(videoNode);
if (!channelId) {
return;
}
const channelIdValue = await GM_getValue(channelId);
if (channelIdValue === gmDontRecommendChannel) {
if (await GM_getValue(gmAutomark) === 'true') {
await awaitNodeAdded(ytIronDropdownButtonSelector, videoNode);
await markDontRecommend(videoNode);
}
hide(videoNode);
}
}
// =================================
// 1-Click Feed Maintenance Controls
// =================================
// oc control element ids
const ocAlreadyWatchedButtonId = 'oc-already-watched';
const ocIDontLikeTheVideoButtonId = 'oc-dont-like';
const ocDontRecommendId = 'oc-dont-recommend';
const ocDebugId = 'oc-debug';
// oc control labels
const ocAlreadyWatchedButtonText = 'Already Watched';
const ocIDontLikeTheVideoButtonText = 'Block Video';
const ocDontRecommendText = 'Block Channel';
const ocDebugText = 'Debug';
/*
* "already watched" oc button callback
*/
async function alreadyWatchedOnClick(e) {
e.preventDefault();
const videoNode = e.target.parentNode;
const videoId = getVideoId(videoNode);
if (videoId) {
await GM_setValue(videoId, gmWatchedVideo);
}
await markAlreadyWatched(videoNode);
hide(videoNode);
}
/*
* "I dont like the video" oc button callback
*/
async function iDontLikeTheVideoOnClick(e) {
e.preventDefault();
const videoNode = e.target.parentNode;
const videoId = getVideoId(videoNode);
if (videoId) {
await GM_setValue(videoId, gmDontLikeVideo);
}
await markDontLike(videoNode);
hide(videoNode);
}
/*
* "dont recommend" oc button callback
*/
async function dontRecommendOnClick(e) {
e.preventDefault();
const videoNode = e.target.parentNode;
const channelId = getChannelId(videoNode);
if (channelId) {
await GM_setValue(channelId, gmDontRecommendChannel);
}
await markDontRecommend(videoNode);
hide(videoNode);
}
/*
* "debug" oc button callback
*/
async function debugOnClick(e) {
e.preventDefault();
const videoNode = e.target.parentNode;
log(`video id: ${getVideoId(videoNode)}`);
log(`list id: ${getListId(videoNode)}`);
log(`livestream id: ${getLivestreamId(videoNode)}`);
log(`fundraiser id: ${getFundraiserId(videoNode)}`);
log(`channel id: ${getChannelId(videoNode)}`);
}
// =============================
// 1-Click Feed Maintenance Menu
// =============================
// oc menu element ids
const ocMenuId = 'oc-menu';
const ocMenuOpenButtonId = 'oc-menu-open-button';
const ocMenuCheckboxId = 'oc-menu-checkbox';
const ocMenuLabelId = 'oc-menu-label';
const ocMenuButtonContainerId = 'oc-menu-buttons';
const ocMenuExportButtonId = 'oc-menu-export-button';
const ocMenuSaveButtonId = 'oc-menu-save-button';
const ocMenuCancelButtonId = 'oc-menu-cancel-button';
// oc menu labels
const ocMenuButtonText = '1-Click Config';
const ocMenuExportButtonText = 'Export';
const ocMenuSaveButtonText = 'Save';
const ocMenuCancelButtonText = 'Cancel';
// oc menu jQuery selectors
const ocMenuSelector = '#oc-menu';
/*
* Create the oc menu.
*/
async function createOCMenu() {
const menu = document.createElement(divTag);
menu.id = ocMenuId;
menu.class = ytPopupContainerClass;
menu.style.background = 'white';
menu.style.position = 'fixed';
menu.style.width = '200px';
menu.style.height = '200px';
menu.style.zIndex = 10000;
menu.style.display = 'none';
menu.appendChild(createCheckboxOCMenuItem(gmDebug));
menu.appendChild(createCheckboxOCMenuItem(gmAutomark));
menu.appendChild(createCheckboxOCMenuItem(gmHidepopups));
menu.appendChild(createCheckboxOCMenuItem(gmHidelists));
menu.appendChild(createCheckboxOCMenuItem(gmHidelivestreams));
menu.appendChild(createCheckboxOCMenuItem(gmHidefundraisers));
const menuButtonContainer = createContainer(ocMenuButtonContainerId);
menuButtonContainer.appendChild(createButton(ocMenuExportButtonId, ocMenuExportButtonText, ocMenuExport));
menuButtonContainer.appendChild(createButton(ocMenuSaveButtonId, ocMenuSaveButtonText, ocMenuSave));
menuButtonContainer.appendChild(createButton(ocMenuCancelButtonId, ocMenuCancelButtonText, ocMenuCancel));
menu.appendChild(menuButtonContainer);
return menu;
}
/*
* Onclick for oc menu open button in yt masthead; open the oc menu.
*/
async function ocMenuOpen() {
$(ocMenuSelector).each(async function() {
this.style.display = '';
this.style.left = `${getClientWidth() / 2}px`;
this.style.top = `${getClientHeight() / 2}px`;
});
await restoreOCMenuValues();
}
/*
* Onclick for export button in oc menu; export all GM values, then close the oc menu.
*/
async function ocMenuExport() {
await exportGMValues();
await ocMenuClose();
}
/*
* Onclick for save button in oc menu; save values currently in oc menu to GM, then close the menu.
*/
async function ocMenuSave() {
await saveOCMenuValues();
await ocMenuClose();
}
/*
* Onclick for cancel button in oc menu; restore the oc menu to existing GM values, then close the oc menu.
*/
async function ocMenuCancel() {
await restoreOCMenuValues();
await ocMenuClose();
}
/*
* Close the oc menu.
*/
async function ocMenuClose() {
$(ocMenuSelector).each(async function() {
this.style.display = 'none';
});
}
/*
* Save values currently in oc menu to GM
*/
async function saveOCMenuValues() {
await saveOCMenuItemCheckbox(gmDebug);
await saveOCMenuItemCheckbox(gmAutomark);
await saveOCMenuItemCheckbox(gmHidepopups);
await saveOCMenuItemCheckbox(gmHidelists);
await saveOCMenuItemCheckbox(gmHidelivestreams);
await saveOCMenuItemCheckbox(gmHidefundraisers);
}
/*
* restore the oc menu to existing GM values
*/
async function restoreOCMenuValues() {
await restoreOCMenuItemCheckbox(gmDebug);
await restoreOCMenuItemCheckbox(gmAutomark);
await restoreOCMenuItemCheckbox(gmHidepopups);
await restoreOCMenuItemCheckbox(gmHidelists);
await restoreOCMenuItemCheckbox(gmHidelivestreams);
await restoreOCMenuItemCheckbox(gmHidefundraisers);
}
/*
* Create and return a new checkbox for the oc menu.
*/
function createCheckboxOCMenuItem(config) {
const ocMenuItemContainer = createContainer(getMenuItemContainerId(config));
const checkbox = document.createElement(inputTag);
checkbox.id = ocMenuCheckboxId;
checkbox.type = checkboxType;
ocMenuItemContainer.appendChild(checkbox);
const label = document.createElement(labelTag);
label.id = ocMenuLabelId;
label.innerHTML = config;
ocMenuItemContainer.appendChild(label);
return ocMenuItemContainer;
}
async function restoreOCMenuItemCheckbox(gmKey) {
$(getOCMenuItemCheckboxSelector(gmKey))[0].checked = await GM_getValue(gmKey) === 'true';
}
async function saveOCMenuItemCheckbox(gmKey) {
await GM_setValue(gmKey, $(getOCMenuItemCheckboxSelector(gmKey))[0].checked.toString());
}
function getOCMenuItemCheckboxSelector(gmKey) {
return `#${getMenuItemContainerId(gmKey)} #${ocMenuCheckboxId}`;
}
function getMenuItemContainerId(gmKey) {
return `oc-menu-container-${gmKey}`
}
// =================
// Youtube functions
// =================
// youtube constants
const ytPopupContainerClass = 'ytd-popup-container';
const ytPaperDialogNodeName = 'tp-yt-paper-dialog';
const ytIronDropdownNodeName = 'tp-yt-iron-dropdown'
// youtube jQuery selectors
const ytPopupContainerSelector = 'ytd-popup-container';
const ytMastheadSelector = 'div#buttons.ytd-masthead';
const ytContentsGridSelector = 'div#contents.ytd-rich-grid-renderer';
const ytTellUsWhySelector = 'tp-yt-paper-button:contains("Tell us why")';
const ytRichItemWithoutOCButtonsSelector = 'ytd-rich-item-renderer:not(:has(#oc-button))';
const ytPaperDialogSelector = 'ytd-popup-container tp-yt-paper-dialog';
const ytPaperDialogIDontLikeTheVideoSelector = 'ytd-popup-container tp-yt-paper-dialog tp-yt-paper-checkbox:contains("I don\'t like the video")';
const ytPaperDialogIveAlreadyWatchedSelector = 'ytd-popup-container tp-yt-paper-dialog tp-yt-paper-checkbox:contains("I\'ve already watched the video")';
const ytPaperDialogSubmitSelector = 'ytd-popup-container tp-yt-paper-dialog tp-yt-paper-button:contains("Submit")';
const ytPaperDialogCancelSelector = 'ytd-popup-container tp-yt-paper-dialog tp-yt-paper-button:contains("Cancel")';
const ytIronDropdownButtonSelector = '#menu button';
const ytIronDropdownSelector = 'ytd-popup-container tp-yt-iron-dropdown';
const ytIronDropdownNotInterestedSelector = 'ytd-popup-container tp-yt-iron-dropdown tp-yt-paper-item:contains("Not interested")';
const ytIronDropdownDontRecommendSelector = 'ytd-popup-container tp-yt-iron-dropdown tp-yt-paper-item:contains("Don\'t recommend channel")';
/*
* Open the iron dropdown for the given video node, then click "not interested", "tell us why",
* then "already watched"
*/
async function markAlreadyWatched(videoNode) {
await openIronDropdown(videoNode);
await clickNotInterested(videoNode);
await clickTellUsWhy(videoNode);
await clickAlreadyWatched(videoNode);
}
/*
* Open the iron dropdown for the given video node, then click "not interested", "tell us why",
* then "i don't like this video"
*/
async function markDontLike(videoNode) {
await openIronDropdown(videoNode);
await clickNotInterested(videoNode);
await clickTellUsWhy(videoNode);
await clickIDontLikeTheVideo(videoNode);
}
/*
* Open the iron dropdown for the given video node, then click "don't recommend"
*/
async function markDontRecommend(videoNode) {
await openIronDropdown(videoNode);
await clickDontRecommend(videoNode);
}
/*
* click menu button on the given video node, wait for iron dropdown to be added, and return iron
* dropdown node.
*/
async function openIronDropdown(videoNode) {
// if iron dropdown button is completely missing, then nothing to do
if (!$(videoNode).find(ytIronDropdownButtonSelector).length) {
warn(`iron dropdown button not found for video id ${getVideoId(videoNode)}`);
return;
}
// hide iron dropdown, so it doesn't flicker when clicking oc buttons
if (await GM_getValue(gmHidepopups) === 'true') {
$(ytIronDropdownSelector).each(async function() {
hide(this);
});
}
await doAndAwaitNodeAdded(ytIronDropdownNodeName, ytPopupContainerSelector, async function() {
$(videoNode).find(ytIronDropdownButtonSelector).trigger('click');
});
// try to hide again, in case iron dropdown didn't exist the first time
if (await GM_getValue(gmHidepopups) === 'true') {
$(ytIronDropdownSelector).each(async function() {
hide(this);
});
}
}
/*
* click not interested button on the given iron dropdown node, and wait for iron dropdown to be removed.
*/
async function clickNotInterested(videoNode) {
try {
if (!$(ytIronDropdownNotInterestedSelector).length) {
warn(`not interested button not found for video id ${getVideoId(videoNode)}`);
return;
}
await doAndAwaitNodeRemoved(ytIronDropdownNodeName, ytPopupContainerSelector, async function() {
$(ytIronDropdownNotInterestedSelector).trigger('click');
});
} finally {
await closeIronDropdown(videoNode);
}
}
/*
* click dont recommend channel button on the given iron dropdown node, and wait for iron dropdown to be
* removed.
*/
async function clickDontRecommend(videoNode) {
try {
if (!$(ytIronDropdownDontRecommendSelector).length) {
warn(`dont recommend button not found for video id ${getVideoId(videoNode)}`);
return;
}
await doAndAwaitNodeRemoved(ytIronDropdownNodeName, ytPopupContainerSelector, async function() {
$(ytIronDropdownDontRecommendSelector).trigger('click');
});
} finally {
await closeIronDropdown(videoNode);
}
}
/*
* click tell us why button on the given video node, wait for paper dialog to be added, and return paper
* dialog node.
*/
async function clickTellUsWhy(videoNode) {
// if paper dialog button is completely missing, then nothing to do
if (!$(videoNode).find(ytTellUsWhySelector).length) {
warn(`tell us why button not found for video id ${getVideoId(videoNode)}`);
return;
}
// hide paper dialog, so it doesn't flicker when clicking oc buttons
if (await GM_getValue(gmHidepopups) === 'true') {
$(ytPaperDialogSelector).each(async function() {
hide(this);
});
}
await doAndAwaitNodeAdded(ytPaperDialogNodeName, ytPopupContainerSelector, async function() {
$(videoNode).find(ytTellUsWhySelector).trigger('click');
});
// try to hide again, in case paper dialog didn't exist the first time
if (await GM_getValue(gmHidepopups) === 'true') {
$(ytPaperDialogSelector).each(async function() {
hide(this);
});
}
}
/*
* click I dont like the video button on the given paper dialog node, click submit button on paper dialog,
* and wait for paper dialog to be removed.
*/
async function clickIDontLikeTheVideo(videoNode) {
try {
if (!$(ytPaperDialogIDontLikeTheVideoSelector).length) {
warn(`i dont like the video button not found for video id ${getVideoId(videoNode)}`);
return;
}
await doAndAwaitNodeRemoved(ytPaperDialogNodeName, ytPopupContainerSelector, async function() {
$(ytPaperDialogIDontLikeTheVideoSelector).trigger('click');
$(ytPaperDialogSubmitSelector).trigger('click');
});
} finally {
await closePaperDialog();
}
}
/*
* click Ive already watched the video button on the given paper dialog node, click submit button on paper
* dialog, and wait for paper dialog to be removed.
*/
async function clickAlreadyWatched(videoNode) {
try {
if (!$(ytPaperDialogIveAlreadyWatchedSelector).length) {
warn(`ive already watched the video button not found for video id ${getVideoId(videoNode)}`);
return;
}
await doAndAwaitNodeRemoved(ytPaperDialogNodeName, ytPopupContainerSelector, async function() {
$(ytPaperDialogIveAlreadyWatchedSelector).trigger('click');
$(ytPaperDialogSubmitSelector).trigger('click');
});
} finally {
await closePaperDialog();
}
}
/*
* If given video node is a list, then get the list id. Otherwise, null.
*/
function getListId(videoNode) {
const videoTitleLink = $(videoNode).find('a#video-title-link')[0];
if (!videoTitleLink) {
return null;
}
let match = videoTitleLink.href.match(/\/watch\?v=[^&]*&list=([^&]*)/);
if (match) {
return match[1];
}
return null;
}
/*
* If given video node is a livestream, then get the video id. Otherwise, null.
*/
function getLivestreamId(videoNode) {
const liveNowBadge = $(videoNode).find('div.badge-style-type-live-now')[0];
if (!liveNowBadge) {
return null;
}
return getVideoId(videoNode);
}
/*
* If given video node is a fundraiser, then get the video id. Otherwise, null.
*/
function getFundraiserId(videoNode) {
const fundraiserBadge = $(videoNode).find('div.badge-style-type-ypc')[0];
if (!fundraiserBadge) {
return null;
}
return getVideoId(videoNode);
}
function getVideoId(videoNode) {
const videoTitleLink = $(videoNode).find('a#video-title-link')[0];
if (!videoTitleLink) {
return null;
}
let match = videoTitleLink.href.match(/\/watch\?v=([^&]*)/);
if (match) {
return match[1];
}
return null;
}
function getChannelId(videoNode) {
const channelName = $(videoNode).find('ytd-channel-name a.yt-simple-endpoint')[0];
if (!channelName) {
return null;
}
let match = channelName.href.match(/\/c\/([^&]*)/);
if (match) {
return match[1];
}
match = channelName.href.match(/\/channel\/([^&]*)/);
if (match) {
return match[1];
}
match = channelName.href.match(/\/user\/([^&]*)/);
if (match) {
return match[1];
}
return null;
}
/*
* click iron dropdown button on the given video node, and wait for iron dropdown to be removed
*/
async function closeIronDropdown(videoNode) {
await $(ytIronDropdownSelector).each(async function() {
unhide(this); // reset visibility in case hidepopups is enabled
while (isDisplayed(this)) {
$('ytd-app').trigger('click');
await delay(1000);
/*
await doAndAwaitNodeRemoved(ytIronDropdownNodeName, ytPopupContainerSelector, async function() {
$(videoNode).find(ytIronDropdownButtonSelector).trigger('click');
});
*/
}
});
}
/*
* click cancel button on the paper dialog, and wait for paper dialog to be removed
*/
async function closePaperDialog() {
await $(ytPaperDialogSelector).each(async function() {
unhide(this); // reset visibility in case hidepopups is enabled
while (isDisplayed(this)) {
$('ytd-app').trigger('click');
await delay(1000);
/*
await doAndAwaitNodeRemoved(ytPaperDialogNodeName, ytPopupContainerSelector, async function() {
$(ytPaperDialogCancelSelector).trigger('click');
});
*/
}
});
}
// =========================
// lwkjef's standard library
// =========================
// library constants
const divTag = 'div';
const buttonTag = 'button';
const buttonType = 'button';
const inputTag = 'input';
const labelTag = 'label';
const checkboxType = 'checkbox';
// library GM keys
const gmDebug = 'debug';
/*
* Log the given text if debug mode is enabled.
*/
async function debug(text) {
if (await GM_getValue(gmDebug) === 'true') {
log(text);
}
}
/*
* Append child node to parent node. If child node is ever removed from parent node, re-append it.
*/
async function appendChildPermanently(parentNode, childNode) {
const observer = new MutationObserver(async function contentsMutated(mutationsList, observer) {
for (const mutation of mutationsList) {
for (const removedNode of mutation.removedNodes) {
if (equalsIgnoreCase(removedNode.id, childNode.id)) {
if (!$(parentNode).find(`#${childNode.id}`)[0]) {
await debug(`${removedNode.id} removed from ${parentNode.id}, re-appending`);
parentNode.appendChild(childNode);
}
}
}
}
});
observer.observe(parentNode, {attributes: false, childList: true, subtree: false});
if (!$(parentNode).find(`#${childNode.id}`)[0]) {
parentNode.appendChild(childNode);
}
}
/*
* Export all GM values as a CSV string, and open browser save file dialog to download it as a file.
*/
async function exportGMValues() {
const csv = await getGMValuesCSV();
exportTextFile(csv);
}
/*
* Export all GM values as a CSV string.
*
* Example: 'key,value\nval1,val2'
*/
async function getGMValuesCSV() {
let csv = 'key,value\n';
for (const gmKey of await GM_listValues()) {
csv += `${gmKey},${await GM_getValue(gmKey)}\n`;
}
return csv;
}
/*
* Wait until at least one node matching nodeSelector is added to ancestorNode.
*/
async function awaitNodeAdded(nodeSelector, ancestorNode) {
await debug(`awaiting ${nodeSelector} added to ${ancestorNode}...`);
let node;
let observer;
await new Promise(resolve => {
observer = new MutationObserver(async function(mutationsList, observer) {
$(ancestorNode).find(nodeSelector).each(async function() {
node = this;
resolve(this);
});
});
$(ancestorNode).each(async function() {
observer.observe(this, {attributes: true, childList: true, subtree: true});
// handle race condition where node added while initializing observer
$(ancestorNode).find(nodeSelector).each(async function() {
node = this;
resolve(this);
});
});
});
observer.disconnect();
await debug(`${nodeSelector} was added to ${ancestorNode}!`);
return node;
}
/*
* Execute the given function, then wait until node is added. ancestorSelector MUST exist and should be as
* close as possible to target node for efficiency.
*/
async function doAndAwaitNodeAdded(nodeName, ancestorSelector, f) {
let node;
let observer;
await new Promise(resolve => {
observer = new MutationObserver(async function(mutationsList, observer) {
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
for (const addedNode of mutation.addedNodes) {
if (equalsIgnoreCase(addedNode.nodeName, nodeName) &&
isDisplayed(addedNode)) {
node = addedNode;
resolve(addedNode);
}
}
} else if (mutation.type === 'attributes') {
if (equalsIgnoreCase(mutation.target.nodeName, nodeName) &&
isDisplayed(mutation.target)) {
node = mutation.target;
resolve(mutation.target);
}
}
}
});
$(ancestorSelector).each(async function() {
observer.observe(this, {attributes: true, childList: true, subtree: true});
});
f();
});
observer.disconnect();
return node;
}
/*
* Execute the given function, then wait until given node is removed. ancestorSelector MUST exist and
* should be as close as possible to target node for efficiency.
*/
async function doAndAwaitNodeRemoved(nodeName, ancestorSelector, f) {
let observer;
await new Promise(resolve => {
observer = new MutationObserver(async function(mutationsList, observer) {
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
for (const removedNode of mutation.removedNodes) {
if (equalsIgnoreCase(removedNode.nodeName, nodeName)) {
resolve(removedNode);
}
}
} else if (mutation.type === 'attributes') {
if (equalsIgnoreCase(mutation.target.nodeName, nodeName) &&
(!isDisplayed(mutation.target))) {
resolve(mutation.target);
}
}
}
});
$(ancestorSelector).each(async function() {
observer.observe(this, {attributes: true, childList: true, subtree: true});
});
f();
});
observer.disconnect();
}
/*
* Get the current width of the browser, in pixels.
*/
function getClientWidth() {
return (window.innerWidth ||
document.documentElement.clientWidth ||
document.body.clientWidth);
}
/*
* Get the current height of the browser, in pixels.
*/
function getClientHeight() {
return (window.innerHeight ||
document.documentElement.clientHeight ||
document.body.clientHeight);
}
/*
* Create and return a new div container with the given id.
*/
function createContainer(id) {
const container = document.createElement(divTag);
container.id = id;
return container;
}
/*
* Create and return a new button with the given id, text, onclick callback, and css style.
*/
function createButton(id, text, onclick, cssObj = {}) {
const button = document.createElement(buttonTag);
button.id = id;
button.type = buttonType;
button.innerHTML = text;
button.onclick = onclick;
Object.keys(cssObj).forEach(key => {button.style[key] = cssObj[key]});
return button;
}
/*
* Return true if node is displayed, false otherwise.
*/
function isDisplayed(node) {
return node.style.display !== 'none';
}
/*
* Hide the given node by setting the visibility style to hidden
*/
function hide(node) {
node.style.visibility = 'hidden';
}
/*
* Unhide the given node by unsetting the visibility style (assuming it was set to hidden)
*/
function unhide(node) {
node.style.visibility = '';
}
/*
* Prepend the script name to the given text
*/
function prependScriptName(text) {
return `[${scriptName}] ${text}`;
}
/*
* Log the given text, prepended by the userscript name, to the browser console at standard log level
*/
function log(text) {
console.log(prependScriptName(text));
}
/*
* Log the given text, prepended by the userscript name, to the browser console at warn log level
*/
function warn(text) {
console.warn(prependScriptName(text));
}
/*
* Log the given text, prepended by the userscript name, to the browser console at error log level
*/
function error(text) {
console.error(prependScriptName(text));
}
/*
* Return a Promise which waits for the given millisecond duration.
*/
async function delay(duration_ms) {
await new Promise((resolve, reject) => {
setTimeout(_ => resolve(), duration_ms)
});
}
/*
* Return true if strings lowercased are equivalent, false otherwise.
*/
function equalsIgnoreCase(one, two) {
return one.toLowerCase() === two.toLowerCase();
}
/*
* Export the given data as a text file and trigger browser to download it.
*
* type may be 'text/csv', 'text/html', 'text/vcard', 'text/txt', 'application/csv', etc.
*
* Example usage: exportFile('col1,col2\nval1,val2', 'text/csv');
*/
function exportTextFile(data, type='text/csv', charset='utf-8', filename='data.csv') {
// IMPORTANT: we must manually revoke this object URL to avoid memory leaks!
const objectURL = window.URL.createObjectURL(new Blob([data], {type: type}));
// alternatively, we may create object URL manually
//const objectURL = `data:${type};charset=${charset},${encodeURIComponent(data)}`
try {
triggerObjectURL(objectURL, filename);
}
finally {
if (objectURL !== null) {
window.URL.revokeObjectURL(objectURL);
}
}
}
/*
* Trigger the browser to download the given objectURL as a file.
*/
function triggerObjectURL(objectURL, filename='data.csv') {
const a = document.createElement('a');
a.href = objectURL;
//supported by chrome 14+ and firefox 20+
a.download = filename;
const body = document.getElementsByTagName('body')[0];
//needed for firefox
body.appendChild(a);
//supported by chrome 20+ and firefox 5+
a.click();
// clean up
body.removeChild(a);
}