Feedly - Mark Previous As Read

Adds a button to Feedly that marks items above the current item "as read".

// ==UserScript==
// @author      Michael Mangino
// @name        Feedly - Mark Previous As Read
// @namespace   http://mangino.net/greasemonkey
// @description Adds a button to Feedly that marks items above the current item "as read".
// @include     http://feedly.com/*
// @include     https://feedly.com/*
// @grant       GM_info
// @icon        https://imageshack.us/a/img59/2623/feedlymarkpreviousasrea.png
// @version     1.19
// ==/UserScript==

//###########################################################################
// C O N F I G U R A T I O N
//###########################################################################

var COLLAPSE_SELECTED_ITEM = false;

var IS_DEBUG_ON = false;

//###########################################################################
// C O N S T A N T S
//###########################################################################

var FEEDLY_APP_VER_MIN = "16.0.530";
var FEEDLY_APP_VER_MAX = "9999.9999.9999";

var EXCLUDED_PAGES = [ " Today \n ",  "Weekend Edition \n ", "\nIndex \n", " Organize \n", " Saved \n",
        " History \n \n", "Preferences", " \nThemes \n" ];

var COLOR_FEEDLY_GREEN = "#2BB24C";

var BUTTON_MPAR_BG_COLOR_ENABLED = COLOR_FEEDLY_GREEN;
var BUTTON_MPAR_BG_COLOR_DISABLED = "darkred";
var BUTTON_MPAR_FG_COLOR_ENABLED = "white";
var BUTTON_MPAR_FG_COLOR_DISABLED = "silver";

var BUTTON_MPAR_ID_MAIN = "buttonMparMain";
var BUTTON_MPAR_ID_FLOAT = "buttonMparFloat";

var BUTTON_MPAR_TEXT = "mark previous read";

var WAIT_MESSAGE_ID = "mprWaitMessage";

var VIEW_CLASS_TITLES = "u0Entry";
var VIEW_CLASS_MAGAZINE = "u4Entry";
var VIEW_CLASS_CARDS = "u5Entry";
var VIEW_CLASS_FULL = "u100Frame";

var FEEDLY_APP_VER = unsafeWindow.feedlyApplicationVersion;

var BROWSER_NAME_CHROME = "Chrome";
var BROWSER_NAME_FIREFOX = "Firefox";

var MSG_NOT_IMPL_FOR_VIEW = "Not implemented for this View.";
var MSG_NOT_IMPL_FOR_VIEW_MAG = "Not implemented for the Magazine or Timeline Views.";
var MSG_NOT_IMPL_FOR_VIEW_CARDS = "Not implemented for the Cards View.";
var MSG_NOT_IMPL_FOR_VIEW_FULL = "Not implemented for the Full Article View.";
        
var MSG_NO_ITEM_SELECTED = "You must first select an item in order to mark previous items as read.";
var MSG_ALL_READ = "All items are already marked as read.";

var MSG_ERROR_INVALID_VIEW_CLASS = "Error: Invalid view class.";
var MSG_ERROR_EXHAUSTED_DOM = "Error: End of DOM reached without finding selcted item.";
var MSG_ERROR_INVALID_ITEM = "Error: Invalid item being processed: ";
var MSG_ERROR_NULL_ITEM = "Error: Null item being processed.";

var MSG_PLEASE_WAIT = "Please wait...";

var DELAY_ITEM_CHECK = 1; // 1/1000th of a second
var DELAY_SCROLL_BACK = 1; // 1/1000th of a second

var LIST_CONTAINER_ID = "feedlyFrame";

//###########################################################################
// G L O B A L S
//###########################################################################

var isFeedlyTooOld = null;
var isFeedlyTooNew = null;

var isBrowserInvalid = null;

var browserName = null;

var buttonBackgroundColor = null;
var buttonForegroundColor = null;

var titleElementMutationObserver = null;
var titleElementMutationObserverInit = null;

var scriptName = null;
var scriptVersion = null;

//###########################################################################
// F U N C T I O N S
//###########################################################################

