CHYOA Map Children Counter

Counts and displays how many chapters each branch has. Has commands on the menu

As of 2021-04-20. See the latest version.

// ==UserScript==
// @name         CHYOA Map Children Counter
// @version      1.0.1
// @description  Counts and displays how many chapters each branch has. Has commands on the menu
// @author       sllypper
// @namespace    https://greasyfork.org/en/users/55535-sllypper
// @homepage     https://greasyfork.org/en/users/55535-sllypper
// @match        *://chyoa.com/story/*/map*
// @icon         https://chyoa.com/favicon.png
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// @grant        GM_registerMenuCommand
// ==/UserScript==

/*
How to use:
* Load the entire map by moving to the bottom, then click the Regenerate button.
* There's commands on the Right-Click menu or clicking on the thingie you used to load the script

* The script only counts and draws the currently loaded chapters.
* If the story has more chapters, the user needs to Redraw or Regenrate for the chapters loaded afterwards.
* Move to the bottom of the page to load more chapters.

What's happening:
* When you load a map page, the script attempts to retrieve the saved map data from cache.
* If it doesn't find the data, it generates and stores in the cache.
* Then it draw the counters on the screen.
*/

// runs automatically when opening a new map or not
let runAutomatically = true

// shows the floating button on the bottom-right of the page
let showFloatingButton = true

/************************/

GM_registerMenuCommand("Regenerate", generateDraw, "r");
GM_registerMenuCommand("Redraw counters from cache", drawChildrenCounter, "d");
GM_registerMenuCommand("Delete cache", deleteCache, "c");
GM_registerMenuCommand("Delete all cache", deleteAllCache, "a");

/************************/

// exposing storyMap for the user to play with the data
window.storyMap;

if (!attemptLoadCache()) {
    // no map data on cache
    if (runAutomatically) {
        generateDraw()
    }
} else {
    // got map data from cache
    drawChildrenCounter()
}

if (showFloatingButton) { document.body.appendChild(btnLoad()) }

addStyle(".title-wrapper > .children { padding-right: 8px; }")

/************************/

function generateDraw() {
    generateMap()
    drawChildrenCounter()
}

function createChildrenElement(childrenCount) {
    var child = document.createElement('span')
    child.setAttribute('class', 'control-item children')
    let icon = document.createElement('i')
    icon.setAttribute('class', 'btb bt-folder')
    child.appendChild(icon);
    let count = document.createTextNode(" "+childrenCount)
    child.appendChild(count)
    return child
}

function addStyle(css) {
    var style = document.createElement('style');
    document.head.appendChild(style);
    style.textContent = css;
};

function attemptLoadCache() {
    const cachedMap = GM_getValue(window.location.pathname)
    if (cachedMap) {
        console.log("!! got map from cache");
        window.storyMap = JSON.parse(cachedMap);
        // console.log(window.storyMap[0])
        return true
    }
    console.log("!! map not found in cache");
    return false
}

function deleteCache() {
    GM_deleteValue(window.location.pathname)
}
function deleteAllCache() {
    GM_listValues().forEach((value) => {
        GM_deleteValue(value)
    })
}


function readOrGenerateMap() {
    if (! attemptLoadCache()) generateMap()

    // const entries = exportAllLevel1Chapters();
    return
}

function generateMap() {
    console.log("!! generating map");
    window.storyMap = getParsedList()
    console.log("storyMap length = ", window.storyMap.length);

    console.log("!! counting children");
    countMapChildren();
    console.log("!! done counting children");

    // cache it
    GM_setValue(window.location.pathname, JSON.stringify(window.storyMap))

    console.log("Map available as a javascript object on `window.storyMap`")
}

function drawChildrenCounter() {
    let list = document.querySelectorAll(".title-wrapper");
    // let list = document.getElementsByClassName("story-map-content");
    // if (list == null || !list) return;
    // list = Array.from(list[0].children);
    list.forEach((elem, i) => {
        let existingChildren = elem.querySelector('.children')
        if (existingChildren) { existingChildren.remove() }

        let child = createChildrenElement(window.storyMap[i].childrenCount)

        let page = elem.querySelector('.page')
        elem.insertBefore(child, page)
        // let title = elem.querySelector('.title')
        // elem.insertBefore(child, title)

        // let controls = elem.querySelector('.controls-wrapper')
        // controls.appendChild(child)
    })
}


/**
* counts and assigns the children count of all elements
*/
function countMapChildren() {
    for (const node of window.storyMap) {
        if (node.children.length !== 0) continue;
        node.childrenCount = 0;
        let next = node.parent != null ? node.parent : -1;

        // rise through the leaf until it can't
        while (next != -1) {
            //console.log("from", node.id, "to", next);
            next = countChildNodesOf(next);
        }
        // then continue to the next node
    }
    // done
}

/**
* Counts and assigns the childrenCount of the node at that index
* Aborts returning -1 if it already has been counted
* or can't because one of its children doesn't have their childrenCount
*
* @param {number} currIndex - The index of the node
* @returns {number} The index of the next node. -1 to abort
*/
function countChildNodesOf(currIndex) {
    let nextIndex = -1;
    const currentNode = window.storyMap[currIndex];
    if (currentNode.childrenCount != undefined) {
        // this node was already been processed
        // abort
        return nextIndex;
    }
    // sum the counts of its children
    const childrenCountSum = getChildrenCountSum(currentNode);
    if (childrenCountSum === -1) {
        // one or more children haven't been processed yet
        // abort
        return nextIndex;
    }

    // on successful sum
    currentNode.childrenCount = childrenCountSum + currentNode.children.length;
    nextIndex = currentNode.parent !== null ? currentNode.parent : -1;

    return nextIndex;
}

