Sleazy Fork is available in English.

CHYOA Map Chapter Counter

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

Verzia zo dňa 20.04.2021. Pozri najnovšiu verziu.

// ==UserScript==
// @name         CHYOA Map Chapter Counter
// @version      1.0.0
// @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("window.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("");
    console.log("Map available as a javascript object on `window.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());
}