function markPreviousItemsAsRead() {

    // If this script isn't compatible with the browser or the installed version of Feedly, don't do anything
    if (!isCompatible(true)) {
        return;
    }

    // Save the position of the scrolled item list
    var userScrollPosition = window.pageYOffset;
    debug("markPreviousItemsAsRead: userScrollPosition: [" + userScrollPosition + "]");

    // If the selected item isn't expanded inline AND the selected item isn't selected via keyboard shortcuts...
    var itemList;
    var selectedItem;
    if (!(selectedItem = document.querySelector(".inlineFrame"))
            && !(selectedItem = document.querySelector(".selectedEntry"))) {
        // ...Set the selected item to the last read item in the visible item list
        if ((itemList = getItemList()) !== null) {
            Array.prototype.slice.call(itemList).forEach(function(item) {
                if (item.querySelector(".read")) {
                    selectedItem = item;
                }
            });
            if (!selectedItem) {
                return;
            }
        } else {
            return;
        }
    }
    debug("markPreviousItemsAsRead: selectedItem: [" + selectedItem.id + "]");
    if (!selectedItem) {
        alert(MSG_NO_ITEM_SELECTED);
        return;
    }
    var selectedItemMasterId = selectedItem.id.split("_")[0] + "_" + selectedItem.id.split("_")[1];

    // Get a list of items in the visible item list
    if ((itemList = getItemList()) === null) {
        return;
    }

    // Declare a hash to keep track of which list items we've already processed as we go through and mark them as read
    var itemsProcessed = {};

    // Hide the item list while we're marking previous items as read
    hideList(document.getElementById(LIST_CONTAINER_ID));
    
    // This self-executing function kicks off the processing of the items to be marked as read
    (function markItemAsRead(currentItem) {
    
        if (currentItem === null) {
            showList(document.getElementById(LIST_CONTAINER_ID));
            alert(MSG_ERROR_NULL_ITEM);
            return;
        }

        debug("markItemAsRead: ######## currentItem: [" + currentItem.nodeName + " | " + currentItem.id + "]");

        // If current item is not a DIV, or doesn't end in "_main", "_main_abstract" or "_inlineframe", try next sibling
        if (currentItem.nodeName != "DIV" || (!currentItem.id.match(".+_main$")
                && !currentItem.id.match(".+_main_abstract$") && !currentItem.id.match(".+_inlineframe$"))) {
            if (currentItem.nextSibling) {
                debug("markItemAsRead: -------- currentItem not a DIV, or doesn't end in _main, _main_abstract or "
                        + "_inlineframe. Try next sibling: ["
                        + currentItem.nextSibling.nodeName + " | " + currentItem.nextSibling.id + "]");
                markItemAsRead(currentItem.nextSibling);
            } else {
                showList(document.getElementById(LIST_CONTAINER_ID));
                alert(MSG_ERROR_EXHAUSTED_DOM);
            }
            return;
        }

        // Determine "master" item ID for tracking (e.g., ID "2a2a7bf95c78ac9c_main" has master ID "2a2a7bf95c78ac9c")
        var currentItemMasterId = null;
        if (currentItem.id.match(".+_inlineframe$") || currentItem.id.match(".+_main$")
                || currentItem.id.match(".+_main_abstract$")) {
            currentItemMasterId = currentItem.id.split("_")[0] + "_" + currentItem.id.split("_")[1];
            debug("markItemAsRead: -------- currentItemMasterId: [" + currentItemMasterId + "]");
        } else {
            showList(document.getElementById(LIST_CONTAINER_ID));
            alert(MSG_ERROR_INVALID_ITEM + "[" + currentItem.nodeName + " | " + currentItem.id + "]");
            return;
        }

        // If we've reached the selected item, we're done, so scroll back to the original position and un-hide the list
        if (currentItemMasterId == selectedItemMasterId) {
            debug("markItemAsRead: -------- Reached selected item: [" + selectedItem.id + "]");
            setTimeout(function() {
                click(document.getElementById(selectedItemMasterId + "_main"));
                if (COLLAPSE_SELECTED_ITEM) {
                    setTimeout(function() {
                        click(document.querySelector(".frameActionsTop"));
                        showListAndScroll(LIST_CONTAINER_ID, userScrollPosition);
                    }, DELAY_SCROLL_BACK);
                    return;
                }
                showListAndScroll(LIST_CONTAINER_ID, userScrollPosition);
            }, DELAY_SCROLL_BACK);
            return;
        }

        // If the current item hasn't been read yet and hasn't been processed yet, click it's "main" entry
        if (!currentItem.querySelector(".read") && !itemsProcessed[currentItemMasterId]) {
            itemsProcessed[currentItemMasterId] = true;
            var elementToClick;
            if ((elementToClick = getElementToClick(currentItem)) !== null) {
                click(elementToClick);
                
                // Click again to close the item so that it will get marked as read (as of Feedly 25.0.919)
                setTimeout(function() {
                    click(document.querySelector(".frameActionsTop"));
                }, DELAY_ITEM_CHECK);
                
                debug("markItemAsRead: -------- START processing currentItemMasterId [" + currentItemMasterId + "]");
                setTimeout(function() {
                    markItemAsRead(currentItem);
                }, DELAY_ITEM_CHECK * 2);
            } else {
                showList(document.getElementById(LIST_CONTAINER_ID));
                alert(MSG_ERROR_INVALID_VIEW_CLASS + ": [" + className + "]");
                removeMparButtons();
            }
            return;
        }
        
        // Else if the current item is being processed but hasn't been marked read yet, wait a bit and check it again
        else if (!currentItem.querySelector(".read") && itemsProcessed[currentItemMasterId]) {
            debug("markItemAsRead: -------- CONTINUE processing currentItemMasterId [" + currentItemMasterId + "]");
            setTimeout(function() {
                markItemAsRead(currentItem);
            }, DELAY_ITEM_CHECK);
            return;
        }
        
        // Else we're done with the current item, so go on to the next one
        else {
            debug("markItemAsRead: -------- FINISHED processing currentItemMasterId [" + currentItemMasterId + "]");
            if (currentItem.nextSibling) {
                setTimeout(function() {
                    markItemAsRead(currentItem.nextSibling);
                }, DELAY_ITEM_CHECK);
            } else {
                showList(document.getElementById(LIST_CONTAINER_ID));
                alert(MSG_ERROR_EXHAUSTED_DOM);
                return;
            }
            return;
        }
        
    })(itemList[0]);
}

