bbbbbr / Goodreads Rating Tags

// ==UserScript==
// @name        Goodreads Rating Tags
// @namespace   http://www.goodreads.com/*
// @description Converts selected tags on GoodReads into rating images (such as tags with half-star ratings)
// @include     http://*.goodreads.com/*
// @include     http://goodreads.com/*
// @include     https://*.goodreads.com/*
// @include     https://goodreads.com/*
// @grant       none
// @version     1.0.5
// @updateURL   https://openuserjs.org/install/bbbbbr/Goodreads_Rating_Tags.user.js
// @downloadURL https://openuserjs.org/install/bbbbbr/Goodreads_Rating_Tags.user.js
// @homepageURL https://openuserjs.org/scripts/bbbbbr/Goodreads_Rating_Tags
// @license     GPL-2.0-or-later
// ==/UserScript==

// TODO : Consider narrowing scope of anchor node scan and use query selector instead

// Some examples of the tag naming format that will get matched :
// (The range is 0-0 to 5-0)
//
//   clouds-3-0
//   stars-0-5
//   rating-clouds-3-0
//   rating-stars-0-5
//   example-clouds-2-0
//   example-stars-0-5
//   another-example-clouds-3-0
//   another-example-stars-0-5



//
// Load tag image data into keyed hash
//
function initImageData()
{
    /*
    // GoodReads style stars
    tagImages['stars-on'  ]  = { imgData: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAMAAAAMCGV4AAAAUVBMVEUAAAD7m1iCXkfwl1WebUqlcEpVRETIgE64d0v+nlj8nFa2d0v4mVOwdUzkj1CZaEa/ekzTh1CucUmAYEhlVEb8m1X6mVPejFDNg03xllP0llFfj+n0AAAAFXRSTlMAQFCPjzAP7+/f39/Pz49/b0A/IDrp95/4AAAAbElEQVQI11XMWQ6AIAxF0YqA82wf6P4XKlAw4X7QnISWJGOoqm1rD0NFw2yq78xl4WhCI/MY56FocfznlptIe4jgNcVUh1SnSDrFJ+VW4HmAtXiGmyaHOfN6vbZW+/cS771KZ/tdvFmZdgvPB4/9Bmn/QwdxAAAAAElFTkSuQmCC" };
    tagImages['stars-half']  = { imgData: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAMAAAAMCGV4AAAAilBMVEUAAADc3Nz7m1jslFTIyMjc3Nz9nVfY2Ni9vb2ebUq1tbWEX0fIyMjAwMClcEqnp6dVRETIgE64d0vExMS2d0vZ2dnDw8P4mVOwdUzT09O7u7uZaEbHx8e/ekyysrLTh1CucUmsrKy0tLSAYEjb29v8m1X6mVPZ2dnY2NjR0dHMzMzzllLejFDNg00dOFS2AAAAJHRSTlMAQECP79/fj4+PT08/MDAPD+/v39/Pz8/Pj39/b29XQD86ICBFgkJ2AAAAe0lEQVQI11XM1w6DMAxAUbcFyobuyU7ssP7/90hkQMp9iHWk2MClCVidzrY912IqRWJ9l2JbqI46Twr3oItLeE/SJEz9swEIZmTT4IMpuiAiETkhcDU73q8jjoros/mF0/3Wq8fKbpyDPPMH1bKLa2RG6PzZv5xn9tXPAm2HCyy7k3wtAAAAAElFTkSuQmCC" };
    tagImages['stars-off' ]  = { imgData: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAMAAAAMCGV4AAAAZlBMVEUAAABRUVGnp6dPT09dXV3Z2dm+vr7c3NympqaWlpagoKCDg4PIyMioqKiSkpJ/f39paWl8fHxxcXGWlpZQUFBMTEze3t7Q0NDd3d3IyMjY2NjW1taWlpbDw8O4uLi1tbWkpKSVlZWzvvflAAAAFnRSTlMAP08wD+/v39/fz8+Pj4+Pj39vPyBXxSPejwAAAHhJREFUCNdVzEcChCAMQNEI2HuZSQLY7n9JxeiCv4C8RQKS1hA1DLHLMqLe92ihZ+5BSkIFYvEMP6hP74/V2vXw/qwTgJYZHZFD5hZCKqX/HaUKpNkG2xneOgzG7nOFlOeE1ctlc40xjdsW8ZSp52w2iUcjvxnv5wL9swc1kspz8wAAAABJRU5ErkJggg==" };
    */

    // Readinglist style stars
    tagImages['stars-on'  ]  = { imgData: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAMAAAAMCGV4AAAArlBMVEUAAACZhA2SfAeTfQiWgAqjjhSbhhCZhA6VfwmTfQiSfAe/qiG8pBq+qCG1oyOXgQyqlx2ZhA2ahQ6Ygg2SfAeSfAfFrh/DsS+ahg+1pCuznhuwmxqXggyahQ+ahQ+bhhCahQ+WgAuSfAeSfAeSfAeSfAeSfAeSfAf93SD92xz83yv82yDz0x741Rf95Dnawyvz0Bbw3UTo1D3izz333TPgyC/y1ijjySb21yH62BsxGQzCAAAAKHRSTlMA9Rr9+PDNpoqECvr49/Pw7+axmjkW/vvz8vLx7tXFwruRXEdELyACk7+DAAAAAJFJREFUCNdtj1cOgzAQRE0zjuktvVdXID25/8ViAkRBYn92nrR62gH9Yw86eDIWHd764+M/LwsHtVnX9dQUz+iggsK5pmmWWwrXUgECgMzLQ5RScpaPguo+i5yXfFPC/BX+CnA8pJSSadIa1wUhnHu/l4z8NvPYHTa4D68msq2zgWtOJjADIA3CXdNlU++4EnwAIugKeWWWGPcAAAAASUVORK5CYII=" };
    tagImages['stars-half']  = { imgData: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAMAAAAMCGV4AAAAtFBMVEUAAACwsLCwsLCwsLCWgAqZhA2wsLCwsLCSfAeTfAe1oyewsLCwsLCwsLCwsLCZgw2wsLCVfwmwsLCwsLCSfAewsLDFrh+/qiK9pBiwsLCwmxqplhykjxSXggyYgw2wsLCbhhCahQ+ahQ+ahQ+ZhA6wsLCWgAuwsLCSfAeSfAeSfAewsLCwsLCwsLCSfAf93B754DXy0h/10hbo1D3izz3bxC3YwSry1ij21yH62Bv41hjyzxbr3p9MAAAAL3RSTlMA5pMa+PWjgxr98tTHvLaeiYlFJBYG/vr6+PHv7+7n4M/Fu7Spm5GMXEQ7OC8NCqlfTewAAACKSURBVAjXbcxVDsMwEEXRZzt2HGywzMyBcrv/fVVWHKmRej9m5vwM/rc0m7bcBrf2JP717L4J6ltKuTeK2DkyxgCMOOfD7gfUIYR4QGhcHkVZAkBA1EzGnef7BaTCP0F1XrSzDBAUuvlNmZq1rTzvU0Se5m5wNcKVy3pp5ZY9TYAD8SPtdbWFevAFqBcJHezWuyEAAAAASUVORK5CYII=" };
    tagImages['stars-off' ]  = { imgData: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAMAAAAMCGV4AAAAVFBMVEUAAACwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLDhRk/qAAAAHHRSTlMA5hqTo4yIg/rvxry2m0MlDQbd1K2nV0g5Fr8vhYZdwQAAAHRJREFUCNdtjkkShCAMRT8EUBAUnO2+/z07QGtplX+RvLfIgPeE5umte+iqxuHuftPmZCnlbjFMXwZWwXEaoImhA4y9Jo3I9Rh1saR8LBCXcnwh/DOvuVJzPZPiTOi7agy9NcHJNlUn9TmAXfitetC1q7zgB1YDA4NW818dAAAAAElFTkSuQmCC" };


    // Readinglist style clouds
    tagImages['clouds-on'  ] = { imgData: "data:image/gif;base64,R0lGODlhDwAPAMQAAP///9XV1bi4uJ6enp2dnZycnJqampmZmZSUlJKSko6OjoiIiIaGhoSEhIODg4KCgoGBgYCAgHFxcXBwcG5ubm1tbWxsbGpqamlpaf///wAAAAAAAAAAAAAAAAAAAAAAACH5BAEAABkALAAAAAAPAA8AAAVHYCaOZEkOjDgMprq+AyKZBLwWjmnDy7jbhonrt2JgRBDF4aB4NBKFBIQiHFUul8pkYsFYZq0wKTIWkcuj89m0FmfabXFcFAIAOw==" };
    tagImages['clouds-half'] = { imgData: "data:image/gif;base64,R0lGODlhDwAPAMQAAP///9XV1bi4uJ6enp2dnZycnJqampmZmZSUlJKSko6OjoiIiIaGhoSEhIODg4KCgoGBgYCAgHFxcXBwcG5ubm1tbWxsbGpqamlpaf///wAAAAAAAAAAAAAAAAAAAAAAACH5BAEAABkALAAAAAAPAA8AAAVEYCaOZEkOjCgIpji8bxCsJgEPQM6WN57Po94r99sJfcQdRHE4KB4NIgA4qlwulclq2+qWIqTdbgQmq7plryidGavZoxAAOw==" };
    tagImages['clouds-off' ] = { imgData: "data:image/gif;base64,R0lGODlhDwAPAMQAAP///9XV1bi4uJ6enp2dnZycnJqampmZmZSUlJKSko6OjoiIiIaGhoSEhIODg4KCgoGBgYCAgHFxcXBwcG5ubm1tbWxsbGpqamlpaf///wAAAAAAAAAAAAAAAAAAAAAAACH5BAEAABkALAAAAAAPAA8AAAU0YCaOZEkKgoiaahCg7loKQG0D6Xzb7wjvvBwNeBMKAkRAz4dqOlnQ2Ukl9VFZueh1q82SQgA7" };
}


//
// Installs a mutation observer callback for nodes matching the given css selector
//
function registerMutationObserver(selectorCriteria, monitorSubtree, callbackFunction)
{
    // Cross browser mutation observer support
    var MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver;

    // Find the requested DOM nodes
    var targetNodeList = document.querySelectorAll(selectorCriteria);


    // Make sure the required elements were found, otherwise don't install the observer
    if ((targetNodeList != null) && (MutationObserver != null)) {

        // Create an observer and callback
        var observer = new MutationObserver( callbackFunction );

        // Start observing the target element(s)
        //   Note : Repeated observe calls on the same target just replace the previous observe, so it's
        //          ok to re-observe the same target in the future without first disconnecting from it
        for(var i = 0; i < targetNodeList.length; ++i) {

            observer.observe(targetNodeList[i], {
                attributes: true,
                childList: true,
                characterData: true,
                subtree: monitorSubtree
            });
        }
    }
}


//
// Monitors and converts dynamic content updates to tag shelfs (such as when a user edits tags via a pop-up)
// * Called on page load and by other dynamic content hooks (to capture new tag shelfs that get added after page load)
//
function installTagShelfUpdateHook()
{
    // Matches/watches the various types of tag shelfs (on the book list page, review edit page, book page, etc)
    // Subtree monitoring enabled to catch updates to the shelvesSection div where the entire shelfList div is replaced
    registerMutationObserver('[id*=shelfList],[id*=shelvesSection]', true,
        function(mutations) { convertTagsToImages(); }
    );
}


//
// Handles converting the in-page popup review editor (launched from book list pages)
//
function installPopupReviewHook()
{
    registerMutationObserver('[id=boxContents]', false,
        function(mutations) { convertTagsToImages(); installTagShelfUpdateHook(); }
    );
}


//
// Handles content updates generated by Infinite Scroll mode on book list pages
//
function installInfiniteScrollHook()
{
    // Subtree monitoring enabled in order to catch the text content changes in the infiniteStatus div
    registerMutationObserver('[id=infiniteStatus]', true,
        function(mutations) { convertTagsToImages(); installTagShelfUpdateHook(); }
    );
}

//
// Monitors for tags getting in-place renamed on the edit shelves page
//
function installEditShelfHook()
{
    registerMutationObserver('[class=displayShelfNameLnk]', false,
        function(mutations) {

            mutations.forEach( function(mutation) {

                // Only inspect nodes which have an 'inspected' flag to remove
                if (mutation.target.hasAttribute("data-tag-image-inspected") == true)
                {
                    // If a rename detection sub-tag was not found then it was probably removed by a rename event
                    // and this node needs to have it's 'inspected' flag cleared so it gets re-examined
                    if (mutation.target.querySelector('[class=tagRenameCanary]') == null) {
                            delete mutation.target.dataset.tagImageInspected;
                    }
                }
            } );

            convertTagsToImages();
        } ); // End observer callback function
}


//
// Attempt to match and support a variety of rating tag formats created by other users
//
// ---------------------------------------
// Some rating tag specimens from the wild
//
//   Whole star rating
//      four-stars
//      actual-rating-4-stars
//
//   Half star ratings
//      four-and-one-half
//      four-and-a-half-star
//      4-half-star
//      3-and-a-half-stars
//      four-ana-half-stars
//      actual-rating-4-half-star
//
function rewiteAlternateRatingFormats(tagName)
{
    // Ignore strings with the GoodReads format of 'N of N stars' in the formal (non-tag) rating area
    // (Narrowing scope of anchor node search would remove need for this)
    var ignoreGoodReadsRatingsAreaMatch = /.*\d of \d stars.*/i.exec(tagName);
    if (ignoreGoodReadsRatingsAreaMatch == null)
    {
        // Convert string numerics to digits
        tagName = tagName.replace(/five/gi,  "5");
        tagName = tagName.replace(/four/gi,  "4");
        tagName = tagName.replace(/three/gi, "3");
        tagName = tagName.replace(/two/gi,   "2");
        tagName = tagName.replace(/one/gi,   "1");
        tagName = tagName.replace(/zero/gi,  "0");

        // Remove leading and..."one"/"1"... sometimes found in front of "half" (complicates regexes below)
        tagName = tagName.replace(/and( |-)*1( |-)*half/gi,  "half");

        // Attempt to match half-star first, then whole star (whole star format is looser match, so must occur after half star)
        //
        // match : [0] = full match text, [1] = optional label, [2] = first numeric component, [3] = 'half'(half star regex) or 'star'(whole star regex)
        var tagMatchHalf  = /(.*)(\d).*(half).*/i.exec(tagName);
        var tagMatchWhole = /(.*)(\d).*(star).*/i.exec(tagName);

        // If a match was found then rewrite it to the desired format of : <label>-<stars>-<N-N>
        if (tagMatchHalf != null) {
            tagName = tagMatchHalf[1] + '-stars-' + tagMatchHalf[2] + '-5';
        } else if (tagMatchWhole != null) {
            tagName = tagMatchWhole[1] + '-stars-' + tagMatchWhole[2] + '-0';
        }
    }

    return(tagName);
}


//
// Append an <img> tag with the given image data to an element
//
function appendImage(parentObj, imgData)
{
    var tagImg     = document.createElement('img');
        tagImg.src = imgData;
    parentObj.appendChild(tagImg);
}


//
// Append a <span> tag as a rating label (with a trailing space) to an element.
// The label won't be added if the text is blank or has the generic name "rating"
//
function appendTagLabel(parentObj, labelText)
{
    if ((labelText != "") && (labelText != "rating"))
    {
        var tagSpan                   = document.createElement('span');

        tagSpan.innerHTML             = labelText;
        tagSpan.style.color           = "#555";
        tagSpan.style.backgroundColor = "#ddd";
        tagSpan.style.borderRadius    = "2px";
        tagSpan.style.padding         = "2px 5px 2px 5px";
        tagSpan.style.marginRight     = "5px";

        parentObj.appendChild(tagSpan);
    }
}


//
// Append an empty <span> tag to an element to help with detecting tag rename events
//
function appendRenameCanary(parentObj)
{
    var tagSpan           = document.createElement('span');
    tagSpan.style.display = "none";
    tagSpan.className     = "tagRenameCanary";

    parentObj.appendChild(tagSpan);
}


//
// Render a tag rating based on a type ("stars","clouds) and a numeric value in tag format ("4-0","1-5", etc)
//
function renderTagImages(parentObj, imgType, imgValue)
{
    var valMax = 5.0;
    var valOn  = parseFloat( imgValue.replace("-", ".") );
    var valOff = valMax - valOn;

    if ((imgType == "stars") || (imgType == "clouds"))
    {
        // Render whole "on" icons first
        while (valOn > 0.5) {
            appendImage(parentObj, tagImages[ imgType + '-on' ].imgData );
            valOn -= 1.0;
        }

        // Render half "on" icon if needed for 0.5 values
        if (valOn == 0.5) {
            appendImage(parentObj, tagImages[ imgType + '-half' ].imgData );
        }

        // Render the remaining slots as placeholders ("off")
        while (valOff >= 1) {
            appendImage(parentObj, tagImages[ imgType + '-off' ].imgData );
            valOff -= 1.0;
        }
    }
}


//
//  Find links with matching tag text and convert them to the paired images
//  (non-jquery version to avoid GoodReads breakage with jquery version conflict)
//
function convertTagsToImages()
{
    var nodeText;
    var objText;
    var elAnchor;
    var elLinks = document.getElementsByTagName( 'a' );

    // Walk through all the anchor tags on the page
    for ( var i = 0; i < elLinks.length; i++ ) {

        elAnchor = elLinks[ i ];

        // Only convert anchor tags which haven't already been inspected
        if (elAnchor.hasAttribute("data-tag-image-inspected") == false)
        {
            nodeText = elAnchor.text;

            // match[0] = full match text, [1] = optional label, [2] = "stars" or "clouds", [3] = "N1-N2" where (ideally) N1 is a digit 0-9 and N2 is 0 or 5
            var match = /([\w-]*?)-*(stars|clouds)-(\d-\d)/i.exec(nodeText);

            // No match? Rework the tag format if possible and try the match again
            if (match == null) {
                nodeText = rewiteAlternateRatingFormats(nodeText);
                match = /([\w-]*?)-*(stars|clouds)-(\d-\d)/i.exec(nodeText);
            }

            if (match != null) {

                // Strip out tag name and save off any trailing text (trailing text gets re-appended later)
                nodeText = nodeText.replace(match[0], "");

                // Remove tag text temporarily
                elAnchor.innerHTML = "";

                // Append the tag label, if suitable
                appendTagLabel(elAnchor, match[1]);

                // Render tag image
                renderTagImages(elAnchor, match[2], match[3]);

                // Prevent line breaks in the middle of rating images and labels
                elAnchor.style.whiteSpace="nowrap";

                // Restore trailing text
                elAnchor.innerHTML = elAnchor.innerHTML + nodeText;

            } // End regex string match test


            // If it's a tag on the edit-shelves page then add a shim to detect when they get renamed
            if (elAnchor.className.indexOf('displayShelfNameLnk') > -1) {
                appendRenameCanary(elAnchor);
            }

            // Flag the anchor has having been inspected so it won't get images appended
            // multiple times if the page is re-scanned to catch dynamic content (if tag text was not cleared).
            //
            //   Note : Data set name becomes "data-tag-image-inspected" when referenced as an Attribute.
            //
            elAnchor.dataset.tagImageInspected = "true";

        } // End previously inspected test

    } // End loop through all matching elements
}


// A couple globals
var tagImages = Object.create(null);  // Hash for storing tag image data by key name
var strInfiniteScrollStatusLast;
var objInfiniteStatusDiv;

// Initialize our tag images
initImageData();

// Convert any tags found on the page
convertTagsToImages();

// Install hooks for converting dynamic content that appears after initial page load
installInfiniteScrollHook();
installTagShelfUpdateHook();
installPopupReviewHook();
installEditShelfHook();