/**
* Sums the childrenCount of all children of the attribute node
*/
function getChildrenCountSum(node) {
    return node.children.reduce((acc, curr) => {
        const currentNode = window.storyMap[curr];
        if (currentNode.childrenCount == undefined) {
            return -1;
        }
        return acc !== -1 ? acc + currentNode.childrenCount : acc;
    }, 0);
}

// gets html dom element
// return parsed element as an object
function parseEl(el, id) {
    const pel = {};

    pel.id = parseInt(id);

    let tempEl = el.querySelector(".title");

    pel.title = tempEl.textContent;
    pel.url = tempEl.href;

    // sometimes the author is empty and there's no <a> inside it
    tempEl = el.querySelector(".username > a");
    pel.author = tempEl ? tempEl.textContent : "";

    // is always wrong for loaded chapters. It resets
    pel.page = el.querySelector(".page").textContent;

    // Sometimes the date is empty, but there's no issue here
    pel.date = el.querySelector(".date").textContent;

    pel.parent = null;
    pel.children = [];
    pel.linksTo = null;

    pel.margin = parseInt(el.style["margin-left"]);

    // link chapters don't have views, likes, or comments
    // so find out if the chapter is a link chapter or not

    const viewsEl = el.querySelector(".views");
    if (viewsEl == null) {
        pel.views = null;
        pel.likes = null;
        pel.comments = null;
        pel.isLinkChapter = 1;

        return pel;
    }

    pel.views = parseInt(viewsEl.textContent.split(",").join("")) || 0;
    pel.likes = parseInt(el.querySelector(".likes").textContent) || 0;
    pel.comments = parseInt(el.querySelector(".comments").textContent) || 0;
    pel.isLinkChapter = 0;

    return pel;
}

// final list like [ parsedElement(), (...) ]
// where every element has its parent and children noted
function getParsedList() {
    // temporary list, to get the DOM element from the page
    let list = document.getElementsByClassName("story-map-content");
    if (list == null || !list) return;
    list = Array.from(list[0].children);

    let prevParentI = -1;
    const finalList = [];

    for (const i in list) {
        // console.log("   Processing Chapter", i);
        const el = list[i];

        // parse el and add it to the final list
        const parsedEl = parseEl(el, i);
        finalList[i] = parsedEl;
        // console.log(parsedEl)

        // now we find the parent of el

        // before checking margin
        // check if it's the first element of the list
        if (i == 0) {
            prevParentI = 0;
            continue;
        }

        // check margins

        const currElMargin = parsedEl.margin
        const prevElMargin = finalList[i-1].margin

        // check if el is child of prev el
        if (prevElMargin < currElMargin) {
            // prev el is parent
            parsedEl.parent = parseInt(i - 1);
            // add this el as child of prev element
            finalList[i - 1].children.push(parseInt(i));
            // set prev parent to prev element
            prevParentI = i - 1;
            continue;
        }

        // check if el is sibling of prev el
        if (prevElMargin == currElMargin) {
            // they share the same parent

            // prevParent is parent
            parsedEl.parent = prevParentI;
            // add this el as child of prevParent
            finalList[prevParentI].children.push(i);
            continue;
        }

        // then el must be the "uncle" of prev el
        // prevElMargin > currElMargin
        /*
            ALTERNATIVE METHOD
        */
        // use a loop go back through the parents from the previous node
        // to find the first element with margin smaller than self
        const selfMargin = parsedEl.margin;
        for (let j = i - 1; j >= 0; j = finalList[j].parent) {
            if (finalList[j].margin < selfMargin) {
                // found the parent: j
                const actualParentI = j;
                parsedEl.parent = actualParentI;
                // add this el as child of actual parent
                finalList[actualParentI].children.push(parsedEl.id);
                // set prev parent to actual parent
                prevParentI = actualParentI;
                break;
            }
        }
        continue;
        /*
            END OF ALTERNATIVE METHOD
        */
        // deleted old method of searching parent when uncle
    }
    return finalList;
}

// button stuff

function createButton(text, action, styleStr) {
    let button = document.createElement('button');
    button.textContent = text;
    button.onclick = action;
    button.setAttribute('style', styleStr || '');
    return button;
};
function toStyleStr(obj) {
    let stack = [],
        key;
    for (key in obj) {
        if (obj.hasOwnProperty(key)) {
            stack.push(key + ':' + obj[key]);
        }
    }
    return stack.join(';');
};
function btnLoadCss() {
    return toStyleStr({
        'position': 'fixed',
        'bottom': 0,
        'right': 0,
        'padding': '2px',
        'margin': '0 10px 10px 0',
        'color': '#333',
        'background-color': 'rgb(246, 245, 244)',
        'z-index': '9999999999'
    })
}
function btnLoad() {
    return createButton('Regenerate', function() {
        generateDraw()
        // this.remove();
    }, btnLoadCss());
}