function hideList(listContainer) {

    // Hide the list
    listContainer.style.display = "none";
    
    // Create the "Please wait..." message
    var waitMessage = document.createElement("div");
    waitMessage.id = WAIT_MESSAGE_ID;
    waitMessage.style.backgroundColor = COLOR_FEEDLY_GREEN;
    waitMessage.style.color = "white";
    waitMessage.style.whiteSpace="nowrap"
    waitMessage.style.fontSize = "3em";
    waitMessage.innerHTML = MSG_PLEASE_WAIT;
    waitMessage.style.width = "7em";
    waitMessage.style.height = "1.5em";
    waitMessage.style.lineHeight = "1.5em";
    waitMessage.style.marginLeft = "auto";
    waitMessage.style.marginRight = "auto";
    waitMessage.style.position = "relative";
    waitMessage.style.textAlign = "center";
    waitMessage.style.verticalAlign = "middle";
    waitMessage.style.borderRadius = "0.25em";

    // Insert the (hidden) message element so that the "used values" will be available for getting the computed style,
    // then determine the 'top' style value and un-hide the message element
    waitMessage.style.display = "none";
    document.body.insertBefore(waitMessage, document.body.childNodes[0]);
    var body = document.body
    var html = document.documentElement;
    var docHeight = Math.max(body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight);
    var waitMessageHeight = parseInt(window.getComputedStyle(waitMessage).height.split("px"));
    var waitMessageTop = Math.floor(docHeight / 2) - (waitMessageHeight / 2);
    waitMessage.style.top = waitMessageTop + 'px';
    waitMessage.style.display = "block";
}

function showList(listContainer) {
    var waitMessage = document.getElementById(WAIT_MESSAGE_ID);
    waitMessage.parentNode.removeChild(waitMessage);
    listContainer.style.display = "block";
}

function showListAndScroll(listContainerId, scrollPosition) {
    showList(document.getElementById(listContainerId));
    window.scrollTo(0, scrollPosition);
}

function getItemList() {
    var sectionColumn = document.getElementById("section0_column0");
    if (sectionColumn !== null) {
        if (sectionColumn.children.length == 0) {
            return null;
        } else {
            switch (sectionColumn.children[0].className.split(" ")[0].trim()) {
                case VIEW_CLASS_MAGAZINE:
                    alert(MSG_NOT_IMPL_FOR_VIEW_MAG);
                    removeMparButtons();
                    return null;
                case VIEW_CLASS_CARDS:
                    alert(MSG_NOT_IMPL_FOR_VIEW_CARDS);
                    removeMparButtons();
                    return null;
                case VIEW_CLASS_FULL:
                    alert(MSG_NOT_IMPL_FOR_VIEW_FULL);
                    removeMparButtons();
                    return null;
            }
        }
        return sectionColumn.children;
    } else {
        alert(MSG_NOT_IMPL_FOR_VIEW);
        removeMparButtons();
        return null;
    }
}

function getElementToClick(item) {
    var className = item.className.split(" ")[0].trim();
    debug("getElementToClick: className: [" + className + "]");
    switch (className) {
        case VIEW_CLASS_TITLES:
            return item;
        case VIEW_CLASS_MAGAZINE:
        case VIEW_CLASS_CARDS:
        case VIEW_CLASS_FULL:
        default:
            return null;
    }
}

function click(element) {
    if (element != null) {
        debug("click: element [" + element.id + "]");
        var mouseEvent = document.createEvent("MouseEvents");
        mouseEvent.initMouseEvent("click", true, false, Window.self, 1, 0, 0, 0, 0, false, false, false, false, 0, null);
        element.dispatchEvent(mouseEvent);
    }
}

function createMparButton(titleBarId, buttonMparId) {

    // Create the MPAR button
    var buttonMpar = document.createElement("span");
    buttonMpar.id = buttonMparId;
    buttonMpar.style.marginLeft = "10px";
    buttonMpar.style.backgroundColor = buttonBackgroundColor;
    buttonMpar.style.color = buttonForegroundColor;
    buttonMpar.style.whiteSpace="nowrap"
    buttonMpar.className = "hAction secondary";
    buttonMpar.innerHTML = BUTTON_MPAR_TEXT;
    buttonMpar.addEventListener("click", markPreviousItemsAsRead, true);

    // Add the MPAR button to the specified title bar, before the span that displays the unread count
    var feedlyTitleBar = document.getElementById(titleBarId);
    var buttonInserted = false;
    Array.prototype.slice.call(feedlyTitleBar.getElementsByTagName("span")).some(function(spanElement) {
        if (spanElement.className == "hhint") {
            feedlyTitleBar.insertBefore(buttonMpar, spanElement);
            buttonInserted = true;
            return true;
        }
    });
    
    // If there is no hint bar when loading a category (which I suspect is a Feedly bug), add the button anyway
    if (buttonInserted == false) {
        feedlyTitleBar.appendChild(buttonMpar);
    }
}

function removeMparButtons() {
    document.getElementById(BUTTON_MPAR_ID_MAIN).parentNode.removeChild(document.getElementById(BUTTON_MPAR_ID_MAIN));
    document.getElementById(BUTTON_MPAR_ID_FLOAT).parentNode.removeChild(document.getElementById(BUTTON_MPAR_ID_FLOAT));
}

function checkCompatibility() {

    // Check browser validity
    switch (browserName) {
        case BROWSER_NAME_CHROME:
        case BROWSER_NAME_FIREFOX:
            isBrowserInvalid = false;
            break;
        default:
            isBrowserInvalid = true;
            break;
    }

    isFeedlyTooOld = false;
    isFeedlyTooNew = false;

    var feedlyAppVerComponents = FEEDLY_APP_VER.split("\.");

    // Check if Feedly version is too old
    var feedlyAppVerMinComponents = FEEDLY_APP_VER_MIN.split("\.");
    var componentCount = feedlyAppVerComponents.length > feedlyAppVerMinComponents.length
            ? feedlyAppVerComponents.length : feedlyAppVerMinComponents.length
    var i = 0;
    for (i = 0; i < componentCount; i++) {
        var feedly = feedlyAppVerComponents[i] ? feedlyAppVerComponents[i] : 0;
        var min = feedlyAppVerMinComponents[i] ? feedlyAppVerMinComponents[i] : 0;
        if (feedly == min) {
            continue;
        } else if (feedly < min) {
            isFeedlyTooOld = true;
            return;
        } else if (feedly > min) {
            break;
        }
    }

    // Check if Feedly version is too new
    var feedlyAppVerMaxComponents = FEEDLY_APP_VER_MAX.split("\.");
    var componentCount = feedlyAppVerComponents.length > feedlyAppVerMaxComponents.length
            ? feedlyAppVerComponents.length : feedlyAppVerMaxComponents.length
    var i = 0;
    for (i = 0; i < componentCount; i++) {
        var feedly = feedlyAppVerComponents[i] ? feedlyAppVerComponents[i] : 0;
        var max = feedlyAppVerMaxComponents[i] ? feedlyAppVerMaxComponents[i] : 0;
        if (feedly == max) {
            continue;
        } else if (feedly > max) {
            isFeedlyTooNew = true;
            return;
        } else if (feedly < max) {
            break;
        }
    }
}

function isCompatible(isWarningDisplayed) {

    if (isFeedlyTooOld === null || isFeedlyTooNew === null || isBrowserInvalid === null) {
        checkCompatibility();
    }

    if (isFeedlyTooOld && isWarningDisplayed) {
        alert("Script '" + scriptName + "' is incompatible with outdated Feedly version " + FEEDLY_APP_VER + ".");
    } else if (isFeedlyTooNew && isWarningDisplayed) {
        alert("Script '" + scriptName + "' is incompatible with updated Feedly version "  + FEEDLY_APP_VER
            + ". Check the script's homepage to determine if a newer version is available.");
    } else if (isBrowserInvalid && isWarningDisplayed) {
        alert("Script '" + scriptName + "' only runs on " + BROWSER_NAME_CHROME + " and " + BROWSER_NAME_FIREFOX + ".");
    }

    if (isFeedlyTooOld || isFeedlyTooNew || isBrowserInvalid) {
        return false;
    } else {
        return true;
    }
}

function initialize() {

    // Get script info when using Greasemonkey or Tampermonkey
    if (typeof GM_info != 'undefined') {
        scriptName = GM_info.script.name;
        scriptVersion = GM_info.script.version;
    }
    // Else get script info when using Scriptish or ???
    else {
        scriptName = GM_getMetadata("name");;
        scriptVersion = GM_getMetadata("version");;
    }

    console.log("Loading userscript \"" + scriptName + "\" v" + scriptVersion);

    if (navigator.userAgent.match(new RegExp(BROWSER_NAME_CHROME))) {
        browserName = BROWSER_NAME_CHROME;
    } else if (navigator.userAgent.match(new RegExp(BROWSER_NAME_FIREFOX))) {
        browserName = BROWSER_NAME_FIREFOX;
    }
        
    if (isCompatible(true)) {
        buttonBackgroundColor = BUTTON_MPAR_BG_COLOR_ENABLED;
        buttonForegroundColor = BUTTON_MPAR_FG_COLOR_ENABLED;
    } else {
        buttonBackgroundColor = BUTTON_MPAR_BG_COLOR_DISABLED;
        buttonForegroundColor = BUTTON_MPAR_FG_COLOR_DISABLED;
    }

    titleElementMutationObserver = new MutationObserver(function(mutationRecordArray) {

        // Get the text of the mutated title element
        titleText = document.querySelector("title").innerHTML;
        debug("titleElementMutationObserver: titleText: [" + titleText + "]");

        // Don't put the MPAR buttons on certain pages
        if (EXCLUDED_PAGES.some(function(excludedPage) {
            if (titleText.match(excludedPage)) {
                return true;
            }
        })) {
            return;
        }

        // Create MPAR buttons and add them to the title bars
        createMparButton("feedlyTitleBar", BUTTON_MPAR_ID_MAIN);
        createMparButton("floatingTitleBar", BUTTON_MPAR_ID_FLOAT);
    });

    titleElementMutationObserverInit = {
        childList : true,
        attributes : true,
        characterData : true,
        subtree : true
    };
}

function debug(message) {
    if (IS_DEBUG_ON) {
        console.debug(scriptName + ": " + message);
    }
}

//###########################################################################
// M A I N
//###########################################################################

// Perform some start-up tasks
initialize();

// Observe changes to the title element, informing us that Feedly has started populating the page
titleElementMutationObserver.observe(document.querySelector("title"), titleElementMutationObserverInit);